Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
64 changes: 63 additions & 1 deletion src/services/checkin/checkin-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
} from "./checkin-schema";
import RoleChecker from "../../middleware/role-checker";
import { Role } from "../auth/auth-models";
import { validateQrHash, checkInUserToEvent } from "./checkin-utils";
import {
validateQrHash,
checkInUserToEvent,
undoCheckInUserToEvent,
} from "./checkin-utils";

const checkinRouter = Router();

Expand Down Expand Up @@ -41,6 +45,41 @@ checkinRouter.post(
}
);

checkinRouter.post(
"/scan/staff/undo",
RoleChecker([Role.Enum.ADMIN, Role.Enum.STAFF]),
async (req, res) => {
const { eventId, qrCode } = ScanValidator.parse(req.body);

const { userId, expTime } = validateQrHash(qrCode);

Comment thread
milindkumar1 marked this conversation as resolved.
Outdated
// Even if QR is expired, we might want to let them undo?
// Let's check expiration just like the scan endpoint.
if (Date.now() / 1000 > expTime) {
return res
.status(StatusCodes.UNAUTHORIZED)
.json({ error: "QR code has expired" });
}
Comment thread
milindkumar1 marked this conversation as resolved.
Outdated

try {
await undoCheckInUserToEvent(eventId, userId);
} catch (error: unknown) {
console.error("Undo check-in failed:", error);
if (
error instanceof Error &&
error.message === "AttendanceNotFound"
) {
return res
.status(StatusCodes.NOT_FOUND)
.json({ error: "AttendanceNotFound" });
}
return res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR);
}

return res.status(StatusCodes.OK).json(userId);
}
);

checkinRouter.post(
"/event",
RoleChecker([Role.Enum.ADMIN, Role.Enum.STAFF]),
Expand All @@ -61,6 +100,29 @@ checkinRouter.post(
}
);

checkinRouter.post(
"/event/undo",
RoleChecker([Role.Enum.ADMIN, Role.Enum.STAFF]),
async (req, res) => {
const { eventId, userId } = EventValidator.parse(req.body);

try {
await undoCheckInUserToEvent(eventId, userId);
} catch (error: unknown) {
Comment thread
milindkumar1 marked this conversation as resolved.
if (
error instanceof Error &&
error.message === "AttendanceNotFound"
) {
return res
.status(StatusCodes.NOT_FOUND)
.json({ error: "AttendanceNotFound" });
}
return res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR);
}
return res.status(StatusCodes.OK).json(userId);
}
);

checkinRouter.post(
"/scan/merch",
RoleChecker([Role.Enum.ADMIN, Role.Enum.STAFF]),
Expand Down
66 changes: 66 additions & 0 deletions src/services/checkin/checkin-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,46 @@ async function updateAttendanceRecords(eventId: string, userId: string) {
.throwOnError();
}

async function undoAttendanceRecords(eventId: string, userId: string) {
// 1. Remove from attendee attendances array
const { data: attendeeAttendance } =
await SupabaseDB.ATTENDEE_ATTENDANCES.select("eventsAttended")
.eq("userId", userId)
.maybeSingle()
.throwOnError();

const eventsAttended = attendeeAttendance?.eventsAttended || [];

if (eventsAttended.includes(eventId)) {
const newEventsAttended = eventsAttended.filter((id) => id !== eventId);
await SupabaseDB.ATTENDEE_ATTENDANCES.upsert({
userId: userId,
eventsAttended: newEventsAttended,
}).throwOnError();
}

// 2. Delete the row from EVENT_ATTENDANCES
await SupabaseDB.EVENT_ATTENDANCES.delete()
.eq("eventId", eventId)
.eq("attendee", userId)
.throwOnError();
Comment thread
milindkumar1 marked this conversation as resolved.

Comment on lines +181 to +185

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EVENT_ATTENDANCES.delete() doesn’t verify that a row was actually deleted. If the attendance row disappears between the earlier existence check and this delete (or if duplicates/constraints behave unexpectedly), the code can still proceed to decrement attendanceCount, causing it to drift. Consider checking the delete result (e.g., requesting an exact count/returning rows) and only decrementing when a row was removed.

Suggested change
await SupabaseDB.EVENT_ATTENDANCES.delete()
.eq("eventId", eventId)
.eq("attendee", userId)
.throwOnError();
const { data: deletedAttendances } = await SupabaseDB.EVENT_ATTENDANCES
.delete()
.eq("eventId", eventId)
.eq("attendee", userId)
.select("eventId")
.throwOnError();
if (!deletedAttendances || deletedAttendances.length === 0) {
return;
}

Copilot uses AI. Check for mistakes.
// 3. Decrement attendanceCount on EVENTS
const { data: eventData } = await SupabaseDB.EVENTS.select(
"attendanceCount"
)
.eq("eventId", eventId)
.single()
.throwOnError();

const currentCount = eventData?.attendanceCount || 0;
if (currentCount > 0) {
await SupabaseDB.EVENTS.update({ attendanceCount: currentCount - 1 })
.eq("eventId", eventId)
.throwOnError();
}
Comment on lines +186 to +199

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attendanceCount is updated with a read-modify-write sequence (select then update currentCount - 1). Under concurrent check-ins/undos, this can lose updates and produce incorrect counts. Consider moving the increment/decrement into an atomic database operation (e.g., Postgres function/RPC or a single SQL update expression) to avoid race conditions.

Suggested change
// 3. Decrement attendanceCount on EVENTS
const { data: eventData } = await SupabaseDB.EVENTS.select(
"attendanceCount"
)
.eq("eventId", eventId)
.single()
.throwOnError();
const currentCount = eventData?.attendanceCount || 0;
if (currentCount > 0) {
await SupabaseDB.EVENTS.update({ attendanceCount: currentCount - 1 })
.eq("eventId", eventId)
.throwOnError();
}
// 3. Atomically decrement attendanceCount on EVENTS without going below zero
await SupabaseDB.rpc("decrement_event_attendance_count", {
target_event_id: eventId,
}).throwOnError();

Copilot uses AI. Check for mistakes.
}

async function assignPixelsToUser(userId: string, pixels: number) {
await addPoints(userId, pixels);
}
Expand Down Expand Up @@ -173,6 +213,32 @@ export async function checkInUserToEvent(eventId: string, userId: string) {
await assignPixelsToUser(userId, event.points);
}

export async function undoCheckInUserToEvent(eventId: string, userId: string) {
await checkEventAndAttendeeExist(eventId, userId);

const { data: event } = await SupabaseDB.EVENTS.select("eventType, points")

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undoCheckInUserToEvent selects eventType but never uses it. Consider selecting only points (or using eventType if needed) to keep the query minimal and avoid unused-data drift over time.

Suggested change
const { data: event } = await SupabaseDB.EVENTS.select("eventType, points")
const { data: event } = await SupabaseDB.EVENTS.select("points")

Copilot uses AI. Check for mistakes.
.eq("eventId", eventId)
.single()
.throwOnError();

Comment thread
milindkumar1 marked this conversation as resolved.
// Check if the attendance record exists, else throw
const isAttended = await SupabaseDB.EVENT_ATTENDANCES.select()
.eq("eventId", eventId)
.eq("attendee", userId)
.maybeSingle()
.throwOnError();

if (!isAttended.data) {
throw new Error("AttendanceNotFound");
}

// Updates attendance records first
await undoAttendanceRecords(eventId, userId);

// Take back pixels
await assignPixelsToUser(userId, -event.points);
}
Comment thread
milindkumar1 marked this conversation as resolved.

export function generateQrHash(userId: string, expTime: number) {
let hashStr = userId + "#" + expTime;
const hashIterations = Config.QR_HASH_ITERATIONS;
Expand Down
Loading