|
| 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 | +} |
0 commit comments