Skip to content
Merged
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,85 @@
export const HACKER_EMAIL_SUBJECT =
'[ACTION REQUIRED] Your Ticket to HackDavis 2026!';

export default function hackerInviteTemplate(
fname: string,
titoUrl: string,
hubInviteUrl: string
) {
const HEADER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_header.png`;
const FOOTER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_footer.png`;
const DISCORD_SERVER_URL = 'https://discord.gg/wc6QQEc';
const CLAIM_TITO_TICKET_DEADLINE = 'May 6th, 11:59pm PDT';
const DOE_DATE = 'May 9th, 2026';
const DOE = 'May 9 - 10, 2026';
const DOE_LOCATION = 'University Credit Union Center, UC Davis';

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${HACKER_EMAIL_SUBJECT}</title>
<style>
body { margin: 0; padding: 0; font-family: 'DM Mono', -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: 28px; font-weight: bold; margin: 30px 0; color: #000000; }
.content-box { background-color: #ffffff; margin: 20px 0; }
.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 p.special-note { font-size: 14px; color: #8d9ca2; }
.bordered-section { border-width: 2px; border-style: solid; border-color: #e5e5e5; border-radius: 5px; padding: 12px 24px; margin: 16px 0; }
.bold { font-weight: bold; }
.divider { height: 2px; background-color: #F2F2F2; 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="${HEADER_IMAGE_URL}" alt="HackDavis 2026 header" class="header-image">
<h1 class="title">
<span style="color: #173a52;">Welcome to </span>
<span style="color: #57dade;">HackDavis 2026!</span>
</h1>
<p style="color: #173a52; text-align: center; font-size: 14px;">✦ ${DOE} ✦ ${DOE_LOCATION}</p>
<div class="divider"></div>
<div class="content-box">
<p>Hi ${fname}, you're in!</p>
<br/>
<p>We can't wait to see the amazing ideas you’ll bring. Before you arrive, there are a few things you’ll need to complete. 💕</p>
<p class="bold" style="color: #57dade;">COMPLETE BEFORE THE EVENT</p>
<div class="bordered-section">
<p class="bold">1️⃣ Claim your Ticket by ${CLAIM_TITO_TICKET_DEADLINE}</p>
<p>You must claim a ticket to attend — no ticket, no entry. Check in on your friends too and make sure everyone has theirs. </p>
<p>👉 Tito Ticket: <a href="${titoUrl}">${titoUrl}</a></p>
<p class="special-note">Do NOT share your unique link with anyone else.</p>
</div>
<div class="bordered-section">
<p class="bold">2️⃣ Create a HackDavis Hub Account by ${DOE_DATE}</p>
<p>HackDavis Hub is where you'll find exciting information like prizes, workshops, starter kit, demo tips, live judging info and more!</p>
<p>👉 Hub Invite: <a href="${hubInviteUrl}">${hubInviteUrl}</a></p>
<p class="special-note">Use this unique link to create your account. Do NOT share it with anyone else.</p>
</div>
<div class="bordered-section">
<p class="bold">3️⃣ Join our Discord server</p>
<p>All event communication happens here. After joining, follow <span class="bold">#read-me-first</span> to get your Hacker role and unlock day-of channels.</p>
<p>👉 Discord Server: <a href="${DISCORD_SERVER_URL}">${DISCORD_SERVER_URL}</a></p>
</div>
<p>Please feel free to reach out if you have any questions or concerns. We can't wait to see what you build!</p>
<br/>
<p style="margin-bottom: 0;">Thank you,<br/>The HackDavis Team</p>
</div>
<div class="divider"></div>
<img src="${FOOTER_IMAGE_URL}" alt="HackDavis 2026 footer" class="footer-image">
</div>
</body>
</html>`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
export const HACKER_REJECTION_EMAIL_SUBJECT =
'Update to Your Status for HackDavis 2026';

export default function hackerRejectionTemplate(fname: string) {
const HEADER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_header.png`;
const FOOTER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_footer.png`;

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${HACKER_REJECTION_EMAIL_SUBJECT}</title>
<style>
body { margin: 0; padding: 0; font-family: 'DM Mono', -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: 28px; font-weight: bold; margin: 30px 0; color: #000000; }
.content-box { background-color: #ffffff; margin: 20px 0; }
.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; }
.bold { font-weight: bold; }
.divider { height: 2px; background-color: #F2F2F2; 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="${HEADER_IMAGE_URL}" alt="HackDavis 2026 header" class="header-image">
<h1 class="title">
<span style="color: #173a52;">Update from </span>
<span style="color: #57dade;">HackDavis 2026</span>
</h1>
<div class="divider"></div>
<div class="content-box">
<p>Hi ${fname},</p>
<br/>
<p>Thank you so much for your interest in HackDavis 2026. We appreciate your enthusiasm and patience throughout this process.</p>
<p>Unfortunately, due to overwhelming interest and limited capacity, <span class="bold">we’re no longer able to accommodate hackers currently on the waitlist.</span> We know this is disappointing, and we’re just as bummed out as you are.</p>
<p>If you have any questions, concerns, or comments, please reach out to <a href="mailto:hello@hackdavis.io">hello@hackdavis.io</a>.</p>
<br/>
<p style="margin-bottom: 0;">Warmly,<br/>The HackDavis Team</p>
</div>
<div class="divider"></div>
<img src="${FOOTER_IMAGE_URL}" alt="HackDavis 2026 footer" class="footer-image">
</div>
</body>
</html>`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
export const HACKER_WAITLIST_ACCEPT_EMAIL_SUBJECT =
"[ACTION REQUIRED] You're Off The HackDavis 2026 Waitlist";

export default function hackerWaitlistAcceptTemplate(
fname: string,
titoUrl: string,
hubInviteUrl: string
) {
const HEADER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_header.png`;
const FOOTER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_footer.png`;
const DISCORD_SERVER_URL = 'https://discord.gg/wc6QQEc';
const CLAIM_TITO_TICKET_DEADLINE = '11:59pm on May 4th';
const DOE_DATE = 'May 9th, 2026';
const DOE = 'May 9 - 10, 2026';
const DOE_LOCATION = 'University Credit Union Center, UC Davis';

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${HACKER_WAITLIST_ACCEPT_EMAIL_SUBJECT}</title>
<style>
body { margin: 0; padding: 0; font-family: 'DM Mono', -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: 28px; font-weight: bold; margin: 30px 0; color: #000000; }
.content-box { background-color: #ffffff; margin: 20px 0; }
.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 p.special-note { font-size: 14px; color: #8d9ca2; }
.bordered-section { border-width: 2px; border-style: solid; border-color: #e5e5e5; border-radius: 5px; padding: 12px 24px; margin: 16px 0; }
.bold { font-weight: bold; }
.divider { height: 2px; background-color: #F2F2F2; 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="${HEADER_IMAGE_URL}" alt="HackDavis 2026 header" class="header-image">
<h1 class="title">
<span style="color: #173a52;">Welcome to </span>
<span style="color: #57dade;">HackDavis 2026!</span>
</h1>
<p style="color: #173a52; text-align: center; font-size: 14px;">✦ ${DOE} ✦ ${DOE_LOCATION}</p>
<div class="divider"></div>
<div class="content-box">
<p>Hi ${fname}, you're in!</p>
<br/>
<p><span class="bold">Congrats — you’re off the waitlist!</span> We can't wait to see the amazing ideas you’ll bring. Before you arrive, there are a few things you’ll need to complete. 💕</p>
<p class="bold" style="color: #57dade;">COMPLETE BEFORE THE EVENT</p>
<div class="bordered-section">
<p class="bold">1️⃣ Claim your Ticket by ${CLAIM_TITO_TICKET_DEADLINE}</p>
<p>You must claim a ticket to attend — no ticket, no entry. Check in on your friends too and make sure everyone has theirs. </p>
<p>👉 Tito Ticket: <a href="${titoUrl}">${titoUrl}</a></p>
<p class="special-note">Do NOT share your unique link with anyone else.</p>
</div>
<div class="bordered-section">
<p class="bold">2️⃣ Create a HackDavis Hub Account by ${DOE_DATE}</p>
<p>HackDavis Hub is where you'll find exciting information like prizes, workshops, starter kit, demo tips, live judging info and more!</p>
<p>👉 Hub Invite: <a href="${hubInviteUrl}">${hubInviteUrl}</a></p>
<p class="special-note">Use this unique link to create your account. Do NOT share it with anyone else.</p>
</div>
<div class="bordered-section">
<p class="bold">3️⃣ Join our Discord server</p>
<p>All event communication happens here. After joining, follow <span class="bold">#read-me-first</span> to get your Hacker role and unlock day-of channels.</p>
<p>👉 Discord Server: <a href="${DISCORD_SERVER_URL}">${DISCORD_SERVER_URL}</a></p>
</div>
<p>Please feel free to reach out if you have any questions or concerns. We can't wait to see what you build!</p>
<br/>
<p style="margin-bottom: 0;">Thank you,<br/>The HackDavis Team</p>
</div>
<div class="divider"></div>
<img src="${FOOTER_IMAGE_URL}" alt="HackDavis 2026 footer" class="footer-image">
</div>
</body>
</html>`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
export const HACKER_WAITLIST_EMAIL_SUBJECT =
'[IMPORTANT] HackDavis 2026 Application Update';

export default function hackerWaitlistTemplate(fname: string) {
const HEADER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_header.png`;
const FOOTER_IMAGE_URL = `${process.env.BASE_URL}/email/2026_footer.png`;

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${HACKER_WAITLIST_EMAIL_SUBJECT}</title>
<style>
body { margin: 0; padding: 0; font-family: 'DM Mono', -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: 28px; font-weight: bold; margin: 30px 0; color: #000000; }
.content-box { background-color: #ffffff; margin: 20px 0; }
.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; }
.bold { font-weight: bold; }
.divider { height: 2px; background-color: #F2F2F2; 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="${HEADER_IMAGE_URL}" alt="HackDavis 2026 header" class="header-image">
<h1 class="title">
<span style="color: #173a52;">Update from </span>
<span style="color: #57dade;">HackDavis 2026</span>
</h1>
<div class="divider"></div>
<div class="content-box">
<p>Hi ${fname},</p>
<br/>
<p>Thank you for applying! Unfortunately, due to a high volume of applications, you have been <span class="bold">waitlisted</span> for HackDavis 2026.</p>
<p>We are unable to offer you admission currently, but spots may open up later! Just hang in there, we will get back to you about a change in your status as soon as possible.</p>
<p>If you have any questions, concerns, or comments, please reach out to <a href="mailto:hello@hackdavis.io">hello@hackdavis.io</a>.</p>
<br/>
<p style="margin-bottom: 0;">Warmly,<br/>The HackDavis Team</p>
</div>
<div class="divider"></div>
<img src="${FOOTER_IMAGE_URL}" alt="HackDavis 2026 footer" class="footer-image">
</div>
</body>
</html>`;
}
116 changes: 116 additions & 0 deletions app/(api)/_actions/emails/parseHackerAdmissionsCSV.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { parse } from 'csv-parse/sync';
import { z } from 'zod';
import {
HackerInviteData,
HackerAdmissionType,
HACKER_ADMISSION_TYPES,
} from '@typeDefs/emails';

const emailSchema = z.string().email();

const VALID_TYPES = new Set<string>(HACKER_ADMISSION_TYPES);

function normalizeType(raw: string): HackerAdmissionType | null {
const normalized = raw.trim().toLowerCase().replace(/[- ]/g, '_');
return VALID_TYPES.has(normalized)
? (normalized as HackerAdmissionType)
: null;
}

interface ParseResult {
ok: true;
body: HackerInviteData[];
}

interface ParseError {
ok: false;
error: string;
}

/**
* Parses a hacker admissions CSV with columns:
* First Name, Last Name, Email, Type
*
* Type must be one of: accept, waitlist_accept, waitlist, reject
*/
export default function parseHackerAdmissionsCSV(
csvText: string
): ParseResult | ParseError {
try {
if (!csvText.trim()) {
return { ok: false, error: 'CSV file is empty.' };
}

const rows: string[][] = parse(csvText, {
trim: true,
skip_empty_lines: true,
});

if (rows.length === 0) {
return { ok: false, error: 'CSV file has no rows.' };
}

const firstRow = rows[0].map((cell) => cell.toLowerCase());
const hasHeader =
firstRow.some((cell) => cell.includes('first')) ||
firstRow.some((cell) => cell.includes('email'));
const dataRows = hasHeader ? rows.slice(1) : rows;

if (dataRows.length === 0) {
return { ok: false, error: 'CSV has a header but no data rows.' };
}

const results: HackerInviteData[] = [];
const errors: string[] = [];

for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i];
const rowNum = hasHeader ? i + 2 : i + 1;

if (row.length < 4) {
errors.push(
`Row ${rowNum}: expected 4 columns (First Name, Last Name, Email, Type), got ${row.length}.`
);
continue;
}

const [firstName, lastName, email, typeRaw] = row;

if (!firstName) {
errors.push(`Row ${rowNum}: First Name is empty.`);
continue;
}
if (!lastName) {
errors.push(`Row ${rowNum}: Last Name is empty.`);
continue;
}

const emailResult = emailSchema.safeParse(email);
if (!emailResult.success) {
errors.push(`Row ${rowNum}: "${email}" is not a valid email address.`);
continue;
}

const admissionType = normalizeType(typeRaw ?? '');
if (!admissionType) {
errors.push(
`Row ${rowNum}: "${typeRaw}" is not a valid type. Must be one of: ${HACKER_ADMISSION_TYPES.join(
', '
)}.`
);
continue;
}

results.push({ firstName, lastName, email, admissionType });
}

if (errors.length > 0) {
return { ok: false, error: errors.join('\n') };
}

return { ok: true, body: results };
} catch (e) {
const error = e as Error;
return { ok: false, error: `Failed to parse CSV: ${error.message}` };
}
}
Loading
Loading