From f1a522ce89efee073d23f97a144bdc30ca1564ec Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Thu, 24 Sep 2026 21:41:17 -0400
Subject: [PATCH 1/3] feat: open event check-in to everyone, not just members
events.checkIn no longer requires an active membership: anyone signed in
can scan an event QR and have their attendance recorded. A member's
check-in still carries their memberId. Bootcamp-only sessions keep their
own current-term enrolment check.
The only self check-in scanner lived on /club, which sends non-members
to /dashboard, so a non-member had no way to scan in even at an event
open to them. The scanner, mutation, and result modals move into
useEventCheckIn (components/portal/EventCheckIn.tsx), shared by /club
and a new /checkin page that takes anyone signed in. /checkin is in the
portal nav for everyone and in proxy.ts's private (no-store) prefixes.
The admin "Members only" toggle is gone and create/update no longer
accept membersOnly (zod strips it from older clients). The members_only
column stays: schema changes here are additive only, so it is left
unread and can be dropped in a later change.
---
docs/packages/api.md | 2 +-
.../src/.internal-tests/qr-checkin.test.ts | 58 +++++----
.../api/src/.internal-tests/routers.test.ts | 4 +-
packages/api/src/routers/events.ts | 31 +----
packages/db/src/schemas/events.ts | 5 +-
sites/mainweb/app/(portal)/admin/page.tsx | 5 -
sites/mainweb/app/(portal)/checkin/page.tsx | 92 ++++++++++++++
sites/mainweb/app/(portal)/club/page.tsx | 100 +--------------
sites/mainweb/app/(portal)/dashboard/page.tsx | 2 +-
.../components/portal/EventCheckIn.tsx | 118 ++++++++++++++++++
.../components/portal/EventFormModal.tsx | 23 ----
sites/mainweb/lib/portal-nav.test.ts | 2 +
sites/mainweb/lib/portal-nav.ts | 2 +
sites/mainweb/proxy.ts | 1 +
14 files changed, 258 insertions(+), 187 deletions(-)
create mode 100644 sites/mainweb/app/(portal)/checkin/page.tsx
create mode 100644 sites/mainweb/components/portal/EventCheckIn.tsx
diff --git a/docs/packages/api.md b/docs/packages/api.md
index 903c8cc9..ecdbd101 100644
--- a/docs/packages/api.md
+++ b/docs/packages/api.md
@@ -96,7 +96,7 @@ Batch status updates allow up to **2500** ids. The global array cap in `scrubMar
**Members:** `me`, `register`, `update`, `history`, `myPass`, `rotatePass`, `checkStatus`; admin `list`, `getById`, `adminSearch`, `adminHistory`, `adminGrant`, `adminRevoke`.
-**Events:** admin CRUD + QR regenerate; public `list`; member `checkIn`, `myEvents`, `myStats`; scanner `manualCheckIn`, `scanMemberPass`, `attendees`, `removeAttendance`.
+**Events:** admin CRUD + QR regenerate; public `list`; signed-in `checkIn` (members and non-members; `/checkin` page), `myEvents`, `myStats`; scanner `manualCheckIn`, `scanMemberPass`, `attendees`, `removeAttendance`.
**Initiatives:** leaders `listMine`, `getById`, `create`, `update`, `setStatus`, `setArchived`, `decide`; members `list`, apply/withdraw, propose; admin proposal review; super admin `setLeader`.
diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts
index 26d2dac5..105e07d9 100644
--- a/packages/api/src/.internal-tests/qr-checkin.test.ts
+++ b/packages/api/src/.internal-tests/qr-checkin.test.ts
@@ -297,7 +297,7 @@ describe("QR check-in", () => {
// -------------------------------------------------------------------
describe("Club event door QR", () => {
- it("turns a lapsed member away and records nothing", async () => {
+ it("admits a lapsed member and keeps their member row on the check-in", async () => {
mockFindFirst.mockImplementation((table: string) => {
if (table === "events") return clubEvent();
if (table === "hackathons") return { id: HACK_A };
@@ -306,15 +306,13 @@ describe("QR check-in", () => {
});
const caller = appRouter.createCaller(createMockCtx("lapsed_user"));
- const err: any = await caller.events
- .checkIn({ qrCode: QR_OLD })
- .catch((e: unknown) => e);
-
- expect(err.code).toBe("FORBIDDEN");
- expect(err.message).toMatch(/membership is not active/);
- // No attendance row, no capacity counter movement.
- expect(mockInsert).not.toHaveBeenCalled();
- expect(mockUpdate).not.toHaveBeenCalled();
+ await expect(
+ caller.events.checkIn({ qrCode: QR_OLD }),
+ ).resolves.toMatchObject({ success: true });
+ expect(mockInsert.mock.calls.at(-1)?.[2]?.[0]).toMatchObject({
+ userId: "lapsed_user",
+ memberId: activeMember.id,
+ });
});
// Sold by the semester while a membership runs a year, so the door asks
@@ -1151,10 +1149,11 @@ describe("QR check-in", () => {
// -------------------------------------------------------------------
/**
- * A kickoff or interest meeting is run to recruit members, so refusing
- * everyone who is not one yet left exactly those events with no attendance.
+ * Check-in is open to everyone signed in. A kickoff or interest meeting is
+ * run to recruit members, so refusing everyone who is not one yet left
+ * exactly those events with no attendance.
*/
- describe("Events open to non-members", () => {
+ describe("Check-in for non-members", () => {
// clearAllMocks keeps implementations, so the write simulations the
// concurrency tests above install would otherwise decide these outcomes.
beforeEach(() => {
@@ -1172,14 +1171,22 @@ describe("QR check-in", () => {
return appRouter.createCaller(createMockCtx("guest_user"));
};
- it("admits a non-member to an event that is not members only", async () => {
+ it("admits a non-member", async () => {
await expect(
- nonMemberAt({ membersOnly: false }).events.checkIn({ qrCode: QR_OLD }),
+ nonMemberAt({}).events.checkIn({ qrCode: QR_OLD }),
+ ).resolves.toMatchObject({ success: true });
+ });
+
+ // Events created before the change still carry members_only = true, and
+ // the door no longer reads it.
+ it("admits a non-member to an event still flagged members only", async () => {
+ await expect(
+ nonMemberAt({ membersOnly: true }).events.checkIn({ qrCode: QR_OLD }),
).resolves.toMatchObject({ success: true });
});
it("records the check-in with no member row attached", async () => {
- await nonMemberAt({ membersOnly: false }).events.checkIn({
+ await nonMemberAt({}).events.checkIn({
qrCode: QR_OLD,
});
@@ -1191,21 +1198,12 @@ describe("QR check-in", () => {
});
});
- it("still turns a non-member away from a members-only event", async () => {
- await expect(
- nonMemberAt({ membersOnly: true }).events.checkIn({ qrCode: QR_OLD }),
- ).rejects.toThrow(/Must be a member/i);
- });
-
- /**
- * Fail closed. A row read before the column existed reports `undefined`,
- * and an access gate that reads that as "open to everyone" is the wrong
- * way round.
- */
- it("treats an unknown membersOnly value as members only", async () => {
+ it("still turns a non-member away from a bootcamp-only session", async () => {
await expect(
- nonMemberAt({}).events.checkIn({ qrCode: QR_OLD }),
- ).rejects.toThrow(/Must be a member/i);
+ nonMemberAt({ bootcampOnly: true, bootcampWeek: 3 }).events.checkIn({
+ qrCode: QR_OLD,
+ }),
+ ).rejects.toThrow(/bootcamp members/i);
});
});
diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts
index af18dc21..401eb744 100644
--- a/packages/api/src/.internal-tests/routers.test.ts
+++ b/packages/api/src/.internal-tests/routers.test.ts
@@ -1308,7 +1308,7 @@ describe("Router Integration and Access Control Verification Suite", () => {
expect(res.eventTitle).toBe("General Meeting");
});
- it("should block non-members from checking into events", async () => {
+ it("should let non-members check into events", async () => {
const ctx = createMockCtx("non_member_user_id");
mockFindFirst.mockImplementation((table) => {
@@ -1324,7 +1324,7 @@ describe("Router Integration and Access Control Verification Suite", () => {
const caller = appRouter.createCaller(ctx);
await expect(
caller.events.checkIn({ qrCode: "00000000-0000-4000-8000-000000000099" }),
- ).rejects.toThrowError("Must be a member to check in");
+ ).resolves.toMatchObject({ success: true });
});
});
diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts
index eba32d17..ff540984 100644
--- a/packages/api/src/routers/events.ts
+++ b/packages/api/src/routers/events.ts
@@ -29,7 +29,6 @@ export const eventRouter = createTRPCRouter({
location: z.string().max(200).optional(),
eventDate: z.date(),
maxCheckIns: z.number().int().positive().optional(),
- membersOnly: z.boolean().optional(),
/** Marks this event as week N of the bootcamp running this term. */
bootcampWeek: z.number().int().min(1).max(52).optional(),
bootcampOnly: z.boolean().optional(),
@@ -80,7 +79,6 @@ export const eventRouter = createTRPCRouter({
eventDate: z.date().optional(),
/** Null removes the cap. */
maxCheckIns: z.number().int().positive().nullable().optional(),
- membersOnly: z.boolean().optional(),
/** Null takes the event back out of the bootcamp. */
bootcampWeek: z.number().int().min(1).max(52).nullable().optional(),
bootcampOnly: z.boolean().optional(),
@@ -363,31 +361,10 @@ export const eventRouter = createTRPCRouter({
}),
]);
- // An event marked open to everyone takes attendance from non-members too —
- // that is the point of a kickoff. Only an explicit false opens the door:
- // anything else, including a row read before the column existed, keeps the
- // membership gate.
- if (event.membersOnly !== false) {
- if (!member) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "Must be a member to check in",
- });
- }
-
- // isActive alone still admits a lapsed membership the portal already reports
- // as expired.
- if (
- !member.isActive ||
- !member.membershipEndDate ||
- member.membershipEndDate <= new Date()
- ) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "Your membership is not active",
- });
- }
- }
+ // Check-in is open to everyone signed in, member or not: attendance is
+ // recorded for whoever is in the room. The member row is still looked up
+ // so a member's check-in carries their memberId. events.membersOnly is no
+ // longer read.
// Bought per semester, so last term's seat is not this term's. Officers can
// still check somebody in by hand.
diff --git a/packages/db/src/schemas/events.ts b/packages/db/src/schemas/events.ts
index a3641487..d8faf932 100644
--- a/packages/db/src/schemas/events.ts
+++ b/packages/db/src/schemas/events.ts
@@ -26,9 +26,8 @@ export const events = pgTable(
eventDate: timestamp("event_date").notNull(),
qrCode: text("qr_code").notNull().unique(),
checkInEnabled: boolean("check_in_enabled").notNull().default(true),
- // A kickoff or interest meeting is run to recruit members, so refusing
- // everyone who is not one yet leaves exactly those events with no recordable
- // attendance. Defaults true so existing events keep their behaviour.
+ // No longer read: check-in is open to everyone signed in. Kept because
+ // schema changes here are additive only; drop it in a later change.
membersOnly: boolean("members_only").notNull().default(true),
/** Week N of the bootcamp. Null on every event that is not a session. */
bootcampWeek: integer("bootcamp_week"),
diff --git a/sites/mainweb/app/(portal)/admin/page.tsx b/sites/mainweb/app/(portal)/admin/page.tsx
index a2b53518..c95d14a9 100644
--- a/sites/mainweb/app/(portal)/admin/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/page.tsx
@@ -21,7 +21,6 @@ type Event = {
checkInEnabled: boolean;
currentCheckIns: number;
maxCheckIns: number | null;
- membersOnly: boolean;
bootcampWeek: number | null;
bootcampOnly: boolean;
};
@@ -117,7 +116,6 @@ export default function AdminPage() {
location: string;
eventDate: string;
maxCheckIns: string;
- membersOnly: boolean;
bootcampWeek: string;
bootcampOnly: boolean;
};
@@ -147,7 +145,6 @@ export default function AdminPage() {
location: formData.location || undefined,
eventDate: new Date(formData.eventDate),
maxCheckIns: parseCapacity(formData.maxCheckIns),
- membersOnly: formData.membersOnly,
bootcampWeek: parseWeek(formData.bootcampWeek),
bootcampOnly: formData.bootcampOnly,
});
@@ -163,7 +160,6 @@ export default function AdminPage() {
location: formData.location || null,
eventDate: new Date(formData.eventDate),
maxCheckIns: parseCapacity(formData.maxCheckIns) ?? null,
- membersOnly: formData.membersOnly,
bootcampWeek: parseWeek(formData.bootcampWeek) ?? null,
bootcampOnly: formData.bootcampOnly,
});
@@ -205,7 +201,6 @@ export default function AdminPage() {
maxCheckIns: editingEvent.maxCheckIns
? String(editingEvent.maxCheckIns)
: "",
- membersOnly: editingEvent.membersOnly,
bootcampWeek: editingEvent.bootcampWeek
? String(editingEvent.bootcampWeek)
: "",
diff --git a/sites/mainweb/app/(portal)/checkin/page.tsx b/sites/mainweb/app/(portal)/checkin/page.tsx
new file mode 100644
index 00000000..653c8690
--- /dev/null
+++ b/sites/mainweb/app/(portal)/checkin/page.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { useSession } from "next-auth/react";
+import { QrCode, Search } from "lucide-react";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+import { useEventCheckIn } from "@/components/portal/EventCheckIn";
+import { trpc } from "@/lib/trpc";
+
+/**
+ * Event check-in for anyone signed in. The Club Portal has the same scanner,
+ * but it is a members' page; attendance is not, so this route takes everyone.
+ */
+export default function CheckInPage() {
+ const { data: session, status } = useSession();
+ const router = useRouter();
+ const checkIn = useEventCheckIn();
+
+ const { data: myStats } = trpc.events.myStats.useQuery(undefined, {
+ enabled: !!session,
+ });
+
+ // Straight back here after signing in: the QR is on the wall in front of
+ // them, and a detour through the dashboard loses it.
+ useEffect(() => {
+ if (status === "unauthenticated") {
+ router.push(`/login?callbackUrl=${encodeURIComponent("/checkin")}`);
+ }
+ }, [status, router]);
+
+ if (status === "loading" || !session) {
+ return ;
+ }
+
+ return (
+
+ {checkIn.modals}
+
+
+
+ Events
+
+
+ Event Check-In
+
+
+ Scan the QR code shown at the event. Open to everyone, no membership
+ needed.
+
+
+
+
+
+
+
+
+
+
+ At an event?
+
+
+ Point your camera at the event's QR code to record your
+ attendance.
+