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
155 changes: 155 additions & 0 deletions src/app/api/admin/ambassadors/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyRegistrationStaff } from '@/lib/auth-server';
import { isUuid, readJsonObject } from '@/lib/security';

export const runtime = 'nodejs';

/**
* Campus ambassadors, for admins and the registration team only (verifyRegistrationStaff): the AMBASSADORS tab of the
* admin dashboard is the programme's only portal. Students become ambassadors themselves (GET MY CODE, POST
* /api/ambassador/code); staff watch the counts, contact them and revoke a code that is abused. Everything runs as the
* service role: no client role can read or write the table or the leaderboard view (supabase/schema.sql, section 16).
*
* GET every ambassador with their counts (ambassador_leaderboard), plus
* their email (this table) and phone (their profile)
* PATCH { id, action: 'revoke' | 'restore' } or { id, name?, college? }
* DELETE ?id= only while no registration has used the code (otherwise revoke)
*/

/** PostgREST answers at most its "Max rows" (1000 by default) per request. */
const MAX_LIST_ROWS = 1000;
// Ids per `in.(...)` lookup, keeping the request URL short.
const LOOKUP_CHUNK = 150;

const LEADERBOARD_COLUMNS = 'rank, id, code, name, college, status, created_at, confirmed_registrations, pending_registrations';

const cleanText = (v: unknown): string => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : '');

const dbError = (where: string, error: unknown) => {
console.error(`/api/admin/ambassadors ${where} database error:`, error);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
};

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 { supabase } = auth;

const [board, contacts] = await Promise.all([
supabase.from('ambassador_leaderboard').select(LEADERBOARD_COLUMNS).order('created_at').order('id').range(0, MAX_LIST_ROWS - 1),
supabase.from('campus_ambassadors').select('id, email, user_id').order('created_at').order('id').range(0, MAX_LIST_ROWS - 1),
]);
if (board.error) return dbError('GET', board.error);
if (contacts.error) return dbError('GET', contacts.error);

// Phones come from the ambassadors' profiles (every signed-in user is asked for one).
const userIds = [...new Set((contacts.data ?? []).map((c) => c.user_id).filter((id): id is string => !!id))];
const phoneByUser = new Map<string, string | null>();
for (let i = 0; i < userIds.length; i += LOOKUP_CHUNK) {
const { data, error } = await supabase.from('profiles').select('id, phone').in('id', userIds.slice(i, i + LOOKUP_CHUNK));
if (error) return dbError('GET', error);
for (const p of data ?? []) phoneByUser.set(p.id, p.phone);
}

const byId = new Map((contacts.data ?? []).map((c) => [c.id, c]));
const ambassadors = (board.data ?? [])
.map((a) => {
const contact = byId.get(a.id);
return { ...a, email: contact?.email ?? '', phone: (contact?.user_id && phoneByUser.get(contact.user_id)) || null };
})
// Active ambassadors by rank (most confirmed first), then the revoked ones; oldest first within a tie.
.sort((x, y) =>
(x.rank ?? Number.MAX_SAFE_INTEGER) - (y.rank ?? Number.MAX_SAFE_INTEGER) ||
y.pending_registrations - x.pending_registrations ||
String(x.created_at).localeCompare(String(y.created_at))
);
const truncated = ambassadors.length >= MAX_LIST_ROWS;
if (truncated) console.error(`/api/admin/ambassadors: ${MAX_LIST_ROWS}+ ambassadors, the list is cut short`);

return NextResponse.json({ success: true, ambassadors, truncated });
} catch (err: unknown) {
console.error('/api/admin/ambassadors GET error:', err);
return NextResponse.json({ error: 'Failed to fetch campus ambassadors' }, { status: 500 });
}
}

export async function PATCH(req: NextRequest) {
try {
const auth = await verifyRegistrationStaff(req);
if ('error' in auth) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}

const body = await readJsonObject(req);
if (!body.ok) return NextResponse.json({ error: body.error }, { status: body.status });
const { id, action } = body.value;
if (!isUuid(id)) return NextResponse.json({ error: 'Invalid ambassador id' }, { status: 400 });

const update: Record<string, string | null> = {};
if (action !== undefined) {
if (action === 'revoke') update.status = 'revoked';
else if (action === 'restore') update.status = 'active';
else return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
} else {
if ('name' in body.value) {
const name = cleanText(body.value.name);
if (!name || name.length > 120) return NextResponse.json({ error: 'Names must be 1 to 120 characters.' }, { status: 400 });
update.name = name;
}
if ('college' in body.value) {
const college = body.value.college === null ? '' : cleanText(body.value.college);
if (college.length > 200) {
return NextResponse.json({ error: 'College names must be at most 200 characters.' }, { status: 400 });
}
update.college = college || null;
}
if (!Object.keys(update).length) return NextResponse.json({ error: 'Nothing to change' }, { status: 400 });
}

const { data, error } = await auth.supabase
.from('campus_ambassadors')
.update(update)
.eq('id', id)
.select('id, name, college, code, status')
.maybeSingle();
if (error) return dbError('PATCH', error);
if (!data) return NextResponse.json({ error: 'Ambassador not found' }, { status: 404 });
return NextResponse.json({ success: true, ambassador: data });
} catch (err: unknown) {
console.error('/api/admin/ambassadors PATCH error:', err);
return NextResponse.json({ error: 'Failed to update the campus ambassador' }, { status: 500 });
}
}

export async function DELETE(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 ambassador id' }, { status: 400 });

const { data, error } = await auth.supabase.from('campus_ambassadors').delete().eq('id', id).select('id');
if (error) {
// registrations.ambassador_id ON DELETE RESTRICT (restrict_violation; 23503 if it ever becomes NO ACTION):
// someone registered with this code.
if (error.code === '23001' || error.code === '23503') {
return NextResponse.json(
{ error: 'Someone has already registered with this code, so it can only be revoked, not deleted.' },
{ status: 409 }
);
}
return dbError('DELETE', error);
}
if (!data?.length) return NextResponse.json({ error: 'Ambassador not found' }, { status: 404 });
return NextResponse.json({ success: true });
} catch (err: unknown) {
console.error('/api/admin/ambassadors DELETE error:', err);
return NextResponse.json({ error: 'Failed to delete the campus ambassador' }, { status: 500 });
}
}
55 changes: 54 additions & 1 deletion src/app/api/admin/registrations/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import type { SupabaseClient } from '@supabase/supabase-js';
import { verifyRegistrationStaff } from '@/lib/auth-server';
import { isUuid, readJsonObject, sanitizeFilterValue } from '@/lib/security';

Expand All @@ -9,6 +10,47 @@ export const runtime = 'nodejs';
// empty batch does. MAX_BATCHES bounds the work per request.
const BATCH_SIZE = 1000;
const MAX_BATCHES = 20;
// Ids per `in.(...)` lookup, keeping the request URL short.
const LOOKUP_CHUNK = 150;

/** Who brought a registration in: the registrant's code they entered, or a campus ambassador's (never both). */
type ReferredVia = { kind: 'registrant' | 'ambassador'; code: string; name: string } | null;

/** Reads `columns` of the rows of `table` with these ids, in chunks. */
async function rowsById(supabase: SupabaseClient, table: string, columns: string, ids: string[]) {
const rows: Record<string, string>[] = [];
for (let i = 0; i < ids.length; i += LOOKUP_CHUNK) {
const { data, error } = await supabase.from(table).select(columns).in('id', ids.slice(i, i + LOOKUP_CHUNK));
if (error) return { rows, error };
rows.push(...((data ?? []) as unknown as Record<string, string>[]));
}
return { rows, error: null };
}

/** referred_via for every row: the referrer's code and name, looked up whether or not the filters list the referrer. */
async function withReferredVia(supabase: SupabaseClient, registrations: Record<string, unknown>[]) {
const ids = (key: string) => [...new Set(registrations.map((r) => r[key]).filter((v): v is string => typeof v === 'string'))];
const [friends, ambassadors] = await Promise.all([
rowsById(supabase, 'registrations', 'id, referral_code, name', ids('referred_by')),
rowsById(supabase, 'campus_ambassadors', 'id, code, name', ids('ambassador_id')),
]);
if (friends.error || ambassadors.error) return { error: friends.error || ambassadors.error, registrations };
const friendById = new Map(friends.rows.map((f) => [f.id, f]));
const ambassadorById = new Map(ambassadors.rows.map((a) => [a.id, a]));
return {
error: null,
registrations: registrations.map((r) => {
const friend = friendById.get(r.referred_by as string);
const amb = ambassadorById.get(r.ambassador_id as string);
const referred_via: ReferredVia = amb
? { kind: 'ambassador', code: amb.code, name: amb.name }
: friend
? { kind: 'registrant', code: friend.referral_code, name: friend.name }
: null;
return { ...r, referred_via };
}),
};
}

// GET all registrations (with optional filtering)
export async function GET(req: NextRequest) {
Expand All @@ -22,6 +64,8 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const status = searchParams.get('status');
const studentType = searchParams.get('student_type');
// Only the registrations made with this campus ambassador's code (the AMBASSADORS tab's per-ambassador list).
const ambassador = searchParams.get('ambassador');
// Stripped of PostgREST filter syntax so a search term can't inject extra `.or()` conditions.
const query = sanitizeFilterValue(searchParams.get('q'));

Expand All @@ -31,6 +75,9 @@ export async function GET(req: NextRequest) {
if (studentType && studentType !== 'all' && !['internal', 'external'].includes(studentType)) {
return NextResponse.json({ error: 'Invalid student_type filter' }, { status: 400 });
}
if (ambassador && !isUuid(ambassador)) {
return NextResponse.json({ error: 'Invalid ambassador filter' }, { status: 400 });
}

// Oldest first with a unique tie-breaker: a total order, and registrations created while we page land after
// the rows already read instead of shifting them (no duplicates, no skips). Reversed to newest first below.
Expand All @@ -43,6 +90,7 @@ export async function GET(req: NextRequest) {

if (status && status !== 'all') dbQuery = dbQuery.eq('status', status);
if (studentType && studentType !== 'all') dbQuery = dbQuery.eq('student_type', studentType);
if (ambassador) dbQuery = dbQuery.eq('ambassador_id', ambassador);
if (query) {
dbQuery = dbQuery.or(
`name.ilike.%${query}%,email.ilike.%${query}%,registration_id.ilike.%${query}%,college.ilike.%${query}%,utr.ilike.%${query}%,enrollment_no.ilike.%${query}%`
Expand Down Expand Up @@ -80,7 +128,12 @@ export async function GET(req: NextRequest) {
from += data.length;
}

return NextResponse.json({ success: true, registrations: registrations.reverse() });
const enriched = await withReferredVia(supabase, registrations.reverse());
if (enriched.error) {
console.error('/api/admin/registrations referrer lookup error:', enriched.error);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
return NextResponse.json({ success: true, registrations: enriched.registrations });
} catch (err: unknown) {
console.error('/api/admin/registrations error:', err);
const message = 'Failed to fetch registrations';
Expand Down
139 changes: 139 additions & 0 deletions src/app/api/ambassador/code/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server';
import type { SupabaseClient, User } from '@supabase/supabase-js';
import { getSupabaseAdmin } from '@/lib/supabase';
import { getAuthenticatedUser } from '@/lib/auth-server';
import { createRateLimiter, readJsonObject } from '@/lib/security';

export const runtime = 'nodejs';

const codeLimiter = createRateLimiter({ limit: 30, windowMs: 10 * 60 * 1000 });

const COLUMNS = 'id, code, name, status, user_id';

type AmbassadorRow = { id: string; code: string; name: string; status: string; user_id: string | null };

const cleanText = (v: unknown): string => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : '');

/** The name staff see on the AMBASSADORS tab: the profile's, else Google's, else the email's local part. */
async function displayName(client: SupabaseClient, user: User, email: string): Promise<string> {
const { data: profile } = await client.from('profiles').select('full_name').eq('id', user.id).maybeSingle();
const md = user.user_metadata || {};
return (cleanText(profile?.full_name) || cleanText(md.full_name) || cleanText(md.name) || email.split('@')[0]).slice(0, 120);
}

/**
* POST /api/ambassador/code {} or { create: true, college }
* GET MY CODE in the home page's Campus Ambassador section. Any signed-in student with a verified email can be a
* campus ambassador; nobody approves them (the section's APPLY button only opens the organisers' Google Form).
* The request proxy gates /api/ambassador/* to signed-in callers too. Matched on the session's verified email
* (Supabase Auth, never the profile or the body), against supabase/schema.sql section 16:
* {} -> { ambassador: { code, name, status: 'active' } } their permanent code, or
* { ambassador: null, email, name, college } none yet: the dialog asks for their
* college (pre-filled from their
* registration, if any)
* { create: true, college } -> creates it (the database makes the code) and answers like the first case
* either -> { ambassador: { status: 'revoked' } } revoked by staff: no code
* The row is linked to the caller's account (user_id), which /api/register uses to refuse an ambassador's own code
* and the AMBASSADORS tab uses for their phone. Only this route creates rows; no client role can touch the table.
* A POST, not a GET, because it writes: the request proxy refuses cross-site POSTs.
*/
export async function POST(req: NextRequest) {
try {
const auth = await getAuthenticatedUser(req);
if (!auth) {
return NextResponse.json({ error: 'Please log in to get your ambassador code.' }, { status: 401 });
}
const { user, token } = auth;
const email = (user.email || '').trim().toLowerCase();
if (!email || !user.email_confirmed_at) {
return NextResponse.json(
{ error: 'Your sign-in email is not verified. Please log in with Google again.' },
{ status: 403 }
);
}

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

const body = await readJsonObject(req);
if (!body.ok && body.status === 413) return NextResponse.json({ error: body.error }, { status: 413 });
const input: Record<string, unknown> = body.ok ? body.value : {};

const client = getSupabaseAdmin(token);
const byEmail = () => client.from('campus_ambassadors').select(COLUMNS).eq('email', email).maybeSingle();
const found = await byEmail();
if (found.error) {
console.error('/api/ambassador/code lookup failed:', found.error);
return NextResponse.json({ error: 'Failed to check your ambassador code. Please try again.' }, { status: 500 });
}
let amb = found.data as AmbassadorRow | null;

if (!amb && input.create !== true) {
const { data: reg } = await client
.from('registrations')
.select('college')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
return NextResponse.json({
success: true,
ambassador: null,
email,
name: await displayName(client, user, email),
college: reg?.college ?? '',
});
}

if (!amb) {
const college = cleanText(input.college);
if (college.length < 3 || college.length > 200) {
return NextResponse.json(
{ error: 'Enter your college name (3 to 200 characters).', field: 'college' },
{ status: 400 }
);
}
const row = { email, name: await displayName(client, user, email), college, user_id: user.id };
let ins = await client.from('campus_ambassadors').insert(row).select(COLUMNS).single();
// A generated code that collided with one made at the same moment: the trigger draws a new one on retry.
if (ins.error?.code === '23505' && /code/i.test(`${ins.error.message} ${ins.error.details ?? ''}`)) {
ins = await client.from('campus_ambassadors').insert(row).select(COLUMNS).single();
}
if (ins.error?.code === '23505') {
// Created by a double tap a moment ago: answer with that one.
const again = await byEmail();
if (again.data) amb = again.data as AmbassadorRow;
else {
// This account already has a code under the email it used to have.
console.warn('/api/ambassador/code: account already linked to another ambassador row', ins.error);
return NextResponse.json(
{ error: 'Your account already has an ambassador code under another email. Please contact the registration team.' },
{ status: 409 }
);
}
} else if (ins.error || !ins.data) {
console.error('/api/ambassador/code create failed:', ins.error);
return NextResponse.json({ error: 'Failed to create your ambassador code. Please try again.' }, { status: 500 });
} else {
amb = ins.data as AmbassadorRow;
}
} else if (amb.user_id !== user.id) {
// The verified email is theirs, so is the code: link this account (e.g. their sign-in was deleted and created
// again). Not fatal if it fails; the code is still shown.
const { error: linkErr } = await client.from('campus_ambassadors').update({ user_id: user.id }).eq('id', amb.id);
if (linkErr) console.warn('/api/ambassador/code link failed:', linkErr);
}

if (amb.status !== 'active') {
return NextResponse.json({ success: true, ambassador: { status: 'revoked' }, email });
}
return NextResponse.json({ success: true, ambassador: { code: amb.code, name: amb.name, status: 'active' }, email });
} catch (err: unknown) {
console.error('API /api/ambassador/code error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Loading
Loading