diff --git a/__tests__/mongoClient.test.ts b/__tests__/mongoClient.test.ts new file mode 100644 index 000000000..67b49bd6d --- /dev/null +++ b/__tests__/mongoClient.test.ts @@ -0,0 +1,58 @@ +/** @jest-environment node */ +import { getClient, resetClient } from '@utils/mongodb/mongoClient.mjs'; +import { MongoClient } from 'mongodb'; + +describe('getClient', () => { + beforeEach(async () => { + await resetClient(); + jest.restoreAllMocks(); + }); + + it('should throw an error if MONGODB_URI is missing', async () => { + const originalUri = process.env.MONGODB_URI; + delete process.env.MONGODB_URI; + + await resetClient(); + + await expect(getClient()).rejects.toThrow( + 'Missing MONGODB_URI environment variable.' + ); + + process.env.MONGODB_URI = originalUri; + }); + + it('should return the same instance on multiple calls', async () => { + const mockDb = { db: jest.fn() }; + const spy = jest + .spyOn(MongoClient.prototype, 'connect') + .mockResolvedValue(mockDb as any); + + const c1 = await getClient(); + const c2 = await getClient(); + + expect(c1).toBe(c2); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should dedupe concurrent callers using cachedPromise', async () => { + const mockDb = { db: jest.fn() }; + const spy = jest + .spyOn(MongoClient.prototype, 'connect') + .mockResolvedValue(mockDb as any); + const [c1, c2] = await Promise.all([getClient(), getClient()]); + expect(c1).toBe(c2); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should retry after a failed connection', async () => { + const spy = jest + .spyOn(MongoClient.prototype, 'connect') + .mockRejectedValueOnce(new Error('Network Fail')) + .mockResolvedValueOnce({ db: jest.fn() } as any); + + await expect(getClient()).rejects.toThrow('Network Fail'); + + await expect(getClient()).resolves.toBeDefined(); + expect(spy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/(api)/_actions/emails/emailTemplates/2026JudgeHubInviteTemplate.ts b/app/(api)/_actions/emails/emailTemplates/2026JudgeHubInviteTemplate.ts new file mode 100644 index 000000000..db6eda9e9 --- /dev/null +++ b/app/(api)/_actions/emails/emailTemplates/2026JudgeHubInviteTemplate.ts @@ -0,0 +1,79 @@ +export default function judgeHubInviteTemplate( + fname: string, + inviteLink: string +) { + const EMAIL_SUBJECT = '[ACTION REQUIRED] HackDavis 2026 Judging App Invite'; + const HEADER_IMAGE_URL = `${process.env.BASE_URL}/email/2025_email_header.png`; + const FOOTER_IMAGE_URL = `${process.env.BASE_URL}/email/2025_email_footer.png`; + const MEETING_RECORDING_URL = + 'https://drive.google.com/file/d/1Lit5fvhev2q8mkv2QyDgTgeh3cfLeX9l/view?usp=sharing'; + const JUDGING_GUIDE_URL = + 'https://www.notion.so/hackdavis/HackDavis-2025-Judging-Guide-1c32d37fcae880b1ba3aeb0a9a7841b7?pvs=4'; + const INVITATION_TO_REGISTER_GUIDE_URL = + 'https://www.notion.so/hackdavis/HackDavis-2025-Judging-Guide-1c32d37fcae880b1ba3aeb0a9a7841b7?pvs=4#1cb2d37fcae880b6a5f4e3d793349bf6'; + const DISCORD_SERVER_URL = 'https://discord.gg/wc6QQEc'; + + return ` + + + + + ${EMAIL_SUBJECT} + + + +
+ HackDavis 2026 header +

Welcome to HackDavis 2026! πŸŽ‰

+
+

Hi ${fname},

+
+

Thank you again for joining us as a judge, we’re thrilled to have you on board! Here are some key resources from our virtual orientation:

+

πŸ”Ή Meeting Recording: ${MEETING_RECORDING_URL}

+

πŸ”Ή Judging Guide: ${JUDGING_GUIDE_URL}

+

You are requested to carefully review the judging guide and familiarize yourself with its content before the event for a smooth judging experience. Kindly do not share the Judging Guide with anyone outside the judging team.

+
+

IMPORTANT NEXT STEP: Create an account on our Judging Application

+

⚠️ The Judging Application is a key prerequisite for the day of the event! Please carefully review the Invitation to Register section of the Judging Guide before proceeding to create your account.

+

Please use the following unique invite link below to create your judge account. Do NOT share it with anyone else.

+

πŸ‘‰ Invite Link: ${inviteLink}

+
+

OPTIONAL: Join our Discord

+

We’ll be using Discord server as our main space for announcements and support during the event. Joining is totally optional for judges, but it’s a great way to:

+

πŸ”Ή Get quick answers from the team

+

πŸ”Ή Stay in the loop on event updates

+

πŸ”Ή Connect with other judges & participants

+

πŸ‘‰ Discord Server: ${DISCORD_SERVER_URL}

+
+

Lastly, we are grateful for your thoughtful feedback during the orientation. As suggested, we will be sharing more details soon about the prize tracks and their eligibility criteria and rubrics to help you get a sense of the tracks ahead of time.

+

Please feel free to reach out if you have any questions or concerns. Looking forward to seeing you at the event!

+
+

Thank you,

+

The HackDavis Team

+
+
+ HackDavis 2026 footer +
+ +`; +} diff --git a/app/(api)/_actions/emails/emailTemplates/2026MentorInviteTemplate.ts b/app/(api)/_actions/emails/emailTemplates/2026MentorInviteTemplate.ts new file mode 100644 index 000000000..47b3a8e67 --- /dev/null +++ b/app/(api)/_actions/emails/emailTemplates/2026MentorInviteTemplate.ts @@ -0,0 +1,65 @@ +export default function mentorInviteTemplate(fname: string, titoUrl: string) { + const EMAIL_SUBJECT = 'Mentor Invitation - HackDavis 2026'; + const HEADER_IMAGE_URL = `${process.env.BASE_URL}/email/2025_email_header.png`; + const FOOTER_IMAGE_URL = `${process.env.BASE_URL}/email/2025_email_footer.png`; + const DISCORD_SERVER_URL = 'https://discord.gg/wc6QQEc'; + + return ` + + + + + ${EMAIL_SUBJECT} + + + +
+ HackDavis 2026 header +

Congratulations from HackDavis! πŸŽ‰

+
+

Hi ${fname},

+

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

+

Here's what we need from you:

+ + Claim Your Mentor Ticket +

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

+

${titoUrl}

+

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

+

See you at HackDavis! ✨

+

The HackDavis Team

+
+
+ HackDavis 2026 footer +
+ +`; +} diff --git a/app/(api)/_actions/emails/parseInviteCSV.ts b/app/(api)/_actions/emails/parseInviteCSV.ts 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..ac8949ba7 --- /dev/null +++ b/app/(api)/_actions/emails/sendBulkJudgeHubInvites.ts @@ -0,0 +1,96 @@ +'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; + + // Single upfront duplicate check for all emails at once + const allEmails = allJudges.map((j) => j.email); + const existingUsers = await GetManyUsers({ email: { $in: allEmails } }); + const existingEmailSet = new Set( + existingUsers.ok + ? existingUsers.body.map((u: { email: string }) => u.email) + : [] + ); + + // 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); + } + } + + for (let i = 0; i < judges.length; i += CONCURRENCY) { + const batch: JudgeInviteData[] = judges.slice(i, i + CONCURRENCY); + + const batchResults = await Promise.allSettled( + batch.map((judge) => sendSingleJudgeHubInvite(judge, true)) + ); + + for (let j = 0; j < batchResults.length; j++) { + const result = batchResults[j]; + const email = batch[j].email; + + if (result.status === 'fulfilled' && result.value.ok) { + results.push({ + email, + success: true, + inviteUrl: result.value.inviteUrl, + }); + successCount++; + } else { + const errorMsg = + result.status === 'rejected' + ? result.reason?.message ?? 'Unknown error' + : result.value.error ?? 'Unknown error'; + console.error(`[Bulk Judge Invites] βœ— Failed: ${email}`, errorMsg); + results.push({ email, success: false, error: errorMsg }); + failureCount++; + } + } + } + + return { + ok: failureCount === 0, + results, + successCount, + failureCount, + error: + failureCount > 0 ? `${failureCount} invite(s) failed to send.` : null, + }; +} diff --git a/app/(api)/_actions/emails/sendBulkMentorInvites.ts b/app/(api)/_actions/emails/sendBulkMentorInvites.ts new file mode 100644 index 000000000..3997b43eb --- /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 './emailTemplates/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..31104b7cb --- /dev/null +++ b/app/(api)/_actions/emails/sendSingleJudgeHubInvite.ts @@ -0,0 +1,66 @@ +'use server'; + +import GenerateInvite from '@datalib/invite/generateInvite'; +import { GetManyUsers } from '@datalib/users/getUser'; +import { DuplicateError, HttpError } from '@utils/response/Errors'; +import judgeHubInviteTemplate from './emailTemplates/2026JudgeHubInviteTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import { JudgeInviteData, SingleJudgeInviteResponse } from '@typeDefs/emails'; + +const EMAIL_SUBJECT = '[ACTION REQUIRED] HackDavis 2025 Judging App Invite'; + +export default async function sendSingleJudgeHubInvite( + options: JudgeInviteData, + skipDuplicateCheck = false +): Promise { + const totalStart = Date.now(); + const { firstName, lastName, email } = options; + + try { + // Step 1: duplicate check (skipped in bulk flow β€” checked upfront there) + if (!skipDuplicateCheck) { + const users = await GetManyUsers({ email }); + if (users.ok && users.body.length > 0) { + throw new DuplicateError(`User with email ${email} already exists.`); + } + } + + // Step 2: generate HMAC-signed invite link + const invite = await GenerateInvite( + { email, name: `${firstName} ${lastName}`, role: 'judge' }, + 'invite' + ); + 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 + await transporter.sendMail({ + from: DEFAULT_SENDER, + to: email, + subject: EMAIL_SUBJECT, + html: htmlContent, + }); + 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..e5589f7db --- /dev/null +++ b/app/(api)/_actions/emails/sendSingleMentorInvite.ts @@ -0,0 +1,68 @@ +'use server'; + +import getOrCreateTitoInvitation from '@actions/tito/getOrCreateTitoInvitation'; +import mentorInviteTemplate from './emailTemplates/2026MentorInviteTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import { MentorInviteData, SingleMentorInviteResponse } from '@typeDefs/emails'; + +interface MentorInviteOptions extends MentorInviteData { + rsvpListSlug: string; + releaseIds: string; +} + +export default async function sendSingleMentorInvite( + options: MentorInviteOptions +): Promise { + const totalStart = Date.now(); + const { firstName, lastName, email, rsvpListSlug, releaseIds } = options; + + try { + console.log(`[Mentor Invite] Starting invite for ${email}`); + + // Step 1: Get or create Tito invitation (with duplicate recovery) + const titoStart = Date.now(); + const titoResult = await getOrCreateTitoInvitation({ + firstName, + lastName, + email, + rsvpListSlug, + releaseIds, + }); + console.log(`[Mentor Invite] Tito: ${Date.now() - titoStart}ms`); + + if (!titoResult.ok) { + throw new Error(titoResult.error); + } + + if (!DEFAULT_SENDER) { + throw new Error('Email configuration missing: SENDER_EMAIL is not set.'); + } + + // Step 2: Send email with Tito URL + const mailStart = Date.now(); + await transporter.sendMail({ + from: DEFAULT_SENDER, + to: email, + subject: "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/(api)/_utils/mongodb/mongoClient.mjs b/app/(api)/_utils/mongodb/mongoClient.mjs index 33bcfaaf5..615b4bca5 100644 --- a/app/(api)/_utils/mongodb/mongoClient.mjs +++ b/app/(api)/_utils/mongodb/mongoClient.mjs @@ -1,15 +1,41 @@ import { MongoClient } from 'mongodb'; -const uri = process.env.MONGODB_URI; let cachedClient = null; +let cachedPromise = null; export async function getClient() { + const uri = process.env.MONGODB_URI; + if (!uri) { + throw new Error('Missing MONGODB_URI environment variable.'); + } + if (cachedClient) { return cachedClient; } - const client = new MongoClient(uri); - cachedClient = client; - return cachedClient; + + if (!cachedPromise) { + const client = new MongoClient(uri); + cachedPromise = client + .connect() + .then((connectedClient) => { + cachedClient = connectedClient; + return connectedClient; + }) + .catch((error) => { + client.close().catch(() => {}); + cachedPromise = null; + cachedClient = null; + throw error; + }); + } + + return cachedPromise; +} + +// Helper function for testing +export async function resetClient() { + cachedClient = null; + cachedPromise = null; } export async function getDatabase() { diff --git a/app/(pages)/(hackers)/_components/HomeJudging/HackersChoiceAwards.tsx b/app/(pages)/(hackers)/_components/HomeJudging/HackersChoiceAwards.tsx index 34a3edad2..c5a786470 100644 --- a/app/(pages)/(hackers)/_components/HomeJudging/HackersChoiceAwards.tsx +++ b/app/(pages)/(hackers)/_components/HomeJudging/HackersChoiceAwards.tsx @@ -1,3 +1,32 @@ +import Image from 'next/image'; +import hackers_choice_mascots from 'public/hackers/hackers-choice/hackers_choice_mascots.svg'; +import TextCard from '../HomeHacking/_components/TextCard'; + export default function HackerChoiceAward() { - return
Hacker Choice Award
; + return ( +
+
+
+ cow and frog hackdavis mascots cheering +
+
+ +
+
+
+ ); } diff --git a/app/(pages)/admin/_components/InviteLinkForm/InviteLinkForm.tsx b/app/(pages)/admin/_components/InviteLinkForm/InviteLinkForm.tsx index 81259b06c..7fa5376a7 100644 --- a/app/(pages)/admin/_components/InviteLinkForm/InviteLinkForm.tsx +++ b/app/(pages)/admin/_components/InviteLinkForm/InviteLinkForm.tsx @@ -47,7 +47,7 @@ export default function InviteLinkForm() { return ( <>
-

Invite a User

+

Invite a User [to be deprecated & replaced]

{error}

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/page.tsx b/app/(pages)/admin/invite-hackers/page.tsx similarity index 52% rename from app/(pages)/admin/invite-link/page.tsx rename to app/(pages)/admin/invite-hackers/page.tsx index 1e03eb424..e042ca6cd 100644 --- a/app/(pages)/admin/invite-link/page.tsx +++ b/app/(pages)/admin/invite-hackers/page.tsx @@ -1,11 +1,10 @@ 'use client'; import InviteLinkForm from '../_components/InviteLinkForm/InviteLinkForm'; -import styles from './invite.module.scss'; -export default function AdminInviteLinkPage() { +export default function InviteJudgesPage() { return ( -
+
); 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/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..cb69ab440 100644 --- a/app/(pages)/admin/page.tsx +++ b/app/(pages)/admin/page.tsx @@ -22,8 +22,12 @@ const action_links = [ body: 'Create Panels', }, { - href: '/admin/invite-link', - body: 'Invite Judges', + href: '/admin/invites', + body: 'Invites', + }, + { + href: '/admin/invite-hackers', + body: 'Invite Hackers', }, { 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_footer.png b/public/email/2025_email_footer.png new file mode 100644 index 000000000..0c829d693 Binary files /dev/null and b/public/email/2025_email_footer.png differ diff --git a/public/email/2025_email_header.png b/public/email/2025_email_header.png new file mode 100644 index 000000000..07f7bc97e Binary files /dev/null and b/public/email/2025_email_header.png differ diff --git a/public/hackers/hackers-choice/hackers_choice_mascots.svg b/public/hackers/hackers-choice/hackers_choice_mascots.svg new file mode 100644 index 000000000..f2ff5325e --- /dev/null +++ b/public/hackers/hackers-choice/hackers_choice_mascots.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +