diff --git a/app/(api)/_actions/emails/emailFormats/2026JudgeHubInviteTemplate.ts b/app/(api)/_actions/emails/emailFormats/2026JudgeHubInviteTemplate.ts new file mode 100644 index 000000000..dc9010166 --- /dev/null +++ b/app/(api)/_actions/emails/emailFormats/2026JudgeHubInviteTemplate.ts @@ -0,0 +1,61 @@ +export default function judgeHubInviteTemplate( + fname: string, + inviteLink: string +) { + return ` + + + + + Judge Invitation - HackDavis 2026 + + + +
+ HackDavis 2026 +

Welcome to HackDavis 2026! πŸŽ‰

+
+

Hi ${fname},

+

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

+

Here's what you need to do:

+ + Create Your Hub Account +

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

+

${inviteLink}

+

See you at HackDavis! ✨

+

The HackDavis Team

+
+
+ HackDavis 2026 +
+ +`; +} diff --git a/app/(api)/_actions/emails/emailFormats/2026MentorInviteTemplate.ts b/app/(api)/_actions/emails/emailFormats/2026MentorInviteTemplate.ts new file mode 100644 index 000000000..0d51ba6e4 --- /dev/null +++ b/app/(api)/_actions/emails/emailFormats/2026MentorInviteTemplate.ts @@ -0,0 +1,60 @@ +export default function mentorInviteTemplate(fname: string, titoUrl: string) { + return ` + + + + + Mentor Invitation - HackDavis 2026 + + + +
+ HackDavis 2026 +

Congratulations from HackDavis! πŸŽ‰

+
+

Hi ${fname},

+

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

+

Here's what we need from you:

+ + Claim Your Mentor Ticket +

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

+

${titoUrl}

+

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

+

See you at HackDavis! ✨

+

The HackDavis Team

+
+
+ HackDavis 2026 +
+ +`; +} diff --git a/app/(api)/_actions/emails/parseInviteCSV.ts b/app/(api)/_actions/emails/parseInviteCSV.ts new file mode 100644 index 000000000..57ba39fd4 --- /dev/null +++ b/app/(api)/_actions/emails/parseInviteCSV.ts @@ -0,0 +1,88 @@ +import { parse } from 'csv-parse/sync'; +import { z } from 'zod'; +import { JudgeInviteData } from '@typeDefs/emails'; + +const emailSchema = z.string().email(); + +interface ParseResult { + ok: true; + body: JudgeInviteData[]; +} + +interface ParseError { + ok: false; + error: string; +} + +export default function parseInviteCSV( + 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.' }; + } + + // Detect and skip header row + 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: JudgeInviteData[] = []; + 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 < 3) { + errors.push( + `Row ${rowNum}: expected 3 columns (First Name, Last Name, Email), got ${row.length}.` + ); + continue; + } + + const [firstName, lastName, email] = 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; + } + + results.push({ firstName, lastName, email }); + } + + 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/sendBulkJudgeHubInvites.ts b/app/(api)/_actions/emails/sendBulkJudgeHubInvites.ts new file mode 100644 index 000000000..37d511db7 --- /dev/null +++ b/app/(api)/_actions/emails/sendBulkJudgeHubInvites.ts @@ -0,0 +1,127 @@ +'use server'; + +import { GetManyUsers } from '@datalib/users/getUser'; +import parseInviteCSV from './parseInviteCSV'; +import sendSingleJudgeHubInvite from './sendSingleJudgeHubInvite'; +import { + BulkJudgeInviteResponse, + JudgeInviteData, + JudgeInviteResult, +} from '@typeDefs/emails'; + +const CONCURRENCY = 10; + +export default async function sendBulkJudgeHubInvites( + csvText: string +): Promise { + // Parse and validate CSV + const parsed = parseInviteCSV(csvText); + if (!parsed.ok) { + return { + ok: false, + results: [], + successCount: 0, + failureCount: 0, + error: parsed.error, + }; + } + + const allJudges = parsed.body; + const results: JudgeInviteResult[] = []; + let successCount = 0; + let failureCount = 0; + + const totalStartTime = Date.now(); + + // Single upfront duplicate check for all emails at once + const dupStart = Date.now(); + const allEmails = allJudges.map((j) => j.email); + const existingUsers = await GetManyUsers({ email: { $in: allEmails } }); + const existingEmailSet = new Set( + existingUsers.ok + ? existingUsers.body.map((u: { email: string }) => u.email) + : [] + ); + console.log( + `[Bulk Judge Invites] Duplicate check (${allEmails.length} emails): ${ + Date.now() - dupStart + }ms β€” ${existingEmailSet.size} already registered` + ); + + // Partition judges into duplicates (immediate failure) and new (to send) + const judges: JudgeInviteData[] = []; + for (const judge of allJudges) { + if (existingEmailSet.has(judge.email)) { + results.push({ + email: judge.email, + success: false, + error: 'User already exists.', + }); + failureCount++; + } else { + judges.push(judge); + } + } + + const totalBatches = Math.ceil(judges.length / CONCURRENCY); + console.log( + `[Bulk Judge Invites] Sending to ${judges.length} new judges (concurrency: ${CONCURRENCY}, ${totalBatches} batches)` + ); + + for (let i = 0; i < judges.length; i += CONCURRENCY) { + const batch: JudgeInviteData[] = judges.slice(i, i + CONCURRENCY); + const batchNum = Math.floor(i / CONCURRENCY) + 1; + const batchStartTime = Date.now(); + console.log( + `[Bulk Judge Invites] Processing batch ${batchNum}/${totalBatches} (${batch.length} judges)` + ); + + const batchResults = await Promise.allSettled( + batch.map((judge) => sendSingleJudgeHubInvite(judge, true)) + ); + + for (let j = 0; j < batchResults.length; j++) { + const result = batchResults[j]; + const email = batch[j].email; + + if (result.status === 'fulfilled' && result.value.ok) { + results.push({ + email, + success: true, + inviteUrl: result.value.inviteUrl, + }); + successCount++; + } else { + const errorMsg = + result.status === 'rejected' + ? result.reason?.message ?? 'Unknown error' + : result.value.error ?? 'Unknown error'; + console.error(`[Bulk Judge Invites] βœ— Failed: ${email}`, errorMsg); + results.push({ email, success: false, error: errorMsg }); + failureCount++; + } + } + + console.log( + `[Bulk Judge Invites] Batch ${batchNum}/${totalBatches} completed in ${ + Date.now() - batchStartTime + }ms` + ); + } + + const totalTime = Date.now() - totalStartTime; + console.log( + `[Bulk Judge Invites] Complete: ${successCount} success, ${failureCount} failed in ${( + totalTime / 1000 + ).toFixed(1)}s` + ); + + return { + ok: failureCount === 0, + results, + successCount, + failureCount, + error: + failureCount > 0 ? `${failureCount} invite(s) failed to send.` : null, + }; +} diff --git a/app/(api)/_actions/emails/sendBulkMentorInvites.ts b/app/(api)/_actions/emails/sendBulkMentorInvites.ts new file mode 100644 index 000000000..6a6a46ef5 --- /dev/null +++ b/app/(api)/_actions/emails/sendBulkMentorInvites.ts @@ -0,0 +1,161 @@ +'use server'; + +import parseInviteCSV from './parseInviteCSV'; +import getOrCreateTitoInvitation from '@actions/tito/getOrCreateTitoInvitation'; +import mentorInviteTemplate from './emailFormats/2026MentorInviteTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import { + BulkMentorInviteResponse, + MentorInviteData, + MentorInviteResult, +} from '@typeDefs/emails'; + +const TITO_CONCURRENCY = 20; +const EMAIL_CONCURRENCY = 10; + +/** + * Returns an async function that enforces at most `concurrency` simultaneous + * calls. Each slot is released as soon as its fn resolves/rejects, so the + * pool is always kept as full as possible β€” no batch-boundary idle time. + */ +function createLimiter(concurrency: number) { + let active = 0; + const queue: (() => void)[] = []; + + return async function run(fn: () => Promise): Promise { + if (active >= concurrency) { + await new Promise((resolve) => queue.push(resolve)); + } + active++; + try { + return await fn(); + } finally { + active--; + queue.shift()?.(); + } + }; +} + +export default async function sendBulkMentorInvites( + csvText: string, + rsvpListSlug: string, + releaseIds: string +): Promise { + const parsed = parseInviteCSV(csvText); + if (!parsed.ok) { + return { + ok: false, + results: [], + successCount: 0, + failureCount: 0, + error: parsed.error, + }; + } + + const mentors = parsed.body as MentorInviteData[]; + + // Fail fast β€” no point creating Tito invites if email can't send + if (!DEFAULT_SENDER) { + return { + ok: false, + results: [], + successCount: 0, + failureCount: mentors.length, + error: 'Email configuration missing: SENDER_EMAIL is not set.', + }; + } + const sender = DEFAULT_SENDER; // capture for TypeScript narrowing inside async closures + + const totalStart = Date.now(); + console.log( + `[Bulk Mentor Invites] Starting ${mentors.length} mentors β€” Tito pool: ${TITO_CONCURRENCY}, Email pool: ${EMAIL_CONCURRENCY}` + ); + + const titoLimiter = createLimiter(TITO_CONCURRENCY); + const emailLimiter = createLimiter(EMAIL_CONCURRENCY); + + const results: MentorInviteResult[] = []; + let successCount = 0; + let failureCount = 0; + let completed = 0; + + await Promise.allSettled( + mentors.map(async (mentor) => { + // ── Stage 1: Tito ────────────────────────────────────────────────────── + // Slot is released as soon as Tito resolves, before email starts. + const titoResult = await titoLimiter(() => + getOrCreateTitoInvitation({ ...mentor, rsvpListSlug, releaseIds }) + ); + + if (!titoResult.ok) { + console.error( + `[Bulk Mentor Invites] βœ— Tito failed: ${mentor.email}`, + titoResult.error + ); + results.push({ + email: mentor.email, + success: false, + error: titoResult.error, + }); + failureCount++; + console.log( + `[Bulk Mentor Invites] Progress: ${++completed}/${mentors.length}` + ); + return; + } + + // ── Stage 2: Email ───────────────────────────────────────────────────── + // Tito slot is already free; email slot acquired independently. + // While this person waits for an email slot, other people can be running + // their Tito stage in those freed Tito slots. + try { + await emailLimiter(() => + transporter.sendMail({ + from: sender, + to: mentor.email, + subject: "You're Invited to Mentor at HackDavis 2026", + html: mentorInviteTemplate(mentor.firstName, titoResult.titoUrl), + }) + ); + results.push({ + email: mentor.email, + success: true, + titoUrl: titoResult.titoUrl, + }); + successCount++; + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error'; + console.error( + `[Bulk Mentor Invites] βœ— Email failed: ${mentor.email}`, + errorMsg + ); + results.push({ + email: mentor.email, + success: false, + error: `Email send failed: ${errorMsg}`, + }); + failureCount++; + } + + console.log( + `[Bulk Mentor Invites] Progress: ${++completed}/${mentors.length}` + ); + }) + ); + + const totalTime = Date.now() - totalStart; + console.log( + `[Bulk Mentor Invites] Complete: ${successCount} success, ${failureCount} failed in ${( + totalTime / 1000 + ).toFixed(1)}s` + ); + + return { + ok: failureCount === 0, + results, + successCount, + failureCount, + error: + failureCount > 0 ? `${failureCount} invite(s) failed to send.` : null, + }; +} diff --git a/app/(api)/_actions/emails/sendSingleJudgeHubInvite.ts b/app/(api)/_actions/emails/sendSingleJudgeHubInvite.ts new file mode 100644 index 000000000..e9863a1b4 --- /dev/null +++ b/app/(api)/_actions/emails/sendSingleJudgeHubInvite.ts @@ -0,0 +1,82 @@ +'use server'; + +import GenerateInvite from '@datalib/invite/generateInvite'; +import { GetManyUsers } from '@datalib/users/getUser'; +import { DuplicateError, HttpError } from '@utils/response/Errors'; +import judgeHubInviteTemplate from './emailFormats/2026JudgeHubInviteTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import { JudgeInviteData, SingleJudgeInviteResponse } from '@typeDefs/emails'; + +export default async function sendSingleJudgeHubInvite( + options: JudgeInviteData, + skipDuplicateCheck = false +): Promise { + const totalStart = Date.now(); + const { firstName, lastName, email } = options; + + try { + console.log(`[Judge Hub Invite] Starting invite for ${email}`); + + // Step 1: duplicate check (skipped in bulk flow β€” checked upfront there) + if (!skipDuplicateCheck) { + const dupStart = Date.now(); + const users = await GetManyUsers({ email }); + console.log( + `[Judge Hub Invite] Duplicate check: ${Date.now() - dupStart}ms` + ); + if (users.ok && users.body.length > 0) { + throw new DuplicateError(`User with email ${email} already exists.`); + } + } + + // Step 2: generate HMAC-signed invite link + const genStart = Date.now(); + const invite = await GenerateInvite( + { email, name: `${firstName} ${lastName}`, role: 'judge' }, + 'invite' + ); + console.log( + `[Judge Hub Invite] Invite generation: ${Date.now() - genStart}ms` + ); + if (!invite.ok || !invite.body) { + throw new HttpError(invite.error ?? 'Failed to generate invite link.'); + } + + if (!DEFAULT_SENDER) { + throw new Error('Email configuration missing: SENDER_EMAIL is not set.'); + } + + const htmlContent = judgeHubInviteTemplate(firstName, invite.body); + + // Step 3: send email + const mailStart = Date.now(); + await transporter.sendMail({ + from: DEFAULT_SENDER, + to: email, + subject: "You're Invited to HackDavis 2026 - Create Your Hub Account", + html: htmlContent, + }); + console.log(`[Judge Hub Invite] sendMail: ${Date.now() - mailStart}ms`); + + console.log( + `[Judge Hub Invite] βœ“ Done (${email}) β€” total: ${ + Date.now() - totalStart + }ms` + ); + return { ok: true, inviteUrl: invite.body, error: null }; + } catch (e) { + const errorMessage = + e instanceof Error + ? e.message + : typeof e === 'string' + ? e + : 'Unknown error'; + console.error( + `[Judge Hub Invite] βœ— Failed (${email}) after ${ + Date.now() - totalStart + }ms:`, + errorMessage + ); + return { ok: false, error: errorMessage }; + } +} diff --git a/app/(api)/_actions/emails/sendSingleMentorInvite.ts b/app/(api)/_actions/emails/sendSingleMentorInvite.ts new file mode 100644 index 000000000..78a52090b --- /dev/null +++ b/app/(api)/_actions/emails/sendSingleMentorInvite.ts @@ -0,0 +1,68 @@ +'use server'; + +import getOrCreateTitoInvitation from '@actions/tito/getOrCreateTitoInvitation'; +import mentorInviteTemplate from './emailFormats/2026MentorInviteTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import { MentorInviteData, SingleMentorInviteResponse } from '@typeDefs/emails'; + +interface MentorInviteOptions extends MentorInviteData { + rsvpListSlug: string; + releaseIds: string; +} + +export default async function sendSingleMentorInvite( + options: MentorInviteOptions +): Promise { + const totalStart = Date.now(); + const { firstName, lastName, email, rsvpListSlug, releaseIds } = options; + + try { + console.log(`[Mentor Invite] Starting invite for ${email}`); + + // Step 1: Get or create Tito invitation (with duplicate recovery) + const titoStart = Date.now(); + const titoResult = await getOrCreateTitoInvitation({ + firstName, + lastName, + email, + rsvpListSlug, + releaseIds, + }); + console.log(`[Mentor Invite] Tito: ${Date.now() - titoStart}ms`); + + if (!titoResult.ok) { + throw new Error(titoResult.error); + } + + if (!DEFAULT_SENDER) { + throw new Error('Email configuration missing: SENDER_EMAIL is not set.'); + } + + // Step 2: Send email with Tito URL + const mailStart = Date.now(); + await transporter.sendMail({ + from: DEFAULT_SENDER, + to: email, + subject: "You're Invited to Mentor at HackDavis 2026", + html: mentorInviteTemplate(firstName, titoResult.titoUrl), + }); + console.log(`[Mentor Invite] sendMail: ${Date.now() - mailStart}ms`); + + console.log( + `[Mentor Invite] βœ“ Done (${email}) β€” total: ${Date.now() - totalStart}ms` + ); + return { ok: true, titoUrl: titoResult.titoUrl, error: null }; + } catch (e) { + const errorMessage = + e instanceof Error + ? e.message + : typeof e === 'string' + ? e + : 'Unknown error'; + console.error( + `[Mentor Invite] βœ— Failed (${email}) after ${Date.now() - totalStart}ms:`, + errorMessage + ); + return { ok: false, error: errorMessage }; + } +} diff --git a/app/(api)/_actions/emails/transporter.ts b/app/(api)/_actions/emails/transporter.ts new file mode 100644 index 000000000..ada44c0a3 --- /dev/null +++ b/app/(api)/_actions/emails/transporter.ts @@ -0,0 +1,28 @@ +import nodemailer from 'nodemailer'; + +const SENDER_EMAIL = process.env.SENDER_EMAIL; +const SENDER_PWD = process.env.SENDER_PWD; + +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( + ', ' + )}` + ); +} + +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, + }, +}); + +export const DEFAULT_SENDER = SENDER_EMAIL; diff --git a/app/(api)/_actions/tito/createRsvpInvitation.ts b/app/(api)/_actions/tito/createRsvpInvitation.ts new file mode 100644 index 000000000..067a7871c --- /dev/null +++ b/app/(api)/_actions/tito/createRsvpInvitation.ts @@ -0,0 +1,87 @@ +'use server'; + +import { + ReleaseInvitation, + ReleaseInvitationRequest, + TitoResponse, +} from '@typeDefs/tito'; +import { TitoRequest } from './titoClient'; + +const MAX_RETRIES = 5; +const BASE_DELAY_MS = 1000; + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export default async function createRsvpInvitation( + data: ReleaseInvitationRequest +): Promise> { + try { + if (!data.email?.trim()) throw new Error('Email is required'); + if (!data.rsvpListSlug) throw new Error('RSVP list slug is required'); + if (!data.releaseIds?.trim()) throw new Error('Release IDs are required'); + + const releaseIdsArray = data.releaseIds + .split(',') + .map((id) => parseInt(id.trim(), 10)) + .filter((id) => !isNaN(id)); + + if (releaseIdsArray.length === 0) { + throw new Error( + 'Invalid release IDs format. Use comma-separated numbers.' + ); + } + + const requestBody: { + email: string; + release_ids: number[]; + first_name?: string; + last_name?: string; + discount_code?: string; + } = { email: data.email.trim(), release_ids: releaseIdsArray }; + + if (data.firstName?.trim()) requestBody.first_name = data.firstName.trim(); + if (data.lastName?.trim()) requestBody.last_name = data.lastName.trim(); + if (data.discountCode?.trim()) + requestBody.discount_code = data.discountCode.trim(); + + const url = `/rsvp_lists/${data.rsvpListSlug}/release_invitations`; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const response = await TitoRequest<{ + release_invitation: ReleaseInvitation; + }>(url, { + method: 'POST', + body: JSON.stringify({ release_invitation: requestBody }), + }); + console.log(`[Tito] βœ“ Created invitation for ${data.email}`); + return { ok: true, body: response.release_invitation, error: null }; + } catch (err: any) { + if (err.message.includes('429') && attempt < MAX_RETRIES) { + const waitMs = err.retryAfter + ? parseFloat(err.retryAfter) * BASE_DELAY_MS + : Math.pow(2, attempt) * BASE_DELAY_MS + + Math.random() * BASE_DELAY_MS; + console.warn( + `[Tito] 429 rate-limited for ${ + data.email + }, retrying in ${Math.round(waitMs)}ms (attempt ${ + attempt + 1 + }/${MAX_RETRIES})` + ); + await delay(waitMs); + continue; + } + throw err; + } + } + + throw new Error('Tito API rate limit exceeded after 5 retries'); + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + console.error('[Tito] createRsvpInvitation failed:', error); + return { ok: false, body: null, error }; + } +} diff --git a/app/(api)/_actions/tito/deleteRsvpInvitationByEmail.ts b/app/(api)/_actions/tito/deleteRsvpInvitationByEmail.ts new file mode 100644 index 000000000..1f0909b81 --- /dev/null +++ b/app/(api)/_actions/tito/deleteRsvpInvitationByEmail.ts @@ -0,0 +1,59 @@ +'use server'; + +import { ReleaseInvitation } from '@typeDefs/tito'; +import { TitoRequest } from './titoClient'; + +export default async function deleteRsvpInvitationByEmail( + rsvpListSlug: string, + email: string +): Promise<{ ok: boolean; error: string | null }> { + try { + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail) throw new Error('Email is required'); + if (!rsvpListSlug?.trim()) throw new Error('RSVP list slug is required'); + + const pageSize = 1000; + let page = 1; + let foundSlug: string | null = null; + + while (!foundSlug) { + const url = `/rsvp_lists/${rsvpListSlug}/release_invitations?page[size]=${pageSize}&page[number]=${page}`; + const data = await TitoRequest<{ + release_invitations: ReleaseInvitation[]; + }>(url); + const invitations = data.release_invitations ?? []; + + const match = invitations.find( + (inv) => inv.email?.toLowerCase() === normalizedEmail + ); + if (match?.slug) { + foundSlug = match.slug; + break; + } + + if (invitations.length < pageSize) break; + page++; + } + + if (!foundSlug) { + return { + ok: false, + error: 'No existing invitation found for this email', + }; + } + + await TitoRequest( + `/rsvp_lists/${rsvpListSlug}/release_invitations/${foundSlug}`, + { + method: 'DELETE', + } + ); + + console.log(`[Tito] Deleted invitation for ${email}`); + return { ok: true, error: null }; + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + console.error('[Tito] deleteRsvpInvitationByEmail failed:', error); + return { ok: false, error }; + } +} diff --git a/app/(api)/_actions/tito/getOrCreateTitoInvitation.ts b/app/(api)/_actions/tito/getOrCreateTitoInvitation.ts new file mode 100644 index 000000000..6f240954e --- /dev/null +++ b/app/(api)/_actions/tito/getOrCreateTitoInvitation.ts @@ -0,0 +1,71 @@ +'use server'; + +import createRsvpInvitation from './createRsvpInvitation'; +import getRsvpInvitationByEmail from './getRsvpInvitationByEmail'; +import deleteRsvpInvitationByEmail from './deleteRsvpInvitationByEmail'; +import { ReleaseInvitationRequest } from '@typeDefs/tito'; + +function isDuplicateTicketError(error: string | null | undefined): boolean { + if (!error) return false; + const normalized = error.toLowerCase(); + return ( + normalized.includes('already has a tito ticket attached') || + normalized.includes('already has a ticket attached') || + normalized.includes('email has already been taken') || + normalized.includes('has already been taken') || + (normalized.includes('"email"') && normalized.includes('already taken')) || + normalized.includes('already exists') || + (normalized.includes('already') && normalized.includes('invitation')) + ); +} + +export default async function getOrCreateTitoInvitation( + data: ReleaseInvitationRequest +): Promise<{ ok: true; titoUrl: string } | { ok: false; error: string }> { + const { email, rsvpListSlug } = data; + + let titoResponse = await createRsvpInvitation(data); + + // Duplicate recovery: reuse existing URL if possible, otherwise delete + recreate + if (!titoResponse.ok && isDuplicateTicketError(titoResponse.error)) { + console.warn(`[Tito] Duplicate detected for ${email}, attempting recovery`); + + const existingRes = await getRsvpInvitationByEmail(rsvpListSlug, email); + if (existingRes.ok && existingRes.body) { + const existingUrl = existingRes.body.unique_url ?? existingRes.body.url; + if (existingUrl) { + console.log(`[Tito] Reusing existing URL for ${email}`); + return { ok: true, titoUrl: existingUrl }; + } + } + + console.warn( + `[Tito] No usable URL found, deleting and recreating for ${email}` + ); + const deleteRes = await deleteRsvpInvitationByEmail(rsvpListSlug, email); + if (!deleteRes.ok) { + return { + ok: false, + error: `Duplicate recovery failed (delete): ${deleteRes.error}`, + }; + } + titoResponse = await createRsvpInvitation(data); + } + + if (!titoResponse.ok || !titoResponse.body) { + return { + ok: false, + error: titoResponse.error ?? 'Failed to create Tito invitation', + }; + } + + const titoUrl = titoResponse.body.unique_url ?? titoResponse.body.url; + if (!titoUrl) { + return { + ok: false, + error: 'Tito invitation created but no URL was returned', + }; + } + + return { ok: true, titoUrl }; +} diff --git a/app/(api)/_actions/tito/getReleases.ts b/app/(api)/_actions/tito/getReleases.ts new file mode 100644 index 000000000..e3c66b8bd --- /dev/null +++ b/app/(api)/_actions/tito/getReleases.ts @@ -0,0 +1,20 @@ +'use server'; + +import { Release, TitoResponse } from '@typeDefs/tito'; +import { TitoRequest } from './titoClient'; + +export default async function getReleases(): Promise> { + try { + const start = Date.now(); + const data = await TitoRequest<{ releases: Release[] }>('/releases'); + console.log(`[Tito] getReleases: ${Date.now() - start}ms`); + + const releases = data.releases ?? []; + console.log(`[Tito] Fetched ${releases.length} releases`); + return { ok: true, body: releases, error: null }; + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + console.error('[Tito] getReleases failed:', error); + return { ok: false, body: null, error }; + } +} diff --git a/app/(api)/_actions/tito/getRsvpInvitationByEmail.ts b/app/(api)/_actions/tito/getRsvpInvitationByEmail.ts new file mode 100644 index 000000000..1a5788c95 --- /dev/null +++ b/app/(api)/_actions/tito/getRsvpInvitationByEmail.ts @@ -0,0 +1,48 @@ +'use server'; + +import { ReleaseInvitation, TitoResponse } from '@typeDefs/tito'; +import { TitoRequest } from './titoClient'; + +export default async function getRsvpInvitationByEmail( + rsvpListSlug: string, + email: string +): Promise> { + try { + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail) throw new Error('Email is required'); + if (!rsvpListSlug?.trim()) throw new Error('RSVP list slug is required'); + + const pageSize = 1000; + let page = 1; + let hasMorePages = true; + + while (hasMorePages) { + const url = `/rsvp_lists/${rsvpListSlug}/release_invitations?page[size]=${pageSize}&page[number]=${page}`; + const data = await TitoRequest<{ + release_invitations: ReleaseInvitation[]; + }>(url); + const invitations = data.release_invitations ?? []; + + const match = invitations.find( + (inv) => inv.email?.toLowerCase() === normalizedEmail + ); + if (match) return { ok: true, body: match, error: null }; + + if (invitations.length < pageSize) { + hasMorePages = false; + } else { + page++; + } + } + + return { + ok: false, + body: null, + error: 'No existing invitation found for this email', + }; + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + console.error('[Tito] getRsvpInvitationByEmail failed:', error); + return { ok: false, body: null, error }; + } +} diff --git a/app/(api)/_actions/tito/getRsvpLists.ts b/app/(api)/_actions/tito/getRsvpLists.ts new file mode 100644 index 000000000..027ebcc80 --- /dev/null +++ b/app/(api)/_actions/tito/getRsvpLists.ts @@ -0,0 +1,22 @@ +'use server'; + +import { RsvpList, TitoResponse } from '@typeDefs/tito'; +import { TitoRequest } from './titoClient'; + +export default async function getRsvpLists(): Promise< + TitoResponse +> { + try { + const start = Date.now(); + const data = await TitoRequest<{ rsvp_lists: RsvpList[] }>('/rsvp_lists'); + console.log(`[Tito] getRsvpLists: ${Date.now() - start}ms`); + + const rsvpLists = data.rsvp_lists ?? []; + console.log(`[Tito] Fetched ${rsvpLists.length} RSVP lists`); + return { ok: true, body: rsvpLists, error: null }; + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + console.error('[Tito] getRsvpLists failed:', error); + return { ok: false, body: null, error }; + } +} diff --git a/app/(api)/_actions/tito/titoClient.ts b/app/(api)/_actions/tito/titoClient.ts new file mode 100644 index 000000000..df5a48d46 --- /dev/null +++ b/app/(api)/_actions/tito/titoClient.ts @@ -0,0 +1,38 @@ +const TITO_API_TOKEN = process.env.TITO_API_TOKEN; +const TITO_ACCOUNT_SLUG = process.env.TITO_ACCOUNT_SLUG; +const TITO_EVENT_SLUG = process.env.TITO_EVENT_SLUG; + +export async function TitoRequest( + endpoint: string, + options: RequestInit = {} +): Promise { + if (!TITO_API_TOKEN || !TITO_ACCOUNT_SLUG || !TITO_EVENT_SLUG) { + throw new Error('Missing Tito API configuration in environment variables'); + } + + const baseUrl = `https://api.tito.io/v3/${TITO_ACCOUNT_SLUG}/${TITO_EVENT_SLUG}`; + const url = `${baseUrl}${endpoint}`; + + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Token token=${TITO_API_TOKEN}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + const retryAfter = response.headers.get('Retry-After'); + const error = new Error(`Tito API ${response.status}: ${errorText}`); + if (retryAfter) (error as any).retryAfter = retryAfter; + throw error; + } + + // DELETE responses may return 204 No Content + if (response.status === 204) return {} as T; + + return response.json(); +} diff --git a/app/(pages)/admin/_components/JudgeInvites/JudgeBulkInviteForm.tsx b/app/(pages)/admin/_components/JudgeInvites/JudgeBulkInviteForm.tsx new file mode 100644 index 000000000..6d78a0a62 --- /dev/null +++ b/app/(pages)/admin/_components/JudgeInvites/JudgeBulkInviteForm.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { ChangeEvent, useState } from 'react'; +import sendBulkJudgeHubInvites from '@actions/emails/sendBulkJudgeHubInvites'; +import { BulkJudgeInviteResponse, JudgeInviteData } from '@typeDefs/emails'; + +/** + * Browser-safe CSV preview parser (no Node.js deps). Full validation runs server-side. + * Note: uses simple comma-split, so quoted fields containing commas are not supported. + * This is acceptable since judge names/emails rarely contain commas. + */ +function previewCSV( + text: string +): { ok: true; rows: JudgeInviteData[] } | { ok: false; error: string } { + const lines = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + if (lines.length === 0) return { ok: false, error: 'CSV is empty.' }; + + const firstCells = lines[0].toLowerCase(); + const hasHeader = + firstCells.includes('first') || firstCells.includes('email'); + const dataLines = hasHeader ? lines.slice(1) : lines; + if (dataLines.length === 0) + return { ok: false, error: 'No data rows found.' }; + + const rows: JudgeInviteData[] = []; + for (let i = 0; i < dataLines.length; i++) { + const cols = dataLines[i].split(',').map((c) => c.trim()); + if (cols.length < 3) { + return { + ok: false, + error: `Row ${hasHeader ? i + 2 : i + 1}: expected 3 columns, got ${ + cols.length + }.`, + }; + } + rows.push({ firstName: cols[0], lastName: cols[1], email: cols[2] }); + } + return { ok: true, rows }; +} + +type Status = 'idle' | 'previewing' | 'sending' | 'done'; + +export default function JudgeBulkInviteForm() { + const [status, setStatus] = useState('idle'); + const [csvText, setCsvText] = 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; + 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 handleReset = () => { + setStatus('idle'); + setCsvText(''); + 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.failureCount > 0 && ( +
+

+ Failed invites +

+
+ {result.results + .filter((r) => !r.success) + .map((r, i) => ( +
+ + {r.email} + + {r.error} +
+ ))} +
+
+ )} + + +
+ )} +
+ ); +} diff --git a/app/(pages)/admin/_components/JudgeInvites/JudgeSingleInviteForm.tsx b/app/(pages)/admin/_components/JudgeInvites/JudgeSingleInviteForm.tsx new file mode 100644 index 000000000..8b5e00d2b --- /dev/null +++ b/app/(pages)/admin/_components/JudgeInvites/JudgeSingleInviteForm.tsx @@ -0,0 +1,96 @@ +'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/MentorInvites/MentorBulkInviteForm.tsx b/app/(pages)/admin/_components/MentorInvites/MentorBulkInviteForm.tsx new file mode 100644 index 000000000..a082489bf --- /dev/null +++ b/app/(pages)/admin/_components/MentorInvites/MentorBulkInviteForm.tsx @@ -0,0 +1,372 @@ +'use client'; + +import { ChangeEvent, useState } from 'react'; +import sendBulkMentorInvites from '@actions/emails/sendBulkMentorInvites'; +import { BulkMentorInviteResponse, MentorInviteData } from '@typeDefs/emails'; +import { Release, RsvpList } from '@typeDefs/tito'; +import { generateInviteResultsCSV } from '../../_utils/generateInviteResultsCSV'; + +/** + * Browser-safe CSV preview parser (no Node.js deps). Full validation runs server-side. + * Note: uses simple comma-split β€” quoted fields containing commas are not supported. + */ +function previewCSV( + text: string +): { ok: true; rows: MentorInviteData[] } | { ok: false; error: string } { + const lines = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + if (lines.length === 0) return { ok: false, error: 'CSV is empty.' }; + + const firstCells = lines[0].toLowerCase(); + const hasHeader = + firstCells.includes('first') || firstCells.includes('email'); + const dataLines = hasHeader ? lines.slice(1) : lines; + if (dataLines.length === 0) + return { ok: false, error: 'No data rows found.' }; + + const rows: MentorInviteData[] = []; + for (let i = 0; i < dataLines.length; i++) { + const cols = dataLines[i].split(',').map((c) => c.trim()); + if (cols.length < 3) { + return { + ok: false, + error: `Row ${hasHeader ? i + 2 : i + 1}: expected 3 columns, got ${ + cols.length + }.`, + }; + } + rows.push({ firstName: cols[0], lastName: cols[1], email: cols[2] }); + } + return { ok: true, rows }; +} + +type Status = 'idle' | 'previewing' | 'sending' | 'done'; + +interface Props { + rsvpLists: RsvpList[]; + releases: Release[]; +} + +export default function MentorBulkInviteForm({ rsvpLists, releases }: Props) { + const [status, setStatus] = useState('idle'); + const [csvText, setCsvText] = useState(''); + const [preview, setPreview] = useState([]); + const [parseError, setParseError] = useState(''); + const [result, setResult] = useState(null); + const [selectedListSlug, setSelectedListSlug] = useState( + rsvpLists[0]?.slug ?? '' + ); + const [selectedReleases, setSelectedReleases] = useState([]); + const [configError, setConfigError] = useState(''); + + const toggleRelease = (id: string) => + setSelectedReleases((prev) => + prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id] + ); + + const handleFileChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result as string; + setCsvText(text); + const parsed = previewCSV(text); + if (parsed.ok) { + setPreview(parsed.rows); + setParseError(''); + setStatus('previewing'); + } else { + setParseError(parsed.error); + setPreview([]); + setStatus('idle'); + } + }; + reader.readAsText(file); + }; + + const handleSend = async () => { + if (!selectedListSlug) { + setConfigError('Please select an RSVP list.'); + return; + } + if (selectedReleases.length === 0) { + setConfigError('Please select at least one release.'); + return; + } + setConfigError(''); + setStatus('sending'); + setResult(null); + + const response = await sendBulkMentorInvites( + csvText, + selectedListSlug, + selectedReleases.join(',') + ); + setResult(response); + setStatus('done'); + }; + + const handleDownloadCSV = () => { + if (!result) return; + const resultMap = new Map( + result.results.map((r) => [r.email.toLowerCase(), r]) + ); + const rows = preview.map((mentor) => { + const res = resultMap.get(mentor.email.toLowerCase()); + return { + firstName: mentor.firstName, + lastName: mentor.lastName, + email: mentor.email, + titoUrl: res?.titoUrl, + success: res?.success ?? false, + error: res?.error, + }; + }); + const csv = generateInviteResultsCSV(rows); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `mentor-invites-${ + new Date().toISOString().split('T')[0] + }.csv`; + link.click(); + URL.revokeObjectURL(url); + }; + + const handleReset = () => { + setStatus('idle'); + setCsvText(''); + setPreview([]); + setParseError(''); + setResult(null); + setConfigError(''); + setSelectedReleases([]); + }; + + return ( +
+ {/* File input */} +
+ + +
+ + {/* Parse error */} + {parseError && ( +
+

CSV errors:

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

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

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

+ {configError} +

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

+ {result.successCount} +

+

Sent

+
+
+

+ {result.failureCount} +

+

Failed

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

+ Failed invites +

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

+ {loadError} +

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

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

+ +
+ ) : ( +
+

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

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

+ {error} +

+ )} + {titoUrl && ( +
+

Invite sent!

+

{titoUrl}

+
+ )} +
+ ); +} diff --git a/app/(pages)/admin/_utils/generateInviteResultsCSV.ts b/app/(pages)/admin/_utils/generateInviteResultsCSV.ts new file mode 100644 index 000000000..401ec3a77 --- /dev/null +++ b/app/(pages)/admin/_utils/generateInviteResultsCSV.ts @@ -0,0 +1,48 @@ +export interface InviteResultRow { + firstName: string; + lastName: string; + email: string; + titoUrl?: string; + hubUrl?: string; // populated for hacker invites; omitted for mentor-only + success: boolean; + error?: string; +} + +function escapeCell(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} + +/** + * Generates a CSV string from bulk invite results. + * @param rows Merged invite result rows (one per person). + * @param includeHub Set true for hacker invites that include a Hub URL column. + */ +export function generateInviteResultsCSV( + rows: InviteResultRow[], + includeHub = false +): string { + const headers = [ + 'Email', + 'First Name', + 'Last Name', + 'Tito Invite URL', + ...(includeHub ? ['Hub Invite URL'] : []), + 'Success', + 'Notes', + ]; + + const csvRows = rows.map((row) => { + const cells = [ + row.email, + row.firstName, + row.lastName, + row.titoUrl ?? '', + ...(includeHub ? [row.hubUrl ?? ''] : []), + row.success ? 'TRUE' : 'FALSE', + row.success ? '' : row.error ?? 'Unknown error', + ]; + return cells.map(escapeCell).join(','); + }); + + return [headers.join(','), ...csvRows].join('\n'); +} diff --git a/app/(pages)/admin/invite-link/invite.module.scss b/app/(pages)/admin/invite-link/invite.module.scss deleted file mode 100644 index 847ca7f68..000000000 --- a/app/(pages)/admin/invite-link/invite.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -.container { - min-height: 100vh; - background-color: var(--background-tertiary); - padding: 24px; -} \ No newline at end of file diff --git a/app/(pages)/admin/invite-link/page.tsx b/app/(pages)/admin/invite-link/page.tsx deleted file mode 100644 index 1e03eb424..000000000 --- a/app/(pages)/admin/invite-link/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -'use client'; - -import InviteLinkForm from '../_components/InviteLinkForm/InviteLinkForm'; -import styles from './invite.module.scss'; - -export default function AdminInviteLinkPage() { - return ( -
- -
- ); -} diff --git a/app/(pages)/admin/invites/page.tsx b/app/(pages)/admin/invites/page.tsx new file mode 100644 index 000000000..7e1aed0fc --- /dev/null +++ b/app/(pages)/admin/invites/page.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useState } from 'react'; +import JudgeSingleInviteForm from '../_components/JudgeInvites/JudgeSingleInviteForm'; +import JudgeBulkInviteForm from '../_components/JudgeInvites/JudgeBulkInviteForm'; +import MentorInvitesPanel from '../_components/MentorInvites/MentorInvitesPanel'; + +type Tab = 'judges' | 'mentors'; + +export default function InvitesPage() { + const [tab, setTab] = useState('judges'); + + return ( +
+

Invites

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

Invite a Judge

+

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

+ +
+ +
+ +
+

Bulk Invite Judges

+

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

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

Mentor Invites

+ +
+ )} +
+ ); +} diff --git a/app/(pages)/admin/page.tsx b/app/(pages)/admin/page.tsx index c7a1f1b5d..e5b25fc2c 100644 --- a/app/(pages)/admin/page.tsx +++ b/app/(pages)/admin/page.tsx @@ -22,8 +22,8 @@ const action_links = [ body: 'Create Panels', }, { - href: '/admin/invite-link', - body: 'Invite Judges', + href: '/admin/invites', + body: 'Invites', }, { href: '/admin/randomize-projects', diff --git a/app/_types/emails.ts b/app/_types/emails.ts new file mode 100644 index 000000000..5f749368c --- /dev/null +++ b/app/_types/emails.ts @@ -0,0 +1,56 @@ +// Judge Hub invite types +export interface JudgeInviteData { + firstName: string; + lastName: string; + email: string; +} + +export interface JudgeInviteResult { + email: string; + success: boolean; + inviteUrl?: string; + error?: string; +} + +export interface BulkJudgeInviteResponse { + ok: boolean; + results: JudgeInviteResult[]; + successCount: number; + failureCount: number; + error: string | null; +} + +export interface SingleJudgeInviteResponse { + ok: boolean; + inviteUrl?: string; + error: string | null; +} + +// Mentor Hub invite types + +export interface MentorInviteData { + firstName: string; + lastName: string; + email: string; +} + +export interface MentorInviteResult { + email: string; + success: boolean; + titoUrl?: string; + error?: string; +} + +export interface BulkMentorInviteResponse { + ok: boolean; + results: MentorInviteResult[]; + successCount: number; + failureCount: number; + error: string | null; +} + +export interface SingleMentorInviteResponse { + ok: boolean; + titoUrl?: string; + error: string | null; +} diff --git a/app/_types/tito.ts b/app/_types/tito.ts new file mode 100644 index 000000000..a1bc99202 --- /dev/null +++ b/app/_types/tito.ts @@ -0,0 +1,41 @@ +export interface RsvpList { + id: string; + slug: string; + title: string; + release_ids?: number[]; + question_ids?: number[]; + activity_ids?: number[]; +} + +export interface Release { + id: string; + slug: string; + title: string; + quantity?: number; +} + +export interface ReleaseInvitation { + id: string; + slug: string; + email: string; + first_name: string; + last_name: string; + url?: string; + unique_url?: string; + created_at: string; +} + +export interface ReleaseInvitationRequest { + firstName: string; + lastName: string; + email: string; + rsvpListSlug: string; + releaseIds: string; // comma-separated release IDs + discountCode?: string; +} + +export interface TitoResponse { + ok: boolean; + body: T | null; + error: string | null; +} diff --git a/public/email/2025_email_header.png b/public/email/2025_email_header.png new file mode 100644 index 000000000..0c829d693 Binary files /dev/null and b/public/email/2025_email_header.png differ