From fd6537947771a475e1b3bfe1479eccdaa7d88f01 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Thu, 15 Oct 2026 11:11:31 -0400
Subject: [PATCH 01/27] fix(points): refuse event-code check-in for banned
hackers
points.me.checkIn writes attendance and awards points, so it now sits
behind notHackathonBanned like the other participation writes. The
summary, leaderboard and store reads stay open. Adds a test that a banned
hacker's check-in is refused before any participant lookup or insert.
---
.../hackathon-admin-edge.test.ts | 19 +++++++++++++++++++
packages/api/src/middleware/procedures.ts | 2 +-
packages/api/src/routers/points/me.ts | 8 ++++++--
3 files changed, 26 insertions(+), 3 deletions(-)
diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
index 3ab02749..905e20ef 100644
--- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
+++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
@@ -546,6 +546,25 @@ describe("Hackathon admin management edge cases", () => {
).rejects.toThrow(refused);
});
+ // A banned hacker can keep a registration, so the code check-in is a way
+ // of taking part that has to stop at the gate, before any lookup.
+ it("refuses a banned hacker's event code check-in", async () => {
+ const caller = participant(true);
+
+ await expect(
+ caller.points.me.checkIn({ hackathonId: HACK_A, code: "ABC234" }),
+ ).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message: expect.stringMatching(/can't take part in Hacklytics/),
+ });
+
+ expect(mockFindFirst).not.toHaveBeenCalledWith(
+ "hackathonParticipants",
+ expect.anything(),
+ );
+ expect(mockInsert).not.toHaveBeenCalled();
+ });
+
it("lets everyone else through the ban gate", async () => {
const caller = participant(false);
const result = await caller.hackathon
diff --git a/packages/api/src/middleware/procedures.ts b/packages/api/src/middleware/procedures.ts
index 67392057..db6ce2f7 100644
--- a/packages/api/src/middleware/procedures.ts
+++ b/packages/api/src/middleware/procedures.ts
@@ -195,7 +195,7 @@ export const hackathonBanCacheKey = (email: string) =>
// Refuses people banned from hackathons. Goes on the procedures where someone
// takes part — registering, the interest list, teams, submitting, applying to
-// judge — and not on reads or on the ways out (withdraw, leave), so a banned
+// judge, event-code check-in — and not on reads or on the ways out (withdraw, leave), so a banned
// person can still see and undo what they already did. The ban is by email,
// so it holds across providers and new accounts on the same address. Cached
// 60s, the negative answer too; ban and unban clear the key.
diff --git a/packages/api/src/routers/points/me.ts b/packages/api/src/routers/points/me.ts
index 1a688067..1fb62242 100644
--- a/packages/api/src/routers/points/me.ts
+++ b/packages/api/src/routers/points/me.ts
@@ -9,6 +9,7 @@ import {
} from "@query/db";
import type { DrizzleDB } from "@query/db";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
+import { notHackathonBanned } from "../../middleware/procedures";
import { rateLimit } from "../../middleware/security";
import {
isSoldOut,
@@ -213,8 +214,11 @@ export const pointsMeRouter = createTRPCRouter({
}),
// Self check-in with the code shown at the event. Every refusal says what
- // to do instead, since the hacker is standing in a doorway reading it.
- checkIn: protectedProcedure
+ // to do instead, since the hacker is standing in a doorway reading it. Behind
+ // the ban gate because it writes: a banned hacker who kept their
+ // registration could otherwise keep collecting points. The reads above stay
+ // open, as with the rest of the API.
+ checkIn: notHackathonBanned
.input(scope.extend({ code: z.string().max(32) }))
.mutation(async ({ ctx, input }) => {
// There are 31^6 codes; this keeps anyone from walking the space. Per
From a746a6877969318aa0729778985466100a863431 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Thu, 15 Oct 2026 11:11:33 -0400
Subject: [PATCH 02/27] fix(announcements): stop counting in-app posts as
emailed
listAnnouncements counted every stamped sentAt as sent, but an in-app
post is written with sentAt already set. Only sendBatch claims rows, so
sent now counts rows that were claimed and sent, pending is counted
directly in SQL, and a new inAppOnly flag marks posts that went to event
pages without email.
---
.../src/.internal-tests/announcements.test.ts | 38 +++++++++++++++++++
.../api/src/routers/hackathon/announce.ts | 17 +++++----
2 files changed, 48 insertions(+), 7 deletions(-)
diff --git a/packages/api/src/.internal-tests/announcements.test.ts b/packages/api/src/.internal-tests/announcements.test.ts
index 0f2b2d0f..39df9907 100644
--- a/packages/api/src/.internal-tests/announcements.test.ts
+++ b/packages/api/src/.internal-tests/announcements.test.ts
@@ -148,6 +148,7 @@ vi.mock("@query/db", () => {
email: "email",
sentAt: "sent_at",
failedAt: "failed_at",
+ claimedAt: "claimed_at",
},
};
});
@@ -513,6 +514,43 @@ describe("Announcements", () => {
expect(res.inAppOnly).toBe(false);
expect(recipientRowsOf()[0]).not.toHaveProperty("sentAt");
});
+
+ /**
+ * The counts are SQL aggregates, so the rows here are canned as the query
+ * would return them. What this pins is the router's side: an all-stamped,
+ * never-claimed announcement is reported as in-app with nothing pending,
+ * and `pending` is taken from the query rather than derived from
+ * total - sent - failed, which would read 2 here and put it under
+ * "Unfinished sends".
+ */
+ it("lists an in-app announcement as published, not as an unfinished send", async () => {
+ asAdmin();
+ mockSelectRows.mockReturnValue([
+ {
+ id: ANNOUNCEMENT,
+ subject: "Registration is open",
+ audience: "interested",
+ createdAt: new Date("2026-02-01T12:00:00Z"),
+ total: 2,
+ sent: 0,
+ failed: 0,
+ pending: 0,
+ inAppOnly: true,
+ },
+ ]);
+
+ const [first] = await callerFor(ADMIN).hackathon.listAnnouncements({
+ hackathonId: HACK,
+ });
+
+ expect(first).toMatchObject({
+ total: 2,
+ sent: 0,
+ failed: 0,
+ pending: 0,
+ inAppOnly: true,
+ });
+ });
});
/**
diff --git a/packages/api/src/routers/hackathon/announce.ts b/packages/api/src/routers/hackathon/announce.ts
index a837e3bf..a37feafa 100644
--- a/packages/api/src/routers/hackathon/announce.ts
+++ b/packages/api/src/routers/hackathon/announce.ts
@@ -171,20 +171,28 @@ export const hackathonAnnounceRouter = createTRPCRouter({
// Announcements for this edition and how far each got, so a reopened tab can
// pick an unfinished send back up instead of starting a duplicate.
+ //
+ // `sentAt` alone does not mean "emailed": an in-app post is born with it
+ // stamped. Only sendBatch sets `claimedAt`, so `sent` counts rows with both,
+ // and `inAppOnly` is an announcement whose every row is stamped but never
+ // claimed. `pending` is counted directly, not total - sent - failed, which
+ // would leave in-app rows looking like an unfinished send.
listAnnouncements: isAdmin
.input(z.object({ hackathonId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const db = ctx.db as DrizzleDB;
- const rows = await db
+ return await db
.select({
id: hackathonAnnouncements.id,
subject: hackathonAnnouncements.subject,
audience: hackathonAnnouncements.audience,
createdAt: hackathonAnnouncements.createdAt,
total: sql`count(${hackathonAnnouncementRecipients.id})::int`,
- sent: sql`count(${hackathonAnnouncementRecipients.sentAt})::int`,
+ sent: sql`count(${hackathonAnnouncementRecipients.id}) filter (where ${hackathonAnnouncementRecipients.sentAt} is not null and ${hackathonAnnouncementRecipients.claimedAt} is not null)::int`,
failed: sql`count(${hackathonAnnouncementRecipients.failedAt})::int`,
+ pending: sql`count(${hackathonAnnouncementRecipients.id}) filter (where ${hackathonAnnouncementRecipients.sentAt} is null and ${hackathonAnnouncementRecipients.failedAt} is null)::int`,
+ inAppOnly: sql`count(${hackathonAnnouncementRecipients.id}) > 0 and count(${hackathonAnnouncementRecipients.id}) filter (where ${hackathonAnnouncementRecipients.sentAt} is not null and ${hackathonAnnouncementRecipients.claimedAt} is null) = count(${hackathonAnnouncementRecipients.id})`,
})
.from(hackathonAnnouncements)
.leftJoin(
@@ -198,11 +206,6 @@ export const hackathonAnnounceRouter = createTRPCRouter({
.groupBy(hackathonAnnouncements.id)
.orderBy(desc(hackathonAnnouncements.createdAt))
.limit(50);
-
- return rows.map((row) => ({
- ...row,
- pending: row.total - row.sent - row.failed,
- }));
}),
// Freezes a message and its audience. Sends nothing. The recipient rows are
From 1c42309ccb7b3d704e001d43a01c4e76e7d1dead Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Thu, 15 Oct 2026 11:11:35 -0400
Subject: [PATCH 03/27] fix(submit): encode the hackathon id in submit page
links
The id can come from the URL or the picker, so it is passed through
encodeURIComponent once and every link on the page uses that path.
---
sites/mainweb/app/(portal)/submit/page.tsx | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/sites/mainweb/app/(portal)/submit/page.tsx b/sites/mainweb/app/(portal)/submit/page.tsx
index cbc0130b..8b6a7353 100644
--- a/sites/mainweb/app/(portal)/submit/page.tsx
+++ b/sites/mainweb/app/(portal)/submit/page.tsx
@@ -59,6 +59,9 @@ function SubmitPortalContent() {
const [selectedHackathonId, setSelectedHackathonId] = useState(
urlHackathonId || "",
);
+ // The id can come straight from the URL or the picker, so it is encoded
+ // wherever it becomes part of a link path.
+ const hackathonPath = `/hackathons/${encodeURIComponent(selectedHackathonId)}`;
const [confirmWithdraw, setConfirmWithdraw] = useState(false);
const [projectName, setProjectName] = useState("");
@@ -353,7 +356,7 @@ function SubmitPortalContent() {
)}
{currentReg && (currentReg.team || admitted) && (
{currentReg.team ? "Manage team" : "Create or join a team"}
@@ -372,7 +375,7 @@ function SubmitPortalContent() {
Your project is in judging. Your team presents to judges for
each challenge it entered, and the{" "}
Judging tab
@@ -659,7 +662,7 @@ function SubmitPortalContent() {
submit points there. */}
{projectSubmitted ? (
Go to Judging
From 4bec2297c6ea2c007fdb9549e5fdd8682853e68e Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Thu, 15 Oct 2026 11:11:37 -0400
Subject: [PATCH 04/27] fix(registration): keep sensitive answers out of the
saved draft
The application draft in localStorage no longer holds gender, race or the
emergency contact. The portal is used on shared and borrowed laptops; the
fields still work in the form and are still submitted.
---
sites/mainweb/components/hackathon/InfoTab.tsx | 11 +++--------
1 file changed, 3 insertions(+), 8 deletions(-)
diff --git a/sites/mainweb/components/hackathon/InfoTab.tsx b/sites/mainweb/components/hackathon/InfoTab.tsx
index 8731b18b..a494ae03 100644
--- a/sites/mainweb/components/hackathon/InfoTab.tsx
+++ b/sites/mainweb/components/hackathon/InfoTab.tsx
@@ -247,14 +247,15 @@ export function InfoTab({
// Switching tabs unmounts this one, so the answers are kept as they change.
// The consents are left out on purpose: they are ticked fresh at submit.
+ // So are gender, race and the emergency contact: this portal is used on
+ // shared and borrowed laptops, and sensitive personal details should not
+ // sit in localStorage in clear text.
const draft = JSON.stringify({
firstName,
lastName,
phone,
age,
- gender,
pronouns,
- race,
underrepresented,
school,
major,
@@ -269,8 +270,6 @@ export function InfoTab({
whyAttend,
shirtSize,
dietary,
- emergencyContact,
- emergencyPhone,
needsHardware,
});
useEffect(() => {
@@ -293,9 +292,7 @@ export function InfoTab({
lastName: setLastName,
phone: setPhone,
age: setAge,
- gender: setGender,
pronouns: setPronouns,
- race: setRace,
school: setSchool,
major: setMajor,
graduationYear: setGraduationYear,
@@ -305,8 +302,6 @@ export function InfoTab({
linkedinUrl: setLinkedinUrl,
githubUrl: setGithubUrl,
whyAttend: setWhyAttend,
- emergencyContact: setEmergencyContact,
- emergencyPhone: setEmergencyPhone,
};
for (const [key, set] of Object.entries(text)) {
const value = saved[key];
From 1c867da592ef4129383c2bcaa2869b6171b9976e Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Thu, 15 Oct 2026 12:21:58 -0400
Subject: [PATCH 05/27] feat(points): refuse overlapping and back-to-back event
check-ins
Organisers' rules for bad actors: nobody is checked into two events that
run at the same time, or into two less than 30 minutes apart. Both live
in recordEventAttendance, the one writer of attendance rows, so pass
scans, event codes and manual check-in all follow them.
- Meals are exempt on both sides; back-to-back events do not overlap.
- A repeat of an event already attended still reads "already checked in".
- The participant row is locked (FOR NO KEY UPDATE) before the read, so
two check-ins for one person at the same moment cannot both pass.
- The rule and the row use one app clock rather than the database's now(),
which follows the session time zone.
- Refusals are FORBIDDEN with a sentence for the hacker or for staff; staff
are told to remove the earlier check-in if it was a mistake.
---
docs/architecture.md | 1 +
.../api/src/.internal-tests/_db-tx-mock.ts | 8 +
.../hackathon-admin-edge.test.ts | 176 ++++++++-
.../src/.internal-tests/qr-checkin.test.ts | 47 ++-
packages/api/src/routers/hackathon/admin.ts | 13 +-
packages/api/src/routers/points/me.ts | 1 +
packages/api/src/services/points.test.ts | 367 +++++++++++++++++-
packages/api/src/services/points.ts | 177 ++++++++-
8 files changed, 770 insertions(+), 20 deletions(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index 5f6fa53b..17426854 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -57,6 +57,7 @@ Edition-scoped event operations. Everything hangs off `hackathon`.
- Judge roster (`judge`, `judge_assignment`): applications and approval
- Judging: room-based judging with Devpost import — `judging_*` tables (`judging.ts`). The older pool-dispatch tables (`judging_project`, `judge_vote`, `judge_queue`, `hackathon_result`) are retired and kept for past editions' results
- Points: `hackathon_event` points, staff adjustments and the point store (`points.ts`)
+- Event check-in rules: nobody is checked into two events that run at the same time, or into two less than 30 minutes apart (meals are exempt). `recordEventAttendance` enforces this for pass scans, event codes and manual check-in
- Announcements and acceptance waves
Hackathon participation is **open to non-members**. Membership is not a registration requirement.
diff --git a/packages/api/src/.internal-tests/_db-tx-mock.ts b/packages/api/src/.internal-tests/_db-tx-mock.ts
index 9af27af6..804f78dc 100644
--- a/packages/api/src/.internal-tests/_db-tx-mock.ts
+++ b/packages/api/src/.internal-tests/_db-tx-mock.ts
@@ -147,6 +147,14 @@ export const createTransactionMock = ({
if (select) {
tx.select = (...selectArgs: any[]) => ({
from: (...fromArgs: any[]) => ({
+ // A join is a plain read, never a locking one; the hook gets the
+ // joined table as a fifth argument.
+ innerJoin: (...joinArgs: any[]) => ({
+ where: (...wArgs: any[]) =>
+ Promise.resolve(
+ select("select", selectArgs, fromArgs, wArgs, joinArgs),
+ ),
+ }),
// Lazy: `.for("update")` must be the thing that takes the lock, and
// eagerly building the unlocked promise would run the query twice.
where: (...wArgs: any[]) => {
diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
index 905e20ef..1777c6b6 100644
--- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
+++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
@@ -19,6 +19,11 @@ const mockDelete = vi.fn();
const mockCount = vi.fn();
/** maxParticipants as the capacity check reads it; undefined = uncapped. */
const mockMaxParticipants = vi.fn<() => number | undefined>(() => undefined);
+/**
+ * A participant's earlier check-ins as recordEventAttendance reads them:
+ * attendance joined to its event. Empty unless a test says otherwise.
+ */
+const mockEarlierAttendance = vi.fn<() => unknown[]>(() => []);
// Acceptance emails are sent through a dynamic import; intercept it so we can
// see exactly who a mass-approval actually mailed.
const mockSendAcceptanceEmail = vi.fn();
@@ -111,7 +116,7 @@ vi.mock("@query/db", () => {
select: vi.fn().mockImplementation(() => ({
from: (tbl: any) => {
const count = mockCount(tbl?._t) ?? 0;
- return thenable([
+ const node = thenable([
{
count,
n: count,
@@ -119,6 +124,10 @@ vi.mock("@query/db", () => {
tbl?._t === "hackathons" ? mockMaxParticipants() : undefined,
},
]);
+ if (tbl?._t === "hackathonEventAttendees") {
+ node.innerJoin = () => thenable(mockEarlierAttendance());
+ }
+ return node;
},
})),
},
@@ -292,6 +301,7 @@ describe("Hackathon admin management edge cases", () => {
cache.clear();
mockCount.mockReturnValue(0);
mockMaxParticipants.mockImplementation(() => undefined);
+ mockEarlierAttendance.mockReturnValue([]);
mockFindMany.mockReturnValue([]);
mockInsert.mockReturnValue([{}]);
mockUpdate.mockReturnValue([{}]);
@@ -1293,6 +1303,170 @@ describe("Hackathon admin management edge cases", () => {
});
});
+ // =====================================================================
+ // Organisers' rules for bad actors: nobody is in two events that run at the
+ // same time, or in two within 30 minutes. They live in recordEventAttendance,
+ // so every way of writing attendance is held to them.
+ describe("Event check-in rules", () => {
+ const MINUTE = 60 * 1000;
+
+ const person = {
+ id: PART_A1,
+ hackathonId: HACK_A,
+ registrationStatus: "checked_in",
+ firstName: "Ada",
+ lastName: "Lovelace",
+ user: { name: "Ada Lovelace", email: "ada@example.com" },
+ };
+ const fireside = {
+ id: EVENT_A,
+ hackathonId: HACK_A,
+ name: "Full Sail Fireside",
+ type: "sponsor_session",
+ points: 10,
+ selfCheckIn: true,
+ startTime: new Date(Date.now() - HOUR),
+ endTime: new Date(Date.now() + HOUR),
+ };
+
+ // A check-in for another event, as the attendance read returns it.
+ const earlier = (event: Record, minutesAgo: number) => ({
+ event: {
+ id: "99999999-9999-4999-8999-999999999999",
+ name: "BNY Workshop",
+ type: "workshop",
+ startTime: new Date(Date.now() - 5 * HOUR),
+ endTime: new Date(Date.now() - 4 * HOUR),
+ ...event,
+ },
+ checkedInAt: new Date(Date.now() - minutesAgo * MINUTE),
+ });
+
+ // The hacker's own session, past the ban gate, with the event live.
+ const hackerCaller = () => {
+ mockFindFirst.mockImplementation((table: string) => {
+ if (table === "users") return { email: "ada@example.com" };
+ if (table === "hackathonParticipants") return person;
+ if (table === "hackathonEvents") return fireside;
+ return undefined;
+ });
+ return appRouter.createCaller(createMockCtx("rules-hacker"));
+ };
+
+ const attendanceWrites = () =>
+ mockInsert.mock.calls.filter((c) => c[1][0] === hackathonEventAttendees);
+
+ it("refuses a code check-in into an event that overlaps one already attended", async () => {
+ const caller = hackerCaller();
+ mockEarlierAttendance.mockReturnValue([
+ earlier(
+ {
+ startTime: new Date(Date.now() - 30 * MINUTE),
+ endTime: new Date(Date.now() + 30 * MINUTE),
+ },
+ 20,
+ ),
+ ]);
+
+ await expect(
+ caller.points.me.checkIn({ hackathonId: HACK_A, code: "ABC234" }),
+ ).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message:
+ "You're already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. You can check into one event at a time.",
+ });
+ expect(attendanceWrites()).toHaveLength(0);
+ });
+
+ it("lets a code check-in through when nothing overlaps and the last was long ago", async () => {
+ const caller = hackerCaller();
+ mockEarlierAttendance.mockReturnValue([earlier({}, 120)]);
+
+ await expect(
+ caller.points.me.checkIn({ hackathonId: HACK_A, code: "ABC234" }),
+ ).resolves.toMatchObject({ eventName: "Full Sail Fireside" });
+ expect(attendanceWrites()).toHaveLength(1);
+ });
+
+ it("refuses a pass scan within 30 minutes of the last check-in", async () => {
+ const caller = adminCaller({
+ hackathonParticipants: { ...person, registrationStatus: "approved" },
+ hackathonEvents: fireside,
+ hackathonEventAttendees: undefined,
+ });
+ mockEarlierAttendance.mockReturnValue([earlier({}, 10)]);
+
+ await expect(
+ caller.hackathon.scanParticipantPass({
+ hackathonId: HACK_A,
+ eventId: EVENT_A,
+ participantId: PART_A1,
+ }),
+ ).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message:
+ "Ada Lovelace checked into BNY Workshop 10 minutes ago. Event check-ins have to be at least 30 minutes apart. If that was a mistake, remove them from BNY Workshop first.",
+ });
+ // The whole scan rolls back: no attendance, and no arrival either.
+ expect(attendanceWrites()).toHaveLength(0);
+ expect(mockUpdate).not.toHaveBeenCalled();
+ });
+
+ it("refuses a manual check-in into an event that overlaps one already attended", async () => {
+ const caller = adminCaller({
+ hackathonParticipants: person,
+ hackathonEvents: fireside,
+ });
+ mockEarlierAttendance.mockReturnValue([
+ earlier(
+ {
+ startTime: new Date(Date.now() - 30 * MINUTE),
+ endTime: new Date(Date.now() + 30 * MINUTE),
+ },
+ 45,
+ ),
+ ]);
+
+ await expect(
+ caller.hackathon.manualEventCheckIn({
+ hackathonId: HACK_A,
+ eventId: EVENT_A,
+ participantId: PART_A1,
+ }),
+ ).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message:
+ "Ada Lovelace is already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. If that was a mistake, remove them from BNY Workshop first.",
+ });
+ expect(attendanceWrites()).toHaveLength(0);
+ });
+
+ it("does not hold a meal to the rules", async () => {
+ const caller = adminCaller({
+ hackathonParticipants: person,
+ hackathonEvents: { ...fireside, name: "Dinner", type: "meal" },
+ });
+ mockEarlierAttendance.mockReturnValue([
+ earlier(
+ {
+ startTime: new Date(Date.now() - 30 * MINUTE),
+ endTime: new Date(Date.now() + 30 * MINUTE),
+ },
+ 5,
+ ),
+ ]);
+
+ await expect(
+ caller.hackathon.manualEventCheckIn({
+ hackathonId: HACK_A,
+ eventId: EVENT_A,
+ participantId: PART_A1,
+ }),
+ ).resolves.toMatchObject({ eventName: "Dinner" });
+ expect(attendanceWrites()).toHaveLength(1);
+ });
+ });
+
// =====================================================================
describe("Chasing unconfirmed seats", () => {
const whereOf = () =>
diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts
index d3e9af21..db3024b6 100644
--- a/packages/api/src/.internal-tests/qr-checkin.test.ts
+++ b/packages/api/src/.internal-tests/qr-checkin.test.ts
@@ -21,6 +21,9 @@ const mockInsert = vi.fn();
const mockUpdate = vi.fn();
const mockDelete = vi.fn();
const mockSelect = vi.fn();
+// A participant's earlier check-ins, which recordEventAttendance reads as
+// attendance joined to its event. Empty unless a test says otherwise.
+const mockEarlierAttendance = vi.fn<() => unknown[]>(() => []);
vi.mock("@query/db", async () => {
const { createTransactionMock } = await import("./_db-tx-mock");
@@ -261,7 +264,10 @@ describe("QR check-in", () => {
cache.clear();
// Aggregate reads default to "nothing there"; the concurrency tests below
// override this to hand back the live row a FOR UPDATE would have locked.
- mockSelect.mockReturnValue([{ count: 0 }]);
+ mockSelect.mockImplementation((_op, _sel, _from, _where, joined) =>
+ joined ? mockEarlierAttendance() : [{ count: 0 }],
+ );
+ mockEarlierAttendance.mockReturnValue([]);
});
const createMockCtx = (userId?: string) =>
@@ -898,6 +904,45 @@ describe("QR check-in", () => {
expect(err.message).toMatch(/is already checked into/);
});
+ it("refuses a scan into an event that runs at the same time as one already attended", async () => {
+ // Named and typed outright: the default fixture reads as a meal, and
+ // meals are exempt.
+ const caller = scannerCtx({
+ event: hackEvent({
+ name: "Full Sail Fireside",
+ type: "sponsor_session",
+ endTime: new Date(Date.now() + HOUR),
+ }),
+ });
+ mockEarlierAttendance.mockReturnValue([
+ {
+ event: {
+ id: CLUB_EVENT,
+ name: "BNY Workshop",
+ type: "workshop",
+ startTime: new Date(Date.now() - 2 * HOUR),
+ endTime: new Date(Date.now() + 2 * HOUR),
+ },
+ checkedInAt: new Date(Date.now() - 90 * 60 * 1000),
+ },
+ ]);
+
+ const err: any = await caller.hackathon
+ .scanParticipantPass({
+ hackathonId: HACK_A,
+ eventId: HACK_EVENT,
+ participantId: PARTICIPANT,
+ })
+ .catch((e: unknown) => e);
+
+ expect(err.code).toBe("FORBIDDEN");
+ expect(err.message).toBe(
+ "Ada Lovelace is already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. If that was a mistake, remove them from BNY Workshop first.",
+ );
+ expect(mockInsert).not.toHaveBeenCalled();
+ expect(mockUpdate).not.toHaveBeenCalled();
+ });
+
// BUG: scanParticipantPass matches on (id, hackathonId) only
// (admin.ts:287-298) and never compares startTime/endTime to now, so one
// pass of the scanner can check a participant into the whole weekend.
diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts
index a8b951d7..ac10fe55 100644
--- a/packages/api/src/routers/hackathon/admin.ts
+++ b/packages/api/src/routers/hackathon/admin.ts
@@ -272,7 +272,9 @@ async function checkInParticipant(
// 4. Record attendance. The unique index is the real guard, and
// recordEventAttendance turns a racing scanner's 23505 into the same
- // conflict as the read above.
+ // conflict as the read above. It also refuses an event that overlaps, or
+ // comes too soon after, one this person is already in; that refusal ends the
+ // scan before the promotion below.
// Attendance and the roster promotion are one transaction. Split, a promotion
// failing after the attendance row committed left the scan half-done — and
// the retry hits the duplicate guard, so that attendee could never submit.
@@ -1313,7 +1315,14 @@ export const hackathonAdminRouter = createTRPCRouter({
eq(hackathonEvents.id, input.eventId),
eq(hackathonEvents.hackathonId, input.hackathonId),
),
- columns: { id: true, name: true, points: true, endTime: true },
+ columns: {
+ id: true,
+ name: true,
+ type: true,
+ points: true,
+ startTime: true,
+ endTime: true,
+ },
}),
]);
diff --git a/packages/api/src/routers/points/me.ts b/packages/api/src/routers/points/me.ts
index 1fb62242..2aa5f314 100644
--- a/packages/api/src/routers/points/me.ts
+++ b/packages/api/src/routers/points/me.ts
@@ -267,6 +267,7 @@ export const pointsMeRouter = createTRPCRouter({
columns: {
id: true,
name: true,
+ type: true,
points: true,
startTime: true,
endTime: true,
diff --git a/packages/api/src/services/points.test.ts b/packages/api/src/services/points.test.ts
index 8b175e73..e6f67dbc 100644
--- a/packages/api/src/services/points.test.ts
+++ b/packages/api/src/services/points.test.ts
@@ -3,8 +3,11 @@ import { TRPCError } from "@trpc/server";
import {
CHECK_IN_CODE_ALPHABET,
CHECK_IN_CODE_LENGTH,
+ CHECK_IN_GAP_MS,
alreadyCheckedInMessage,
assignCheckInCode,
+ attendanceConflict,
+ attendanceConflictMessage,
canSupply,
fullName,
generateCheckInCode,
@@ -19,7 +22,7 @@ import {
recordEventAttendance,
selfCheckInWindow,
} from "./points";
-import type { PointsDb } from "./points";
+import type { EarlierAttendance, PointsDb } from "./points";
import type { DrizzleDB } from "@query/db";
// Drizzle wraps the pg error, so the SQLSTATE sits on `.cause`.
@@ -341,27 +344,293 @@ describe("selfCheckInWindow", () => {
});
});
+const MIN = 60 * 1000;
+
+describe("attendanceConflict", () => {
+ const T0 = new Date("2026-10-10T18:00:00Z").getTime();
+ const at = (minutes: number) => new Date(T0 + minutes * MIN);
+
+ const ev = (
+ id: string,
+ startMin: number,
+ endMin: number,
+ type = "workshop",
+ ) => ({
+ id,
+ type,
+ startTime: at(startMin),
+ endTime: at(endMin),
+ });
+
+ // An earlier check-in. Defaults to long ago, so only the overlap rule can
+ // fire unless a test says otherwise.
+ const attended = (
+ id: string,
+ startMin: number,
+ endMin: number,
+ opts: { type?: string; checkedInMin?: number; name?: string } = {},
+ ): EarlierAttendance => ({
+ event: {
+ ...ev(id, startMin, endMin, opts.type),
+ name: opts.name ?? `Event ${id}`,
+ },
+ checkedInAt: at(opts.checkedInMin ?? -600),
+ });
+
+ it("allows a first check-in", () => {
+ expect(attendanceConflict(ev("a", 0, 60), [], at(0))).toBeNull();
+ });
+
+ it("refuses an event that runs at the same time as one already attended", () => {
+ const bny = attended("bny", 0, 60, { name: "BNY Workshop" });
+ expect(attendanceConflict(ev("fs", 30, 90), [bny], at(30))).toEqual({
+ kind: "overlap",
+ other: { name: "BNY Workshop" },
+ });
+ // Contained and containing count too.
+ expect(attendanceConflict(ev("fs", 10, 20), [bny], at(30))?.kind).toBe(
+ "overlap",
+ );
+ expect(attendanceConflict(ev("fs", -30, 120), [bny], at(30))?.kind).toBe(
+ "overlap",
+ );
+ });
+
+ it("lets back-to-back events through", () => {
+ const first = attended("a", 0, 60);
+ expect(attendanceConflict(ev("b", 60, 120), [first], at(60))).toBeNull();
+ const second = attended("c", 60, 120);
+ expect(attendanceConflict(ev("d", 0, 60), [second], at(30))).toBeNull();
+ });
+
+ it("refuses a second check-in 29 minutes after the first and allows it at 30", () => {
+ const first = attended("a", 0, 60, { checkedInMin: 0, name: "Workshop" });
+ // Back-to-back events, so only the gap is in play.
+ const next = ev("b", 60, 120);
+ expect(attendanceConflict(next, [first], at(29))).toEqual({
+ kind: "too_soon",
+ other: { name: "Workshop" },
+ minutesAgo: 29,
+ });
+ expect(attendanceConflict(next, [first], at(30))).toBeNull();
+ expect(CHECK_IN_GAP_MS).toBe(30 * MIN);
+ });
+
+ it("reports whole minutes, zero for a check-in just now", () => {
+ const first = attended("a", 0, 60, { checkedInMin: 0 });
+ const next = ev("b", 60, 120);
+ expect(
+ attendanceConflict(next, [first], new Date(T0 + 59 * 1000))?.minutesAgo,
+ ).toBe(0);
+ expect(
+ attendanceConflict(next, [first], new Date(T0 + 61 * 1000))?.minutesAgo,
+ ).toBe(1);
+ });
+
+ it("does not report a negative wait when the clock is behind the database", () => {
+ const first = attended("a", 0, 60, { checkedInMin: 1 });
+ expect(
+ attendanceConflict(ev("b", 60, 120), [first], at(0))?.minutesAgo,
+ ).toBe(0);
+ });
+
+ it("names the most recent check-in when several are too recent", () => {
+ const older = attended("a", 0, 10, { checkedInMin: 0, name: "Older" });
+ const newer = attended("b", 20, 30, { checkedInMin: 10, name: "Newer" });
+ const expected = {
+ kind: "too_soon",
+ other: { name: "Newer" },
+ minutesAgo: 10,
+ };
+ expect(attendanceConflict(ev("c", 60, 90), [newer, older], at(20))).toEqual(
+ expected,
+ );
+ expect(attendanceConflict(ev("c", 60, 90), [older, newer], at(20))).toEqual(
+ expected,
+ );
+ });
+
+ it("does not block a meal, and a meal does not block anything", () => {
+ const workshop = attended("w", 0, 60, { checkedInMin: 0 });
+ expect(
+ attendanceConflict(ev("lunch", 10, 50, "meal"), [workshop], at(10)),
+ ).toBeNull();
+
+ const lunch = attended("lunch", 0, 60, { type: "meal", checkedInMin: 0 });
+ expect(attendanceConflict(ev("w", 10, 50), [lunch], at(10))).toBeNull();
+ });
+
+ it("ignores the same event, which the unique index handles", () => {
+ const same = attended("a", 0, 60, { checkedInMin: 0 });
+ expect(attendanceConflict(ev("a", 0, 60), [same], at(5))).toBeNull();
+ // A check-in elsewhere since then does not turn a repeat into "too soon":
+ // the true answer is still "already checked in".
+ const since = attended("b", 60, 120, { checkedInMin: 50 });
+ expect(
+ attendanceConflict(ev("a", 0, 60), [same, since], at(55)),
+ ).toBeNull();
+ });
+
+ it("reports an overlap before a recent check-in", () => {
+ const overlapping = attended("a", 0, 60, {
+ checkedInMin: 10,
+ name: "Overlaps",
+ });
+ const recent = attended("b", -120, -90, {
+ checkedInMin: 14,
+ name: "Recent",
+ });
+ expect(
+ attendanceConflict(ev("c", 30, 90), [recent, overlapping], at(15)),
+ ).toEqual({ kind: "overlap", other: { name: "Overlaps" } });
+ });
+});
+
+describe("attendanceConflictMessage", () => {
+ const input = {
+ event: { name: "Full Sail Fireside" },
+ participant: { name: "Ada Lovelace" },
+ };
+ const overlap = { kind: "overlap", other: { name: "BNY Workshop" } } as const;
+ const soon = (minutesAgo: number) =>
+ ({
+ kind: "too_soon",
+ other: { name: "BNY Workshop" },
+ minutesAgo,
+ }) as const;
+
+ it("speaks to the hacker who entered the code about an overlap", () => {
+ expect(
+ attendanceConflictMessage({ ...input, method: "code" }, overlap),
+ ).toBe(
+ "You're already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. You can check into one event at a time.",
+ );
+ });
+
+ it("tells staff to remove the participant from the other event", () => {
+ for (const method of ["scan", "manual"] as const) {
+ expect(attendanceConflictMessage({ ...input, method }, overlap)).toBe(
+ "Ada Lovelace is already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. If that was a mistake, remove them from BNY Workshop first.",
+ );
+ }
+ });
+
+ it("tells the hacker how long to wait", () => {
+ expect(
+ attendanceConflictMessage({ ...input, method: "code" }, soon(12)),
+ ).toBe(
+ "You checked into BNY Workshop 12 minutes ago. Event check-ins have to be at least 30 minutes apart, so try again in 18 minutes.",
+ );
+ });
+
+ it("tells staff the same without a countdown", () => {
+ for (const method of ["scan", "manual"] as const) {
+ expect(attendanceConflictMessage({ ...input, method }, soon(12))).toBe(
+ "Ada Lovelace checked into BNY Workshop 12 minutes ago. Event check-ins have to be at least 30 minutes apart. If that was a mistake, remove them from BNY Workshop first.",
+ );
+ }
+ });
+
+ it("uses singular wording at one minute", () => {
+ expect(
+ attendanceConflictMessage({ ...input, method: "code" }, soon(1)),
+ ).toBe(
+ "You checked into BNY Workshop 1 minute ago. Event check-ins have to be at least 30 minutes apart, so try again in 29 minutes.",
+ );
+ expect(
+ attendanceConflictMessage({ ...input, method: "code" }, soon(29)),
+ ).toContain("so try again in 1 minute.");
+ });
+
+ it("says less than a minute for zero", () => {
+ expect(
+ attendanceConflictMessage({ ...input, method: "code" }, soon(0)),
+ ).toBe(
+ "You checked into BNY Workshop less than a minute ago. Event check-ins have to be at least 30 minutes apart, so try again in 30 minutes.",
+ );
+ expect(
+ attendanceConflictMessage({ ...input, method: "manual" }, soon(0)),
+ ).toContain("checked into BNY Workshop less than a minute ago.");
+ });
+});
+
describe("recordEventAttendance", () => {
- const fakeDb = (fail?: () => Error) => {
+ // Stands in for the pool: a transaction hands back a builder that answers the
+ // participant lock, the attendance read and the insert, noting the order.
+ const fakeDb = (
+ opts: { fail?: () => Error; earlier?: EarlierAttendance[] } = {},
+ ) => {
const inserted: unknown[] = [];
- const db = {
+ const calls: string[] = [];
+ const tx = {
+ select: () => ({
+ from: () => ({
+ where: () => ({
+ for: async (mode: string) => {
+ calls.push(`lock:${mode}`);
+ return [{ id: "part-1" }];
+ },
+ }),
+ innerJoin: () => ({
+ where: async () => {
+ calls.push("read");
+ return opts.earlier ?? [];
+ },
+ }),
+ }),
+ }),
insert: () => ({
values: async (values: unknown) => {
- if (fail) throw fail();
+ calls.push("insert");
+ if (opts.fail) throw opts.fail();
inserted.push(values);
},
}),
};
- return { db: db as unknown as PointsDb, inserted };
+ const db = {
+ transaction: async (run: (tx: unknown) => unknown) => run(tx),
+ };
+ return { db: db as unknown as PointsDb, inserted, calls };
};
+ // A meal, so the rules stay out of the way unless a test switches to the
+ // workshop below.
const input = {
- event: { id: "event-1", name: "Lunch", points: 15 },
+ event: {
+ id: "event-1",
+ name: "Lunch",
+ points: 15,
+ type: "meal",
+ startTime: new Date(Date.now() - 30 * MIN),
+ endTime: new Date(Date.now() + 30 * MIN),
+ },
participant: { id: "part-1", name: "Ada Lovelace" },
method: "manual" as const,
actorId: "staff-1",
};
+ const workshop = {
+ ...input.event,
+ id: "event-2",
+ name: "Workshop",
+ type: "workshop",
+ };
+
+ const earlierRow = (
+ event: Partial = {},
+ checkedInAt = new Date(Date.now() - 300 * MIN),
+ ): EarlierAttendance => ({
+ event: {
+ id: "event-9",
+ name: "BNY Workshop",
+ type: "workshop",
+ startTime: new Date(Date.now() - 30 * MIN),
+ endTime: new Date(Date.now() + 30 * MIN),
+ ...event,
+ },
+ checkedInAt,
+ });
+
it("copies the event's points and records how and by whom", async () => {
const { db, inserted } = fakeDb();
await expect(recordEventAttendance(db, input)).resolves.toEqual({
@@ -374,12 +643,63 @@ describe("recordEventAttendance", () => {
pointsAwarded: 15,
method: "manual",
checkedInById: "staff-1",
+ checkedInAt: expect.any(Date),
},
]);
});
+ it("locks the participant before it reads their attendance", async () => {
+ const { db, calls } = fakeDb();
+ await recordEventAttendance(db, input);
+ expect(calls).toEqual(["lock:no key update", "read", "insert"]);
+ });
+
+ it("refuses a check-in into an event that overlaps one already attended", async () => {
+ const { db, inserted } = fakeDb({ earlier: [earlierRow()] });
+ const error = await recordEventAttendance(db, {
+ ...input,
+ event: workshop,
+ method: "code",
+ }).catch((e: unknown) => e);
+ expect(error).toBeInstanceOf(TRPCError);
+ expect((error as TRPCError).code).toBe("FORBIDDEN");
+ expect((error as TRPCError).message).toBe(
+ "You're already checked into BNY Workshop, which runs at the same time as Workshop. You can check into one event at a time.",
+ );
+ expect(inserted).toEqual([]);
+ });
+
+ it("refuses a check-in under 30 minutes after the last one", async () => {
+ const { db, inserted } = fakeDb({
+ earlier: [
+ earlierRow(
+ {
+ startTime: new Date(Date.now() - 300 * MIN),
+ endTime: new Date(Date.now() - 240 * MIN),
+ },
+ new Date(Date.now() - 10 * MIN),
+ ),
+ ],
+ });
+ const error = await recordEventAttendance(db, {
+ ...input,
+ event: workshop,
+ }).catch((e: unknown) => e);
+ expect((error as TRPCError).code).toBe("FORBIDDEN");
+ expect((error as TRPCError).message).toMatch(
+ /^Ada Lovelace checked into BNY Workshop 10 minutes ago\./,
+ );
+ expect(inserted).toEqual([]);
+ });
+
+ it("lets a meal through alongside an overlapping event", async () => {
+ const { db, inserted } = fakeDb({ earlier: [earlierRow()] });
+ await recordEventAttendance(db, input);
+ expect(inserted).toHaveLength(1);
+ });
+
it("reads a duplicate as already checked in", async () => {
- const { db } = fakeDb(uniqueViolation);
+ const { db } = fakeDb({ fail: uniqueViolation });
const error = await recordEventAttendance(db, input).catch(
(e: unknown) => e,
);
@@ -390,15 +710,44 @@ describe("recordEventAttendance", () => {
);
});
+ it("reads a repeat as already checked in after a later check-in elsewhere", async () => {
+ const { db } = fakeDb({
+ fail: uniqueViolation,
+ earlier: [
+ earlierRow(
+ { id: workshop.id, name: workshop.name },
+ new Date(Date.now() - 40 * MIN),
+ ),
+ // Starts as the workshop ends, checked into five minutes ago.
+ earlierRow(
+ {
+ startTime: workshop.endTime,
+ endTime: new Date(Date.now() + 90 * MIN),
+ },
+ new Date(Date.now() - 5 * MIN),
+ ),
+ ],
+ });
+ const error = await recordEventAttendance(db, {
+ ...input,
+ event: workshop,
+ method: "code",
+ }).catch((e: unknown) => e);
+ expect((error as TRPCError).code).toBe("CONFLICT");
+ expect((error as TRPCError).message).toBe(
+ "You are already checked into Workshop.",
+ );
+ });
+
it("speaks to the hacker when they entered the code", async () => {
- const { db } = fakeDb(uniqueViolation);
+ const { db } = fakeDb({ fail: uniqueViolation });
await expect(
recordEventAttendance(db, { ...input, method: "code" }),
).rejects.toThrow("You are already checked into Lunch.");
});
it("passes other failures through", async () => {
- const { db } = fakeDb(() => new Error("connection reset"));
+ const { db } = fakeDb({ fail: () => new Error("connection reset") });
await expect(recordEventAttendance(db, input)).rejects.toThrow(
"connection reset",
);
diff --git a/packages/api/src/services/points.ts b/packages/api/src/services/points.ts
index a2ad9dd4..9e1e0303 100644
--- a/packages/api/src/services/points.ts
+++ b/packages/api/src/services/points.ts
@@ -240,6 +240,81 @@ export function selfCheckInWindow(
// ---------------------------------------------------------------------------
// Attendance
+// Two events at once, or two check-ins in quick succession, mean somebody is
+// collecting points for events they are not at. Meals are the exception: people
+// eat alongside whatever else is on, and a meal scan only counts food.
+export const CHECK_IN_GAP_MS = 30 * 60 * 1000;
+
+type AttendedEvent = {
+ id: string;
+ type: string;
+ startTime: Date;
+ endTime: Date;
+};
+
+/** One of the participant's existing check-ins, with the event it was for. */
+export type EarlierAttendance = {
+ event: AttendedEvent & { name: string };
+ checkedInAt: Date;
+};
+
+export type AttendanceConflict = {
+ /** overlap: the events run at the same time. too_soon: under the gap. */
+ kind: "overlap" | "too_soon";
+ other: { name: string };
+ /** Whole minutes since the earlier check-in. Only on too_soon. */
+ minutesAgo?: number;
+};
+
+/**
+ * Why `event` cannot be checked into now, given the participant's earlier
+ * check-ins, or null when it can. Overlap is reported before too_soon because
+ * it is the firmer answer: waiting does not fix it. Pure, so the rule is
+ * testable without a database.
+ */
+export function attendanceConflict(
+ event: AttendedEvent,
+ earlier: readonly EarlierAttendance[],
+ now: Date,
+): AttendanceConflict | null {
+ if (event.type === "meal") return null;
+
+ // Already in this event: the duplicate case, which the unique index answers
+ // with "already checked in". Checking the others too would answer a repeat
+ // code with "try again in 20 minutes" after a later check-in elsewhere.
+ if (earlier.some((row) => row.event.id === event.id)) return null;
+
+ const others = earlier.filter((row) => row.event.type !== "meal");
+
+ // Strict, so an event that ends at 2:00 does not clash with one that starts
+ // at 2:00.
+ const overlapping = others.find(
+ (row) =>
+ row.event.startTime < event.endTime &&
+ event.startTime < row.event.endTime,
+ );
+ if (overlapping) {
+ return { kind: "overlap", other: { name: overlapping.event.name } };
+ }
+
+ let latest: EarlierAttendance | null = null;
+ for (const row of others) {
+ if (now.getTime() - row.checkedInAt.getTime() >= CHECK_IN_GAP_MS) continue;
+ if (!latest || row.checkedInAt > latest.checkedInAt) latest = row;
+ }
+ if (!latest) return null;
+
+ return {
+ kind: "too_soon",
+ other: { name: latest.event.name },
+ // A clock a little behind the database can put the check-in in the future.
+ minutesAgo: Math.max(
+ 0,
+ Math.floor((now.getTime() - latest.checkedInAt.getTime()) / 60_000),
+ ),
+ };
+}
+
/**
* The one writer of attendance rows: pass scans, event codes and manual
* check-in all come through here, so every row carries the event's points at
@@ -248,25 +323,80 @@ export function selfCheckInWindow(
* attend. The unique index on (event, participant) is the duplicate guard;
* two stations racing both pass any earlier read, and the loser gets the same
* sentence a single duplicate would.
+ *
+ * It also holds the bad-actor rules (see attendanceConflict): nobody is in two
+ * events that run at the same time, or in two within CHECK_IN_GAP_MS of each
+ * other. Those are read-then-write, so the participant's row is locked first:
+ * two check-ins for one person at the same moment (two tabs, two stations)
+ * queue up, and the second one sees the first. A refusal is FORBIDDEN, not
+ * CONFLICT: the self check-in page reads CONFLICT as "already checked in".
*/
export async function recordEventAttendance(
db: PointsDb,
input: {
- event: { id: string; name: string; points: number };
+ event: {
+ id: string;
+ name: string;
+ points: number;
+ type: string;
+ startTime: Date;
+ endTime: Date;
+ };
participant: { id: string; name: string };
method: AttendanceMethod;
actorId: string | null;
},
): Promise<{ pointsAwarded: number }> {
const { event, participant, method, actorId } = input;
+ // One clock for the rule and the row it writes. Left to the database's
+ // now(), checked_in_at (no time zone) follows the session's TimeZone and
+ // would not compare with this one.
+ const now = new Date();
try {
- await db.insert(hackathonEventAttendees).values({
- eventId: event.id,
- participantId: participant.id,
- pointsAwarded: event.points,
- method,
- checkedInById: actorId,
+ await db.transaction(async (tx) => {
+ // No key update: enough to queue this person's check-ins behind each
+ // other without holding up other writes that reference the row.
+ await tx
+ .select({ id: hackathonParticipants.id })
+ .from(hackathonParticipants)
+ .where(eq(hackathonParticipants.id, participant.id))
+ .for("no key update");
+
+ const earlier = await tx
+ .select({
+ event: {
+ id: hackathonEvents.id,
+ name: hackathonEvents.name,
+ type: hackathonEvents.type,
+ startTime: hackathonEvents.startTime,
+ endTime: hackathonEvents.endTime,
+ },
+ checkedInAt: hackathonEventAttendees.checkedInAt,
+ })
+ .from(hackathonEventAttendees)
+ .innerJoin(
+ hackathonEvents,
+ eq(hackathonEvents.id, hackathonEventAttendees.eventId),
+ )
+ .where(eq(hackathonEventAttendees.participantId, participant.id));
+
+ const conflict = attendanceConflict(event, earlier, now);
+ if (conflict) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: attendanceConflictMessage(input, conflict),
+ });
+ }
+
+ await tx.insert(hackathonEventAttendees).values({
+ eventId: event.id,
+ participantId: participant.id,
+ pointsAwarded: event.points,
+ method,
+ checkedInById: actorId,
+ checkedInAt: now,
+ });
});
} catch (error) {
if (isUniqueViolation(error)) {
@@ -293,6 +423,39 @@ export function alreadyCheckedInMessage(input: {
: `${input.participant.name} is already checked into ${input.event.name}.`;
}
+const minutesLabel = (n: number) => `${n} minute${n === 1 ? "" : "s"}`;
+
+/** The refusal sentence for an attendanceConflict, worded like the one above. */
+export function attendanceConflictMessage(
+ input: {
+ event: { name: string };
+ participant: { name: string };
+ method: AttendanceMethod;
+ },
+ conflict: AttendanceConflict,
+): string {
+ const { event, participant, method } = input;
+ const other = conflict.other.name;
+ const self = method === "code";
+
+ if (conflict.kind === "overlap") {
+ return self
+ ? `You're already checked into ${other}, which runs at the same time as ${event.name}. You can check into one event at a time.`
+ : `${participant.name} is already checked into ${other}, which runs at the same time as ${event.name}. If that was a mistake, remove them from ${other} first.`;
+ }
+
+ const ago = conflict.minutesAgo ?? 0;
+ const when =
+ ago === 0 ? "less than a minute ago" : `${minutesLabel(ago)} ago`;
+ const rule = `Event check-ins have to be at least ${CHECK_IN_GAP_MS / 60_000} minutes apart`;
+ if (!self) {
+ return `${participant.name} checked into ${other} ${when}. ${rule}. If that was a mistake, remove them from ${other} first.`;
+ }
+ // minutesAgo is floored, so what is left of the gap rounds up on its own.
+ const left = Math.max(1, CHECK_IN_GAP_MS / 60_000 - ago);
+ return `You checked into ${other} ${when}. ${rule}, so try again in ${minutesLabel(left)}.`;
+}
+
// ---------------------------------------------------------------------------
// Balances
From b1957293300c8f37c297f56e9e5e641d2be23670 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Thu, 15 Oct 2026 15:12:51 -0400
Subject: [PATCH 06/27] feat(points): record refused check-ins as flags for
organisers
A check-in the rules refuse (two events at once, or two under 30 minutes
apart) rolls back, so the attempt is now written afterwards on the pool to
a new hackathon_check_in_flag table. Repeats of the same pair add to an
attempt count. Writing the flag is best effort and never changes the
refusal the person sees. All three check-in paths (event codes, pass
scans, manual check-in) are wrapped.
Judging entries gain nullable prize_ineligible_at and prize_ineligible_by_id
columns. Schema changes are additive only.
---
docs/architecture.md | 1 +
docs/operations/testing.md | 1 +
docs/packages/db.md | 2 +-
.../hackathon-admin-edge.test.ts | 196 +++++
.../src/.internal-tests/qr-checkin.test.ts | 81 +-
packages/api/src/routers/hackathon/admin.ts | 24 +-
packages/api/src/routers/points/me.ts | 21 +-
.../api/src/services/check-in-flags.test.ts | 693 ++++++++++++++++++
packages/api/src/services/check-in-flags.ts | 405 ++++++++++
packages/api/src/services/points.test.ts | 42 +-
packages/api/src/services/points.ts | 62 +-
packages/db/src/schemas/hackathons.ts | 67 ++
packages/db/src/schemas/judging.ts | 8 +
13 files changed, 1578 insertions(+), 25 deletions(-)
create mode 100644 packages/api/src/services/check-in-flags.test.ts
create mode 100644 packages/api/src/services/check-in-flags.ts
diff --git a/docs/architecture.md b/docs/architecture.md
index 17426854..a87d2b6f 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -58,6 +58,7 @@ Edition-scoped event operations. Everything hangs off `hackathon`.
- Judging: room-based judging with Devpost import — `judging_*` tables (`judging.ts`). The older pool-dispatch tables (`judging_project`, `judge_vote`, `judge_queue`, `hackathon_result`) are retired and kept for past editions' results
- Points: `hackathon_event` points, staff adjustments and the point store (`points.ts`)
- Event check-in rules: nobody is checked into two events that run at the same time, or into two less than 30 minutes apart (meals are exempt). `recordEventAttendance` enforces this for pass scans, event codes and manual check-in
+- Check-in rule-break flags: each refused attempt is recorded (`hackathon_check_in_flag`, `services/check-in-flags.ts`) and shown in judging on the hacker's team, in full to organisers and as a bare marker to judges. Organisers decide whether to pull the team from prizes (`judging_entry.prize_ineligible_at`). Hackers never see flags, and they stay out of public and cached payloads
- Announcements and acceptance waves
Hackathon participation is **open to non-members**. Membership is not a registration requirement.
diff --git a/docs/operations/testing.md b/docs/operations/testing.md
index 63c81425..d6f0f425 100644
--- a/docs/operations/testing.md
+++ b/docs/operations/testing.md
@@ -28,6 +28,7 @@ Config: `packages/api/vitest.config.ts`. Includes `src/**/*.test.ts` and `src/.i
| Hackathon flow | `hackathon-flow.test.ts`, `hackathon-admin-edge.test.ts`, `hackathon-interest.test.ts` |
| Payments | `stripe-payments.test.ts` |
| QR / check-in | `qr-checkin.test.ts` |
+| Check-in rule-break flags | `services/check-in-flags.test.ts`, `.internal-tests/check-in-flags-*.test.ts` |
| Security / sanitizer / rate limit | `security.test.ts`, `resilience.test.ts` |
| Judges / participants / initiatives / bootcamp / announcements | matching `*-edge.test.ts` / `bootcamp.test.ts` / `announcements.test.ts` |
| Portal context | `services/portal-context.test.ts`, `routers/user/portal-context.test.ts` |
diff --git a/docs/packages/db.md b/docs/packages/db.md
index d928531e..31b19450 100644
--- a/docs/packages/db.md
+++ b/docs/packages/db.md
@@ -27,7 +27,7 @@ Files in `src/schemas/`, re-exported from `schemas/index.ts`. `drizzle.config.ts
| `auth.ts` | `user`, `account`, `session`, `verificationToken` |
| `members.ts` | `user_profile`, `member`, `membership_history` |
| `admins.ts` | `admin` |
-| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_interest`, `hackathon_event`, `hackathon_event_attendee`, `hackathon_announcement`, `hackathon_announcement_recipient` |
+| `hackathons.ts` | `hackathon`, `hackathon_team`, `hackathon_participant`, `hackathon_project`, `hackathon_interest`, `hackathon_event`, `hackathon_event_attendee`, `hackathon_check_in_flag`, `hackathon_announcement`, `hackathon_announcement_recipient` |
| `judge.ts` | `judge`, `judge_assignment` (live roster); `judging_project`, `judge_vote`, `judge_queue`, `hackathon_result` (retired, kept for past editions) |
| `judging.ts` | `judging_*`: config, entries and members, challenges, buildings, rooms and room links, judges, guest sessions, presence, schedule and appointments, rubric, evaluations, deliberation, placements |
| `points.ts` | `hackathon_point_adjustment`, `hackathon_store_item`, `hackathon_store_purchase` |
diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
index 1777c6b6..5612dc42 100644
--- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
+++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
@@ -1,11 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { appRouter } from "../root";
+import { errorFormatter } from "../trpc";
import { cache } from "../middleware/cache";
import {
db,
hackathons,
hackathonParticipants,
hackathonEventAttendees,
+ hackathonCheckInFlags,
} from "@query/db";
// Fully mock the DB at the file level. vi.mock factories are hoisted and
@@ -195,6 +197,16 @@ vi.mock("@query/db", () => {
participantId: "participant_id",
checkedInAt: "checked_in_at",
},
+ hackathonCheckInFlags: {
+ _t: "hackathonCheckInFlags",
+ id: "id",
+ hackathonId: "hackathon_id",
+ participantId: "participant_id",
+ eventId: "event_id",
+ conflictEventId: "conflict_event_id",
+ attempts: "attempts",
+ lastAttemptAt: "last_attempt_at",
+ },
members: {
_t: "members",
id: "id",
@@ -1356,6 +1368,14 @@ describe("Hackathon admin management edge cases", () => {
const attendanceWrites = () =>
mockInsert.mock.calls.filter((c) => c[1][0] === hackathonEventAttendees);
+ // Each refused attempt leaves a flag for organisers; the values are the
+ // third element of the mock's recorded call.
+ const flagWrites = () =>
+ mockInsert.mock.calls.filter((c) => c[1][0] === hackathonCheckInFlags);
+
+ // The event the fixtures' earlier check-ins are for.
+ const BNY = "99999999-9999-4999-8999-999999999999";
+
it("refuses a code check-in into an event that overlaps one already attended", async () => {
const caller = hackerCaller();
mockEarlierAttendance.mockReturnValue([
@@ -1441,6 +1461,182 @@ describe("Hackathon admin management edge cases", () => {
expect(attendanceWrites()).toHaveLength(0);
});
+ describe("flags for organisers", () => {
+ const overlapping = () =>
+ earlier(
+ {
+ startTime: new Date(Date.now() - 30 * MINUTE),
+ endTime: new Date(Date.now() + 30 * MINUTE),
+ },
+ 20,
+ );
+
+ it("records a refused code check-in against the hacker", async () => {
+ const caller = hackerCaller();
+ mockEarlierAttendance.mockReturnValue([overlapping()]);
+
+ await expect(
+ caller.points.me.checkIn({ hackathonId: HACK_A, code: "ABC234" }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+
+ expect(flagWrites()).toHaveLength(1);
+ expect(flagWrites()[0]![2][0]).toEqual({
+ hackathonId: HACK_A,
+ participantId: PART_A1,
+ eventId: EVENT_A,
+ conflictEventId: BNY,
+ rule: "overlap",
+ method: "code",
+ attemptedById: "rules-hacker",
+ attempts: 1,
+ createdAt: expect.any(Date),
+ lastAttemptAt: expect.any(Date),
+ });
+ });
+
+ it("records a refused pass scan, with the staff member who scanned", async () => {
+ const caller = adminCaller({
+ hackathonParticipants: { ...person, registrationStatus: "approved" },
+ hackathonEvents: fireside,
+ hackathonEventAttendees: undefined,
+ });
+ mockEarlierAttendance.mockReturnValue([earlier({}, 10)]);
+
+ await expect(
+ caller.hackathon.scanParticipantPass({
+ hackathonId: HACK_A,
+ eventId: EVENT_A,
+ participantId: PART_A1,
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+
+ expect(flagWrites()).toHaveLength(1);
+ expect(flagWrites()[0]![2][0]).toMatchObject({
+ hackathonId: HACK_A,
+ participantId: PART_A1,
+ eventId: EVENT_A,
+ conflictEventId: BNY,
+ rule: "too_soon",
+ method: "scan",
+ attemptedById: ADMIN_USER,
+ });
+ });
+
+ it("records a refused manual check-in", async () => {
+ const caller = adminCaller({
+ hackathonParticipants: person,
+ hackathonEvents: fireside,
+ });
+ mockEarlierAttendance.mockReturnValue([overlapping()]);
+
+ await expect(
+ caller.hackathon.manualEventCheckIn({
+ hackathonId: HACK_A,
+ eventId: EVENT_A,
+ participantId: PART_A1,
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+
+ expect(flagWrites()).toHaveLength(1);
+ expect(flagWrites()[0]![2][0]).toMatchObject({
+ participantId: PART_A1,
+ rule: "overlap",
+ method: "manual",
+ attemptedById: ADMIN_USER,
+ });
+ });
+
+ it("records nothing for a check-in that goes through", async () => {
+ mockEarlierAttendance.mockReturnValue([earlier({}, 120)]);
+
+ await hackerCaller().points.me.checkIn({
+ hackathonId: HACK_A,
+ code: "ABC234",
+ });
+ await adminCaller({
+ hackathonParticipants: person,
+ hackathonEvents: fireside,
+ }).hackathon.manualEventCheckIn({
+ hackathonId: HACK_A,
+ eventId: EVENT_A,
+ participantId: PART_A1,
+ });
+
+ expect(attendanceWrites()).toHaveLength(2);
+ expect(flagWrites()).toHaveLength(0);
+ });
+
+ it("records nothing for a duplicate scan", async () => {
+ const caller = adminCaller({
+ hackathonParticipants: { ...person, registrationStatus: "approved" },
+ hackathonEvents: fireside,
+ hackathonEventAttendees: { id: "already" },
+ });
+
+ await expect(
+ caller.hackathon.scanParticipantPass({
+ hackathonId: HACK_A,
+ eventId: EVENT_A,
+ participantId: PART_A1,
+ }),
+ ).rejects.toMatchObject({ code: "CONFLICT" });
+
+ expect(flagWrites()).toHaveLength(0);
+ });
+
+ it("gives the hacker the same refusal when the flag cannot be written", async () => {
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+ const caller = hackerCaller();
+ mockEarlierAttendance.mockReturnValue([overlapping()]);
+ mockInsert.mockImplementation((_op, [table]) => {
+ if (table === hackathonCheckInFlags) throw new Error("db is down");
+ return [{}];
+ });
+
+ await expect(
+ caller.points.me.checkIn({ hackathonId: HACK_A, code: "ABC234" }),
+ ).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message:
+ "You're already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. You can check into one event at a time.",
+ });
+ expect(consoleError).toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ it("keeps the flag out of what the hacker is sent", async () => {
+ const caller = hackerCaller();
+ mockEarlierAttendance.mockReturnValue([overlapping()]);
+
+ const error: any = await caller.points.me
+ .checkIn({ hackathonId: HACK_A, code: "ABC234" })
+ .catch((e: unknown) => e);
+
+ // The record rides on the error server side only; the formatter is what
+ // builds the response.
+ const response = JSON.stringify(
+ errorFormatter({
+ shape: {
+ message: error.message,
+ code: -32003,
+ data: {
+ code: "FORBIDDEN",
+ httpStatus: 403,
+ path: "points.me.checkIn",
+ },
+ },
+ error,
+ }),
+ );
+ expect(response).not.toContain(PART_A1);
+ expect(response).not.toContain(BNY);
+ expect(response).not.toContain("CheckInRuleBreak");
+ expect(response).not.toContain("conflictEventId");
+ });
+ });
+
it("does not hold a meal to the rules", async () => {
const caller = adminCaller({
hackathonParticipants: person,
diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts
index db3024b6..c402667a 100644
--- a/packages/api/src/.internal-tests/qr-checkin.test.ts
+++ b/packages/api/src/.internal-tests/qr-checkin.test.ts
@@ -1,7 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { appRouter } from "../root";
import { cache } from "../middleware/cache";
-import { db, events, eventCheckIns, hackathonEventAttendees } from "@query/db";
+import {
+ db,
+ events,
+ eventCheckIns,
+ hackathonCheckInFlags,
+ hackathonEventAttendees,
+} from "@query/db";
// The real rule: a hardcoded "2026-fall" would fail every January.
import { currentTerm } from "@query/db/services/membership";
import { __onRollback } from "./_db-tx-mock";
@@ -191,6 +197,12 @@ vi.mock("@query/db", async () => {
eventId: "event_id",
participantId: "participant_id",
},
+ hackathonCheckInFlags: {
+ participantId: "participant_id",
+ eventId: "event_id",
+ conflictEventId: "conflict_event_id",
+ attempts: "attempts",
+ },
members: { id: "id", userId: "user_id", hackathonId: "hackathon_id" },
events: {
id: "id",
@@ -939,10 +951,75 @@ describe("QR check-in", () => {
expect(err.message).toBe(
"Ada Lovelace is already checked into BNY Workshop, which runs at the same time as Full Sail Fireside. If that was a mistake, remove them from BNY Workshop first.",
);
- expect(mockInsert).not.toHaveBeenCalled();
+ // No attendance and no arrival, but the attempt is on record for
+ // organisers: it is the one write that survives the refusal.
+ const inserts = mockInsert.mock.calls as any[][];
+ expect(
+ inserts.filter((c) => c[1][0] === hackathonEventAttendees),
+ ).toHaveLength(0);
+ const flags = inserts.filter((c) => c[1][0] === hackathonCheckInFlags);
+ expect(flags).toHaveLength(1);
+ expect(flags[0]![2][0]).toMatchObject({
+ hackathonId: HACK_A,
+ participantId: PARTICIPANT,
+ eventId: HACK_EVENT,
+ conflictEventId: CLUB_EVENT,
+ rule: "overlap",
+ method: "scan",
+ attemptedById: "admin_user_id",
+ attempts: 1,
+ });
expect(mockUpdate).not.toHaveBeenCalled();
});
+ it("writes the flag after the refused scan has rolled back, not inside it", async () => {
+ const caller = scannerCtx({
+ event: hackEvent({
+ name: "Full Sail Fireside",
+ type: "sponsor_session",
+ endTime: new Date(Date.now() + HOUR),
+ }),
+ });
+ mockEarlierAttendance.mockReturnValue([
+ {
+ event: {
+ id: CLUB_EVENT,
+ name: "BNY Workshop",
+ type: "workshop",
+ startTime: new Date(Date.now() - 2 * HOUR),
+ endTime: new Date(Date.now() + 2 * HOUR),
+ },
+ checkedInAt: new Date(Date.now() - 90 * 60 * 1000),
+ },
+ ]);
+ // A write made through a transaction registers an undo that runs when the
+ // refusal throws; one made on the pool registers nothing.
+ let flagRolledBack = false;
+ mockInsert.mockImplementation((_op, [table]) => {
+ if (table === hackathonCheckInFlags) {
+ __onRollback(() => {
+ flagRolledBack = true;
+ });
+ }
+ return [{}];
+ });
+
+ await expect(
+ caller.hackathon.scanParticipantPass({
+ hackathonId: HACK_A,
+ eventId: HACK_EVENT,
+ participantId: PARTICIPANT,
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+
+ expect(
+ (mockInsert.mock.calls as any[][]).filter(
+ (c) => c[1][0] === hackathonCheckInFlags,
+ ),
+ ).toHaveLength(1);
+ expect(flagRolledBack).toBe(false);
+ });
+
// BUG: scanParticipantPass matches on (id, hackathonId) only
// (admin.ts:287-298) and never compares startTime/endTime to now, so one
// pass of the scanner can check a participant into the whole weekend.
diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts
index ac10fe55..4737c9f8 100644
--- a/packages/api/src/routers/hackathon/admin.ts
+++ b/packages/api/src/routers/hackathon/admin.ts
@@ -14,6 +14,7 @@ import {
recordEventAttendance,
searchParticipants,
} from "../../services/points";
+import { flaggingRuleBreaks } from "../../services/check-in-flags";
import { CacheKeys } from "../../middleware/cache";
import { MASS_EMAIL_BATCH } from "../../services/email-limits";
import {
@@ -281,7 +282,7 @@ async function checkInParticipant(
// Rolling the attendance back makes a rescan the fix.
let promotedToCheckedIn = false;
- await ctx.db.transaction(async (tx) => {
+ const attendAndPromote = () => ctx.db.transaction(async (tx) => {
await recordEventAttendance(tx, attendance);
// The first scan is the check-in. `checked_in` previously had no writer
@@ -308,6 +309,10 @@ async function checkInParticipant(
promotedToCheckedIn = !!promoted;
});
+ // A refusal for the check-in rules rolls that transaction back, so the flag
+ // for organisers is written here, on the pool, once it has.
+ await flaggingRuleBreaks(ctx.db, input.hackathonId, attendAndPromote);
+
// A scan changes one event's attendee count and, on the first one, this
// attendee's roster row. This runs at every door all weekend, so eviction
// stays narrow — the old blanket pattern wiped every cached registration.
@@ -1358,12 +1363,17 @@ export const hackathonAdminRouter = createTRPCRouter({
});
}
- const { pointsAwarded } = await recordEventAttendance(db, {
- event,
- participant: { id: participant.id, name },
- method: "manual",
- actorId: ctx.userId,
- });
+ const { pointsAwarded } = await flaggingRuleBreaks(
+ db,
+ input.hackathonId,
+ () =>
+ recordEventAttendance(db, {
+ event,
+ participant: { id: participant.id, name },
+ method: "manual",
+ actorId: ctx.userId,
+ }),
+ );
ctx.cache.delete(`hackathon:${input.hackathonId}:events`);
diff --git a/packages/api/src/routers/points/me.ts b/packages/api/src/routers/points/me.ts
index 2aa5f314..2e665a7a 100644
--- a/packages/api/src/routers/points/me.ts
+++ b/packages/api/src/routers/points/me.ts
@@ -11,6 +11,7 @@ import type { DrizzleDB } from "@query/db";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
import { notHackathonBanned } from "../../middleware/procedures";
import { rateLimit } from "../../middleware/security";
+import { flaggingRuleBreaks } from "../../services/check-in-flags";
import {
isSoldOut,
leaderboardName,
@@ -304,12 +305,20 @@ export const pointsMeRouter = createTRPCRouter({
});
}
- const { pointsAwarded } = await recordEventAttendance(db, {
- event,
- participant: { id: participant.id, name: "You" },
- method: "code",
- actorId: ctx.userId,
- });
+ // A refusal for the check-in rules is recorded for organisers, on the
+ // pool and outside the transaction it rolled back. The hacker sees only
+ // the refusal.
+ const { pointsAwarded } = await flaggingRuleBreaks(
+ db,
+ input.hackathonId,
+ () =>
+ recordEventAttendance(db, {
+ event,
+ participant: { id: participant.id, name: "You" },
+ method: "code",
+ actorId: ctx.userId,
+ }),
+ );
// The public schedule shows how many people came.
ctx.cache.delete(`hackathon:${input.hackathonId}:events`);
diff --git a/packages/api/src/services/check-in-flags.test.ts b/packages/api/src/services/check-in-flags.test.ts
new file mode 100644
index 00000000..e3b3e03c
--- /dev/null
+++ b/packages/api/src/services/check-in-flags.test.ts
@@ -0,0 +1,693 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { TRPCError } from "@trpc/server";
+import { PgDialect } from "drizzle-orm/pg-core";
+import {
+ hackathonCheckInFlags,
+ hackathonEvents,
+ judgingEntries,
+ judgingEntryMembers,
+} from "@query/db";
+import type { DrizzleDB } from "@query/db";
+import {
+ checkInFlagsForEntries,
+ entriesForFlags,
+ flaggedEntryIds,
+ flaggingRuleBreaks,
+ openCheckInFlagCount,
+ recordCheckInFlag,
+} from "./check-in-flags";
+import { CheckInRuleBreak } from "./points";
+import type { CheckInRuleBreakDetails } from "./points";
+
+const HACK = "6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b";
+const AT = new Date("2027-02-06T18:00:00Z");
+
+const breach: CheckInRuleBreakDetails = {
+ participantId: "part-1",
+ eventId: "event-2",
+ conflictEventId: "event-1",
+ rule: "overlap",
+ method: "code",
+ actorId: "user-1",
+ at: AT,
+};
+
+const render = (condition: unknown) =>
+ new PgDialect().sqlToQuery(condition as never);
+
+// ---------------------------------------------------------------------------
+// Writing
+
+describe("recordCheckInFlag", () => {
+ const fakeInsertDb = () => {
+ const writes: {
+ table: unknown;
+ values: Record;
+ conflict: { target: unknown[]; set: Record };
+ }[] = [];
+ const db = {
+ insert: (table: unknown) => ({
+ values: (values: Record) => ({
+ onConflictDoUpdate: async (conflict: {
+ target: unknown[];
+ set: Record;
+ }) => {
+ writes.push({ table, values, conflict });
+ },
+ }),
+ }),
+ };
+ return { db: db as unknown as DrizzleDB, writes };
+ };
+
+ it("inserts one row for the person, the event tried and the event they were in", async () => {
+ const { db, writes } = fakeInsertDb();
+ await recordCheckInFlag(db, HACK, breach);
+
+ expect(writes).toHaveLength(1);
+ expect(writes[0]!.table).toBe(hackathonCheckInFlags);
+ // Both timestamps come from the clock the rule was judged on, not from the
+ // database's now().
+ expect(writes[0]!.values).toEqual({
+ hackathonId: HACK,
+ participantId: "part-1",
+ eventId: "event-2",
+ conflictEventId: "event-1",
+ rule: "overlap",
+ method: "code",
+ attemptedById: "user-1",
+ attempts: 1,
+ createdAt: AT,
+ lastAttemptAt: AT,
+ });
+ });
+
+ it("counts a repeat instead of adding a row, and leaves the dismissal alone", async () => {
+ const { db, writes } = fakeInsertDb();
+ await recordCheckInFlag(db, HACK, breach);
+
+ const { target, set } = writes[0]!.conflict;
+ expect(target).toEqual([
+ hackathonCheckInFlags.participantId,
+ hackathonCheckInFlags.eventId,
+ hackathonCheckInFlags.conflictEventId,
+ ]);
+ expect(Object.keys(set).sort()).toEqual(["attempts", "lastAttemptAt"]);
+ expect(set.lastAttemptAt).toBe(AT);
+ expect(render(set.attempts).sql).toBe(
+ '"hackathon_check_in_flag"."attempts" + 1',
+ );
+ });
+
+ it("keeps the staff member as the one who tried when there is no hacker", async () => {
+ const { db, writes } = fakeInsertDb();
+ await recordCheckInFlag(db, HACK, {
+ ...breach,
+ method: "scan",
+ rule: "too_soon",
+ actorId: null,
+ });
+ expect(writes[0]!.values).toMatchObject({
+ method: "scan",
+ rule: "too_soon",
+ attemptedById: null,
+ });
+ });
+});
+
+describe("flaggingRuleBreaks", () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ const refusal = () =>
+ new TRPCError({
+ code: "FORBIDDEN",
+ message: "You're already checked into BNY Workshop.",
+ cause: new CheckInRuleBreak(breach),
+ });
+
+ // A pool whose insert chain ends in `onConflictDoUpdate`, noting each flag.
+ const pool = (opts: { failWith?: Error } = {}) => {
+ const flags: unknown[] = [];
+ const db = {
+ insert: () => ({
+ values: (values: unknown) => ({
+ onConflictDoUpdate: async () => {
+ if (opts.failWith) throw opts.failWith;
+ flags.push(values);
+ },
+ }),
+ }),
+ };
+ return { db: db as unknown as DrizzleDB, flags };
+ };
+
+ it("returns what the write returned and records nothing", async () => {
+ const { db, flags } = pool();
+ await expect(
+ flaggingRuleBreaks(db, HACK, async () => ({ pointsAwarded: 5 })),
+ ).resolves.toEqual({ pointsAwarded: 5 });
+ expect(flags).toEqual([]);
+ });
+
+ it("records a refusal for the check-in rules and rethrows the same error", async () => {
+ const { db, flags } = pool();
+ const error = refusal();
+
+ const caught = await flaggingRuleBreaks(db, HACK, async () => {
+ throw error;
+ }).catch((e: unknown) => e);
+
+ expect(caught).toBe(error);
+ expect(flags).toEqual([
+ expect.objectContaining({
+ hackathonId: HACK,
+ participantId: "part-1",
+ eventId: "event-2",
+ conflictEventId: "event-1",
+ rule: "overlap",
+ method: "code",
+ attemptedById: "user-1",
+ }),
+ ]);
+ });
+
+ it("ignores every other failure", async () => {
+ for (const error of [
+ new Error("connection reset"),
+ new TRPCError({ code: "CONFLICT", message: "Already checked in." }),
+ new TRPCError({
+ code: "FORBIDDEN",
+ message: "Not accepted.",
+ cause: new Error("something else"),
+ }),
+ // A cause shaped like a rule break is not one.
+ new TRPCError({
+ code: "FORBIDDEN",
+ message: "Lookalike.",
+ cause: Object.assign(new Error("lookalike"), { ...breach }),
+ }),
+ ]) {
+ const { db, flags } = pool();
+ const caught = await flaggingRuleBreaks(db, HACK, async () => {
+ throw error;
+ }).catch((e: unknown) => e);
+ expect(caught).toBe(error);
+ expect(flags).toEqual([]);
+ }
+ });
+
+ it("still throws the original refusal when the flag cannot be written", async () => {
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+ const { db } = pool({ failWith: new Error("database is down") });
+ const error = refusal();
+
+ const caught = await flaggingRuleBreaks(db, HACK, async () => {
+ throw error;
+ }).catch((e: unknown) => e);
+
+ // The person at the door reads the same sentence either way.
+ expect(caught).toBe(error);
+ expect((caught as TRPCError).code).toBe("FORBIDDEN");
+ expect((caught as TRPCError).message).toBe(
+ "You're already checked into BNY Workshop.",
+ );
+ expect(consoleError).toHaveBeenCalledTimes(1);
+ });
+
+ it("is not thrown off when the pool itself is unusable", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const db = {
+ insert: () => {
+ throw new Error("pool is closed");
+ },
+ } as unknown as DrizzleDB;
+ const error = refusal();
+
+ await expect(
+ flaggingRuleBreaks(db, HACK, async () => {
+ throw error;
+ }),
+ ).rejects.toBe(error);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Reading
+
+describe("entriesForFlags", () => {
+ const flag = (
+ id: string,
+ over: Partial<{
+ participantId: string;
+ teamId: string | null;
+ userId: string;
+ }> = {},
+ ) => ({
+ id,
+ participantId: `part-${id}`,
+ teamId: null as string | null,
+ userId: `user-${id}`,
+ ...over,
+ });
+
+ it("puts a hacker's flag on their team's portal entry", () => {
+ const result = entriesForFlags(
+ [flag("f1", { teamId: "team-1" })],
+ [
+ { entryId: "e-team", teamId: "team-1", submittedById: "part-cap" },
+ { entryId: "e-other", teamId: "team-2", submittedById: "part-x" },
+ ],
+ [],
+ );
+ expect(result.get("f1")).toEqual(new Set(["e-team"]));
+ });
+
+ it("matches a solo project by who filed it", () => {
+ const result = entriesForFlags(
+ [flag("f1")],
+ [
+ { entryId: "e-solo", teamId: null, submittedById: "part-f1" },
+ { entryId: "e-not-me", teamId: null, submittedById: "part-other" },
+ ],
+ [],
+ );
+ expect(result.get("f1")).toEqual(new Set(["e-solo"]));
+ });
+
+ it("does not treat a former captain as a member of the team's project", () => {
+ // They filed it, then moved to team-2: only the team link counts for a
+ // project that has a team.
+ const result = entriesForFlags(
+ [flag("f1", { teamId: "team-2" })],
+ [{ entryId: "e-team-1", teamId: "team-1", submittedById: "part-f1" }],
+ [],
+ );
+ expect(result.get("f1")).toEqual(new Set());
+ });
+
+ it("does not match teamless hackers to teamless projects by their missing team", () => {
+ const result = entriesForFlags(
+ [flag("f1", { teamId: null })],
+ [{ entryId: "e-solo", teamId: null, submittedById: "part-other" }],
+ [],
+ );
+ expect(result.get("f1")).toEqual(new Set());
+ });
+
+ it("matches a member by account, which covers Devpost entries and claimed teammates", () => {
+ const result = entriesForFlags(
+ [flag("f1")],
+ [],
+ [
+ { entryId: "e-devpost", userId: "user-f1" },
+ { entryId: "e-unclaimed", userId: null },
+ { entryId: "e-else", userId: "user-else" },
+ ],
+ );
+ expect(result.get("f1")).toEqual(new Set(["e-devpost"]));
+ });
+
+ it("counts an entry once however many ways the hacker reaches it", () => {
+ const result = entriesForFlags(
+ [flag("f1", { teamId: "team-1" })],
+ [{ entryId: "e1", teamId: "team-1", submittedById: null }],
+ [
+ { entryId: "e1", userId: "user-f1" },
+ { entryId: "e2", userId: "user-f1" },
+ ],
+ );
+ expect(result.get("f1")).toEqual(new Set(["e1", "e2"]));
+ });
+
+ it("gives every flag an answer, empty when it is on nobody's entry", () => {
+ const result = entriesForFlags(
+ [flag("f1"), flag("f2", { teamId: "team-1" })],
+ [{ entryId: "e1", teamId: "team-1", submittedById: null }],
+ [],
+ );
+ expect(result.get("f1")).toEqual(new Set());
+ expect(result.get("f2")).toEqual(new Set(["e1"]));
+ });
+});
+
+describe("reading flags", () => {
+ // A db that answers by the table a query reads from, and notes each query so
+ // a test can say how many there were and what they asked for. It does not
+ // evaluate any SQL, so the conditions are checked by rendering them.
+ const fakeReadDb = (rows: Map) => {
+ const calls: {
+ table: unknown;
+ fields: Record;
+ where?: unknown;
+ }[] = [];
+ const db = {
+ select: (fields: Record) => ({
+ from: (table: unknown) => {
+ const call: (typeof calls)[number] = { table, fields };
+ calls.push(call);
+ // Chainable and awaitable, like a drizzle query.
+ type Query = {
+ innerJoin: () => Query;
+ where: (condition: unknown) => Query;
+ then: (
+ resolve: (value: unknown) => unknown,
+ reject: (reason: unknown) => unknown,
+ ) => Promise;
+ };
+ const node: Query = {
+ innerJoin: () => node,
+ where: (condition) => {
+ call.where = condition;
+ return node;
+ },
+ then: (resolve, reject) =>
+ Promise.resolve(rows.get(table) ?? []).then(resolve, reject),
+ };
+ return node;
+ },
+ }),
+ };
+ return { db: db as unknown as DrizzleDB, calls };
+ };
+
+ const flagRow = (id: string, over: Record = {}) => ({
+ id,
+ participantId: `part-${id}`,
+ eventId: "event-2",
+ conflictEventId: "event-1",
+ rule: "overlap",
+ method: "code",
+ attempts: 1,
+ createdAt: new Date("2027-02-06T18:00:00Z"),
+ lastAttemptAt: new Date("2027-02-06T18:00:00Z"),
+ dismissedAt: null,
+ teamId: null,
+ userId: `user-${id}`,
+ firstName: "Ada",
+ lastName: "Lovelace",
+ accountName: "ada l",
+ email: "ada@example.com",
+ ...over,
+ });
+
+ const events = [
+ { id: "event-1", name: "BNY Workshop" },
+ { id: "event-2", name: "Full Sail Fireside" },
+ ];
+
+ const table = (...entries: [unknown, unknown[]][]) => new Map(entries);
+
+ describe("checkInFlagsForEntries", () => {
+ it("asks nothing for an empty list of entries", async () => {
+ const { db, calls } = fakeReadDb(table());
+ const result = await checkInFlagsForEntries(db, HACK, []);
+ expect(result.size).toBe(0);
+ expect(calls).toEqual([]);
+ });
+
+ it("stops after one read when the edition has no flags", async () => {
+ const { db, calls } = fakeReadDb(table());
+ const result = await checkInFlagsForEntries(db, HACK, null);
+ expect(result.size).toBe(0);
+ expect(calls).toHaveLength(1);
+ });
+
+ it("describes each flag with names for organisers", async () => {
+ const { db } = fakeReadDb(
+ table(
+ [
+ hackathonCheckInFlags,
+ [
+ flagRow("f1", {
+ teamId: "team-1",
+ rule: "too_soon",
+ method: "scan",
+ attempts: 3,
+ }),
+ ],
+ ],
+ [hackathonEvents, events],
+ [
+ judgingEntries,
+ [{ entryId: "e1", teamId: "team-1", submittedById: null }],
+ ],
+ ),
+ );
+
+ const result = await checkInFlagsForEntries(db, HACK, ["e1"]);
+
+ expect(result.get("e1")).toEqual([
+ {
+ id: "f1",
+ participantName: "Ada Lovelace",
+ rule: "too_soon",
+ eventName: "Full Sail Fireside",
+ conflictEventName: "BNY Workshop",
+ method: "scan",
+ attempts: 3,
+ createdAt: new Date("2027-02-06T18:00:00Z"),
+ lastAttemptAt: new Date("2027-02-06T18:00:00Z"),
+ dismissed: false,
+ },
+ ]);
+ });
+
+ it("falls back to the account name, then the email, when the registration has none", async () => {
+ const { db } = fakeReadDb(
+ table(
+ [
+ hackathonCheckInFlags,
+ [
+ flagRow("f1", { firstName: null, lastName: null }),
+ flagRow("f2", {
+ firstName: null,
+ lastName: null,
+ accountName: null,
+ }),
+ ],
+ ],
+ [hackathonEvents, events],
+ [
+ judgingEntryMembers,
+ [
+ { entryId: "e1", userId: "user-f1" },
+ { entryId: "e1", userId: "user-f2" },
+ ],
+ ],
+ ),
+ );
+
+ const flags = (await checkInFlagsForEntries(db, HACK, null)).get("e1")!;
+ expect(flags.map((f) => f.participantName).sort()).toEqual([
+ "ada l",
+ "ada@example.com",
+ ]);
+ });
+
+ it("lists open flags before cleared ones, newest attempt first", async () => {
+ const t = (minute: number) => new Date(Date.UTC(2027, 1, 6, 18, minute));
+ const { db } = fakeReadDb(
+ table(
+ [
+ hackathonCheckInFlags,
+ [
+ flagRow("old-open", { lastAttemptAt: t(1) }),
+ flagRow("cleared", {
+ lastAttemptAt: t(30),
+ dismissedAt: t(40),
+ }),
+ flagRow("new-open", { lastAttemptAt: t(20) }),
+ ],
+ ],
+ [hackathonEvents, events],
+ [
+ judgingEntryMembers,
+ ["old-open", "cleared", "new-open"].map((id) => ({
+ entryId: "e1",
+ userId: `user-${id}`,
+ })),
+ ],
+ ),
+ );
+
+ const flags = (await checkInFlagsForEntries(db, HACK, ["e1"])).get("e1")!;
+ expect(flags.map((f) => [f.id, f.dismissed])).toEqual([
+ ["new-open", false],
+ ["old-open", false],
+ ["cleared", true],
+ ]);
+ });
+
+ it("keeps to the entries asked for, and to every entry when asked for null", async () => {
+ const rows = table(
+ [hackathonCheckInFlags, [flagRow("f1", { teamId: "team-1" })]],
+ [hackathonEvents, events],
+ [
+ judgingEntries,
+ [
+ { entryId: "e1", teamId: "team-1", submittedById: null },
+ { entryId: "e2", teamId: "team-1", submittedById: null },
+ ],
+ ],
+ );
+
+ const some = await checkInFlagsForEntries(fakeReadDb(rows).db, HACK, [
+ "e2",
+ "e-unflagged",
+ ]);
+ expect([...some.keys()]).toEqual(["e2"]);
+
+ const all = await checkInFlagsForEntries(fakeReadDb(rows).db, HACK, null);
+ expect([...all.keys()].sort()).toEqual(["e1", "e2"]);
+ // One flag, one description, wherever it shows.
+ expect(all.get("e1")![0]).toBe(all.get("e2")![0]);
+ });
+
+ it("leaves out a flag whose hacker is on no entry", async () => {
+ const { db } = fakeReadDb(
+ table(
+ [hackathonCheckInFlags, [flagRow("f1")]],
+ [hackathonEvents, events],
+ ),
+ );
+ const result = await checkInFlagsForEntries(db, HACK, null);
+ expect(result.size).toBe(0);
+ });
+
+ it("reads in four batched queries however many flags there are", async () => {
+ const many = Array.from({ length: 40 }, (_, i) =>
+ flagRow(`f${i}`, { teamId: `team-${i}` }),
+ );
+ const { db, calls } = fakeReadDb(
+ table([hackathonCheckInFlags, many], [hackathonEvents, events]),
+ );
+
+ await checkInFlagsForEntries(db, HACK, null);
+
+ expect(calls.map((c) => c.table)).toEqual([
+ hackathonCheckInFlags,
+ hackathonEvents,
+ judgingEntries,
+ judgingEntryMembers,
+ ]);
+ });
+
+ it("looks for live entries of this edition only", async () => {
+ const { db, calls } = fakeReadDb(
+ table(
+ [hackathonCheckInFlags, [flagRow("f1", { teamId: "team-1" })]],
+ [hackathonEvents, events],
+ ),
+ );
+ await checkInFlagsForEntries(db, HACK, null);
+
+ const portal = render(
+ calls.find((c) => c.table === judgingEntries)!.where,
+ );
+ const members = render(
+ calls.find((c) => c.table === judgingEntryMembers)!.where,
+ );
+ for (const query of [portal, members]) {
+ expect(query.sql).toContain('"deleted_at" is null');
+ expect(query.params).toContain(HACK);
+ }
+ expect(portal.params).toContain("portal");
+ expect(portal.params).toContain("team-1");
+ expect(members.params).toContain("user-f1");
+ });
+ });
+
+ describe("flaggedEntryIds", () => {
+ const target = (id: string, over: Record = {}) => ({
+ id,
+ participantId: `part-${id}`,
+ teamId: null,
+ userId: `user-${id}`,
+ ...over,
+ });
+
+ it("asks nothing for an empty list of entries", async () => {
+ const { db, calls } = fakeReadDb(table());
+ expect(await flaggedEntryIds(db, HACK, [])).toEqual(new Set());
+ expect(calls).toEqual([]);
+ });
+
+ it("returns only the asked-for entries that carry a flag", async () => {
+ const { db } = fakeReadDb(
+ table(
+ [hackathonCheckInFlags, [target("f1", { teamId: "team-1" })]],
+ [
+ judgingEntries,
+ [
+ { entryId: "e1", teamId: "team-1", submittedById: null },
+ { entryId: "e-not-asked", teamId: "team-1", submittedById: null },
+ ],
+ ],
+ ),
+ );
+ const flagged = await flaggedEntryIds(db, HACK, ["e1", "e2"]);
+ expect(flagged).toEqual(new Set(["e1"]));
+ });
+
+ it("reads open flags only, and never who or why", async () => {
+ const { db, calls } = fakeReadDb(table());
+ await flaggedEntryIds(db, HACK, ["e1"]);
+
+ const flags = calls.find((c) => c.table === hackathonCheckInFlags)!;
+ const where = render(flags.where);
+ expect(where.sql).toContain('"dismissed_at" is null');
+ expect(where.params).toContain(HACK);
+ // Ids to find the team by, and nothing a judge could read.
+ expect(Object.keys(flags.fields).sort()).toEqual([
+ "id",
+ "participantId",
+ "teamId",
+ "userId",
+ ]);
+ });
+ });
+
+ describe("openCheckInFlagCount", () => {
+ it("is zero without reading entries when nothing is open", async () => {
+ const { db, calls } = fakeReadDb(table());
+ expect(await openCheckInFlagCount(db, HACK)).toBe(0);
+ expect(calls).toHaveLength(1);
+ });
+
+ it("counts each open flag that sits on an entry once, and skips the rest", async () => {
+ const { db } = fakeReadDb(
+ table(
+ [
+ hackathonCheckInFlags,
+ [
+ { id: "f1", participantId: "p1", teamId: "team-1", userId: "u1" },
+ { id: "f2", participantId: "p2", teamId: null, userId: "u2" },
+ { id: "f3", participantId: "p3", teamId: null, userId: "u3" },
+ ],
+ ],
+ [
+ judgingEntries,
+ [
+ { entryId: "e1", teamId: "team-1", submittedById: null },
+ { entryId: "e2", teamId: "team-1", submittedById: null },
+ ],
+ ],
+ [judgingEntryMembers, [{ entryId: "e3", userId: "u2" }]],
+ ),
+ );
+ // f1 is on two entries and counts once, f2 reaches one through its
+ // account, f3 is on nobody's.
+ expect(await openCheckInFlagCount(db, HACK)).toBe(2);
+ });
+
+ it("reads open flags of this edition only", async () => {
+ const { db, calls } = fakeReadDb(table());
+ await openCheckInFlagCount(db, HACK);
+ const where = render(calls[0]!.where);
+ expect(where.sql).toContain('"dismissed_at" is null');
+ expect(where.params).toContain(HACK);
+ });
+ });
+});
diff --git a/packages/api/src/services/check-in-flags.ts b/packages/api/src/services/check-in-flags.ts
new file mode 100644
index 00000000..15135ca7
--- /dev/null
+++ b/packages/api/src/services/check-in-flags.ts
@@ -0,0 +1,405 @@
+import { TRPCError } from "@trpc/server";
+import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
+import {
+ hackathonCheckInFlags,
+ hackathonEvents,
+ hackathonParticipants,
+ hackathonProjects,
+ judgingEntries,
+ judgingEntryMembers,
+ users,
+} from "@query/db";
+import type { DrizzleDB } from "@query/db";
+import type { JudgingDb } from "./judging/config";
+import { CheckInRuleBreak, fullName, personName } from "./points";
+import type { CheckInRuleBreakDetails } from "./points";
+
+// Check-in rule breaks, as organisers and judges meet them. recordEventAttendance
+// refuses an overlapping or too-soon check-in and the refusal rolls its
+// transaction back, so the attempt is written here, on the pool, afterwards.
+// A flag belongs to a person; judging shows it on the team they are on, found
+// when it is read (see entriesForFlags) because people change teams and
+// projects are promoted late. Hackers never read any of this, and nothing here
+// may reach a cached or public payload.
+
+/** One refused attempt, as organisers read it. Judges get only an entry id. */
+export type CheckInFlagView = {
+ id: string;
+ participantName: string;
+ rule: "overlap" | "too_soon";
+ /** The event they tried to enter. */
+ eventName: string;
+ /** The event they were already in. */
+ conflictEventName: string;
+ method: "scan" | "code" | "manual";
+ attempts: number;
+ createdAt: Date;
+ lastAttemptAt: Date;
+ /** An organiser reviewed it and cleared it. */
+ dismissed: boolean;
+};
+
+/**
+ * Stores a refused attempt. The same person trying the same pair of events
+ * again adds to `attempts` instead of a second row; a dismissal stays as it
+ * is, so a cleared flag does not reopen itself but still shows the retry count.
+ */
+export async function recordCheckInFlag(
+ db: DrizzleDB,
+ hackathonId: string,
+ breach: CheckInRuleBreakDetails,
+): Promise {
+ await db
+ .insert(hackathonCheckInFlags)
+ .values({
+ hackathonId,
+ participantId: breach.participantId,
+ eventId: breach.eventId,
+ conflictEventId: breach.conflictEventId,
+ rule: breach.rule,
+ method: breach.method,
+ attemptedById: breach.actorId,
+ attempts: 1,
+ createdAt: breach.at,
+ lastAttemptAt: breach.at,
+ })
+ .onConflictDoUpdate({
+ target: [
+ hackathonCheckInFlags.participantId,
+ hackathonCheckInFlags.eventId,
+ hackathonCheckInFlags.conflictEventId,
+ ],
+ set: {
+ attempts: sql`${hackathonCheckInFlags.attempts} + 1`,
+ lastAttemptAt: breach.at,
+ },
+ });
+}
+
+/**
+ * Runs an attendance write and, if the check-in rules refuse it, records the
+ * attempt on the pool before rethrowing. `db` has to be the pool and not the
+ * transaction the refusal rolled back: a row written inside it would go with
+ * it. Writing the flag is best effort. The person at the door must see the same
+ * refusal whether or not the flag landed, so a failure here is logged and the
+ * original error still goes out.
+ */
+export async function flaggingRuleBreaks(
+ db: DrizzleDB,
+ hackathonId: string,
+ run: () => Promise,
+): Promise {
+ try {
+ return await run();
+ } catch (error) {
+ if (error instanceof TRPCError && error.cause instanceof CheckInRuleBreak) {
+ try {
+ await recordCheckInFlag(db, hackathonId, error.cause);
+ } catch (flagError) {
+ // Deliberate server-side logging: the refusal is the answer the person
+ // gets, so the console is the only place a lost flag can show up.
+ // eslint-disable-next-line no-console
+ console.error(
+ "[CheckInFlags] Failed to record a rule break:",
+ flagError,
+ );
+ }
+ }
+ throw error;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Reading flags against judging entries
+
+/** What finding a flag's entries needs from the flag and its participant. */
+export type FlagTarget = {
+ id: string;
+ participantId: string;
+ teamId: string | null;
+ userId: string;
+};
+
+/** A portal entry and the project it was promoted from. */
+export type PortalEntryLink = {
+ entryId: string;
+ teamId: string | null;
+ submittedById: string | null;
+};
+
+/** A person on an entry, claimed or not. */
+export type MemberEntryLink = { entryId: string; userId: string | null };
+
+const pushTo = (map: Map, key: string, value: string) => {
+ const list = map.get(key);
+ if (list) list.push(value);
+ else map.set(key, [value]);
+};
+
+/**
+ * The entries each flag belongs to, by flag id. A participant is on an entry
+ * when it is a portal project filed by their team, or by them alone when it
+ * has no team, or when they are one of its members (which covers Devpost
+ * entries and claimed teammates). Reaching the same entry by more than one
+ * route counts once. A flag on nobody's entry maps to an empty set. Pure, so
+ * the matching is testable without a database.
+ */
+export function entriesForFlags(
+ flags: readonly FlagTarget[],
+ portal: readonly PortalEntryLink[],
+ members: readonly MemberEntryLink[],
+): Map> {
+ const byTeam = new Map();
+ const bySoloSubmitter = new Map();
+ for (const link of portal) {
+ if (link.teamId) pushTo(byTeam, link.teamId, link.entryId);
+ else if (link.submittedById) {
+ pushTo(bySoloSubmitter, link.submittedById, link.entryId);
+ }
+ }
+ const byUser = new Map();
+ for (const link of members) {
+ if (link.userId) pushTo(byUser, link.userId, link.entryId);
+ }
+
+ const result = new Map>();
+ for (const flag of flags) {
+ const entries = new Set();
+ const sources = [
+ flag.teamId ? byTeam.get(flag.teamId) : undefined,
+ bySoloSubmitter.get(flag.participantId),
+ byUser.get(flag.userId),
+ ];
+ for (const ids of sources) for (const id of ids ?? []) entries.add(id);
+ result.set(flag.id, entries);
+ }
+ return result;
+}
+
+/** Resolves flags to live entries of the edition in two batched reads. */
+async function entriesFor(
+ db: JudgingDb,
+ hackathonId: string,
+ flags: readonly FlagTarget[],
+): Promise
)}
{next.title}
- {where(next, false)}
+
+
{where(next, false)}
+ {next.flagged && (
+
+ Flagged
+
+ )}
+