Skip to content

Commit 85cd92e

Browse files
committed
Merge branch 'main' of https://github.com/HackDavis/hackdavis-hub into hackbot-user-widget
2 parents 8d265e1 + fe47692 commit 85cd92e

264 files changed

Lines changed: 9049 additions & 3960 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/production.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,14 @@ jobs:
4747
printf "${{ secrets.HMAC_INVITE_SECRET }}" | vercel env add HMAC_INVITE_SECRET production --force --token=${{ secrets.VERCEL_TOKEN }}
4848
printf "${{ secrets.SENDER_PWD }}" | vercel env add SENDER_PWD production --force --token=${{ secrets.VERCEL_TOKEN }}
4949
printf "${{ secrets.CHECK_IN_CODE }}" | vercel env add CHECK_IN_CODE production --force --token=${{ secrets.VERCEL_TOKEN }}
50+
printf "${{ secrets.TITO_API_TOKEN }}" | vercel env add TITO_API_TOKEN production --force --token=${{ secrets.VERCEL_TOKEN }}
5051
printf "${{ secrets.OPENAI_API_KEY }}" | vercel env add OPENAI_API_KEY production --force --token=${{ secrets.VERCEL_TOKEN }}
5152
5253
printf "${{ vars.ENV_URL }}" | vercel env add BASE_URL production --force --token=${{ secrets.VERCEL_TOKEN }}
5354
printf "${{ vars.INVITE_DEADLINE }}" | vercel env add INVITE_DEADLINE production --force --token=${{ secrets.VERCEL_TOKEN }}
5455
printf "${{ vars.SENDER_EMAIL }}" | vercel env add SENDER_EMAIL production --force --token=${{ secrets.VERCEL_TOKEN }}
56+
printf "${{ vars.TITO_ACCOUNT_SLUG }}" | vercel env add TITO_ACCOUNT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
57+
printf "${{ vars.TITO_EVENT_SLUG }}" | vercel env add TITO_EVENT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
5558
printf "${{ vars.OPENAI_MODEL }}" | vercel env add OPENAI_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }}
5659
printf "${{ vars.OPENAI_EMBEDDING_MODEL }}" | vercel env add OPENAI_EMBEDDING_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }}
5760
printf "${{ vars.OPENAI_MAX_TOKENS }}" | vercel env add OPENAI_MAX_TOKENS production --force --token=${{ secrets.VERCEL_TOKEN }}

.github/workflows/staging.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,14 @@ jobs:
4949
printf "${{ secrets.HMAC_INVITE_SECRET }}" | vercel env add HMAC_INVITE_SECRET production --force --token=${{ secrets.VERCEL_TOKEN }}
5050
printf "${{ secrets.SENDER_PWD }}" | vercel env add SENDER_PWD production --force --token=${{ secrets.VERCEL_TOKEN }}
5151
printf "${{ secrets.CHECK_IN_CODE }}" | vercel env add CHECK_IN_CODE production --force --token=${{ secrets.VERCEL_TOKEN }}
52+
printf "${{ secrets.TITO_API_TOKEN }}" | vercel env add TITO_API_TOKEN production --force --token=${{ secrets.VERCEL_TOKEN }}
5253
printf "${{ secrets.OPENAI_API_KEY }}" | vercel env add OPENAI_API_KEY production --force --token=${{ secrets.VERCEL_TOKEN }}
5354
5455
printf "${{ vars.ENV_URL }}" | vercel env add BASE_URL production --force --token=${{ secrets.VERCEL_TOKEN }}
5556
printf "${{ vars.INVITE_DEADLINE }}" | vercel env add INVITE_DEADLINE production --force --token=${{ secrets.VERCEL_TOKEN }}
5657
printf "${{ vars.SENDER_EMAIL }}" | vercel env add SENDER_EMAIL production --force --token=${{ secrets.VERCEL_TOKEN }}
58+
printf "${{ vars.TITO_ACCOUNT_SLUG }}" | vercel env add TITO_ACCOUNT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
59+
printf "${{ vars.TITO_EVENT_SLUG }}" | vercel env add TITO_EVENT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
5760
printf "${{ vars.OPENAI_MODEL }}" | vercel env add OPENAI_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }}
5861
printf "${{ vars.OPENAI_EMBEDDING_MODEL }}" | vercel env add OPENAI_EMBEDDING_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }}
5962
printf "${{ vars.OPENAI_MAX_TOKENS }}" | vercel env add OPENAI_MAX_TOKENS production --force --token=${{ secrets.VERCEL_TOKEN }}

__tests__/createLimiter.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import createLimiter from '@actions/emails/createLimiter';
2+
3+
describe('createLimiter', () => {
4+
it('runs tasks up to the concurrency limit in parallel', async () => {
5+
const limiter = createLimiter(2);
6+
const running: string[] = [];
7+
const log: string[] = [];
8+
9+
const task = (id: string, ms: number) =>
10+
limiter(async () => {
11+
running.push(id);
12+
log.push(`start:${id}(concurrent:${running.length})`);
13+
await new Promise((r) => setTimeout(r, ms));
14+
running.splice(running.indexOf(id), 1);
15+
log.push(`end:${id}`);
16+
return id;
17+
});
18+
19+
const results = await Promise.all([
20+
task('a', 50),
21+
task('b', 50),
22+
task('c', 10),
23+
]);
24+
25+
expect(results).toEqual(['a', 'b', 'c']);
26+
// a and b start concurrently (concurrent:1 then concurrent:2)
27+
// c waits until one finishes, so it starts at concurrent:1 or concurrent:2
28+
// The key invariant: concurrent count never exceeds 2
29+
for (const entry of log) {
30+
const match = entry.match(/concurrent:(\d+)/);
31+
if (match) {
32+
expect(Number(match[1])).toBeLessThanOrEqual(2);
33+
}
34+
}
35+
});
36+
37+
it('returns the resolved value from the wrapped function', async () => {
38+
const limiter = createLimiter(1);
39+
const result = await limiter(() => Promise.resolve(42));
40+
expect(result).toBe(42);
41+
});
42+
43+
it('propagates rejections', async () => {
44+
const limiter = createLimiter(1);
45+
await expect(
46+
limiter(() => Promise.reject(new Error('boom')))
47+
).rejects.toThrow('boom');
48+
});
49+
50+
it('releases the slot on rejection so subsequent tasks run', async () => {
51+
const limiter = createLimiter(1);
52+
await limiter(() => Promise.reject(new Error('fail'))).catch(() => {});
53+
const result = await limiter(() => Promise.resolve('ok'));
54+
expect(result).toBe('ok');
55+
});
56+
57+
it('processes all items with concurrency 1 (serial)', async () => {
58+
const limiter = createLimiter(1);
59+
const order: number[] = [];
60+
61+
await Promise.all(
62+
[1, 2, 3].map((n) =>
63+
limiter(async () => {
64+
order.push(n);
65+
await new Promise((r) => setTimeout(r, 10));
66+
})
67+
)
68+
);
69+
70+
expect(order).toEqual([1, 2, 3]);
71+
});
72+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { validateCsvBlob } from '@utils/csv-ingestion/csvAlgorithm';
2+
3+
type TeamRow = {
4+
tableNumber: number;
5+
projectTitle: string;
6+
status: string;
7+
primaryTrack: string;
8+
};
9+
10+
function buildCsv(rows: TeamRow[]): string {
11+
const header = [
12+
'Table Number',
13+
'Project Status',
14+
'Project Title',
15+
'Track #1 (Primary Track)',
16+
'Track #2',
17+
'Track #3',
18+
'Opt-In Prizes',
19+
].join(',');
20+
21+
const lines = rows.map((row) =>
22+
[
23+
String(row.tableNumber),
24+
row.status,
25+
row.projectTitle,
26+
row.primaryTrack,
27+
'',
28+
'',
29+
'',
30+
].join(',')
31+
);
32+
33+
return `${header}\n${lines.join('\n')}`;
34+
}
35+
36+
describe('csvAlgorithm table assignment', () => {
37+
it('assigns the first hardware team to A1', async () => {
38+
const csv = buildCsv([
39+
{
40+
tableNumber: 1,
41+
status: 'Submitted',
42+
projectTitle: 'Hardware Team',
43+
primaryTrack: 'Best Hardware Hack',
44+
},
45+
{
46+
tableNumber: 2,
47+
status: 'Submitted',
48+
projectTitle: 'Other Team',
49+
primaryTrack: 'Best AI/ML Hack',
50+
},
51+
]);
52+
53+
const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' }));
54+
55+
expect(res.ok).toBe(true);
56+
expect(res.body).not.toBeNull();
57+
58+
const hardwareTeam = res.body?.find((t) => t.name === 'Hardware Team');
59+
expect(hardwareTeam?.tableNumber).toBe('A1');
60+
});
61+
62+
it('starts floor 2 at I1 after floor 1 capacity is filled', async () => {
63+
// Floor 1: 8 rows * 13 seats = 104. Floor 2 starts at 105th team
64+
const rows: TeamRow[] = Array.from({ length: 105 }, (_, i) => ({
65+
tableNumber: i + 1,
66+
status: 'Submitted',
67+
projectTitle: `Team ${i + 1}`,
68+
primaryTrack: 'Best AI/ML Hack',
69+
}));
70+
71+
const csv = buildCsv(rows);
72+
const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' }));
73+
74+
if (!res.ok) {
75+
console.error('Unexpected error:', res.error);
76+
}
77+
expect(res.ok).toBe(true);
78+
expect(res.body).not.toBeNull();
79+
expect(res.body?.length).toBe(105);
80+
// Last team on floor 1
81+
expect(res.body?.[103].tableNumber).toBe('H13');
82+
// First team on floor 2
83+
expect(res.body?.[104].tableNumber).toBe('I1');
84+
});
85+
86+
it('returns a global warning when team count exceeds venue capacity', async () => {
87+
// Total capacity: 104 (F1) + 60 (F2) = 164
88+
// We provide 167 teams to trigger WAIT-n overflow labels.
89+
const rows: TeamRow[] = Array.from({ length: 167 }, (_, i) => ({
90+
tableNumber: i + 1,
91+
status: 'Submitted',
92+
projectTitle: `Team ${i + 1}`,
93+
primaryTrack: 'Best AI/ML Hack',
94+
}));
95+
96+
const csv = buildCsv(rows);
97+
const res = await validateCsvBlob(new Blob([csv], { type: 'text/csv' }));
98+
99+
// Capacity overflow is non-blocking and surfaced as a global warning.
100+
expect(res.ok).toBe(true);
101+
expect(res.error).toBeNull();
102+
expect(res.report.errorRows).toBe(0);
103+
expect(res.report.warningRows).toBe(0);
104+
expect(res.report.globalWarnings).toBeDefined();
105+
expect(res.report.globalWarnings?.[0]).toContain('Capacity Exceeded');
106+
107+
// Verify that even with overflow, data was processed.
108+
expect(res.body).toHaveLength(167);
109+
110+
// Verify the last team got a WAIT label.
111+
const lastTeam = res.body?.[166];
112+
expect(lastTeam?.tableNumber).toBe('WAIT-3');
113+
});
114+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/** @jest-environment node */
2+
3+
import { retrieveContext } from '@datalib/hackbot/getHackbotContext';
4+
import { getDatabase } from '@utils/mongodb/mongoClient.mjs';
5+
import { embedText } from '@utils/hackbot/embedText';
6+
import { retryWithBackoff } from '@utils/hackbot/retryWithBackoff';
7+
8+
jest.mock('@utils/mongodb/mongoClient.mjs', () => ({
9+
getDatabase: jest.fn(),
10+
}));
11+
12+
jest.mock('@utils/hackbot/embedText', () => ({
13+
embedText: jest.fn(),
14+
}));
15+
16+
jest.mock('@utils/hackbot/retryWithBackoff', () => ({
17+
retryWithBackoff: jest.fn(),
18+
}));
19+
20+
const mockGetDatabase = getDatabase as jest.MockedFunction<typeof getDatabase>;
21+
const mockEmbedText = embedText as jest.MockedFunction<typeof embedText>;
22+
const mockRetryWithBackoff = retryWithBackoff as jest.MockedFunction<
23+
typeof retryWithBackoff
24+
>;
25+
26+
describe('retrieveContext', () => {
27+
const aggregateToArray = jest.fn();
28+
const aggregate = jest.fn(() => ({ toArray: aggregateToArray }));
29+
const collection = jest.fn(() => ({ aggregate }));
30+
31+
beforeEach(() => {
32+
jest.clearAllMocks();
33+
34+
mockRetryWithBackoff.mockImplementation(async (operation: any) =>
35+
operation()
36+
);
37+
mockEmbedText.mockResolvedValue([0.1, 0.2, 0.3]);
38+
mockGetDatabase.mockResolvedValue({ collection } as any);
39+
aggregateToArray.mockResolvedValue([
40+
{
41+
_id: 'doc-1',
42+
type: 'general',
43+
title: 'Doc 1',
44+
text: 'Some useful context',
45+
url: 'https://example.com',
46+
},
47+
]);
48+
});
49+
50+
it('uses adaptive simple limit for greetings', async () => {
51+
await retrieveContext('hello');
52+
53+
const pipeline = aggregate.mock.calls[0][0];
54+
expect(pipeline[0].$vectorSearch.limit).toBe(5);
55+
});
56+
57+
it('uses adaptive complex limit for schedule/list queries', async () => {
58+
await retrieveContext('show me all events this weekend');
59+
60+
const pipeline = aggregate.mock.calls[0][0];
61+
expect(pipeline[0].$vectorSearch.limit).toBe(30);
62+
});
63+
64+
it('honors explicit limit when provided', async () => {
65+
await retrieveContext('what is hacking', { limit: 7 });
66+
67+
const pipeline = aggregate.mock.calls[0][0];
68+
expect(pipeline[0].$vectorSearch.limit).toBe(7);
69+
});
70+
71+
it('adds preferredTypes filter when provided', async () => {
72+
await retrieveContext('schedule', {
73+
preferredTypes: ['schedule', 'general'] as any,
74+
});
75+
76+
const pipeline = aggregate.mock.calls[0][0];
77+
expect(pipeline[0].$vectorSearch.filter).toEqual({
78+
type: { $in: ['schedule', 'general'] },
79+
});
80+
});
81+
82+
it('projects only fields needed by downstream code', async () => {
83+
await retrieveContext('where is check-in?');
84+
85+
const pipeline = aggregate.mock.calls[0][0];
86+
expect(pipeline[1]).toEqual({
87+
$project: {
88+
_id: 1,
89+
type: 1,
90+
title: 1,
91+
text: 1,
92+
url: 1,
93+
},
94+
});
95+
});
96+
});

__tests__/logic/ingestTeams.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@ describe('ingestTeams', () => {
2323
{
2424
name: 'Team 1',
2525
teamNumber: 1,
26-
tableNumber: 1,
26+
tableNumber: 'A1',
2727
tracks: ['Track A'],
2828
active: true,
2929
},
3030
{
3131
name: 'Team 2',
3232
teamNumber: 2,
33-
tableNumber: 2,
33+
tableNumber: 'A2',
3434
tracks: ['Track B'],
3535
active: true,
3636
},
@@ -74,7 +74,7 @@ describe('ingestTeams', () => {
7474
{
7575
name: 'Invalid Team',
7676
teamNumber: 999,
77-
tableNumber: 999,
77+
tableNumber: '999',
7878
tracks: ['Invalid Track'],
7979
active: true,
8080
},
@@ -99,14 +99,14 @@ describe('ingestTeams', () => {
9999
{
100100
name: 'Team 1',
101101
teamNumber: 1,
102-
tableNumber: 1,
102+
tableNumber: 'A1',
103103
tracks: ['Track A'],
104104
active: true,
105105
},
106106
{
107107
name: 'Team 1 Duplicate',
108108
teamNumber: 1,
109-
tableNumber: 2,
109+
tableNumber: 'A2',
110110
tracks: ['Track B'],
111111
active: true,
112112
},
@@ -131,7 +131,7 @@ describe('ingestTeams', () => {
131131
{
132132
name: 'Solo Team',
133133
teamNumber: 1,
134-
tableNumber: 1,
134+
tableNumber: 'A1',
135135
tracks: ['Track A', 'Track B'],
136136
active: true,
137137
},
@@ -157,7 +157,7 @@ describe('ingestTeams', () => {
157157
{
158158
name: 'Complete Team',
159159
teamNumber: 42,
160-
tableNumber: 10,
160+
tableNumber: 'A10',
161161
tracks: ['Track A', 'Track B', 'Track C'],
162162
active: false,
163163
},

0 commit comments

Comments
 (0)