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
181 changes: 115 additions & 66 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.15.0",
"@cloudflare/vitest-pool-workers": "^0.16.15",
"@types/node": "^25.6.0",
"typescript": "^5.5.2",
"vitest": "^4.1.5",
Expand Down
104 changes: 104 additions & 0 deletions src/db/checkin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { SupabaseClient } from "@supabase/supabase-js";

export interface CheckinTokenRow {
token_id: string;
event_id: string;
expires_at: string;
}

interface CheckinRow {
checkin_id: string;
checked_in_at: string;
}

interface EventNameRow {
event_name: string;
}

function throwIfError(error: unknown): void {
if (error) {
throw error;
}
}

export async function findCheckinToken(
client: SupabaseClient,
token: string,
): Promise<CheckinTokenRow | null> {
const { data, error } = await client
.from("event_checkin_tokens")
.select("token_id, event_id, expires_at")
.eq("token", token)
.maybeSingle();

throwIfError(error);

return data as CheckinTokenRow | null;
}

export async function insertCheckin(
client: SupabaseClient,
eventId: string,
contactId: string,
): Promise<CheckinRow | "duplicate"> {
const { data, error } = await client
.from("event_checkins")
.insert({ event_id: eventId, contact_id: contactId })
.select("checkin_id, checked_in_at")
.single();

// 23505 = Postgres unique violation: the UNIQUE (event_id, contact_id)
// constraint was hit. Treat as duplicate, not a server error.
if (error?.code === "23505") return "duplicate";
throwIfError(error);

return data as CheckinRow;
}

export interface AttendeeInsert {
event_id: string;
token_id: string;
psid: number;
email: string;
first_name: string;
last_name: string | null;
}

interface AttendeeRow {
attendee_id: string;
checked_in_at: string;
}

export async function insertAttendee(
client: SupabaseClient,
attendee: AttendeeInsert,
): Promise<AttendeeRow | "duplicate"> {
const { data, error } = await client
.from("event_attendees")
.insert(attendee)
.select("attendee_id, checked_in_at")
.single();

// 23505 = unique violation on UNIQUE (event_id, psid): this guest already
// checked in for this event. Treat as duplicate, not a server error.
if (error?.code === "23505") return "duplicate";
throwIfError(error);

return data as AttendeeRow;
}

export async function getEventName(
client: SupabaseClient,
eventId: string,
): Promise<string | null> {
const { data, error } = await client
.from("events")
.select("event_name")
.eq("id", eventId)
.maybeSingle();

throwIfError(error);

const row = data as EventNameRow | null;
return row?.event_name ?? null;
}
20 changes: 20 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ import {
postExperienceHandler,
type RouteRequest,
} from "./routes/profile";
import { postCheckinHandler, postGuestCheckinHandler } from "./routes/checkin";
import type { Env } from "./types/env";

const router = Router({ base: "/api" });

const allowedOrigins = new Set([
"http://localhost:5173",
"http://localhost:5174",
"https://member.cougarcs.com",
]);

Expand Down Expand Up @@ -140,6 +142,24 @@ router.delete("/profile/experience/:experienceId", requireAuth, requireRole("mem
deleteExperienceHandler(request as RouteRequest, createClient(env)),
);

router.post("/checkin", requireAuth, requireRole("member", "officer", "admin"), (request: Request, env: Env) =>
postCheckinHandler(request as AuthenticatedRequest, createClient(env)),
);

// Unauthenticated guest check-in. Rate limited per client IP (CF-Connecting-IP
// is set by Cloudflare and not client-spoofable) before any work is done.
router.post("/checkin/guest", async (request: Request, env: Env) => {
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
const { success } = await env.GUEST_CHECKIN_RATE_LIMITER.limit({ key: ip });
if (!success) {
return new Response(JSON.stringify({ error: "rate_limited" }), {
status: 429,
headers: { "Content-Type": "application/json" },
});
}
return postGuestCheckinHandler(request, createClient(env));
});

router.all("*", () => new Response("Not Found", { status: 404 }));

export default {
Expand Down
190 changes: 190 additions & 0 deletions src/routes/checkin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { AuthenticatedRequest } from "../middleware/auth";
import { findContactByUserId } from "../db/profile";
import {
findCheckinToken,
insertCheckin,
insertAttendee,
getEventName,
} from "../db/checkin";

const PSID_MIN = 1_000_000;
const PSID_MAX = 9_999_999;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
},
});
}

export async function postCheckinHandler(
request: AuthenticatedRequest,
client: SupabaseClient,
): Promise<Response> {
try {
// Parse and validate request body
let token: string;
try {
const body = (await request.json()) as { token?: unknown };
if (typeof body.token !== "string" || !body.token.trim()) {
return jsonResponse({ error: "Missing token" }, 400);
}
token = body.token.trim();
} catch {
return jsonResponse({ error: "Invalid request body" }, 400);
}

// Resolve contact_id from the user's JWT
const contactId = await findContactByUserId(client, request.userId);
if (!contactId) {
return jsonResponse({ error: "no_contact" }, 404);
}

// Look up the token and verify it hasn't expired
const tokenRow = await findCheckinToken(client, token);
if (!tokenRow || new Date(tokenRow.expires_at) <= new Date()) {
return jsonResponse({ error: "expired" }, 400);
}

const { event_id: eventId } = tokenRow;
const eventName = await getEventName(client, eventId);

// Insert the check-in; the UNIQUE (event_id, contact_id) constraint handles
// both normal and concurrent duplicates — no pre-check needed.
const checkin = await insertCheckin(client, eventId, contactId);
if (checkin === "duplicate") {
return jsonResponse(
{ error: "Already checked in", event_name: eventName ?? "Event" },
409,
);
}

return jsonResponse({
event_name: eventName ?? "Event",
checked_in_at: checkin.checked_in_at,
});
} catch (error) {
console.error("Checkin error:", error);
return jsonResponse({ error: "Internal server error" }, 500);
}
}

interface GuestCheckinBody {
token?: unknown;
psid?: unknown;
email?: unknown;
firstName?: unknown;
lastName?: unknown;
}

interface ValidGuestInput {
token: string;
psid: number;
email: string;
firstName: string;
lastName: string | null;
}

/** Returns the cleaned input, or an error message string if invalid. */
function validateGuestBody(body: GuestCheckinBody): ValidGuestInput | string {
if (typeof body.token !== "string" || !body.token.trim()) {
return "Token is required.";
}
if (
typeof body.psid !== "number" ||
!Number.isInteger(body.psid) ||
body.psid < PSID_MIN ||
body.psid > PSID_MAX
) {
return "PSID must be a 7-digit number.";
}
if (typeof body.email !== "string" || !EMAIL_RE.test(body.email.trim())) {
return "A valid email is required.";
}
if (
typeof body.firstName !== "string" ||
!body.firstName.trim() ||
body.firstName.trim().length > 100
) {
return "First name must be 1–100 characters.";
}
if (
body.lastName != null &&
(typeof body.lastName !== "string" || body.lastName.trim().length > 100)
) {
return "Last name must be 100 characters or fewer.";
}

return {
token: body.token.trim(),
psid: body.psid,
email: body.email.trim().toLowerCase(),
firstName: body.firstName.trim(),
lastName:
typeof body.lastName === "string" && body.lastName.trim()
? body.lastName.trim()
: null,
};
}

/**
* Unauthenticated guest (non-member) check-in. The event is derived solely
* from the token — never from the request body — so a token for event A
* can't be used to check into event B. Writes a pending row to
* event_attendees; officers reconcile it to a member later via pigeon-api.
*
* Deliberately performs no contact lookup: the response is identical whether
* or not the PSID/email belongs to a member, so the endpoint is not a
* membership oracle.
*/
export async function postGuestCheckinHandler(
request: Request,
client: SupabaseClient,
): Promise<Response> {
try {
let body: GuestCheckinBody;
try {
body = (await request.json()) as GuestCheckinBody;
} catch {
return jsonResponse({ error: "Invalid request body" }, 400);
}

const input = validateGuestBody(body);
if (typeof input === "string") {
return jsonResponse({ error: input }, 400);
}

const tokenRow = await findCheckinToken(client, input.token);
if (!tokenRow || new Date(tokenRow.expires_at) <= new Date()) {
return jsonResponse({ error: "expired" }, 400);
}

const eventName = await getEventName(client, tokenRow.event_id);

const result = await insertAttendee(client, {
event_id: tokenRow.event_id,
token_id: tokenRow.token_id,
psid: input.psid,
email: input.email,
first_name: input.firstName,
last_name: input.lastName,
});

if (result === "duplicate") {
return jsonResponse({ status: "already", event_name: eventName ?? "Event" });
}

return jsonResponse({
status: "ok",
event_name: eventName ?? "Event",
checked_in_at: result.checked_in_at,
});
} catch (error) {
console.error("Guest checkin error:", error);
return jsonResponse({ error: "Internal server error" }, 500);
}
}
2 changes: 2 additions & 0 deletions src/types/env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export interface Env {
SUPABASE_URL: string;
SUPABASE_SECRET_KEY: string;
/** Rate Limiting binding (configured in wrangler.jsonc) for guest check-in. */
GUEST_CHECKIN_RATE_LIMITER: RateLimit;
}
Loading