Skip to content

Commit eed2a25

Browse files
authored
Merge pull request #59 from OpencodeNIT-R/development
Campus Ambassador section and referral
2 parents a24203f + 71027a6 commit eed2a25

20 files changed

Lines changed: 2023 additions & 59 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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+
}

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextRequest, NextResponse } from 'next/server';
2+
import type { SupabaseClient } from '@supabase/supabase-js';
23
import { verifyRegistrationStaff } from '@/lib/auth-server';
34
import { isUuid, readJsonObject, sanitizeFilterValue } from '@/lib/security';
45

@@ -9,6 +10,47 @@ export const runtime = 'nodejs';
910
// empty batch does. MAX_BATCHES bounds the work per request.
1011
const BATCH_SIZE = 1000;
1112
const MAX_BATCHES = 20;
13+
// Ids per `in.(...)` lookup, keeping the request URL short.
14+
const LOOKUP_CHUNK = 150;
15+
16+
/** Who brought a registration in: the registrant's code they entered, or a campus ambassador's (never both). */
17+
type ReferredVia = { kind: 'registrant' | 'ambassador'; code: string; name: string } | null;
18+
19+
/** Reads `columns` of the rows of `table` with these ids, in chunks. */
20+
async function rowsById(supabase: SupabaseClient, table: string, columns: string, ids: string[]) {
21+
const rows: Record<string, string>[] = [];
22+
for (let i = 0; i < ids.length; i += LOOKUP_CHUNK) {
23+
const { data, error } = await supabase.from(table).select(columns).in('id', ids.slice(i, i + LOOKUP_CHUNK));
24+
if (error) return { rows, error };
25+
rows.push(...((data ?? []) as unknown as Record<string, string>[]));
26+
}
27+
return { rows, error: null };
28+
}
29+
30+
/** referred_via for every row: the referrer's code and name, looked up whether or not the filters list the referrer. */
31+
async function withReferredVia(supabase: SupabaseClient, registrations: Record<string, unknown>[]) {
32+
const ids = (key: string) => [...new Set(registrations.map((r) => r[key]).filter((v): v is string => typeof v === 'string'))];
33+
const [friends, ambassadors] = await Promise.all([
34+
rowsById(supabase, 'registrations', 'id, referral_code, name', ids('referred_by')),
35+
rowsById(supabase, 'campus_ambassadors', 'id, code, name', ids('ambassador_id')),
36+
]);
37+
if (friends.error || ambassadors.error) return { error: friends.error || ambassadors.error, registrations };
38+
const friendById = new Map(friends.rows.map((f) => [f.id, f]));
39+
const ambassadorById = new Map(ambassadors.rows.map((a) => [a.id, a]));
40+
return {
41+
error: null,
42+
registrations: registrations.map((r) => {
43+
const friend = friendById.get(r.referred_by as string);
44+
const amb = ambassadorById.get(r.ambassador_id as string);
45+
const referred_via: ReferredVia = amb
46+
? { kind: 'ambassador', code: amb.code, name: amb.name }
47+
: friend
48+
? { kind: 'registrant', code: friend.referral_code, name: friend.name }
49+
: null;
50+
return { ...r, referred_via };
51+
}),
52+
};
53+
}
1254

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

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

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

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

83-
return NextResponse.json({ success: true, registrations: registrations.reverse() });
131+
const enriched = await withReferredVia(supabase, registrations.reverse());
132+
if (enriched.error) {
133+
console.error('/api/admin/registrations referrer lookup error:', enriched.error);
134+
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
135+
}
136+
return NextResponse.json({ success: true, registrations: enriched.registrations });
84137
} catch (err: unknown) {
85138
console.error('/api/admin/registrations error:', err);
86139
const message = 'Failed to fetch registrations';
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import type { SupabaseClient, User } from '@supabase/supabase-js';
3+
import { getSupabaseAdmin } from '@/lib/supabase';
4+
import { getAuthenticatedUser } from '@/lib/auth-server';
5+
import { createRateLimiter, readJsonObject } from '@/lib/security';
6+
7+
export const runtime = 'nodejs';
8+
9+
const codeLimiter = createRateLimiter({ limit: 30, windowMs: 10 * 60 * 1000 });
10+
11+
const COLUMNS = 'id, code, name, status, user_id';
12+
13+
type AmbassadorRow = { id: string; code: string; name: string; status: string; user_id: string | null };
14+
15+
const cleanText = (v: unknown): string => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : '');
16+
17+
/** The name staff see on the AMBASSADORS tab: the profile's, else Google's, else the email's local part. */
18+
async function displayName(client: SupabaseClient, user: User, email: string): Promise<string> {
19+
const { data: profile } = await client.from('profiles').select('full_name').eq('id', user.id).maybeSingle();
20+
const md = user.user_metadata || {};
21+
return (cleanText(profile?.full_name) || cleanText(md.full_name) || cleanText(md.name) || email.split('@')[0]).slice(0, 120);
22+
}
23+
24+
/**
25+
* POST /api/ambassador/code {} or { create: true, college }
26+
* GET MY CODE in the home page's Campus Ambassador section. Any signed-in student with a verified email can be a
27+
* campus ambassador; nobody approves them (the section's APPLY button only opens the organisers' Google Form).
28+
* The request proxy gates /api/ambassador/* to signed-in callers too. Matched on the session's verified email
29+
* (Supabase Auth, never the profile or the body), against supabase/schema.sql section 16:
30+
* {} -> { ambassador: { code, name, status: 'active' } } their permanent code, or
31+
* { ambassador: null, email, name, college } none yet: the dialog asks for their
32+
* college (pre-filled from their
33+
* registration, if any)
34+
* { create: true, college } -> creates it (the database makes the code) and answers like the first case
35+
* either -> { ambassador: { status: 'revoked' } } revoked by staff: no code
36+
* The row is linked to the caller's account (user_id), which /api/register uses to refuse an ambassador's own code
37+
* and the AMBASSADORS tab uses for their phone. Only this route creates rows; no client role can touch the table.
38+
* A POST, not a GET, because it writes: the request proxy refuses cross-site POSTs.
39+
*/
40+
export async function POST(req: NextRequest) {
41+
try {
42+
const auth = await getAuthenticatedUser(req);
43+
if (!auth) {
44+
return NextResponse.json({ error: 'Please log in to get your ambassador code.' }, { status: 401 });
45+
}
46+
const { user, token } = auth;
47+
const email = (user.email || '').trim().toLowerCase();
48+
if (!email || !user.email_confirmed_at) {
49+
return NextResponse.json(
50+
{ error: 'Your sign-in email is not verified. Please log in with Google again.' },
51+
{ status: 403 }
52+
);
53+
}
54+
55+
if (!codeLimiter.hit(user.id)) {
56+
return NextResponse.json(
57+
{ error: 'Too many requests. Please wait a few minutes and try again.' },
58+
{ status: 429, headers: { 'Retry-After': String(codeLimiter.retryAfterSeconds(user.id)) } }
59+
);
60+
}
61+
62+
const body = await readJsonObject(req);
63+
if (!body.ok && body.status === 413) return NextResponse.json({ error: body.error }, { status: 413 });
64+
const input: Record<string, unknown> = body.ok ? body.value : {};
65+
66+
const client = getSupabaseAdmin(token);
67+
const byEmail = () => client.from('campus_ambassadors').select(COLUMNS).eq('email', email).maybeSingle();
68+
const found = await byEmail();
69+
if (found.error) {
70+
console.error('/api/ambassador/code lookup failed:', found.error);
71+
return NextResponse.json({ error: 'Failed to check your ambassador code. Please try again.' }, { status: 500 });
72+
}
73+
let amb = found.data as AmbassadorRow | null;
74+
75+
if (!amb && input.create !== true) {
76+
const { data: reg } = await client
77+
.from('registrations')
78+
.select('college')
79+
.eq('user_id', user.id)
80+
.order('created_at', { ascending: false })
81+
.limit(1)
82+
.maybeSingle();
83+
return NextResponse.json({
84+
success: true,
85+
ambassador: null,
86+
email,
87+
name: await displayName(client, user, email),
88+
college: reg?.college ?? '',
89+
});
90+
}
91+
92+
if (!amb) {
93+
const college = cleanText(input.college);
94+
if (college.length < 3 || college.length > 200) {
95+
return NextResponse.json(
96+
{ error: 'Enter your college name (3 to 200 characters).', field: 'college' },
97+
{ status: 400 }
98+
);
99+
}
100+
const row = { email, name: await displayName(client, user, email), college, user_id: user.id };
101+
let ins = await client.from('campus_ambassadors').insert(row).select(COLUMNS).single();
102+
// A generated code that collided with one made at the same moment: the trigger draws a new one on retry.
103+
if (ins.error?.code === '23505' && /code/i.test(`${ins.error.message} ${ins.error.details ?? ''}`)) {
104+
ins = await client.from('campus_ambassadors').insert(row).select(COLUMNS).single();
105+
}
106+
if (ins.error?.code === '23505') {
107+
// Created by a double tap a moment ago: answer with that one.
108+
const again = await byEmail();
109+
if (again.data) amb = again.data as AmbassadorRow;
110+
else {
111+
// This account already has a code under the email it used to have.
112+
console.warn('/api/ambassador/code: account already linked to another ambassador row', ins.error);
113+
return NextResponse.json(
114+
{ error: 'Your account already has an ambassador code under another email. Please contact the registration team.' },
115+
{ status: 409 }
116+
);
117+
}
118+
} else if (ins.error || !ins.data) {
119+
console.error('/api/ambassador/code create failed:', ins.error);
120+
return NextResponse.json({ error: 'Failed to create your ambassador code. Please try again.' }, { status: 500 });
121+
} else {
122+
amb = ins.data as AmbassadorRow;
123+
}
124+
} else if (amb.user_id !== user.id) {
125+
// The verified email is theirs, so is the code: link this account (e.g. their sign-in was deleted and created
126+
// again). Not fatal if it fails; the code is still shown.
127+
const { error: linkErr } = await client.from('campus_ambassadors').update({ user_id: user.id }).eq('id', amb.id);
128+
if (linkErr) console.warn('/api/ambassador/code link failed:', linkErr);
129+
}
130+
131+
if (amb.status !== 'active') {
132+
return NextResponse.json({ success: true, ambassador: { status: 'revoked' }, email });
133+
}
134+
return NextResponse.json({ success: true, ambassador: { code: amb.code, name: amb.name, status: 'active' }, email });
135+
} catch (err: unknown) {
136+
console.error('API /api/ambassador/code error:', err);
137+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
138+
}
139+
}

0 commit comments

Comments
 (0)