Skip to content

Commit 58f09f4

Browse files
authored
Merge pull request #69 from Krish-2118/development
Development
2 parents 1013066 + 26f1fac commit 58f09f4

28 files changed

Lines changed: 1399 additions & 184 deletions

‎docs/pass-experience.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Registration, payment approval and INNO pass
22

3-
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.
3+
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.
44

55
## Reviewer preview and the single off switch
66

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

1616
## Deployment prerequisites
1717

18-
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.
18+
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.
1919

2020
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.
2121

2222
## Pass behavior
2323

2424
- `/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.
25+
- 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.
2526
- 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.
2627
- Database claims elect one tab to show the celebration. A 30-second lease recovers abandoned claims. In-flight claims are shared across React remounts.
2728
- 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.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { verifyRegistrationStaff } from '@/lib/auth-server';
3+
import { isIdCardPath, isUuid, readJsonObject } from '@/lib/security';
4+
import { ID_CARD_URL_TTL_SECONDS, signIdCardUrl } from '@/lib/id-card-storage';
5+
import { isIdCardReuploadReason } from '@/lib/id-cards';
6+
7+
export const runtime = 'nodejs';
8+
9+
/**
10+
* GET /api/admin/registrations/id-card?id=<registration uuid>
11+
* Registration-Team/Admin only. Returns a short-lived signed URL for the registration's private college ID card photo.
12+
* Staff have no Storage read access of their own: this route is the only way to view a card (the admin list and the
13+
* gate scanner both use it).
14+
*/
15+
export async function GET(req: NextRequest) {
16+
try {
17+
const auth = await verifyRegistrationStaff(req);
18+
if ('error' in auth) {
19+
return NextResponse.json({ error: auth.error }, { status: auth.status });
20+
}
21+
22+
const id = new URL(req.url).searchParams.get('id');
23+
if (!isUuid(id)) {
24+
return NextResponse.json({ error: 'Invalid registration id' }, { status: 400 });
25+
}
26+
27+
const { data: registration, error } = await auth.supabase
28+
.from('registrations')
29+
.select('user_id, id_card_path')
30+
.eq('id', id)
31+
.maybeSingle();
32+
33+
if (error) {
34+
console.error('/api/admin/registrations/id-card database error:', error);
35+
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
36+
}
37+
if (!registration?.id_card_path) {
38+
return NextResponse.json({ error: 'No ID card uploaded for this registration' }, { status: 404 });
39+
}
40+
41+
// Only ever sign an object inside the registrant's own folder, never an arbitrary path stored in the row.
42+
if (!isIdCardPath(registration.id_card_path, registration.user_id)) {
43+
return NextResponse.json({ error: 'Stored ID card is not a recognised upload' }, { status: 422 });
44+
}
45+
const url = await signIdCardUrl(registration.id_card_path);
46+
if (!url) {
47+
return NextResponse.json({ error: 'Failed to load the ID card' }, { status: 500 });
48+
}
49+
50+
return NextResponse.json(
51+
{ success: true, url, expiresAt: new Date(Date.now() + ID_CARD_URL_TTL_SECONDS * 1000).toISOString() },
52+
{ headers: { 'Cache-Control': 'private, no-store' } }
53+
);
54+
} catch (err: unknown) {
55+
console.error('/api/admin/registrations/id-card error:', err);
56+
return NextResponse.json({ error: 'Failed to load the ID card' }, { status: 500 });
57+
}
58+
}
59+
60+
/**
61+
* POST /api/admin/registrations/id-card { registrationId, reason }
62+
* Registration-Team/Admin only. Asks the student for a new ID card photo: clears id_card_path and stores the reason,
63+
* so MY PASS shows "ID card pending" with it and a pending registration can't be approved until they upload again.
64+
* The old photo stays in storage. Not for a rejected registration, one already checked in at the gate, or the
65+
* caller's own.
66+
*/
67+
export async function POST(req: NextRequest) {
68+
try {
69+
const auth = await verifyRegistrationStaff(req);
70+
if ('error' in auth) {
71+
return NextResponse.json({ error: auth.error }, { status: auth.status });
72+
}
73+
74+
const parsed = await readJsonObject(req);
75+
if (!parsed.ok) {
76+
return NextResponse.json({ error: parsed.error }, { status: parsed.status });
77+
}
78+
const { registrationId, reason } = parsed.value;
79+
if (!isUuid(registrationId)) {
80+
return NextResponse.json({ error: 'Invalid registrationId' }, { status: 400 });
81+
}
82+
if (!isIdCardReuploadReason(reason)) {
83+
return NextResponse.json({ error: 'Choose why a new ID card is needed.' }, { status: 400 });
84+
}
85+
86+
const { supabase, user } = auth;
87+
const { data: existing, error: fetchErr } = await supabase
88+
.from('registrations')
89+
.select('id, user_id, registration_id, status, id_card_path')
90+
.eq('id', registrationId)
91+
.maybeSingle();
92+
if (fetchErr) {
93+
console.error('/api/admin/registrations/id-card database error:', fetchErr);
94+
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
95+
}
96+
if (!existing) {
97+
return NextResponse.json({ error: 'Registration not found' }, { status: 404 });
98+
}
99+
if (existing.user_id === user.id) {
100+
return NextResponse.json(
101+
{ error: 'You cannot review your own registration. Ask another staff member.' },
102+
{ status: 403 }
103+
);
104+
}
105+
if (existing.status === 'rejected') {
106+
return NextResponse.json(
107+
{ error: `Registration ${existing.registration_id} is rejected: there is no ID card to ask for.` },
108+
{ status: 400 }
109+
);
110+
}
111+
if (!existing.id_card_path) {
112+
return NextResponse.json(
113+
{ error: `Registration ${existing.registration_id} has no ID card uploaded yet.` },
114+
{ status: 409 }
115+
);
116+
}
117+
118+
const { data: checkin, error: checkinErr } = await supabase
119+
.from('entry_checkins')
120+
.select('id')
121+
.eq('registration_id', existing.id)
122+
.is('voided_at', null)
123+
.limit(1)
124+
.maybeSingle();
125+
if (checkinErr) {
126+
console.error('/api/admin/registrations/id-card check-in lookup error:', checkinErr);
127+
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
128+
}
129+
if (checkin) {
130+
return NextResponse.json(
131+
{ error: `Registration ${existing.registration_id} is already checked in at the gate.` },
132+
{ status: 409 }
133+
);
134+
}
135+
136+
// Only clears the card staff looked at: a new one uploaded meanwhile is kept.
137+
const { data: updated, error: updateErr } = await supabase
138+
.from('registrations')
139+
.update({ id_card_path: null, id_card_uploaded_at: null, id_card_reupload_reason: reason })
140+
.eq('id', existing.id)
141+
.eq('id_card_path', existing.id_card_path)
142+
.neq('status', 'rejected')
143+
.select()
144+
.maybeSingle();
145+
if (updateErr) {
146+
console.error('/api/admin/registrations/id-card database error:', updateErr);
147+
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
148+
}
149+
if (!updated) {
150+
return NextResponse.json(
151+
{ error: `Registration ${existing.registration_id} changed meanwhile. Reload and try again.` },
152+
{ status: 409 }
153+
);
154+
}
155+
156+
return NextResponse.json({ success: true, registration: updated });
157+
} catch (err: unknown) {
158+
console.error('/api/admin/registrations/id-card error:', err);
159+
return NextResponse.json({ error: 'Failed to ask for a new ID card' }, { status: 500 });
160+
}
161+
}

‎src/app/api/admin/registrations/route.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SupabaseClient } from '@supabase/supabase-js';
33
import { verifyRegistrationStaff } from '@/lib/auth-server';
44
import { isUuid, readJsonObject, sanitizeFilterValue } from '@/lib/security';
55
import { isRejectionReason } from '@/lib/rejections';
6+
import { ID_CARD_REQUIRED_ERROR, isIdCardRequiredError } from '@/lib/id-cards';
67

78
export const runtime = 'nodejs';
89

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

@@ -285,6 +287,11 @@ export async function PATCH(req: NextRequest) {
285287
);
286288
}
287289

290+
// No approval without the college ID card (also refused by trg_require_id_card_for_approval). Rejecting is fine.
291+
if (status === 'confirmed' && 'id_card_path' in existing && !existing.id_card_path) {
292+
return NextResponse.json({ error: ID_CARD_REQUIRED_ERROR }, { status: 409 });
293+
}
294+
288295
const { data: updated, error: updateErr } = await supabase
289296
.from('registrations')
290297
.update({
@@ -301,6 +308,10 @@ export async function PATCH(req: NextRequest) {
301308
.maybeSingle();
302309

303310
if (updateErr) {
311+
// A re-upload was asked for between the check above and this update.
312+
if (isIdCardRequiredError(updateErr)) {
313+
return NextResponse.json({ error: ID_CARD_REQUIRED_ERROR }, { status: 409 });
314+
}
304315
console.error('/api/admin/registrations database error:', updateErr);
305316
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
306317
}

‎src/app/api/id-card/route.ts‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getAuthenticatedUser } from '@/lib/auth-server';
3+
import { getSupabaseAdmin } from '@/lib/supabase';
4+
import { uploadIdCard } from '@/lib/id-card-storage';
5+
import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate';
6+
import { ATTEMPTS_USED_UP_ERROR, attemptsLeft } from '@/lib/rejections';
7+
import {
8+
buildOwnedUploadPath,
9+
createRateLimiter,
10+
detectFileType,
11+
formFile,
12+
ID_CARD_MAX_BYTES,
13+
ID_CARD_MIME_TYPES,
14+
isIdCardFileType,
15+
readFormLimited,
16+
} from '@/lib/security';
17+
18+
export const runtime = 'nodejs';
19+
20+
const TOO_LARGE = 'That photo is larger than 2 MB. Please choose a smaller photo of your ID card.';
21+
const ALREADY_UPLOADED = 'Your college ID card is already uploaded. Contact the registration helpdesk to change it.';
22+
23+
// Allow retries but stop bulk abuse of storage.
24+
const idCardLimiter = createRateLimiter({ limit: 20, windowMs: 10 * 60 * 1000 });
25+
26+
/**
27+
* POST /api/id-card
28+
* Uploads a photo of the front of the caller's college ID card to the private `id-cards` bucket (supabase/schema.sql
29+
* section 19). The file type is decided by its magic bytes and the object path is chosen here from the verified user
30+
* id, exactly like /api/upload for payment screenshots. What happens next depends on the caller's registration:
31+
* - none yet (or only rejected ones, with attempts left): the student is filling in the registration form. Only while
32+
* registrations are open. The path is returned and submitted with the registration (/api/register checks it).
33+
* - pending or confirmed without an ID card (registered before ID cards were asked for, or staff asked for a new
34+
* one): the card is attached to it right away, even after registrations close. MY PASS uses this.
35+
* - pending or confirmed with an ID card: refused, a card can't be swapped by the student.
36+
*/
37+
export async function POST(req: NextRequest) {
38+
try {
39+
const auth = await getAuthenticatedUser(req);
40+
if (!auth) {
41+
return NextResponse.json(
42+
{ error: 'Authentication required: please sign in before uploading files.' },
43+
{ status: 401 }
44+
);
45+
}
46+
const userId = auth.user.id;
47+
48+
if (!idCardLimiter.hit(userId)) {
49+
return NextResponse.json(
50+
{ error: 'Too many uploads. Please wait a few minutes and try again.' },
51+
{ status: 429, headers: { 'Retry-After': String(idCardLimiter.retryAfterSeconds(userId)) } }
52+
);
53+
}
54+
55+
// Which case this is, before reading the body (a closed form or a locked card needs no upload).
56+
const client = getSupabaseAdmin(auth.token);
57+
const [active, rejected] = await Promise.all([
58+
client.from('registrations').select('id, id_card_path').eq('user_id', userId).neq('status', 'rejected').limit(1).maybeSingle(),
59+
client.from('registrations').select('id', { count: 'exact', head: true }).eq('user_id', userId).eq('status', 'rejected'),
60+
]);
61+
if (active.error || rejected.error) {
62+
console.error('ID card upload: registration lookup failed:', active.error || rejected.error);
63+
return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 });
64+
}
65+
const registration = active.data as { id: string; id_card_path: string | null } | null;
66+
if (registration?.id_card_path) {
67+
return NextResponse.json({ error: ALREADY_UPLOADED }, { status: 409 });
68+
}
69+
if (!registration) {
70+
if (!registrationsOpen()) return registrationsClosedResponse();
71+
if (attemptsLeft(rejected.count ?? 0) === 0) {
72+
return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 409 });
73+
}
74+
}
75+
76+
const form = await readFormLimited(req, ID_CARD_MAX_BYTES + 64 * 1024);
77+
if (!form.ok) {
78+
return NextResponse.json({ error: form.status === 413 ? TOO_LARGE : form.error }, { status: form.status });
79+
}
80+
const file = formFile(form.value, 'file');
81+
if (!file) {
82+
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
83+
}
84+
if (file.size > ID_CARD_MAX_BYTES) {
85+
return NextResponse.json({ error: TOO_LARGE }, { status: 400 });
86+
}
87+
88+
const buffer = Buffer.from(await file.arrayBuffer());
89+
const type = detectFileType(buffer);
90+
if (!isIdCardFileType(type)) {
91+
return NextResponse.json({ error: 'Upload a photo of your ID card (JPG, PNG or WebP).' }, { status: 400 });
92+
}
93+
94+
// Server-chosen path `<verified user id>/<random uuid>.<ext>`: the client's filename never reaches storage.
95+
const path = buildOwnedUploadPath(userId, type);
96+
const upload = await uploadIdCard(userId, path, buffer, ID_CARD_MIME_TYPES[type]);
97+
if (!upload.ok) {
98+
console.error('ID card upload rejected by storage:', upload.error);
99+
return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 });
100+
}
101+
102+
if (!registration) {
103+
// Registering now: only the storage path is returned (the bucket is private: there is no public URL).
104+
return NextResponse.json({ success: true, attached: false, path, size: file.size });
105+
}
106+
107+
// Only fills an empty slot, so two uploads at once can't both win and a card is never swapped.
108+
const { data: attached, error: attachError } = await client
109+
.from('registrations')
110+
.update({ id_card_path: path, id_card_uploaded_at: new Date().toISOString(), id_card_reupload_reason: null })
111+
.eq('id', registration.id)
112+
.eq('user_id', userId)
113+
.neq('status', 'rejected')
114+
.is('id_card_path', null)
115+
.select('id')
116+
.maybeSingle();
117+
if (attachError) {
118+
console.error('ID card upload: attaching to the registration failed:', attachError);
119+
return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 });
120+
}
121+
if (!attached) {
122+
return NextResponse.json({ error: ALREADY_UPLOADED }, { status: 409 });
123+
}
124+
return NextResponse.json({ success: true, attached: true });
125+
} catch (error: unknown) {
126+
console.error('ID card upload error:', error);
127+
return NextResponse.json({ error: 'Failed to upload your ID card. Please try again.' }, { status: 500 });
128+
}
129+
}

‎src/app/api/pass/route.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ export async function POST(req: NextRequest) {
6868
pass.rejectionReason = details.data?.rejection_reason ?? null;
6969
pass.attemptsLeft = attemptsLeft(rejected.count ?? 0);
7070
}
71+
if ((mine.registration_status === 'pending' || mine.registration_status === 'confirmed') && details.data) {
72+
// The college ID card (section 19): MY PASS asks for it until it is uploaded.
73+
pass.idCard = details.data.id_card_path ? 'uploaded' : 'pending';
74+
pass.idCardReuploadReason = details.data.id_card_path ? null : details.data.id_card_reupload_reason ?? null;
75+
}
7176
if (mine.registration_status !== 'confirmed' || mine.pass_status !== 'active' || !mine.pass) {
7277
return NextResponse.json({ success: true, pass }, { headers: { 'Cache-Control': 'private, no-store' } });
7378
}

0 commit comments

Comments
 (0)