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
5 changes: 3 additions & 2 deletions docs/pass-experience.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Registration, payment approval and INNO pass

The live flow is Google login → participant details → payment QR → screenshot and 12-digit UTR → pending team review → approved pass. College and roll number are entered by the participant. College is never inferred from their email. Financial approval always comes from the authenticated backend.
The live flow is Google login → participant details and college ID card photo → payment QR → screenshot and 12-digit UTR → pending team review → approved pass. College and roll number are entered by the participant. College is never inferred from their email. Financial approval always comes from the authenticated backend.

## Reviewer preview and the single off switch

Expand All @@ -15,13 +15,14 @@ The live flow is Google login → participant details → payment QR → screens

## Deployment prerequisites

Apply `supabase/manual/pass_experience.sql` to the existing Supabase database. New installations can use the updated `supabase/schema.sql`. The idempotent migration creates an owner-authorized acknowledgement table and functions; it does not update financial status. Hosted database migration has not been performed by this code change.
Apply `supabase/manual/pass_experience.sql` and `supabase/manual/id_card_upload.sql` (college ID cards, section 19 of `supabase/schema.sql`) to the existing Supabase database. New installations can use the updated `supabase/schema.sql`. The idempotent migration creates an owner-authorized acknowledgement table and functions; it does not update financial status. Hosted database migration has not been performed by this code change.

The existing entry-pass tables/functions and a valid `ENTRY_PASS_ENCRYPTION_KEY` are required for QR check-in. The pass itself and PDF remain available without a QR. The scanner continues to use the existing staff-authorized opaque-token lookup and atomic check-in functions, including duplicate-check-in protection. No default signing secret or simulated server approval is used.

## Pass behavior

- `/api/pass` intentionally uses POST: first access can mint an owner's opaque QR token. It returns real name, college, registration ID and backend status. Tokens are only available for confirmed registrations with active passes.
- College ID card: `/api/pass` also says whether a pending or confirmed registration has its ID card (`idCard: 'uploaded' | 'pending'`, and the reason when staff asked for a new one). While it is pending, MY PASS shows an **ID card pending** box with an upload button above the status (pending) or above the ticket (confirmed; the QR stays usable), and the MY PASS button carries a gold dot. The upload goes to `POST /api/id-card`, which attaches it to the registration even after registrations close. Staff can't approve a pending registration without one (API and `trg_require_id_card_for_approval`), can view it from the admin list and the gate scanner, and can ask for a new one (`POST /api/admin/registrations/id-card`). The student can't replace an uploaded card themselves.
- Visible registration/pass screens refresh on focus, visibility and a 15-second interval. Hidden tabs avoid polling. Preview records do not poll the live registration API.
- Database claims elect one tab to show the celebration. A 30-second lease recovers abandoned claims. In-flight claims are shared across React remounts.
- Acknowledgement happens after the pass has painted, or an explicit skip. Navigation cleanup acknowledges only a presented pass. Failed acknowledgements retain the claim and retry on focus/connection recovery. No local-storage value is the source of truth.
Expand Down
161 changes: 161 additions & 0 deletions src/app/api/admin/registrations/id-card/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyRegistrationStaff } from '@/lib/auth-server';
import { isIdCardPath, isUuid, readJsonObject } from '@/lib/security';
import { ID_CARD_URL_TTL_SECONDS, signIdCardUrl } from '@/lib/id-card-storage';
import { isIdCardReuploadReason } from '@/lib/id-cards';

export const runtime = 'nodejs';

/**
* GET /api/admin/registrations/id-card?id=<registration uuid>
* Registration-Team/Admin only. Returns a short-lived signed URL for the registration's private college ID card photo.
* Staff have no Storage read access of their own: this route is the only way to view a card (the admin list and the
* gate scanner both use it).
*/
export async function GET(req: NextRequest) {
try {
const auth = await verifyRegistrationStaff(req);
if ('error' in auth) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}

const id = new URL(req.url).searchParams.get('id');
if (!isUuid(id)) {
return NextResponse.json({ error: 'Invalid registration id' }, { status: 400 });
}

const { data: registration, error } = await auth.supabase
.from('registrations')
.select('user_id, id_card_path')
.eq('id', id)
.maybeSingle();

if (error) {
console.error('/api/admin/registrations/id-card database error:', error);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
if (!registration?.id_card_path) {
return NextResponse.json({ error: 'No ID card uploaded for this registration' }, { status: 404 });
}

// Only ever sign an object inside the registrant's own folder, never an arbitrary path stored in the row.
if (!isIdCardPath(registration.id_card_path, registration.user_id)) {
return NextResponse.json({ error: 'Stored ID card is not a recognised upload' }, { status: 422 });
}
const url = await signIdCardUrl(registration.id_card_path);
if (!url) {
return NextResponse.json({ error: 'Failed to load the ID card' }, { status: 500 });
}

return NextResponse.json(
{ success: true, url, expiresAt: new Date(Date.now() + ID_CARD_URL_TTL_SECONDS * 1000).toISOString() },
{ headers: { 'Cache-Control': 'private, no-store' } }
);
} catch (err: unknown) {
console.error('/api/admin/registrations/id-card error:', err);
return NextResponse.json({ error: 'Failed to load the ID card' }, { status: 500 });
}
}

/**
* POST /api/admin/registrations/id-card { registrationId, reason }
* Registration-Team/Admin only. Asks the student for a new ID card photo: clears id_card_path and stores the reason,
* so MY PASS shows "ID card pending" with it and a pending registration can't be approved until they upload again.
* The old photo stays in storage. Not for a rejected registration, one already checked in at the gate, or the
* caller's own.
*/
export async function POST(req: NextRequest) {
try {
const auth = await verifyRegistrationStaff(req);
if ('error' in auth) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}

const parsed = await readJsonObject(req);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: parsed.status });
}
const { registrationId, reason } = parsed.value;
if (!isUuid(registrationId)) {
return NextResponse.json({ error: 'Invalid registrationId' }, { status: 400 });
}
if (!isIdCardReuploadReason(reason)) {
return NextResponse.json({ error: 'Choose why a new ID card is needed.' }, { status: 400 });
}

const { supabase, user } = auth;
const { data: existing, error: fetchErr } = await supabase
.from('registrations')
.select('id, user_id, registration_id, status, id_card_path')
.eq('id', registrationId)
.maybeSingle();
if (fetchErr) {
console.error('/api/admin/registrations/id-card database error:', fetchErr);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
if (!existing) {
return NextResponse.json({ error: 'Registration not found' }, { status: 404 });
}
if (existing.user_id === user.id) {
return NextResponse.json(
{ error: 'You cannot review your own registration. Ask another staff member.' },
{ status: 403 }
);
}
if (existing.status === 'rejected') {
return NextResponse.json(
{ error: `Registration ${existing.registration_id} is rejected: there is no ID card to ask for.` },
{ status: 400 }
);
}
if (!existing.id_card_path) {
return NextResponse.json(
{ error: `Registration ${existing.registration_id} has no ID card uploaded yet.` },
{ status: 409 }
);
}

const { data: checkin, error: checkinErr } = await supabase
.from('entry_checkins')
.select('id')
.eq('registration_id', existing.id)
.is('voided_at', null)
.limit(1)
.maybeSingle();
if (checkinErr) {
console.error('/api/admin/registrations/id-card check-in lookup error:', checkinErr);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
if (checkin) {
return NextResponse.json(
{ error: `Registration ${existing.registration_id} is already checked in at the gate.` },
{ status: 409 }
);
}

// Only clears the card staff looked at: a new one uploaded meanwhile is kept.
const { data: updated, error: updateErr } = await supabase
.from('registrations')
.update({ id_card_path: null, id_card_uploaded_at: null, id_card_reupload_reason: reason })
.eq('id', existing.id)
.eq('id_card_path', existing.id_card_path)
.neq('status', 'rejected')
.select()
.maybeSingle();
if (updateErr) {
console.error('/api/admin/registrations/id-card database error:', updateErr);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
if (!updated) {
return NextResponse.json(
{ error: `Registration ${existing.registration_id} changed meanwhile. Reload and try again.` },
{ status: 409 }
);
}

return NextResponse.json({ success: true, registration: updated });
} catch (err: unknown) {
console.error('/api/admin/registrations/id-card error:', err);
return NextResponse.json({ error: 'Failed to ask for a new ID card' }, { status: 500 });
}
}
13 changes: 12 additions & 1 deletion src/app/api/admin/registrations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { SupabaseClient } from '@supabase/supabase-js';
import { verifyRegistrationStaff } from '@/lib/auth-server';
import { isUuid, readJsonObject, sanitizeFilterValue } from '@/lib/security';
import { isRejectionReason } from '@/lib/rejections';
import { ID_CARD_REQUIRED_ERROR, isIdCardRequiredError } from '@/lib/id-cards';

export const runtime = 'nodejs';

Expand Down Expand Up @@ -256,7 +257,8 @@ export async function PATCH(req: NextRequest) {
// Fetch existing registration to verify status
const { data: existing, error: fetchErr } = await supabase
.from('registrations')
.select('id, status, registration_id, user_id')
// '*' rather than naming id_card_path, so reviewing keeps working on a database without section 19 yet.
.select('*')
.eq('id', registrationId)
.maybeSingle();

Expand Down Expand Up @@ -285,6 +287,11 @@ export async function PATCH(req: NextRequest) {
);
}

// No approval without the college ID card (also refused by trg_require_id_card_for_approval). Rejecting is fine.
if (status === 'confirmed' && 'id_card_path' in existing && !existing.id_card_path) {
return NextResponse.json({ error: ID_CARD_REQUIRED_ERROR }, { status: 409 });
}

const { data: updated, error: updateErr } = await supabase
.from('registrations')
.update({
Expand All @@ -301,6 +308,10 @@ export async function PATCH(req: NextRequest) {
.maybeSingle();

if (updateErr) {
// A re-upload was asked for between the check above and this update.
if (isIdCardRequiredError(updateErr)) {
return NextResponse.json({ error: ID_CARD_REQUIRED_ERROR }, { status: 409 });
}
console.error('/api/admin/registrations database error:', updateErr);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
Expand Down
129 changes: 129 additions & 0 deletions src/app/api/id-card/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthenticatedUser } from '@/lib/auth-server';
import { getSupabaseAdmin } from '@/lib/supabase';
import { uploadIdCard } from '@/lib/id-card-storage';
import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate';
import { ATTEMPTS_USED_UP_ERROR, attemptsLeft } from '@/lib/rejections';
import {
buildOwnedUploadPath,
createRateLimiter,
detectFileType,
formFile,
ID_CARD_MAX_BYTES,
ID_CARD_MIME_TYPES,
isIdCardFileType,
readFormLimited,
} from '@/lib/security';

export const runtime = 'nodejs';

const TOO_LARGE = 'That photo is larger than 2 MB. Please choose a smaller photo of your ID card.';
const ALREADY_UPLOADED = 'Your college ID card is already uploaded. Contact the registration helpdesk to change it.';

// Allow retries but stop bulk abuse of storage.
const idCardLimiter = createRateLimiter({ limit: 20, windowMs: 10 * 60 * 1000 });

/**
* POST /api/id-card
* Uploads a photo of the front of the caller's college ID card to the private `id-cards` bucket (supabase/schema.sql
* section 19). The file type is decided by its magic bytes and the object path is chosen here from the verified user
* id, exactly like /api/upload for payment screenshots. What happens next depends on the caller's registration:
* - none yet (or only rejected ones, with attempts left): the student is filling in the registration form. Only while
* registrations are open. The path is returned and submitted with the registration (/api/register checks it).
* - pending or confirmed without an ID card (registered before ID cards were asked for, or staff asked for a new
* one): the card is attached to it right away, even after registrations close. MY PASS uses this.
* - pending or confirmed with an ID card: refused, a card can't be swapped by the student.
*/
export async function POST(req: NextRequest) {
try {
const auth = await getAuthenticatedUser(req);
if (!auth) {
return NextResponse.json(
{ error: 'Authentication required: please sign in before uploading files.' },
{ status: 401 }
);
}
const userId = auth.user.id;

if (!idCardLimiter.hit(userId)) {
return NextResponse.json(
{ error: 'Too many uploads. Please wait a few minutes and try again.' },
{ status: 429, headers: { 'Retry-After': String(idCardLimiter.retryAfterSeconds(userId)) } }
);
}

// Which case this is, before reading the body (a closed form or a locked card needs no upload).
const client = getSupabaseAdmin(auth.token);
const [active, rejected] = await Promise.all([
client.from('registrations').select('id, id_card_path').eq('user_id', userId).neq('status', 'rejected').limit(1).maybeSingle(),
client.from('registrations').select('id', { count: 'exact', head: true }).eq('user_id', userId).eq('status', 'rejected'),
]);
if (active.error || rejected.error) {
console.error('ID card upload: registration lookup failed:', active.error || rejected.error);
return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 });
}
const registration = active.data as { id: string; id_card_path: string | null } | null;
if (registration?.id_card_path) {
return NextResponse.json({ error: ALREADY_UPLOADED }, { status: 409 });
}
if (!registration) {
if (!registrationsOpen()) return registrationsClosedResponse();
if (attemptsLeft(rejected.count ?? 0) === 0) {
return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 409 });
}
}

const form = await readFormLimited(req, ID_CARD_MAX_BYTES + 64 * 1024);
if (!form.ok) {
return NextResponse.json({ error: form.status === 413 ? TOO_LARGE : form.error }, { status: form.status });
}
const file = formFile(form.value, 'file');
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (file.size > ID_CARD_MAX_BYTES) {
return NextResponse.json({ error: TOO_LARGE }, { status: 400 });
}

const buffer = Buffer.from(await file.arrayBuffer());
const type = detectFileType(buffer);
if (!isIdCardFileType(type)) {
return NextResponse.json({ error: 'Upload a photo of your ID card (JPG, PNG or WebP).' }, { status: 400 });
}

// Server-chosen path `<verified user id>/<random uuid>.<ext>`: the client's filename never reaches storage.
const path = buildOwnedUploadPath(userId, type);
const upload = await uploadIdCard(userId, path, buffer, ID_CARD_MIME_TYPES[type]);
if (!upload.ok) {
console.error('ID card upload rejected by storage:', upload.error);
return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 });
}

if (!registration) {
// Registering now: only the storage path is returned (the bucket is private: there is no public URL).
return NextResponse.json({ success: true, attached: false, path, size: file.size });
}

// Only fills an empty slot, so two uploads at once can't both win and a card is never swapped.
const { data: attached, error: attachError } = await client
.from('registrations')
.update({ id_card_path: path, id_card_uploaded_at: new Date().toISOString(), id_card_reupload_reason: null })
.eq('id', registration.id)
.eq('user_id', userId)
.neq('status', 'rejected')
.is('id_card_path', null)
.select('id')
.maybeSingle();
if (attachError) {
console.error('ID card upload: attaching to the registration failed:', attachError);
return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 });
}
if (!attached) {
return NextResponse.json({ error: ALREADY_UPLOADED }, { status: 409 });
}
return NextResponse.json({ success: true, attached: true });
} catch (error: unknown) {
console.error('ID card upload error:', error);
return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 });
}
}
5 changes: 5 additions & 0 deletions src/app/api/pass/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export async function POST(req: NextRequest) {
pass.rejectionReason = details.data?.rejection_reason ?? null;
pass.attemptsLeft = attemptsLeft(rejected.count ?? 0);
}
if ((mine.registration_status === 'pending' || mine.registration_status === 'confirmed') && details.data) {
// The college ID card (section 19): MY PASS asks for it until it is uploaded.
pass.idCard = details.data.id_card_path ? 'uploaded' : 'pending';
pass.idCardReuploadReason = details.data.id_card_path ? null : details.data.id_card_reupload_reason ?? null;
}
if (mine.registration_status !== 'confirmed' || mine.pass_status !== 'active' || !mine.pass) {
return NextResponse.json({ success: true, pass }, { headers: { 'Cache-Control': 'private, no-store' } });
}
Expand Down
Loading
Loading