|
| 1 | +import { NextRequest, NextResponse } from 'next/server'; |
| 2 | +import { verifyRegistrationStaff } from '@/lib/auth-server'; |
| 3 | +import { isUuid, readJsonObject } from '@/lib/security'; |
| 4 | + |
| 5 | +export const runtime = 'nodejs'; |
| 6 | + |
| 7 | +/** |
| 8 | + * Campus ambassadors, for admins and the registration team only (verifyRegistrationStaff): the AMBASSADORS tab of the |
| 9 | + * admin dashboard is the programme's only portal. Students become ambassadors themselves (GET MY CODE, POST |
| 10 | + * /api/ambassador/code); staff watch the counts, contact them and revoke a code that is abused. Everything runs as the |
| 11 | + * service role: no client role can read or write the table or the leaderboard view (supabase/schema.sql, section 16). |
| 12 | + * |
| 13 | + * GET every ambassador with their counts (ambassador_leaderboard), plus |
| 14 | + * their email (this table) and phone (their profile) |
| 15 | + * PATCH { id, action: 'revoke' | 'restore' } or { id, name?, college? } |
| 16 | + * DELETE ?id= only while no registration has used the code (otherwise revoke) |
| 17 | + */ |
| 18 | + |
| 19 | +/** PostgREST answers at most its "Max rows" (1000 by default) per request. */ |
| 20 | +const MAX_LIST_ROWS = 1000; |
| 21 | +// Ids per `in.(...)` lookup, keeping the request URL short. |
| 22 | +const LOOKUP_CHUNK = 150; |
| 23 | + |
| 24 | +const LEADERBOARD_COLUMNS = 'rank, id, code, name, college, status, created_at, confirmed_registrations, pending_registrations'; |
| 25 | + |
| 26 | +const cleanText = (v: unknown): string => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : ''); |
| 27 | + |
| 28 | +const dbError = (where: string, error: unknown) => { |
| 29 | + console.error(`/api/admin/ambassadors ${where} database error:`, error); |
| 30 | + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); |
| 31 | +}; |
| 32 | + |
| 33 | +export async function GET(req: NextRequest) { |
| 34 | + try { |
| 35 | + const auth = await verifyRegistrationStaff(req); |
| 36 | + if ('error' in auth) { |
| 37 | + return NextResponse.json({ error: auth.error }, { status: auth.status }); |
| 38 | + } |
| 39 | + const { supabase } = auth; |
| 40 | + |
| 41 | + const [board, contacts] = await Promise.all([ |
| 42 | + supabase.from('ambassador_leaderboard').select(LEADERBOARD_COLUMNS).order('created_at').order('id').range(0, MAX_LIST_ROWS - 1), |
| 43 | + supabase.from('campus_ambassadors').select('id, email, user_id').order('created_at').order('id').range(0, MAX_LIST_ROWS - 1), |
| 44 | + ]); |
| 45 | + if (board.error) return dbError('GET', board.error); |
| 46 | + if (contacts.error) return dbError('GET', contacts.error); |
| 47 | + |
| 48 | + // Phones come from the ambassadors' profiles (every signed-in user is asked for one). |
| 49 | + const userIds = [...new Set((contacts.data ?? []).map((c) => c.user_id).filter((id): id is string => !!id))]; |
| 50 | + const phoneByUser = new Map<string, string | null>(); |
| 51 | + for (let i = 0; i < userIds.length; i += LOOKUP_CHUNK) { |
| 52 | + const { data, error } = await supabase.from('profiles').select('id, phone').in('id', userIds.slice(i, i + LOOKUP_CHUNK)); |
| 53 | + if (error) return dbError('GET', error); |
| 54 | + for (const p of data ?? []) phoneByUser.set(p.id, p.phone); |
| 55 | + } |
| 56 | + |
| 57 | + const byId = new Map((contacts.data ?? []).map((c) => [c.id, c])); |
| 58 | + const ambassadors = (board.data ?? []) |
| 59 | + .map((a) => { |
| 60 | + const contact = byId.get(a.id); |
| 61 | + return { ...a, email: contact?.email ?? '', phone: (contact?.user_id && phoneByUser.get(contact.user_id)) || null }; |
| 62 | + }) |
| 63 | + // Active ambassadors by rank (most confirmed first), then the revoked ones; oldest first within a tie. |
| 64 | + .sort((x, y) => |
| 65 | + (x.rank ?? Number.MAX_SAFE_INTEGER) - (y.rank ?? Number.MAX_SAFE_INTEGER) || |
| 66 | + y.pending_registrations - x.pending_registrations || |
| 67 | + String(x.created_at).localeCompare(String(y.created_at)) |
| 68 | + ); |
| 69 | + const truncated = ambassadors.length >= MAX_LIST_ROWS; |
| 70 | + if (truncated) console.error(`/api/admin/ambassadors: ${MAX_LIST_ROWS}+ ambassadors, the list is cut short`); |
| 71 | + |
| 72 | + return NextResponse.json({ success: true, ambassadors, truncated }); |
| 73 | + } catch (err: unknown) { |
| 74 | + console.error('/api/admin/ambassadors GET error:', err); |
| 75 | + return NextResponse.json({ error: 'Failed to fetch campus ambassadors' }, { status: 500 }); |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +export async function PATCH(req: NextRequest) { |
| 80 | + try { |
| 81 | + const auth = await verifyRegistrationStaff(req); |
| 82 | + if ('error' in auth) { |
| 83 | + return NextResponse.json({ error: auth.error }, { status: auth.status }); |
| 84 | + } |
| 85 | + |
| 86 | + const body = await readJsonObject(req); |
| 87 | + if (!body.ok) return NextResponse.json({ error: body.error }, { status: body.status }); |
| 88 | + const { id, action } = body.value; |
| 89 | + if (!isUuid(id)) return NextResponse.json({ error: 'Invalid ambassador id' }, { status: 400 }); |
| 90 | + |
| 91 | + const update: Record<string, string | null> = {}; |
| 92 | + if (action !== undefined) { |
| 93 | + if (action === 'revoke') update.status = 'revoked'; |
| 94 | + else if (action === 'restore') update.status = 'active'; |
| 95 | + else return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); |
| 96 | + } else { |
| 97 | + if ('name' in body.value) { |
| 98 | + const name = cleanText(body.value.name); |
| 99 | + if (!name || name.length > 120) return NextResponse.json({ error: 'Names must be 1 to 120 characters.' }, { status: 400 }); |
| 100 | + update.name = name; |
| 101 | + } |
| 102 | + if ('college' in body.value) { |
| 103 | + const college = body.value.college === null ? '' : cleanText(body.value.college); |
| 104 | + if (college.length > 200) { |
| 105 | + return NextResponse.json({ error: 'College names must be at most 200 characters.' }, { status: 400 }); |
| 106 | + } |
| 107 | + update.college = college || null; |
| 108 | + } |
| 109 | + if (!Object.keys(update).length) return NextResponse.json({ error: 'Nothing to change' }, { status: 400 }); |
| 110 | + } |
| 111 | + |
| 112 | + const { data, error } = await auth.supabase |
| 113 | + .from('campus_ambassadors') |
| 114 | + .update(update) |
| 115 | + .eq('id', id) |
| 116 | + .select('id, name, college, code, status') |
| 117 | + .maybeSingle(); |
| 118 | + if (error) return dbError('PATCH', error); |
| 119 | + if (!data) return NextResponse.json({ error: 'Ambassador not found' }, { status: 404 }); |
| 120 | + return NextResponse.json({ success: true, ambassador: data }); |
| 121 | + } catch (err: unknown) { |
| 122 | + console.error('/api/admin/ambassadors PATCH error:', err); |
| 123 | + return NextResponse.json({ error: 'Failed to update the campus ambassador' }, { status: 500 }); |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +export async function DELETE(req: NextRequest) { |
| 128 | + try { |
| 129 | + const auth = await verifyRegistrationStaff(req); |
| 130 | + if ('error' in auth) { |
| 131 | + return NextResponse.json({ error: auth.error }, { status: auth.status }); |
| 132 | + } |
| 133 | + |
| 134 | + const id = new URL(req.url).searchParams.get('id'); |
| 135 | + if (!isUuid(id)) return NextResponse.json({ error: 'Invalid ambassador id' }, { status: 400 }); |
| 136 | + |
| 137 | + const { data, error } = await auth.supabase.from('campus_ambassadors').delete().eq('id', id).select('id'); |
| 138 | + if (error) { |
| 139 | + // registrations.ambassador_id ON DELETE RESTRICT (restrict_violation; 23503 if it ever becomes NO ACTION): |
| 140 | + // someone registered with this code. |
| 141 | + if (error.code === '23001' || error.code === '23503') { |
| 142 | + return NextResponse.json( |
| 143 | + { error: 'Someone has already registered with this code, so it can only be revoked, not deleted.' }, |
| 144 | + { status: 409 } |
| 145 | + ); |
| 146 | + } |
| 147 | + return dbError('DELETE', error); |
| 148 | + } |
| 149 | + if (!data?.length) return NextResponse.json({ error: 'Ambassador not found' }, { status: 404 }); |
| 150 | + return NextResponse.json({ success: true }); |
| 151 | + } catch (err: unknown) { |
| 152 | + console.error('/api/admin/ambassadors DELETE error:', err); |
| 153 | + return NextResponse.json({ error: 'Failed to delete the campus ambassador' }, { status: 500 }); |
| 154 | + } |
| 155 | +} |
0 commit comments