Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/packages/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
58 changes: 28 additions & 30 deletions packages/api/src/.internal-tests/qr-checkin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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,
});

Expand All @@ -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);
});
});

Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/.internal-tests/routers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 });
});
});

Expand Down
33 changes: 25 additions & 8 deletions packages/api/src/routers/bootcamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,33 @@ async function workshopsForTerm(
}

/** The sessions of one bootcamp, in the order they are taught. */
const sessionColumns = {
id: events.id,
week: events.bootcampWeek,
title: events.title,
description: events.description,
location: events.location,
eventDate: events.eventDate,
checkInEnabled: events.checkInEnabled,
};

async function sessionsForTerm(db: DrizzleDB, term: string): Promise<Session[]> {
return db
.select(sessionColumns)
.from(events)
.where(eq(events.bootcampTerm, term))
.orderBy(asc(events.bootcampWeek));
}

// Staff only. Carries the door QR, which must never reach a member-facing
// read: holding the code is enough to check in from anywhere.
async function adminSessionsForTerm(db: DrizzleDB, term: string) {
return db
.select({
id: events.id,
week: events.bootcampWeek,
title: events.title,
description: events.description,
location: events.location,
eventDate: events.eventDate,
checkInEnabled: events.checkInEnabled,
...sessionColumns,
qrCode: events.qrCode,
currentCheckIns: events.currentCheckIns,
maxCheckIns: events.maxCheckIns,
})
.from(events)
.where(eq(events.bootcampTerm, term))
Expand Down Expand Up @@ -432,7 +449,7 @@ export const bootcampRouter = createTRPCRouter({

// Attendance outlives its semester, so past terms stay reachable.
const [sessions, roster, eventTerms, workshopTerms] = await Promise.all([
sessionsForTerm(db, term),
adminSessionsForTerm(db, term),
db
.select({
userId: members.userId,
Expand Down
31 changes: 4 additions & 27 deletions packages/api/src/routers/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 2 additions & 3 deletions packages/db/src/schemas/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading