Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
c3df639
Judge invites!
ReehalS Feb 26, 2026
302ee39
Linty lint
ReehalS Feb 26, 2026
f4d2371
Fix image URL and error handling for sending emails
ReehalS Feb 26, 2026
2ee04c4
Document browser preview csv parser
ReehalS Feb 26, 2026
47aef86
Throw error for missing email ENV vars
ReehalS Feb 26, 2026
3e88930
Single mentor invite
ReehalS Feb 26, 2026
b8ae04b
Bulk mentor invites and modify invites page
ReehalS Feb 26, 2026
e08f6fa
New format bulk mentor invites
ReehalS Feb 26, 2026
9b2ef1d
Lint fixes
ReehalS Feb 27, 2026
fd07861
Single mentor invite
ReehalS Feb 26, 2026
4e25ab9
Bulk mentor invites and modify invites page
ReehalS Feb 26, 2026
e0aaf45
New format bulk mentor invites
ReehalS Feb 26, 2026
fd85880
Lint fixes
ReehalS Feb 27, 2026
458d2db
Merge branch '376-mentor-email-invites' of https://github.com/HackDav…
ReehalS Mar 3, 2026
a4fc269
Revert "Merge branch '376-mentor-email-invites' of https://github.com…
ReehalS Mar 3, 2026
40ce0dd
Move mentor invites to new folder and add vars
ReehalS Mar 3, 2026
2000a1a
Create InviteCSV parsing test
ReehalS Mar 3, 2026
8862474
Update email subject
ReehalS Mar 3, 2026
6306c93
Update sendSingleMentorInvite.ts
ReehalS Mar 3, 2026
7b63f33
Update sendBulkMentorInvites.ts
ReehalS Mar 3, 2026
013f33a
Move EMAIL_SUBJECT to be common rather than redefined for each file
ReehalS Mar 3, 2026
9162ee5
Move Judge+Mentor bulk invite creation to new processing pipeline
ReehalS Mar 3, 2026
c68ffdd
Add tests for Limiter and processBulkInvites
ReehalS Mar 3, 2026
4ab3a49
Merge branch 'main' of https://github.com/HackDavis/hackdavis-hub int…
michelleyeoh Mar 8, 2026
2471768
fixed mentor template
michelleyeoh Mar 8, 2026
6e0edb7
redo mentor and judge email design
michelleyeoh Mar 14, 2026
f173ff6
extract consts
michelleyeoh Mar 14, 2026
28a286b
cleaned console
michelleyeoh Mar 14, 2026
8a15887
update workflow env vars
michelleyeoh Mar 14, 2026
e973aca
added comment to new file
michelleyeoh Mar 14, 2026
0fa1cee
Merge branch 'main' into 376-mentor-email-invites
michelleyeoh Mar 14, 2026
02745ac
Merge branch 'main' of https://github.com/HackDavis/hackdavis-hub int…
michelleyeoh Mar 14, 2026
89258df
update error msg
michelleyeoh Mar 14, 2026
e706ad6
Merge branch '376-mentor-email-invites' of https://github.com/HackDav…
michelleyeoh Mar 14, 2026
419bfc9
fix extra bracket
michelleyeoh Mar 14, 2026
1aae2a4
added error message for safe handling
michelleyeoh Mar 14, 2026
f7b7649
Merge branch '376-mentor-email-invites' of https://github.com/HackDav…
michelleyeoh Mar 14, 2026
1c01f07
fix loop range
michelleyeoh Mar 14, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@ jobs:
printf "${{ secrets.HMAC_INVITE_SECRET }}" | vercel env add HMAC_INVITE_SECRET production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ secrets.SENDER_PWD }}" | vercel env add SENDER_PWD production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ secrets.CHECK_IN_CODE }}" | vercel env add CHECK_IN_CODE production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ secrets.TITO_API_TOKEN }}" | vercel env add TITO_API_TOKEN production --force --token=${{ secrets.VERCEL_TOKEN }}

printf "${{ vars.ENV_URL }}" | vercel env add BASE_URL production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ vars.INVITE_DEADLINE }}" | vercel env add INVITE_DEADLINE production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ vars.SENDER_EMAIL }}" | vercel env add SENDER_EMAIL production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ vars.TITO_ACCOUNT_SLUG }}" | vercel env add TITO_ACCOUNT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ vars.TITO_EVENT_SLUG }}" | vercel env add TITO_EVENT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}

env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,13 @@ jobs:
printf "${{ secrets.HMAC_INVITE_SECRET }}" | vercel env add HMAC_INVITE_SECRET production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ secrets.SENDER_PWD }}" | vercel env add SENDER_PWD production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ secrets.CHECK_IN_CODE }}" | vercel env add CHECK_IN_CODE production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ secrets.TITO_API_TOKEN }}" | vercel env add TITO_API_TOKEN production --force --token=${{ secrets.VERCEL_TOKEN }}

printf "${{ vars.ENV_URL }}" | vercel env add BASE_URL production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ vars.INVITE_DEADLINE }}" | vercel env add INVITE_DEADLINE production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ vars.SENDER_EMAIL }}" | vercel env add SENDER_EMAIL production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ vars.TITO_ACCOUNT_SLUG }}" | vercel env add TITO_ACCOUNT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ vars.TITO_EVENT_SLUG }}" | vercel env add TITO_EVENT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
Expand Down
72 changes: 72 additions & 0 deletions __tests__/createLimiter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import createLimiter from '@actions/emails/createLimiter';

describe('createLimiter', () => {
it('runs tasks up to the concurrency limit in parallel', async () => {
const limiter = createLimiter(2);
const running: string[] = [];
const log: string[] = [];

const task = (id: string, ms: number) =>
limiter(async () => {
running.push(id);
log.push(`start:${id}(concurrent:${running.length})`);
await new Promise((r) => setTimeout(r, ms));
running.splice(running.indexOf(id), 1);
log.push(`end:${id}`);
return id;
});

const results = await Promise.all([
task('a', 50),
task('b', 50),
task('c', 10),
]);

expect(results).toEqual(['a', 'b', 'c']);
// a and b start concurrently (concurrent:1 then concurrent:2)
// c waits until one finishes, so it starts at concurrent:1 or concurrent:2
// The key invariant: concurrent count never exceeds 2
for (const entry of log) {
const match = entry.match(/concurrent:(\d+)/);
if (match) {
expect(Number(match[1])).toBeLessThanOrEqual(2);
}
}
});

it('returns the resolved value from the wrapped function', async () => {
const limiter = createLimiter(1);
const result = await limiter(() => Promise.resolve(42));
expect(result).toBe(42);
});

it('propagates rejections', async () => {
const limiter = createLimiter(1);
await expect(
limiter(() => Promise.reject(new Error('boom')))
).rejects.toThrow('boom');
});

it('releases the slot on rejection so subsequent tasks run', async () => {
const limiter = createLimiter(1);
await limiter(() => Promise.reject(new Error('fail'))).catch(() => {});
const result = await limiter(() => Promise.resolve('ok'));
expect(result).toBe('ok');
});

it('processes all items with concurrency 1 (serial)', async () => {
const limiter = createLimiter(1);
const order: number[] = [];

await Promise.all(
[1, 2, 3].map((n) =>
limiter(async () => {
order.push(n);
await new Promise((r) => setTimeout(r, 10));
})
)
);

expect(order).toEqual([1, 2, 3]);
});
});
185 changes: 185 additions & 0 deletions __tests__/parseInviteCSV.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import parseInviteCSV from '@actions/emails/parseInviteCSV';

describe('parseInviteCSV', () => {
it('parses valid CSV with header row', () => {
const csv =
'First Name,Last Name,Email\n' +
'Alice,Smith,alice@example.com\n' +
'Bob,Jones,bob@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body).toEqual([
{ firstName: 'Alice', lastName: 'Smith', email: 'alice@example.com' },
{ firstName: 'Bob', lastName: 'Jones', email: 'bob@example.com' },
]);
});

it('parses valid CSV without header row', () => {
const csv = 'Alice,Smith,alice@example.com\nBob,Jones,bob@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body).toHaveLength(2);
expect(result.body[0].firstName).toBe('Alice');
});

it('detects header with "email" keyword', () => {
const csv = 'name_first,name_last,email\nAlice,Smith,alice@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body).toHaveLength(1);
});

it('detects header with "first" keyword', () => {
const csv =
'First,Last,Contact\nAlice,Smith,alice@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body).toHaveLength(1);
});

it('returns error for empty CSV', () => {
const result = parseInviteCSV('');
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe('CSV file is empty.');
});

it('returns error for whitespace-only CSV', () => {
const result = parseInviteCSV(' \n \n ');
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe('CSV file is empty.');
});

it('returns error for header-only CSV', () => {
const csv = 'First Name,Last Name,Email\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe('CSV has a header but no data rows.');
});

it('returns error when row has fewer than 3 columns', () => {
const csv = 'First Name,Last Name,Email\nAlice,Smith\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toMatch(/expect(ed)? 3/i);
});

it('returns error for empty first name', () => {
const csv = 'First Name,Last Name,Email\n,Smith,alice@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain('First Name is empty');
});

it('returns error for empty last name', () => {
const csv = 'First Name,Last Name,Email\nAlice,,alice@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain('Last Name is empty');
});

it('returns error for invalid email', () => {
const csv = 'First Name,Last Name,Email\nAlice,Smith,not-an-email\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain('not a valid email');
});

it('collects multiple row errors', () => {
const csv =
'First Name,Last Name,Email\n' +
',Smith,alice@example.com\n' +
'Bob,,bob@example.com\n' +
'Charlie,Brown,bad-email\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
const errors = result.error.split('\n');
expect(errors).toHaveLength(3);
expect(errors[0]).toContain('Row 2');
expect(errors[1]).toContain('Row 3');
expect(errors[2]).toContain('Row 4');
});

it('trims whitespace from values', () => {
const csv = ' Alice , Smith , alice@example.com \n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body[0]).toEqual({
firstName: 'Alice',
lastName: 'Smith',
email: 'alice@example.com',
});
});

it('skips empty lines', () => {
const csv =
'First Name,Last Name,Email\n' +
'\n' +
'Alice,Smith,alice@example.com\n' +
'\n' +
'Bob,Jones,bob@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body).toHaveLength(2);
});

it('handles extra columns gracefully', () => {
const csv =
'First Name,Last Name,Email,Phone\n' +
'Alice,Smith,alice@example.com,555-1234\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body[0]).toEqual({
firstName: 'Alice',
lastName: 'Smith',
email: 'alice@example.com',
});
});

it('handles quoted fields with commas', () => {
const csv =
'First Name,Last Name,Email\n' +
'"Alice, Jr.",Smith,alice@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.body[0].firstName).toBe('Alice, Jr.');
});

it('row numbers are correct without header', () => {
const csv = 'Alice,Smith,alice@example.com\n,Jones,bob@example.com\n';

const result = parseInviteCSV(csv);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain('Row 2');
});
});
Loading
Loading