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
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ in `drizzle.config.ts`.
| `members.ts` | `user_profile`, `member`, `membership_history` |
| `admins.ts` | `admin` |
| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_event`, `hackathon_event_attendee` |
| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue`, `hackathon_map` |
| `judge.ts` | `judge`, `judge_assignment`, `judging_project`, `judge_vote`, `judge_queue` |
| `initiatives.ts` | `project_leader`, `initiative`, `initiative_application` |
| `events.ts` | `event`, `event_check_in` |
| `stripe.ts` | `stripe_payment`, `user_account_link` |
Expand Down Expand Up @@ -78,14 +78,24 @@ Two aspects share the database and touch nowhere:
`member` is the one crossing case: a paid year still hangs off an edition, so
membership resolves the current hackathon even though initiatives do not.

#### One-off step before the first push that carries this
#### One-off step — only for a database that already has the edition-scoped tables

`migrate:push` cannot work this one out on its own. `project_leader` moved from
`unique(user_id, hackathon_id)` to `unique(user_id)`, so anybody appointed in
more than one edition has more than one row; drizzle-kit fails building the new
index partway and leaves the schema half-applied. Run this against the target
database **once, before** the push. Every statement is guarded, so it is safe to
re-run.
**Check first:**

```sql
SELECT to_regclass('public.project_leader');
```

If that returns `NULL`, this database has never had the club tables. Skip
everything below — `migrate:push` simply creates them in the current shape, and
the statements here would error on tables that do not exist.

If it returns a table name, `migrate:push` cannot work the change out on its
own. `project_leader` moved from `unique(user_id, hackathon_id)` to
`unique(user_id)`, so anybody appointed in more than one edition has more than
one row; drizzle-kit fails building the new index partway and leaves the schema
half-applied. Run this against that database **once, before** the push. Every
statement is guarded, so it is safe to re-run.

```sql
BEGIN;
Expand Down
13 changes: 13 additions & 0 deletions apphosting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,16 @@ env:
value: datascience.gt@gmail.com
- variable: CRON_SECRET
secret: CRON_SECRET
# Flood-protection thresholds, sized per instance for a full venue.
# These are the ceiling for one signed-in person, not for the building —
# the limiter keys on user id when somebody is signed in. The short block
# duration bounds a false positive to a page refresh rather than locking
# an attendee out for five minutes in the middle of a workshop.
- variable: DDOS_BURST_THRESHOLD
value: "3000"
- variable: DDOS_MAX_REQUESTS_PER_MINUTE
value: "20000"
- variable: DDOS_SUSPICIOUS_THRESHOLD
value: "14000"
- variable: DDOS_BLOCK_DURATION_MS
value: "30000"
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"lint": "turbo run lint",
"format": "prettier --write .",
"typecheck": "turbo run typecheck",
"test": "vitest run packages/api packages/db"
"test": "vitest run packages/api packages/db sites/mainweb/lib"
},
"dependencies": {
"next": "16.3.0",
Expand Down
159 changes: 153 additions & 6 deletions packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ vi.mock("@query/db", () => {
hackathonProjects: table("hackathonProjects"),
hackathonEvents: table("hackathonEvents"),
hackathonEventAttendees: table("hackathonEventAttendees"),
hackathonMaps: table("hackathonMaps"),
members: table("members"),
events: table("events"),
eventCheckIns: table("eventCheckIns"),
Expand Down Expand Up @@ -162,7 +161,6 @@ vi.mock("@query/db", () => {
participantId: "participant_id",
checkedInAt: "checked_in_at",
},
hackathonMaps: { _t: "hackathonMaps", id: "id", hackathonId: "hackathon_id" },
members: {
_t: "members",
id: "id",
Expand Down Expand Up @@ -290,6 +288,77 @@ describe("Hackathon admin management edge cases", () => {
return appRouter.createCaller(createMockCtx(ADMIN_USER));
};

// =====================================================================
describe("Volunteer scan tier", () => {
const volunteerCaller = (rows: Record<string, unknown> = {}) =>
adminCaller(rows, "volunteer");

/**
* The whole point of the tier. A volunteer holds an admins row, so without
* an explicit role check they would pass every isAdmin gate in the API —
* including the one that deletes the hackathon and cascades every
* participant, team and vote with it.
*/
it("refuses a volunteer every full-staff action", async () => {
const caller = volunteerCaller({
hackathons: { id: HACK_A, name: "Hacklytics 2027" },
});

await expect(
caller.hackathon.adminGetAttendees({ hackathonId: HACK_A }),
).rejects.toThrow(/Admin access required/);

await expect(
caller.hackathon.exportAttendees({ hackathonId: HACK_A }),
).rejects.toThrow(/Admin access required/);

await expect(
caller.hackathon.delete({
hackathonId: HACK_A,
confirmName: "Hacklytics 2027",
}),
).rejects.toThrow(/Admin access required/);

await expect(
caller.hackathon.batchUpdateParticipantStatus({
hackathonId: HACK_A,
participantIds: [PART_A1],
status: "approved",
}),
).rejects.toThrow(/Admin access required/);
});

it("lets a volunteer work a check-in desk", async () => {
const caller = volunteerCaller({
hackathonEvents: { id: EVENT_A, hackathonId: HACK_A },
});
mockFindMany.mockReturnValue([]);

await expect(
caller.hackathon.getEventAttendees({
hackathonId: HACK_A,
eventId: EVENT_A,
}),
).resolves.toMatchObject({ matching: 0 });
});

// Full staff must keep the scan access they already had — the tier is
// additive at the desk, not a replacement for it.
it("still lets full staff scan", async () => {
const caller = adminCaller({
hackathonEvents: { id: EVENT_A, hackathonId: HACK_A },
});
mockFindMany.mockReturnValue([]);

await expect(
caller.hackathon.getEventAttendees({
hackathonId: HACK_A,
eventId: EVENT_A,
}),
).resolves.toBeDefined();
});
});

const liveHackathon = (overrides: Record<string, unknown> = {}) => ({
id: HACK_A,
name: "Hacklytics 2027",
Expand Down Expand Up @@ -386,10 +455,10 @@ describe("Hackathon admin management edge cases", () => {
const mailed = mockSendAcceptanceEmail.mock.calls.map((c) => c[0].email);
expect(mailed).toEqual(["ada@example.com"]);
// The B participant's row is never updated, so it must not be counted.
expect(res.count).toBe(1);
expect(res.approved).toBe(1);
});

// BUG: `count` is `participantIds.length`, not the number of rows the
// BUG: `approved` is `participantIds.length`, not the number of rows the
// scoped UPDATE actually touched.
it("reports how many participants were really approved, not how many ids were pasted", async () => {
const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } });
Expand All @@ -404,7 +473,29 @@ describe("Hackathon admin management edge cases", () => {
participantIds: [PART_A1, PART_A2, PART_B1],
});

expect(res.count).toBe(2);
expect(res.approved).toBe(2);
});

// A send that the provider rejected must not be reported as delivered:
// "sent to 500" when 0 arrived gives the organiser no reason to look again.
it("counts emails that actually left, separately from approvals", async () => {
const caller = adminCaller({ hackathons: { name: "Hacklytics 2027" } });
mockFindMany.mockReturnValue([
{ id: PART_A1, hackathonId: HACK_A, user: { email: "ada@example.com" } },
{ id: PART_A2, hackathonId: HACK_A, user: { email: "alan@example.com" } },
]);
mockSendAcceptanceEmail.mockRejectedValueOnce(
new Error("450 mailbox unavailable"),
);

const res = await caller.hackathon.sendMassAcceptanceEmails({
hackathonId: HACK_A,
participantIds: [PART_A1, PART_A2],
});

expect(res.approved).toBe(2);
expect(res.emailed).toBe(1);
expect(res.failedEmails).toEqual(["ada@example.com"]);
});
});

Expand Down Expand Up @@ -544,6 +635,42 @@ describe("Hackathon admin management edge cases", () => {
).resolves.toBeDefined();
});

/**
* `undefined` means leave alone, `null` means clear. Without the
* distinction a track list that was once set could never be emptied — the
* edit form would send `[]`, zod would drop it, and the stale value would
* keep routing judges at projects nobody entered for it.
*/
it("clears a field sent as null and leaves omitted ones alone", async () => {
const caller = adminCaller({ hackathons: liveHackathon() });
mockUpdate.mockReturnValue([{ id: HACK_A }]);

await caller.hackathon.update({
id: HACK_A,
tracks: null,
rules: null,
});

const written = mockUpdate.mock.calls.at(-1)?.[2]?.[0];
expect(written).toMatchObject({ tracks: null, rules: null });
// theme was never sent, so it must not appear in the UPDATE at all.
expect(written).not.toHaveProperty("theme");
});

it("stores the tracks it was given", async () => {
const caller = adminCaller({ hackathons: liveHackathon() });
mockUpdate.mockReturnValue([{ id: HACK_A }]);

await caller.hackathon.update({
id: HACK_A,
tracks: ["AI", "Healthcare"],
});

expect(mockUpdate.mock.calls.at(-1)?.[2]?.[0]).toMatchObject({
tracks: ["AI", "Healthcare"],
});
});

// Every child table cascades off this row, so reporting success for an id
// that matched nothing hides a delete that never happened.
it("refuses to delete a hackathon id that does not exist", async () => {
Expand All @@ -553,9 +680,29 @@ describe("Hackathon admin management edge cases", () => {
mockDelete.mockReturnValue([]);

await expect(
caller.hackathon.delete({ hackathonId: HACK_B }),
caller.hackathon.delete({
hackathonId: HACK_B,
confirmName: "Hacklytics 2027",
}),
).rejects.toThrow(/not found/i);
});

// Eleven tables cascade off this row. A click-through confirm is one stray
// Enter key; the name has to be typed and has to match.
it("refuses to delete when the typed name does not match", async () => {
const caller = adminCaller({
hackathons: { id: HACK_A, name: "Hacklytics 2027" },
});

await expect(
caller.hackathon.delete({
hackathonId: HACK_A,
confirmName: "hacklytics 2026",
}),
).rejects.toThrow(/exact name/i);

expect(mockDelete).not.toHaveBeenCalled();
});
});

// =====================================================================
Expand Down
11 changes: 6 additions & 5 deletions packages/api/src/.internal-tests/hackathon-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ vi.mock("@query/db", () => {
hackathonProjects: table("hackathonProjects"),
hackathonEvents: table("hackathonEvents"),
hackathonEventAttendees: table("hackathonEventAttendees"),
hackathonMaps: table("hackathonMaps"),
members: table("members"),
events: table("events"),
eventCheckIns: table("eventCheckIns"),
Expand Down Expand Up @@ -118,7 +117,6 @@ vi.mock("@query/db", () => {
eventId: "event_id",
participantId: "participant_id",
},
hackathonMaps: { id: "id", hackathonId: "hackathon_id" },
members: { id: "id", userId: "user_id", hackathonId: "hackathon_id" },
membershipHistory: { id: "id", memberId: "member_id" },
events: {
Expand Down Expand Up @@ -441,8 +439,11 @@ describe("Hackathon end-to-end flow", () => {
).rejects.toThrow(/Event not found/);
});

it("requires admin rights to scan a pass", async () => {
mockFindFirst.mockImplementation(() => undefined); // not an admin
// Scanning is the one action volunteers may take, so it is gated on
// holding any active admins row rather than on being full staff. An
// ordinary participant still has none and is still refused.
it("requires event staff to scan a pass", async () => {
mockFindFirst.mockImplementation(() => undefined); // no admins row at all
const caller = appRouter.createCaller(createMockCtx("random_user"));

await expect(
Expand All @@ -451,7 +452,7 @@ describe("Hackathon end-to-end flow", () => {
eventId: EVENT_A,
participantId: PARTICIPANT,
}),
).rejects.toThrow(/Admin access required/);
).rejects.toThrow(/Event staff access required/);
});
});

Expand Down
Loading
Loading