diff --git a/app/(api)/_actions/emails/emailTemplates/2026HackerInviteTemplate.ts b/app/(api)/_actions/emails/emailTemplates/2026HackerInviteTemplate.ts new file mode 100644 index 00000000..35f1a2fc --- /dev/null +++ b/app/(api)/_actions/emails/emailTemplates/2026HackerInviteTemplate.ts @@ -0,0 +1,85 @@ +export const HACKER_EMAIL_SUBJECT = + '[ACTION REQUIRED] Your Ticket to HackDavis 2026!'; + +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 = 'May 6th, 11:59pm PDT'; + 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 in!

+
+

We can't wait to see the amazing ideas you’ll bring. Before you arrive, there are a few things you’ll need to complete. πŸ’•

+

COMPLETE BEFORE THE EVENT

+
+

1️⃣ Claim your Ticket by ${CLAIM_TITO_TICKET_DEADLINE}

+

You must claim a ticket to attend β€” no ticket, no entry. Check in on your friends too and make sure everyone has theirs.

+

πŸ‘‰ Tito Ticket: ${titoUrl}

+

Do NOT share your unique link with anyone else.

+
+
+

2️⃣ Create a HackDavis Hub Account by ${DOE_DATE}

+

HackDavis Hub is where you'll find exciting information like prizes, workshops, starter kit, demo tips, live judging info and more!

+

πŸ‘‰ Hub Invite: ${hubInviteUrl}

+

Use this unique link to create your account. Do NOT share it with anyone else.

+
+
+

3️⃣ Join our Discord server

+

All event communication happens here. After joining, follow #read-me-first to get your Hacker role and unlock day-of channels.

+

πŸ‘‰ Discord Server: ${DISCORD_SERVER_URL}

+
+

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/2026HackerRejectionTemplate.ts b/app/(api)/_actions/emails/emailTemplates/2026HackerRejectionTemplate.ts new file mode 100644 index 00000000..78736248 --- /dev/null +++ b/app/(api)/_actions/emails/emailTemplates/2026HackerRejectionTemplate.ts @@ -0,0 +1,54 @@ +export const HACKER_REJECTION_EMAIL_SUBJECT = + 'Update to Your Status for HackDavis 2026'; + +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`; + + return ` + + + + + ${HACKER_REJECTION_EMAIL_SUBJECT} + + + +
+ HackDavis 2026 header +

+ Update from + HackDavis 2026 +

+
+
+

Hi ${fname},

+
+

Thank you so much for your interest in HackDavis 2026. We appreciate your enthusiasm and patience throughout this process.

+

Unfortunately, due to overwhelming interest and limited capacity, we’re no longer able to accommodate hackers currently on the waitlist. We know this is disappointing, and we’re just as bummed out as you are.

+

If you have any questions, concerns, or comments, please reach out to hello@hackdavis.io.

+
+

Warmly,
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..ebb45ee3 --- /dev/null +++ b/app/(api)/_actions/emails/emailTemplates/2026HackerWaitlistAcceptTemplate.ts @@ -0,0 +1,85 @@ +export const HACKER_WAITLIST_ACCEPT_EMAIL_SUBJECT = + "[ACTION REQUIRED] You're Off The HackDavis 2026 Waitlist"; + +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 +

+ Welcome to + HackDavis 2026! +

+

✦ ${DOE} ✦ ${DOE_LOCATION}

+
+
+

Hi ${fname}, you're in!

+
+

Congrats β€” you’re off the waitlist! We can't wait to see the amazing ideas you’ll bring. Before you arrive, there are a few things you’ll need to complete. πŸ’•

+

COMPLETE BEFORE THE EVENT

+
+

1️⃣ Claim your Ticket by ${CLAIM_TITO_TICKET_DEADLINE}

+

You must claim a ticket to attend β€” no ticket, no entry. Check in on your friends too and make sure everyone has theirs.

+

πŸ‘‰ Tito Ticket: ${titoUrl}

+

Do NOT share your unique link with anyone else.

+
+
+

2️⃣ Create a HackDavis Hub Account by ${DOE_DATE}

+

HackDavis Hub is where you'll find exciting information like prizes, workshops, starter kit, demo tips, live judging info and more!

+

πŸ‘‰ Hub Invite: ${hubInviteUrl}

+

Use this unique link to create your account. Do NOT share it with anyone else.

+
+
+

3️⃣ Join our Discord server

+

All event communication happens here. After joining, follow #read-me-first to get your Hacker role and unlock day-of channels.

+

πŸ‘‰ Discord Server: ${DISCORD_SERVER_URL}

+
+

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..e4a081a3 --- /dev/null +++ b/app/(api)/_actions/emails/emailTemplates/2026HackerWaitlistTemplate.ts @@ -0,0 +1,54 @@ +export const HACKER_WAITLIST_EMAIL_SUBJECT = + '[IMPORTANT] HackDavis 2026 Application Update'; + +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`; + + return ` + + + + + ${HACKER_WAITLIST_EMAIL_SUBJECT} + + + +
+ HackDavis 2026 header +

+ Update from + HackDavis 2026 +

+
+
+

Hi ${fname},

+
+

Thank you for applying! Unfortunately, due to a high volume of applications, you have been waitlisted for HackDavis 2026.

+

We are unable to offer you admission currently, but spots may open up later! Just hang in there, we will get back to you about a change in your status as soon as possible.

+

If you have any questions, concerns, or comments, please reach out to hello@hackdavis.io.

+
+

Warmly,
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 new file mode 100644 index 00000000..e81d6893 --- /dev/null +++ b/app/(api)/_actions/emails/sendBulkHackerInvites.ts @@ -0,0 +1,203 @@ +'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 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; +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; + + const titoLimiter = createLimiter(TITO_CONCURRENCY); + const emailLimiter = createLimiter(EMAIL_CONCURRENCY); + + let existingInvitationsMap = new Map(); + + return processBulkInvites(csvText, { + label: 'Hacker', + parse: parseHackerAdmissionsCSV, + + async preprocess(hackers) { + // Only batch-check Hub duplicates for rows that will create Hub accounts + const acceptEmails = hackers + .filter((h) => admissionNeedsTitoAndHub(h.admissionType)) + .map((h) => h.email); + + if (acceptEmails.length > 0) { + existingInvitationsMap = await getAllRsvpInvitations(rsvpListSlug); + } + + 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 ( + admissionNeedsTitoAndHub(hacker.admissionType) && + existingEmailSet.has(hacker.email) + ) { + earlyResults.push({ + email: hacker.email, + admissionType: hacker.admissionType, + success: false, + error: 'User already exists.', + }); + } else { + remaining.push(hacker); + } + } + + return { remaining, earlyResults }; + }, + + async processOne(hacker) { + const { firstName, lastName, email, admissionType } = hacker; + const needsLinks = admissionNeedsTitoAndHub(admissionType); + + 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 2: Hub invite link + const invite = await GenerateInvite( + { email, name: `${firstName} ${lastName}`, role: 'hacker' }, + 'invite' + ); + 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/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/emails/sendSingleHackerInvite.ts b/app/(api)/_actions/emails/sendSingleHackerInvite.ts new file mode 100644 index 00000000..93c74a08 --- /dev/null +++ b/app/(api)/_actions/emails/sendSingleHackerInvite.ts @@ -0,0 +1,138 @@ +'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 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, + // HackerAdmissionType, + SingleHackerInviteResponse, + admissionNeedsTitoAndHub, +} from '@typeDefs/emails'; + +interface HackerInviteOptions extends HackerInviteData { + rsvpListSlug: string; + releaseIds: string; +} + +export default async function sendSingleHackerInvite( + options: HackerInviteOptions +): Promise { + const { + firstName, + lastName, + email, + admissionType, + rsvpListSlug, + releaseIds, + } = options; + + try { + if (!DEFAULT_SENDER) { + throw new Error('Email configuration missing: SENDER_EMAIL is not set.'); + } + + const needsLinks = admissionNeedsTitoAndHub(admissionType); + + 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.`); + } + + const titoResult = await getOrCreateTitoInvitation({ + firstName, + lastName, + email, + rsvpListSlug, + releaseIds, + }); + if (!titoResult.ok) { + throw new Error(titoResult.error); + } + + 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 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); + + 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 + ? e.message + : typeof e === 'string' + ? e + : 'Unknown error'; + console.error( + `[Hacker Invite][${admissionType}] Failed (${email}):`, + errorMessage + ); + return { ok: false, admissionType, error: errorMessage }; + } +} diff --git a/app/(api)/_actions/emails/transporter.ts b/app/(api)/_actions/emails/transporter.ts index ada44c0a..54f00db8 100644 --- a/app/(api)/_actions/emails/transporter.ts +++ b/app/(api)/_actions/emails/transporter.ts @@ -1,28 +1,42 @@ import nodemailer from 'nodemailer'; +import type { Transporter } from 'nodemailer'; -const SENDER_EMAIL = process.env.SENDER_EMAIL; -const SENDER_PWD = process.env.SENDER_PWD; +// Read at import time so callers can do `if (!DEFAULT_SENDER)` guards. +// Does NOT throw β€” callers handle the missing case themselves. +export const DEFAULT_SENDER = process.env.SENDER_EMAIL ?? null; -const missingVars: string[] = []; -if (!SENDER_EMAIL) missingVars.push('SENDER_EMAIL'); -if (!SENDER_PWD) missingVars.push('SENDER_PWD'); -if (missingVars.length > 0) { - throw new Error( - `Email transporter: missing environment variable(s): ${missingVars.join( - ', ' - )}` +let _transporter: Transporter | null = null; + +function getTransporter(): Transporter { + if (_transporter) return _transporter; + + const email = process.env.SENDER_EMAIL; + const pwd = process.env.SENDER_PWD; + const missing = [!email && 'SENDER_EMAIL', !pwd && 'SENDER_PWD'].filter( + Boolean ); -} + if (missing.length > 0) { + throw new Error( + `Email transporter: missing environment variable(s): ${missing.join( + ', ' + )}` + ); + } -export const transporter = nodemailer.createTransport({ - service: 'gmail', - pool: true, - maxConnections: 10, - maxMessages: Infinity, // don't recycle connections mid-batch - auth: { - user: SENDER_EMAIL, - pass: SENDER_PWD, - }, -}); + _transporter = nodemailer.createTransport({ + service: 'gmail', + pool: true, + maxConnections: 10, + maxMessages: Infinity, + auth: { user: email!, pass: pwd! }, + }); + + return _transporter; +} -export const DEFAULT_SENDER = SENDER_EMAIL; +// Thin proxy so callers don't need to change β€” sendMail initialises the +// real transport on first use rather than at module load time. +export const transporter = { + sendMail: (options: nodemailer.SendMailOptions) => + getTransporter().sendMail(options), +}; diff --git a/app/(api)/_actions/tito/getAllRsvpInvitations.ts b/app/(api)/_actions/tito/getAllRsvpInvitations.ts new file mode 100644 index 00000000..2f8679c0 --- /dev/null +++ b/app/(api)/_actions/tito/getAllRsvpInvitations.ts @@ -0,0 +1,45 @@ +'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> { + if (!rsvpListSlug) return new Map(); + const map = new Map(); + const pageSize = 1000; + let page = 1; + + let hasMore = true; + while (hasMore) { + 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); + } + } + + hasMore = invitations.length === pageSize; + page++; + } catch (e) { + console.error('[Tito] getAllRsvpInvitations failed on page', page, e); + hasMore = false; + } + } + + 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`); diff --git a/app/(api)/api/admissions/hackers/route.ts b/app/(api)/api/admissions/hackers/route.ts new file mode 100644 index 00000000..28fe7f8d --- /dev/null +++ b/app/(api)/api/admissions/hackers/route.ts @@ -0,0 +1,265 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; +import { z } from 'zod'; +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 '@actions/emails/emailTemplates/2026HackerInviteTemplate'; +import hackerWaitlistAcceptTemplate, { + HACKER_WAITLIST_ACCEPT_EMAIL_SUBJECT, +} from '@actions/emails/emailTemplates/2026HackerWaitlistAcceptTemplate'; +import hackerWaitlistTemplate, { + HACKER_WAITLIST_EMAIL_SUBJECT, +} from '@actions/emails/emailTemplates/2026HackerWaitlistTemplate'; +import hackerRejectionTemplate, { + HACKER_REJECTION_EMAIL_SUBJECT, +} from '@actions/emails/emailTemplates/2026HackerRejectionTemplate'; +import { DEFAULT_SENDER, transporter } from '@actions/emails/transporter'; +import createLimiter from '@actions/emails/createLimiter'; +import { + HACKER_ADMISSION_TYPES, + admissionNeedsTitoAndHub, +} from '@typeDefs/emails'; +import type { HackerAdmissionType } from '@typeDefs/emails'; + +// ── Auth ───────────────────────────────────────────────────────────────────── + +function isAuthorized(request: NextRequest): boolean { + const key = process.env.ADMISSIONS_API_KEY; + if (!key) return false; // key must be configured; no key = always denied + const header = request.headers.get('authorization') ?? ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : header; + return token === key; +} + +// ── Schema ─────────────────────────────────────────────────────────────────── + +const HackerSchema = z.object({ + firstName: z.string().min(1), + lastName: z.string().min(1), + email: z.string().email(), + type: z.enum( + HACKER_ADMISSION_TYPES as [HackerAdmissionType, ...HackerAdmissionType[]] + ), +}); + +const RequestSchema = z.object({ + hackers: z.array(HackerSchema).min(1), + // Required only when the batch contains accept or waitlist_accept rows. + rsvpListSlug: z.string().optional(), + releaseIds: z.string().optional(), +}); + +// ── Result type ─────────────────────────────────────────────────────────────── + +interface HackerResult { + firstName: string; + lastName: string; + email: string; + type: HackerAdmissionType; + titoUrl: string | null; + hubUrl: string | null; + success: boolean; + notes: string | null; +} + +// ── Concurrency ─────────────────────────────────────────────────────────────── + +const TITO_CONCURRENCY = 20; +const EMAIL_CONCURRENCY = 10; + +// ── Handler ─────────────────────────────────────────────────────────────────── + +export async function POST(request: NextRequest): Promise { + if (!isAuthorized(request)) { + return NextResponse.json( + { ok: false, error: 'Unauthorized' }, + { status: 401 } + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { ok: false, error: 'Invalid JSON body' }, + { status: 400 } + ); + } + + const parsed = RequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + ok: false, + error: parsed.error.issues.map((i) => i.message).join('; '), + }, + { status: 400 } + ); + } + + const { hackers, rsvpListSlug = '', releaseIds = '' } = parsed.data; + + const needsTito = hackers.some((h) => admissionNeedsTitoAndHub(h.type)); + if (needsTito && !rsvpListSlug) { + return NextResponse.json( + { + ok: false, + error: + 'rsvpListSlug is required when the batch contains accept or waitlist_accept entries.', + }, + { status: 400 } + ); + } + + if (!DEFAULT_SENDER) { + return NextResponse.json( + { ok: false, error: 'Email configuration missing on server.' }, + { status: 500 } + ); + } + const sender = DEFAULT_SENDER; + + // Pre-fetch Tito map and Hub duplicate set once for the whole batch + const [existingInvitationsMap, existingEmailSet] = await Promise.all([ + needsTito + ? getAllRsvpInvitations(rsvpListSlug) + : Promise.resolve(new Map()), + (async () => { + const acceptEmails = hackers + .filter((h) => admissionNeedsTitoAndHub(h.type)) + .map((h) => h.email); + if (!acceptEmails.length) return new Set(); + const res = await GetManyUsers({ email: { $in: acceptEmails } }); + return new Set( + res.ok ? res.body.map((u: { email: string }) => u.email) : [] + ); + })(), + ]); + + const titoLimiter = createLimiter(TITO_CONCURRENCY); + const emailLimiter = createLimiter(EMAIL_CONCURRENCY); + + const results: HackerResult[] = await Promise.all( + hackers.map(async ({ firstName, lastName, email, type }) => { + const base: Omit< + HackerResult, + 'titoUrl' | 'hubUrl' | 'success' | 'notes' + > = { + firstName, + lastName, + email, + type, + }; + + const fail = (notes: string): HackerResult => ({ + ...base, + titoUrl: null, + hubUrl: null, + success: false, + notes, + }); + + if (admissionNeedsTitoAndHub(type)) { + // Guard: already has a Hub account + if (existingEmailSet.has(email)) { + return fail('User already exists in Hub.'); + } + + // Tito invitation + const titoResult = await titoLimiter(() => + getOrCreateTitoInvitation( + { firstName, lastName, email, rsvpListSlug, releaseIds }, + existingInvitationsMap + ) + ); + if (!titoResult.ok) + return fail(titoResult.error ?? 'Tito invite failed.'); + + // Hub invite link + const invite = await GenerateInvite( + { email, name: `${firstName} ${lastName}`, role: 'hacker' }, + 'invite' + ); + if (!invite.ok || !invite.body) { + return fail(invite.error ?? 'Failed to generate Hub invite link.'); + } + + // Email + const subject = + type === 'waitlist_accept' + ? HACKER_WAITLIST_ACCEPT_EMAIL_SUBJECT + : HACKER_EMAIL_SUBJECT; + const html = + type === '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 { + ...base, + titoUrl: titoResult.titoUrl, + hubUrl: invite.body!, + success: true, + notes: null, + }; + } catch (e) { + return fail( + `Email send failed: ${ + e instanceof Error ? e.message : 'Unknown error' + }` + ); + } + } else { + // Waitlist / reject β€” email only + const subject = + type === 'waitlist' + ? HACKER_WAITLIST_EMAIL_SUBJECT + : HACKER_REJECTION_EMAIL_SUBJECT; + const html = + type === 'waitlist' + ? hackerWaitlistTemplate(firstName) + : hackerRejectionTemplate(firstName); + + try { + await emailLimiter(() => + transporter.sendMail({ from: sender, to: email, subject, html }) + ); + return { + ...base, + titoUrl: null, + hubUrl: null, + success: true, + notes: null, + }; + } catch (e) { + return fail( + `Email send failed: ${ + e instanceof Error ? e.message : 'Unknown error' + }` + ); + } + } + }) + ); + + const successCount = results.filter((r) => r.success).length; + const failureCount = results.length - successCount; + + return NextResponse.json( + { ok: failureCount === 0, results, successCount, failureCount }, + { status: 200 } + ); +} diff --git a/app/(pages)/admin/_components/InvitePanel/BulkInviteForm.tsx b/app/(pages)/admin/_components/InvitePanel/BulkInviteForm.tsx new file mode 100644 index 00000000..b0c74bc9 --- /dev/null +++ b/app/(pages)/admin/_components/InvitePanel/BulkInviteForm.tsx @@ -0,0 +1,671 @@ +'use client'; + +import { ChangeEvent, useState } from 'react'; +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, + HackerAdmissionType, + HACKER_ADMISSION_TYPES, + HACKER_ADMISSION_LABELS, + admissionNeedsTitoAndHub, +} from '@typeDefs/emails'; +import { Release, RsvpList } from '@typeDefs/tito'; +import { + buildFailureDownloadFilename, + generateInviteFailuresCSV, + InviteDataWithType, +} from '../../_utils/generateInviteFailuresCSV'; +import { + generateInviteResultsCSV, + InviteResultRow, +} from '../../_utils/generateInviteResultsCSV'; +import { InviteRole } from './InvitePanel'; + +interface DisplayResult { + email: string; + success: boolean; + error?: string; + admissionType?: HackerAdmissionType; + titoUrl?: string; + inviteUrl?: string; +} + +interface DisplayBulkResponse { + ok: boolean; + results: DisplayResult[]; + successCount: number; + failureCount: number; + 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 for non-hacker roles (3 columns). + */ +function previewCSV( + text: string +): { ok: true; rows: InviteData[] } | { 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: InviteData[] = []; + 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 }; +} + +/** + * 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 { + rsvpLists: RsvpList[]; + releases: Release[]; + role: InviteRole; +} + +export default function BulkInviteForm({ rsvpLists, releases, role }: Props) { + const [status, setStatus] = useState('idle'); + 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( + rsvpLists[0]?.slug ?? '' + ); + const [selectedReleases, setSelectedReleases] = useState([]); + const [configError, setConfigError] = useState(''); + + 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] + ); + + 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); + + 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 { + 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 (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 && role !== 'hacker' && selectedReleases.length === 0) { + setConfigError('Please select at least one release.'); + return; + } + + setConfigError(''); + setStatus('sending'); + setResult(null); + + 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, + admissionType: res.admissionType, + 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'); + }; + + const handleDownloadCSV = () => { + if (!result) return; + const resultMap = new Map( + result.results.map((r) => [r.email.toLowerCase(), r]) + ); + const includeHub = role === 'hacker' || role === 'judge'; + const rows: InviteResultRow[] = preview.map((person) => { + const res = resultMap.get(person.email.toLowerCase()); + return { + firstName: person.firstName, + lastName: person.lastName, + email: person.email, + admissionType: (person as HackerPreviewRow).admissionType, + titoUrl: res?.titoUrl, + hubUrl: res?.inviteUrl, + success: res?.success ?? false, + error: res?.error, + }; + }); + 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'); + link.href = url; + link.download = `${role}-invites-${ + new Date().toISOString().split('T')[0] + }.csv`; + link.click(); + URL.revokeObjectURL(url); + }; + + const handleDownloadFailuresCSV = () => { + if (!result || result.failureCount === 0) return; + const csv = generateInviteFailuresCSV( + preview as InviteDataWithType[], + 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 || `${role}-invites.csv` + ); + link.click(); + URL.revokeObjectURL(url); + }; + + const handleReset = () => { + setStatus('idle'); + setCsvText(''); + setFileName(''); + setPreview([]); + setHackerPreview([]); + setParseError(''); + setResult(null); + setConfigError(''); + setSelectedReleases([]); + }; + + const previewCount = + role === 'hacker' ? hackerPreview.length : preview.length; + + return ( +
+ {/* File input */} +
+ + +
+ + {/* Parse error */} + {parseError && ( +
+

CSV errors:

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

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

+ +
+
+ + + + + + + {role === 'hacker' && ( + + )} + + + + {(role === 'hacker' ? hackerPreview : preview).map( + (person, i) => ( + + + + + {role === 'hacker' && ( + + )} + + ) + )} + +
+ First Name + + Last Name + + Email + + Type +
+ {person.firstName} + + {person.lastName} + + {person.email} + + +
+
+
+ + {/* 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. +

+ )} + +
+ + +
+ +
+
+ + +
+
+ {releases.map((release) => ( + + ))} +
+
+ + )} + + {configError && ( +

+ {configError} +

+ )} + + +
+ )} + + {/* Sending spinner */} + {status === 'sending' && ( +
+
+ Sending emails… +
+ )} + + {/* 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.admissionType && ( + + + + )} + + {r.error} +
+ ))} +
+
+ )} + +
+ {result.failureCount > 0 && ( + + )} + + +
+
+ )} +
+ ); +} + +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/MentorVolunteerInvites/MentorVolunteerInvitesPanel.tsx b/app/(pages)/admin/_components/InvitePanel/InvitePanel.tsx similarity index 60% rename from app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerInvitesPanel.tsx rename to app/(pages)/admin/_components/InvitePanel/InvitePanel.tsx index 1c9d7669..4ec56eca 100644 --- a/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerInvitesPanel.tsx +++ b/app/(pages)/admin/_components/InvitePanel/InvitePanel.tsx @@ -4,23 +4,43 @@ 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'; +export type InviteRole = 'hacker' | 'judge' | 'mentor' | 'volunteer'; + interface Props { - role: 'mentor' | 'volunteer'; + role: InviteRole; } -export default function MentorVolunteerInvitesPanel({ role }: Props) { +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: + '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'; + +export default function InvitePanel({ 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,15 +100,11 @@ 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}

} + 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 Tito invites to multiple {role}s at once. + to send emails to multiple {label.toLowerCase()}s at once. + {role === 'hacker' && ( + <> + {' '} + Valid types:{' '} + + accept, waitlist_accept, waitlist, reject + + + )}

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

- Note: This template includes Mentor Orientation materials. -

- )} - Note: {note}

} + (null); + const [error, setError] = useState(''); + const [selectedListSlug, setSelectedListSlug] = useState( + rsvpLists[0]?.slug ?? '' + ); + const [selectedReleases, setSelectedReleases] = useState([]); + const [admissionType, setAdmissionType] = + useState('accept'); + + const hasTito = role !== 'judge'; + const hackerNeedsLinks = + role === 'hacker' && admissionNeedsTitoAndHub(admissionType); + + const toggleRelease = (id: string) => + setSelectedReleases((prev) => + prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id] + ); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (role === 'hacker' && hackerNeedsLinks && !selectedListSlug) { + setError('Please select an RSVP list.'); + return; + } + 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; + } + + setLoading(true); + setSuccessUrls(null); + 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; + + let result: { + ok: boolean; + admissionType?: HackerAdmissionType; + 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, + admissionType, + 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) { + setSuccessUrls({ + admissionType: result.admissionType, + titoUrl: result.titoUrl, + inviteUrl: result.inviteUrl, + }); + (e.target as HTMLFormElement).reset(); + setSelectedReleases([]); + } else { + setError(result.error ?? 'An unexpected error occurred.'); + } + }; + + 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 */} +
+
+ + +
+
+ + +
+
+
+ + +
+ + {/* Tito config β€” shown for non-judges; for hackers only when type needs it */} + {hasTito && (role !== 'hacker' || hackerNeedsLinks) && ( + <> +
+ + +
+ +
+
+ + +
+
+ {releases.map((release) => ( + + ))} +
+
+ + )} + + + + {error && ( +

+ {error} +

+ )} + {successUrls && ( +
+

+ Email sent + {successUrls.admissionType + ? ` (${HACKER_ADMISSION_LABELS[successUrls.admissionType]})` + : ''} + ! +

+ {successUrls.titoUrl && ( +
+

Tito ticket:

+

+ {successUrls.titoUrl} +

+
+ )} + {successUrls.inviteUrl && ( +
+

Hub invite:

+

+ {successUrls.inviteUrl} +

+
+ )} +
+ )} +
+ ); +} 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 deleted file mode 100644 index 22783db4..00000000 --- a/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerBulkInviteForm.tsx +++ /dev/null @@ -1,429 +0,0 @@ -'use client'; - -import { ChangeEvent, useState } from 'react'; -import { parse } from 'csv-parse/sync'; -import sendBulkMentorOrVolunteerInvites from '@actions/emails/sendBulkMentorOrVolunteerInvites'; -import { BulkMentorInviteResponse, MentorInviteData } from '@typeDefs/emails'; -import { Release, RsvpList } from '@typeDefs/tito'; -import { - buildFailureDownloadFilename, - generateInviteFailuresCSV, -} from '../../_utils/generateInviteFailuresCSV'; -import { generateInviteResultsCSV } from '../../_utils/generateInviteResultsCSV'; - -/** - * 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 } { - 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: MentorInviteData[] = []; - 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'; - -interface Props { - rsvpLists: RsvpList[]; - releases: Release[]; - role: 'mentor' | 'volunteer'; -} - -export default function MentorVolunteerBulkInviteForm({ - rsvpLists, - releases, - role, -}: Props) { - 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 [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; - 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 () => { - 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 sendBulkMentorOrVolunteerInvites( - csvText, - selectedListSlug, - selectedReleases.join(','), - role - ); - 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 = `${role}-invites-${ - new Date().toISOString().split('T')[0] - }.csv`; - link.click(); - URL.revokeObjectURL(url); - }; - - 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 || `${role}-invites.csv` - ); - link.click(); - URL.revokeObjectURL(url); - }; - - const handleReset = () => { - setStatus('idle'); - setCsvText(''); - setFileName(''); - setPreview([]); - setParseError(''); - setResult(null); - setConfigError(''); - setSelectedReleases([]); - }; - - return ( -
- {/* File input */} -
- - -
- - {/* Parse error */} - {parseError && ( -
-

CSV errors:

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

- {preview.length} {role} - {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.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/MentorVolunteerInvites/MentorVolunteerSingleInviteForm.tsx b/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerSingleInviteForm.tsx deleted file mode 100644 index bf6c5987..00000000 --- a/app/(pages)/admin/_components/MentorVolunteerInvites/MentorVolunteerSingleInviteForm.tsx +++ /dev/null @@ -1,182 +0,0 @@ -'use client'; - -import { FormEvent, useState } from 'react'; -import sendSingleMentorOrVolunteerInvite from '@actions/emails/sendSingleMentorOrVolunteerInvite'; -import { Release, RsvpList } from '@typeDefs/tito'; - -interface Props { - rsvpLists: RsvpList[]; - releases: Release[]; - role: 'mentor' | 'volunteer'; -} - -export default function MentorSingleInviteForm({ - rsvpLists, - releases, - role, -}: 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 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, - }); - - 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/generateInviteFailuresCSV.ts b/app/(pages)/admin/_utils/generateInviteFailuresCSV.ts index 7b030c36..5b97cd44 100644 --- a/app/(pages)/admin/_utils/generateInviteFailuresCSV.ts +++ b/app/(pages)/admin/_utils/generateInviteFailuresCSV.ts @@ -1,4 +1,12 @@ -import { InviteData, InviteResult } from '@typeDefs/emails'; +import { + InviteData, + InviteResult, + HackerAdmissionType, +} from '@typeDefs/emails'; + +export interface InviteDataWithType extends InviteData { + admissionType?: HackerAdmissionType; +} function normalizeCell(value: string): string { return value.replace(/\r?\n+/g, ' ').trim(); @@ -30,17 +38,29 @@ export function buildFailureDownloadFilename(inputFileName: string): string { return `${trimmed}_failures_${timestamp}Z.csv`; } +/** + * Generates a CSV of failed rows only. + * + * When rows include an admissionType (hacker admissions), a Type column is + * included so the file can be re-uploaded directly to retry failed sends. + * The parsers skip any extra trailing columns, so the appended Failure column + * does not interfere with re-uploading. + */ export function generateInviteFailuresCSV( - rows: InviteData[], + rows: InviteDataWithType[], results: InviteResult[] ): string { - const resultMap = new Map( - results.map((result) => [result.email.toLowerCase(), result]) - ); + const resultMap = new Map(results.map((r) => [r.email.toLowerCase(), r])); - const headers = ['First Name', 'Last Name', 'Email', 'Failure']; + const includeType = rows.some((r) => r.admissionType != null); + const headers = [ + 'First Name', + 'Last Name', + 'Email', + ...(includeType ? ['Type'] : []), + 'Failure', + ]; - // Only include rows that actually failed (exclude successes) const failedRows = rows.filter((row) => { const result = resultMap.get(row.email.toLowerCase()); return !result?.success; @@ -50,7 +70,13 @@ export function generateInviteFailuresCSV( const result = resultMap.get(row.email.toLowerCase()); const failureReason = result?.error ?? 'Unknown error'; - return [row.firstName, row.lastName, row.email, failureReason] + return [ + row.firstName, + row.lastName, + row.email, + ...(includeType ? [row.admissionType ?? ''] : []), + failureReason, + ] .map(escapeCell) .join(','); }); diff --git a/app/(pages)/admin/_utils/generateInviteResultsCSV.ts b/app/(pages)/admin/_utils/generateInviteResultsCSV.ts index 401ec3a7..e8f8973d 100644 --- a/app/(pages)/admin/_utils/generateInviteResultsCSV.ts +++ b/app/(pages)/admin/_utils/generateInviteResultsCSV.ts @@ -1,7 +1,10 @@ +import type { HackerAdmissionType } from '@typeDefs/emails'; + export interface InviteResultRow { firstName: string; lastName: string; email: string; + admissionType?: HackerAdmissionType; titoUrl?: string; hubUrl?: string; // populated for hacker invites; omitted for mentor-only success: boolean; @@ -14,17 +17,20 @@ function escapeCell(value: string): string { /** * 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. + * @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 includeType = rows.some((r) => r.admissionType != null); + const headers = [ 'Email', 'First Name', 'Last Name', + ...(includeType ? ['Type'] : []), 'Tito Invite URL', ...(includeHub ? ['Hub Invite URL'] : []), 'Success', @@ -36,6 +42,7 @@ export function generateInviteResultsCSV( row.email, row.firstName, row.lastName, + ...(includeType ? [row.admissionType ?? ''] : []), row.titoUrl ?? '', ...(includeHub ? [row.hubUrl ?? ''] : []), row.success ? 'TRUE' : 'FALSE', diff --git a/app/(pages)/admin/invites/page.tsx b/app/(pages)/admin/invites/page.tsx index ee721814..cfcfa922 100644 --- a/app/(pages)/admin/invites/page.tsx +++ b/app/(pages)/admin/invites/page.tsx @@ -1,15 +1,28 @@ 'use client'; import { useState } from 'react'; -import Link from 'next/link'; -import JudgeSingleInviteForm from '../_components/JudgeInvites/JudgeSingleInviteForm'; -import JudgeBulkInviteForm from '../_components/JudgeInvites/JudgeBulkInviteForm'; -import MentorVolunteerInvitesPanel from '../_components/MentorVolunteerInvites/MentorVolunteerInvitesPanel'; +import InvitePanel from '../_components/InvitePanel/InvitePanel'; -type Tab = 'judges' | 'mentors' | 'volunteers'; +type Tab = 'hackers' | 'judges' | 'mentors' | 'volunteers'; + +const TAB_LABELS: Record = { + hackers: 'Hackers', + judges: 'Judges', + mentors: 'Mentors', + volunteers: 'Volunteers', +}; + +const TAB_DESCRIPTIONS: Record = { + hackers: + 'Send admission emails to hackers. Accepted (and waitlist accepted) hackers receive a Tito e-ticket and HackDavis Hub registration invite; waitlisted and rejected hackers receive email notifications only.', + judges: + 'Send HackDavis Hub invites to judges. Navigate to emergency-invites for one-time links.', + mentors: 'Send Tito e-ticket invites to mentors.', + volunteers: 'Send Tito e-ticket invites to volunteers.', +}; export default function InvitesPage() { - const [tab, setTab] = useState('judges'); + const [tab, setTab] = useState('hackers'); return (
@@ -17,7 +30,7 @@ export default function InvitesPage() { {/* Tab bar */}
- {(['judges', 'mentors', 'volunteers'] as Tab[]).map((t) => ( + {(['hackers', 'judges', 'mentors', 'volunteers'] 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. -

-

- 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]}

+ +
); } diff --git a/app/_types/emails.ts b/app/_types/emails.ts index 9f249baf..faa84376 100644 --- a/app/_types/emails.ts +++ b/app/_types/emails.ts @@ -35,6 +35,53 @@ export interface SingleJudgeInviteResponse { error: string | null; } +// Hacker invite types + +export type HackerAdmissionType = + | 'accept' + | 'waitlist_accept' + | 'waitlist' + | 'reject'; + +export const HACKER_ADMISSION_TYPES: HackerAdmissionType[] = [ + 'accept', + 'waitlist_accept', + 'waitlist', + 'reject', +]; + +export const HACKER_ADMISSION_LABELS: Record = { + accept: 'Accept', + waitlist_accept: 'Waitlist Accept', + waitlist: 'Waitlist', + reject: 'Reject', +}; + +/** Accept and waitlist_accept require Tito + Hub invite; the others email only. */ +export function admissionNeedsTitoAndHub(type: HackerAdmissionType): boolean { + return type === 'accept' || type === 'waitlist_accept'; +} + +export interface HackerInviteData extends InviteData { + admissionType: HackerAdmissionType; +} + +export interface HackerInviteResult extends InviteResult { + admissionType?: HackerAdmissionType; + titoUrl?: string; + inviteUrl?: string; +} + +export type BulkHackerInviteResponse = BulkInviteResponse; + +export interface SingleHackerInviteResponse { + ok: boolean; + admissionType?: HackerAdmissionType; + titoUrl?: string; + inviteUrl?: string; + error: string | null; +} + // Mentor Hub invite types export type MentorInviteData = InviteData;