Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
export default function judgeHubInviteTemplate(
fname: string,
inviteLink: string
) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Judge Invitation - HackDavis 2026</title>
<style>
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #ffffff; }
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; }
.header-image { width: 100%; height: auto; display: block; }
.title { text-align: center; font-size: 32px; font-weight: bold; margin: 30px 0; color: #000000; }
.content-box { background-color: #EDEDED; padding: 40px; margin: 20px 0; border-radius: 8px; }
.content-box p { font-size: 16px; line-height: 1.6; color: #222222; margin: 0 0 16px 0; }
.content-box a { color: #0061FE; text-decoration: none; }
.content-box a:hover { text-decoration: underline; }
.content-box ul { margin: 16px 0; padding-left: 20px; }
.content-box li { font-size: 16px; line-height: 1.6; color: #222222; margin-bottom: 12px; }
.content-box ul ul { margin-top: 8px; }
.highlight { font-weight: bold; }
.button { display: inline-block; background-color: #FFC53D; color: #173a52; font-weight: 600; font-size: 16px; padding: 14px 32px; border-radius: 6px; text-decoration: none; margin: 8px 0 16px; }
.divider { height: 2px; background-color: #000000; margin: 40px 0; }
.footer-image { width: 100%; height: auto; display: block; margin-top: 20px; }
@media only screen and (max-width: 600px) {
.content-box { padding: 24px; margin: 10px; }
.title { font-size: 24px; margin: 20px 0; }
}
</style>
</head>
<body>
<div class="container">
<img src="${process.env.BASE_URL}/email/2025_email_header.png" alt="HackDavis 2026" class="header-image">
<h1 class="title">Welcome to HackDavis 2026! 🎉</h1>
<div class="content-box">
<p>Hi ${fname},</p>
<p>We are thrilled to welcome you as a <span class="highlight">judge</span> at HackDavis 2026! We're excited to have your expertise help our hackers bring their ideas to life.</p>
<p class="highlight">Here's what you need to do:</p>
<ul>
<li>
<span class="highlight">Create your HackDavis Hub account by clicking the button below:</span>
<ul><li><span class="highlight">This link is unique to you — do NOT share it with anyone.</span></li></ul>
</li>
<li>
<span class="highlight">Join our Discord</span> at <a href="https://discord.gg/wc6QQEc">https://discord.gg/wc6QQEc</a> to stay up to date with event details.
</li>
</ul>
<a href="${inviteLink}" class="button">Create Your Hub Account</a>
<p>If the button doesn't work, copy and paste this link into your browser:</p>
<p><a href="${inviteLink}">${inviteLink}</a></p>
<p>See you at HackDavis! ✨</p>
<p style="margin-bottom: 0;">The HackDavis Team</p>
</div>
<div class="divider"></div>
<img src="${process.env.BASE_URL}/email/2025_email_header.png" alt="HackDavis 2026" class="footer-image">
</div>
</body>
</html>`;
}
60 changes: 60 additions & 0 deletions app/(api)/_actions/emails/emailFormats/2026MentorInviteTemplate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
export default function mentorInviteTemplate(fname: string, titoUrl: string) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mentor Invitation - HackDavis 2026</title>
<style>
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #ffffff; }
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; }
.header-image { width: 100%; height: auto; display: block; }
.title { text-align: center; font-size: 32px; font-weight: bold; margin: 30px 0; color: #000000; }
.content-box { background-color: #EDEDED; padding: 40px; margin: 20px 0; border-radius: 8px; }
.content-box p { font-size: 16px; line-height: 1.6; color: #222222; margin: 0 0 16px 0; }
.content-box a { color: #0061FE; text-decoration: none; }
.content-box a:hover { text-decoration: underline; }
.content-box ul { margin: 16px 0; padding-left: 20px; }
.content-box li { font-size: 16px; line-height: 1.6; color: #222222; margin-bottom: 12px; }
.content-box ul ul { margin-top: 8px; }
.highlight { font-weight: bold; }
.button { display: inline-block; background-color: #FFC53D; color: #173a52; font-weight: 600; font-size: 16px; padding: 14px 32px; border-radius: 6px; text-decoration: none; margin: 8px 0 16px; }
.divider { height: 2px; background-color: #000000; margin: 40px 0; }
.footer-image { width: 100%; height: auto; display: block; margin-top: 20px; }
@media only screen and (max-width: 600px) {
.content-box { padding: 24px; margin: 10px; }
.title { font-size: 24px; margin: 20px 0; }
}
</style>
</head>
<body>
<div class="container">
<img src="${process.env.BASE_URL}/email/2025_email_header.png" alt="HackDavis 2026" class="header-image">
<h1 class="title">Congratulations from HackDavis! 🎉</h1>
<div class="content-box">
<p>Hi ${fname},</p>
<p>We are thrilled to welcome you as a <span class="highlight">mentor</span> at HackDavis 2026! We're excited to have your expertise help our hackers bring their ideas to life.</p>
<p class="highlight">Here's what we need from you:</p>
<ul>
<li>
<span class="highlight">Claim your mentor ticket by clicking the button below:</span>
<ul><li><span class="highlight">You MUST claim a ticket to attend the event.</span></li></ul>
</li>
<li>
<span class="highlight">Join our Discord</span> at <a href="https://discord.gg/wc6QQEc">https://discord.gg/wc6QQEc</a> to stay up to date with event details.
<ul><li>To gain access to mentor channels, please follow the instructions in <em>#❗️read-me-first❗️</em>.</li></ul>
</li>
</ul>
<a href="${titoUrl}" class="button">Claim Your Mentor Ticket</a>
<p>If the button doesn't work, copy and paste this link into your browser:</p>
<p><a href="${titoUrl}">${titoUrl}</a></p>
<p>After claiming your ticket, <span class="highlight">you will receive a unique QR code</span> for check-in at the event.</p>
<p>See you at HackDavis! ✨</p>
<p style="margin-bottom: 0;">The HackDavis Team</p>
</div>
<div class="divider"></div>
<img src="${process.env.BASE_URL}/email/2025_email_header.png" alt="HackDavis 2026" class="footer-image">
</div>
</body>
</html>`;
}
88 changes: 88 additions & 0 deletions app/(api)/_actions/emails/parseInviteCSV.ts
Original file line number Diff line number Diff line change
@@ -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}` };
}
}
127 changes: 127 additions & 0 deletions app/(api)/_actions/emails/sendBulkJudgeHubInvites.ts
Original file line number Diff line number Diff line change
@@ -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<BulkJudgeInviteResponse> {
// 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<string>(
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,
};
}
Loading