From e7334107e49855e086148566a20dc8be0b0d4201 Mon Sep 17 00:00:00 2001 From: Krish Agarwal Date: Fri, 23 Oct 2026 14:51:05 +0530 Subject: [PATCH] referral for CAs --- src/app/api/admin/ambassadors/route.ts | 155 +++++++ src/app/api/admin/registrations/route.ts | 55 ++- src/app/api/ambassador/code/route.ts | 139 ++++++ src/app/api/register/check-code/route.ts | 56 +++ src/app/api/register/route.ts | 62 ++- src/app/privacy/page.tsx | 16 +- src/components/innovision/AdminDashboard.tsx | 53 ++- .../innovision/AmbassadorCodeDialog.tsx | 237 ++++++++++ src/components/innovision/AuthOverlay.tsx | 6 +- src/components/innovision/HomeView.tsx | 42 +- src/components/innovision/Innovision.tsx | 182 +++++++- .../innovision/admin/AmbassadorsManager.tsx | 420 ++++++++++++++++++ src/config/ambassador.ts | 13 + src/lib/ambassadors.ts | 104 +++++ src/lib/security.ts | 37 +- src/lib/supabase.ts | 90 +++- src/lib/validation.ts | 29 +- src/proxy.ts | 12 +- supabase/manual/add_campus_ambassadors.sql | 193 ++++++++ supabase/schema.sql | 181 ++++++++ 20 files changed, 2023 insertions(+), 59 deletions(-) create mode 100644 src/app/api/admin/ambassadors/route.ts create mode 100644 src/app/api/ambassador/code/route.ts create mode 100644 src/app/api/register/check-code/route.ts create mode 100644 src/components/innovision/AmbassadorCodeDialog.tsx create mode 100644 src/components/innovision/admin/AmbassadorsManager.tsx create mode 100644 src/config/ambassador.ts create mode 100644 src/lib/ambassadors.ts create mode 100644 supabase/manual/add_campus_ambassadors.sql diff --git a/src/app/api/admin/ambassadors/route.ts b/src/app/api/admin/ambassadors/route.ts new file mode 100644 index 0000000..e2499ad --- /dev/null +++ b/src/app/api/admin/ambassadors/route.ts @@ -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(); + 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 = {}; + 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 }); + } +} diff --git a/src/app/api/admin/registrations/route.ts b/src/app/api/admin/registrations/route.ts index b312886..589a7b7 100644 --- a/src/app/api/admin/registrations/route.ts +++ b/src/app/api/admin/registrations/route.ts @@ -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'; @@ -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[] = []; + 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[])); + } + 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[]) { + 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) { @@ -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')); @@ -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. @@ -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}%` @@ -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'; diff --git a/src/app/api/ambassador/code/route.ts b/src/app/api/ambassador/code/route.ts new file mode 100644 index 0000000..485c5f2 --- /dev/null +++ b/src/app/api/ambassador/code/route.ts @@ -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 { + 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 = 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 }); + } +} diff --git a/src/app/api/register/check-code/route.ts b/src/app/api/register/check-code/route.ts new file mode 100644 index 0000000..ca39f5d --- /dev/null +++ b/src/app/api/register/check-code/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSupabaseAdmin } from '@/lib/supabase'; +import { getAuthenticatedUser } from '@/lib/auth-server'; +import { resolveReferralCode } from '@/lib/ambassadors'; +import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate'; +import { createRateLimiter, normalizeReferralCode, readJsonObject, referralCodeKind, REFERRAL_CODE_FORMAT_ERROR } from '@/lib/security'; + +export const runtime = 'nodejs'; + +// Codes are 6 random characters from 32 (about a billion per kind), so 20 checks per user per 10 minutes can't +// find a working one by guessing. The answer never says whose code it is. +const checkLimiter = createRateLimiter({ limit: 20, windowMs: 10 * 60 * 1000 }); + +/** + * POST /api/register/check-code { code } + * Checks the referral code typed in the registration form before the student pays: { valid: true, kind } or + * { valid: false, error }, under the same rules as POST /api/register (src/lib/ambassadors.ts), which checks it again + * on submit. Signed-in callers only (the request proxy also gates /api/register/*), while registrations are open. + */ +export async function POST(req: NextRequest) { + try { + const auth = await getAuthenticatedUser(req); + if (!auth || !auth.user.email) { + return NextResponse.json({ error: 'Please log in to continue.' }, { status: 401 }); + } + const { user, token } = auth; + const verifiedEmail = auth.user.email.toLowerCase().trim(); + if (!registrationsOpen()) return registrationsClosedResponse(); + + if (!checkLimiter.hit(user.id)) { + return NextResponse.json( + { error: 'Too many code checks. Please wait a few minutes and try again.' }, + { status: 429, headers: { 'Retry-After': String(checkLimiter.retryAfterSeconds(user.id)) } } + ); + } + + const body = await readJsonObject(req); + if (!body.ok) return NextResponse.json({ error: body.error }, { status: body.status }); + if (typeof body.value.code !== 'string' || body.value.code.length > 64) { + return NextResponse.json({ error: 'Send the code as a string.' }, { status: 400 }); + } + const code = normalizeReferralCode(body.value.code); + if (!referralCodeKind(code)) return NextResponse.json({ valid: false, error: REFERRAL_CODE_FORMAT_ERROR }); + + const referral = await resolveReferralCode(getSupabaseAdmin(token), code, { userId: user.id, email: verifiedEmail }); + if (!referral.ok) { + return referral.status === 500 + ? NextResponse.json({ error: referral.error }, { status: 500 }) + : NextResponse.json({ valid: false, error: referral.error }); + } + return NextResponse.json({ valid: true, kind: referral.kind }); + } catch (err: unknown) { + console.error('API /api/register/check-code error:', err); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index d3f568b..07a1d1f 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -3,6 +3,7 @@ import type { SupabaseClient, User } from '@supabase/supabase-js'; import { getSupabaseAdmin } from '@/lib/supabase'; import { getAuthenticatedUser } from '@/lib/auth-server'; import { paymentProofExists } from '@/lib/payment-proofs'; +import { ambassadorGuardError, findOwnAmbassador, resolveReferralCode, type Registrant } from '@/lib/ambassadors'; import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate'; import { isDuplicatePhoneError, PHONE_IN_USE_ERROR_MESSAGE } from '@/lib/validation'; import { @@ -18,6 +19,9 @@ import { const registerLimiter = createRateLimiter({ limit: 10, windowMs: 10 * 60 * 1000 }); +/** Added to a referral-code error at submit: the form goes back to the code with the payment details still filled in. */ +const PAYMENT_KEPT_NOTE = 'Nothing was submitted and your payment details are kept: fix or clear the code, then submit again.'; + /** Latest registration owned by this user (by id, or by their verified email for legacy rows). */ async function findExistingRegistration(client: SupabaseClient, user: User, verifiedEmail: string) { const filter = isFilterSafeEmail(verifiedEmail) @@ -32,6 +36,17 @@ async function findExistingRegistration(client: SupabaseClient, user: User, veri .maybeSingle(); } +/** + * The registration as the registrant sees it: for an active campus ambassador, ambassador_code is their CA code, which + * the pass shows instead of the registration's own 6-character referral_code (it stays valid whatever happens to the + * registration). Not a column. A failed lookup only leaves it out. + */ +async function withAmbassadorCode(client: SupabaseClient, registration: T, registrant: Registrant) { + const { ambassador, error } = await findOwnAmbassador(client, registrant); + if (error) console.warn('Own ambassador lookup failed:', error); + return { ...registration, ambassador_code: ambassador?.status === 'active' ? ambassador.code : null }; +} + /** * Answer to a UTR that's already taken. A retry whose first attempt was saved (but whose response was lost) also * lands here, so first look for the caller's OWN registration, by the verified session user id only: a matching UTR @@ -87,7 +102,9 @@ export async function GET(req: NextRequest) { return NextResponse.json({ success: true, - registration: registration || null, + registration: registration + ? await withAmbassadorCode(adminClient, registration, { userId: user.id, email: verifiedEmail }) + : null, }); } catch (err: unknown) { console.error('API /api/register GET error:', err); @@ -182,29 +199,26 @@ export async function POST(req: NextRequest) { ); } - // Optional referral: the code must belong to a registration that wasn't rejected. Checked last, after the UTR and - // proof, so probing codes costs a real payment proof and a slot of the per-user rate limit. + // Optional referral: a registrant's code (a registration that wasn't rejected) or an active campus ambassador's + // code that isn't the registrant's own (src/lib/ambassadors.ts). Checked last, after the UTR and proof. The form + // checks the code before payment (POST /api/register/check-code); a code that went bad since (an ambassador revoked + // in between) refuses the whole submit, so nothing is saved without the credit the registrant meant to give. The + // UTR is only claimed when the row is written, so the same payment details can be submitted again straight away. + const registrant: Registrant = { userId: user.id, email: verifiedEmail }; let referredBy: string | null = null; + let ambassadorId: string | null = null; if (input.referred_by_code) { - const { data: referrer, error: referrerErr } = await adminClient - .from('registrations') - .select('id, status') - .eq('referral_code', input.referred_by_code) - .maybeSingle(); - if (referrerErr) { - console.error('Referral code lookup failed:', referrerErr); - return NextResponse.json({ error: 'Failed to check the referral code. Please try again.' }, { status: 500 }); - } - if (!referrer || referrer.status === 'rejected') { + const referral = await resolveReferralCode(adminClient, input.referred_by_code, registrant); + if (!referral.ok) { return NextResponse.json( - { - error: 'No registration has this referral code. Check it with the friend who shared it, or leave the field empty.', - field: 'referred_by_code', - }, - { status: 400 } + referral.status === 400 + ? { error: `${referral.error} ${PAYMENT_KEPT_NOTE}`, field: 'referred_by_code' } + : { error: referral.error }, + { status: referral.status } ); } - referredBy = referrer.id; + referredBy = referral.referredBy; + ambassadorId = referral.ambassadorId; } // 4. Status and fee are server constants, the same for every registrant (no free tier): any status, amount or @@ -226,6 +240,9 @@ export async function POST(req: NextRequest) { status: NEW_REGISTRATION_STATUS, // The registrant's own referral_code is generated by the database (trg_assign_referral_code). referred_by: referredBy, + // Only sent when an ambassador's code was used, so registering without one keeps working on a database that + // hasn't had supabase/manual/add_campus_ambassadors.sql yet. + ...(ambassadorId ? { ambassador_id: ambassadorId } : {}), }; let newReg = null; @@ -239,6 +256,11 @@ export async function POST(req: NextRequest) { newReg = result.data; regError = result.error; if (!regError) break; + // The ambassador was revoked between the check above and this insert (trg_guard_registration_ambassador). + const guardError = ambassadorGuardError(regError); + if (guardError) { + return NextResponse.json({ error: `${guardError} ${PAYMENT_KEPT_NOTE}`, field: 'referred_by_code' }, { status: 400 }); + } if (regError.code !== '23505') break; // Unique violation on utr: another registration claimed this UTR between the pre-check and the insert (possibly // this user's own concurrent submit, when the UTR index is checked before the user_id index). @@ -284,7 +306,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true, message: 'Registration submitted successfully. Pending verification.', - registration: newReg, + registration: await withAmbassadorCode(adminClient, newReg, registrant), }); } catch (err: unknown) { console.error('Registration API error:', err); diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 6925963..766f37b 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -14,7 +14,7 @@ export const metadata = pageMetadata({ */ export default function PrivacyPage() { return ( - +

This policy explains what personal data the INNOVISION 2026 website collects, why, who can see it and what you can do about it. “We” means the INNOVISION 2026 organising team at the National Institute of Technology @@ -27,6 +27,11 @@ export default function PrivacyPage() {

  • From Google sign-in: your name, email address and profile photo. We never see your Google password.
  • When you register: your name, gender, college, enrolment or roll number and phone number.
  • For the fee: the UPI transaction ID (UTR) and the screenshot of your payment that you upload. We do not collect card, bank or UPI PIN details.
  • +
  • Referral codes: if you enter a friend's or a campus ambassador's code when you register, we record whose code it was.
  • +
  • + Campus ambassadors: when you get an ambassador code, we store it with your name, college and Google + email. What you fill in on our ambassador application form is kept by the organisers in Google Forms. +
  • At the venue: when your entry pass is scanned, we record the time, the entry desk and which volunteer checked you in.
  • Visit statistics: anonymous page-view counts through Vercel Analytics, which uses no cookies and does not identify you.
  • @@ -45,10 +50,12 @@ export default function PrivacyPage() {
    • You can see your own profile, registration and entry pass after signing in.
    • The registration team sees registration details and payment screenshots to verify payments. Screenshots are kept in private storage and opened only through short-lived links.
    • +
    • Admins and the registration team also see which code each registrant entered, and each campus ambassador's name, email, phone number, college and the registrations their code brought in, to run the programme and contact them. Ambassadors see only their own code, never who used it.
    • Gate volunteers see your registration details when they scan your pass, to match them with your college ID.
    • - Service providers that run the site for us: Google (sign-in), Supabase (database and file storage) and - Vercel (hosting and visit statistics). They process data only to provide those services. + Service providers that run the site for us: Google (sign-in, and Google Forms for ambassador + applications), Supabase (database and file storage) and Vercel (hosting and visit statistics). They process data + only to provide those services.

    We share data with anyone else only if the law requires it.

    @@ -57,7 +64,8 @@ export default function PrivacyPage() {

    We use two sign-in cookies, which last up to 7 and 30 days, so you stay logged in. The site also keeps small, temporary notes in your browser's session storage, for example to reopen the registration form after you sign - in. We use no advertising or tracking cookies. + in. If you open a share link that carries a referral code, the code is kept in your browser's local storage for + up to 30 days, only to fill it in when you register. We use no advertising or tracking cookies.

    How long we keep it

    diff --git a/src/components/innovision/AdminDashboard.tsx b/src/components/innovision/AdminDashboard.tsx index 2b2b193..7505a44 100644 --- a/src/components/innovision/AdminDashboard.tsx +++ b/src/components/innovision/AdminDashboard.tsx @@ -3,13 +3,14 @@ import React, { useState, useEffect, useCallback } from 'react'; import type { UserProfile, Registration } from '@/lib/supabase'; import { getSupabase, fetchSessionFromDatabase, getCookie } from '@/lib/supabase'; -import { csvCell, GENDER_OPTIONS, safeHttpUrl } from '@/lib/validation'; +import { csvCell, formatReferralCode, GENDER_OPTIONS, safeHttpUrl } from '@/lib/validation'; const genderLabel = (value: string) => GENDER_OPTIONS.find((g) => g.value === value)?.label ?? value; import EventsManager from './admin/EventsManager'; import GalleryManager from './admin/GalleryManager'; import EntryManager from './admin/EntryManager'; import ReferralLeaderboard from './admin/ReferralLeaderboard'; +import AmbassadorsManager from './admin/AmbassadorsManager'; import { httpErrorMessage } from '@/lib/api-errors'; interface AdminDashboardProps { @@ -32,7 +33,7 @@ export default function AdminDashboard({ const canReviewRegistrations = isAdmin || isRegTeam; const defaultTab = canReviewRegistrations ? 'registrations' : 'events'; - const [tab, setTab] = useState<'registrations' | 'referrals' | 'events' | 'gallery' | 'users' | 'entry'>(defaultTab); + const [tab, setTab] = useState<'registrations' | 'referrals' | 'ambassadors' | 'events' | 'gallery' | 'users' | 'entry'>(defaultTab); const [registrations, setRegistrations] = useState([]); const [users, setUsers] = useState([]); const [loadingRegs, setLoadingRegs] = useState(false); @@ -205,7 +206,7 @@ export default function AdminDashboard({ let activeTab = tab; if (tab === 'users' && !isAdmin) activeTab = defaultTab; if (tab === 'entry' && !isAdmin) activeTab = defaultTab; - if ((tab === 'registrations' || tab === 'referrals') && !canReviewRegistrations) activeTab = defaultTab; + if ((tab === 'registrations' || tab === 'referrals' || tab === 'ambassadors') && !canReviewRegistrations) activeTab = defaultTab; if ((tab === 'events' || tab === 'gallery') && !isAdmin && !isItTeam) activeTab = defaultTab; if (!isOpen || !isStaff) return null; @@ -336,6 +337,9 @@ export default function AdminDashboard({ 'UPI UTR', 'Payment Screenshot (private ref)', 'Referral Code', + 'Referred Via', + 'Referred Via Code', + 'Referred By', 'Registered At', ]; @@ -353,6 +357,9 @@ export default function AdminDashboard({ r.utr, r.payment_proof_path, r.referral_code, + r.referred_via ? (r.referred_via.kind === 'ambassador' ? 'Campus ambassador' : 'Registrant') : '', + r.referred_via ? formatReferralCode(r.referred_via.code) : '', + r.referred_via?.name ?? '', r.created_at, ].map(csvCell)); @@ -856,6 +863,27 @@ export default function AdminDashboard({ )} + {canReviewRegistrations && ( + + )} + {(isAdmin || isItTeam) && ( + + {state.kind === 'loading' &&

    Checking your ambassador status…

    } + + {state.kind === 'code' && ( + <> +

    + Share this code with students from your campus. Anyone can enter it in the referral field when they + register, as many times as needed. It never changes. +

    +
    + {shown} + +
    +
    + SHARE LINK + {link} + Opening it fills your code in for them when they register. +
    + {copied === 'manual' &&

    Copying isn't allowed here: it's selected, so copy it by hand.

    } +
    + + SHARE ON WHATSAPP + + +
    +

    Signed in as {state.email}.

    + + )} + + {state.kind === 'join' && ( +
    { + e.preventDefault(); + if (state.busy) return; + onCreate(String(new FormData(e.currentTarget).get('college') ?? '')); + }} + style={{ display: 'flex', flexDirection: 'column', gap: '16px' }} + > +

    + Get your own code to share with students from your campus. Anyone can enter it when they register, and you + don't need to register yourself. +

    + + + {formUrl ? ( +

    + Haven't filled in the ambassador form yet?{' '} + Apply via Google Form ↗ +

    + ) : null} +

    + {state.name ? `${state.name} · ` : ''}{state.email}.{' '} + +

    +
    + )} + + {state.kind === 'revoked' && ( +

    + The campus ambassador code for {state.email} has + been deactivated by the organisers and isn't accepted for new registrations. If you think this is a + mistake, please contact the registration team. +

    + )} + + {state.kind === 'error' && ( + <> +

    {state.message}

    + + + )} + + + + ); +} diff --git a/src/components/innovision/AuthOverlay.tsx b/src/components/innovision/AuthOverlay.tsx index 23bfead..534cb9e 100644 --- a/src/components/innovision/AuthOverlay.tsx +++ b/src/components/innovision/AuthOverlay.tsx @@ -163,8 +163,8 @@ export default function AuthOverlay({ v }: { v: V }) { @@ -322,7 +322,7 @@ export default function AuthOverlay({ v }: { v: V }) {
    {v.refCode ? (
    - Your Referral Code: + {v.refCodeLabel} {v.refCode}
    + {/* Campus Ambassador programme (src/config/ambassador.ts): APPLY opens the organisers' Google Form; GET MY CODE gives + any signed-in student their permanent code (a dialog outside this never re-rendering view). #/ambassador lands here. */} +
    + +
    +
    +

    CAMPUS AMBASSADOR PROGRAMME

    +

    Lead your campus into orbit.

    +

    Represent Innovision 2026 at your college. You get a code of your own, and every student who registers with it counts for you, as many as you can bring along.

    +
    + {v.ambassadorFormUrl ? ( + + APPLY VIA GOOGLE FORM ↗ + + ) : ( + APPLICATIONS OPEN SOON + )} + + GET MY CODE + +
    +

    Tap GET MY CODE and log in with Google: your code is ready straight away.

    +
    +
      + {[ + ['01', 'Apply', 'Fill in the ambassador Google Form, so the Innovision team knows who you are.'], + ['02', 'Get your code', 'Tap GET MY CODE and log in with Google. Your code is ready straight away, with no registration needed, and it stays yours.'], + ['03', 'Bring your campus', 'Share your code or link. Students enter it when they register, and there is no limit.'], + ].map(([no, step, text]) => ( +
    1. + {no} + + {step} + {text} + +
    2. + ))} +
    +
    +
    {/* The footer slides up over the closing section: the section is sticky (its top is set by Innovision#homeScroll so it pins once its bottom reaches the bottom of the screen) across the 40vh spacer below it, and the wrapper's negative margin lets the footer overlap that spacer. Sticky runs with the browser's own scrolling, so it stays smooth on diff --git a/src/components/innovision/Innovision.tsx b/src/components/innovision/Innovision.tsx index aaaaeb1..738e43e 100644 --- a/src/components/innovision/Innovision.tsx +++ b/src/components/innovision/Innovision.tsx @@ -24,6 +24,7 @@ import AboutPanel from './AboutPanel'; import ProfileOverlay from './ProfileOverlay'; import RegistrationStatusSync from './RegistrationStatusSync'; import PhoneModal from './PhoneModal'; +import AmbassadorCodeDialog, { type AmbassadorDialogState } from './AmbassadorCodeDialog'; import EventPopup from './EventPopup'; import AdminDashboard from './AdminDashboard'; import Toast from './Toast'; @@ -39,6 +40,8 @@ import { updateUserPhone, fetchUserRegistration, createRegistration, + checkReferralCode, + fetchAmbassadorCode, signInWithGoogle, signOutUser, cleanAuthUrl, @@ -52,9 +55,10 @@ import { type GalleryPhoto, } from '@/lib/supabase'; import { REGISTRATIONS_OPEN, REGISTRATIONS_SOON_PATH, MERCH_PATH, REGISTRATION_PREVIEW_ENABLED } from '@/config/launch'; +import { AMBASSADOR_FORM_URL, AMBASSADOR_SECTION } from '@/config/ambassador'; import { REGISTRATION_FEE, UPI_ID, UPI_PAY_LINK, UPI_QR_SRC } from '@/config/payment'; import { SKIP_INTRO_KEY } from '@/components/coming-soon/SkipIntroOnReturn'; -import { isValidGoogleDriveUrl, GENDER_OPTIONS as GENDERS, normalizeReferralCode, REFERRAL_CODE_PATTERN } from '@/lib/validation'; +import { isValidGoogleDriveUrl, GENDER_OPTIONS as GENDERS, normalizeReferralCode, isReferralCode, formatReferralCode, REFERRAL_CODE_FORMAT_ERROR } from '@/lib/validation'; import { httpErrorMessage, isTransientFailure } from '@/lib/api-errors'; gsap.registerPlugin(ScrollTrigger, ScrambleTextPlugin); @@ -159,11 +163,16 @@ interface State { authReady: boolean; auth: boolean; authMode: string; step: number; err: any; busyLbl: string; files: any; drag: string; copied: boolean; schedFilter: string; /** The referral code on the pass was just copied (shows COPIED). */ refCopied: boolean; + /** GET MY CODE (Campus Ambassador section): what the dialog shows; null while it is closed. */ + ambDialog: AmbassadorDialogState | null; dbGallery: GalleryPhoto[]; dbEvents: EventItem[]; } const SAVED_KEY = 'iv26-schedule-saved'; +/** A share link's referral code (captureReferral) and how long it is kept for the registration form. */ +const SHARED_REF_KEY = 'inv_shared_ref'; +const SHARED_REF_TTL_MS = 30 * 24 * 60 * 60 * 1000; const HINT_KEY = 'iv26-hint-worlds'; const parts = (s: Element): SlideParts => ({ @@ -179,7 +188,7 @@ const partList = (p: SlideParts) => [p.hero, p.rot, ...p.astro, p.outline, ...p. export class Innovision extends Component { rootRef = createRef(); state: State = { view: 'loading', index: 0, dIndex: 0, about: false, compact: false, narrow: false, menu: false, toastOn: false, toastMsg: '', curtainLabel: 'INNOVISION', curtainKicker: 'NOW ENTERING', - auth: false, authMode: 'register', step: 0, err: {} as any, busyLbl: '', user: null, files: {} as any, drag: '', copied: false, refCopied: false, gIdx: 0, schedDay: 0, schedFilter: 'all', saved: [], hudSolid: false, gBusy: false, gErr: '', gUser: null, hint: false, notice: false, coarse: false, lowPower: false, lazy: {}, + auth: false, authMode: 'register', step: 0, err: {} as any, busyLbl: '', user: null, files: {} as any, drag: '', copied: false, refCopied: false, ambDialog: null, gIdx: 0, schedDay: 0, schedFilter: 'all', saved: [], hudSolid: false, gBusy: false, gErr: '', gUser: null, hint: false, notice: false, coarse: false, lowPower: false, lazy: {}, adminOpen: false, profileOpen: false, phoneModalOpen: false, eventPop: null, registration: null, authReady: false, dbGallery: [], dbEvents: [] }; busy = false; pending = false; slideDir = 0; authBusy = false; authClosing = false; @@ -196,6 +205,8 @@ export class Innovision extends Component { hintSeen = false; _hintT?: ReturnType; _noticeT?: ReturnType; /** Home section to scroll to once the home view has been prepared. */ pendingSec: string | null = null; + /** Referral code from a share link (/?ref=CODE, see captureReferral), pre-filled in the registration form. */ + sharedRef = ''; /** * Whether this load skips the loader: the skipLoader prop, or a visitor coming back from a teaser page * (SkipIntroOnReturn). Read once, so a dev strict-mode remount (same instance) doesn't replay the loader. @@ -353,6 +364,9 @@ export class Innovision extends Component { // Auth Middleware Route Inspector: Check for middleware redirects (?auth=login, ?required=register, ?open=register, or #register) if (typeof window !== 'undefined') { const search = new URLSearchParams(window.location.search); + this.captureReferral(search); + // A link to the Campus Ambassador section: the loader always hands over at the top of the home page. + if (window.location.hash === '#/' + AMBASSADOR_SECTION) this.afterBoot(() => this.scrollHome(AMBASSADOR_SECTION, true)); const isAuthRequired = search.get('required') === 'register' || search.get('auth') === 'login'; const isOpenRegister = search.get('open') === 'register' || window.location.hash === '#register'; const isOpenLogin = search.get('open') === 'login'; @@ -1392,6 +1406,7 @@ export class Innovision extends Component { if (v === 'merch') { this.leaveTo(MERCH_PATH, true); return { view: 'home', index: idx }; } if (v === 'gallery' || v === 'schedule') return { view: v, index: idx }; if (v === 'sponsors') return { view: 'home', section: 'sponsors', index: idx }; + if (v === AMBASSADOR_SECTION) return { view: 'home', section: AMBASSADOR_SECTION, index: idx }; return { view: 'home', index: idx }; } go(hash: string, replace?: boolean) { @@ -1572,6 +1587,116 @@ export class Innovision extends Component { const disc = t && t.closest && t.closest('[data-hero]') ? this.$('[data-hero-disc]') : null; this.openAuth('register', disc || t, !!disc); }; + /** + * GET MY CODE in the Campus Ambassador section: any signed-in student can get a code. Signed out: log in first + * (remembered across the Google round trip; handleUserSession comes back here). Signed in: the dialog shows their + * code, or, the first time, asks for their college and creates it (createAmbassadorCode). + */ + // @ts-ignore + getAmbassadorCode = async (e) => { + if (e && e.preventDefault) e.preventDefault(); + if (!this._authReady.done) await this._authReady.p; + if (!this.alive) return; + if (!this.state.user) { + try { sessionStorage.setItem('inv_pending_action', 'ambassador'); } catch {} + this.openAuth('login'); + this.toast('Log in with Google to get your ambassador code.'); + return; + } + this.checkAmbassadorCode(); + }; + checkAmbassadorCode = async () => { + this.setState({ ambDialog: { kind: 'loading' } }); + const res = await fetchAmbassadorCode(); + // Closed while checking: stay closed. + if (!this.alive || !this.state.ambDialog) return; + if (!res.ok) { + if (res.status === 401) { + // The session ran out: log in again, then come straight back to the code. + this.setState({ ambDialog: null }); + try { sessionStorage.setItem('inv_pending_action', 'ambassador'); } catch {} + this.openAuth('login'); + this.toast('Please log in again to get your code.'); + return; + } + this.setState({ ambDialog: { kind: 'error', message: res.error } }); + return; + } + const a = res.ambassador; + this.setState({ + ambDialog: !a ? { kind: 'join', email: res.email, name: res.name, college: res.college } + : a.status === 'revoked' ? { kind: 'revoked', email: res.email } + : { kind: 'code', code: a.code, name: a.name, email: res.email }, + }); + }; + /** The first GET MY CODE: creates the student's code with the college they confirmed. */ + createAmbassadorCode = async (college: string) => { + const join = this.state.ambDialog; + if (!join || join.kind !== 'join' || join.busy) return; + this.setState({ ambDialog: { ...join, college, busy: true, error: '' } }); + const res = await fetchAmbassadorCode({ college }); + if (!this.alive || this.state.ambDialog?.kind !== 'join') return; + if (!res.ok) { + if (res.status === 401) { this.setState({ ambDialog: null }); this.getAmbassadorCode(); return; } + this.setState({ ambDialog: { ...join, college, busy: false, error: res.error } }); + return; + } + const a = res.ambassador; + this.setState({ + ambDialog: a?.status === 'active' ? { kind: 'code', code: a.code, name: a.name, email: res.email } + : a?.status === 'revoked' ? { kind: 'revoked', email: res.email } + : { kind: 'error', message: 'Could not create your ambassador code. Please try again.' }, + }); + }; + /** Runs fn once the loader has handed over to a view and no view transition is running. */ + afterBoot(fn: () => void) { + const run = () => { + if (!this.alive) return; + if (this.state.view === 'loading' || this.busy) { setTimeout(run, 250); return; } + fn(); + }; + run(); + } + /** + * A share link's referral code (/?ref=CAK7QM2X, from an ambassador's GET MY CODE): kept on this device for 30 days, + * across the Google sign-in round trip, and pre-filled in the registration form, where the student can still change + * it. Taken off the address bar. A malformed code is ignored. + */ + captureReferral(search: URLSearchParams) { + const raw = search.get('ref'); + if (raw !== null) { + const code = normalizeReferralCode(raw.slice(0, 64)); + if (isReferralCode(code)) { + try { localStorage.setItem(SHARED_REF_KEY, JSON.stringify({ code, at: Date.now() })); } catch {} + } + try { + const rest = new URLSearchParams(window.location.search); + rest.delete('ref'); + const q = rest.toString(); + window.history.replaceState(null, '', window.location.pathname + (q ? '?' + q : '') + window.location.hash); + } catch {} + } + try { + const saved = JSON.parse(localStorage.getItem(SHARED_REF_KEY) || 'null'); + if (saved && typeof saved.code === 'string' && isReferralCode(saved.code) && Date.now() - Number(saved.at) < SHARED_REF_TTL_MS) { + this.sharedRef = formatReferralCode(saved.code); + } else if (saved) localStorage.removeItem(SHARED_REF_KEY); + } catch {} + } + /** Forgets the share link's code once it has been used for a registration. */ + clearSharedRef() { + this.sharedRef = ''; + try { localStorage.removeItem(SHARED_REF_KEY); } catch {} + } + /** + * The code a registrant shares from their pass: an active campus ambassador's permanent CA code (still valid whatever + * happens to their own registration), otherwise their own referral code (not once the registration was rejected). + */ + shareCode(): string { + const r = this.state.registration, P = this.pass || r || {}; + if (r?.ambassador_code) return formatReferralCode(r.ambassador_code); + return String(P.status || '').toUpperCase() !== 'REJECTED' ? r?.referral_code || '' : ''; + } /** Leaves for the "registrations opening soon" page (/register while REGISTRATIONS_OPEN is false). */ showRegistrationsSoon(replace?: boolean) { if (typeof window === 'undefined') return; @@ -1806,6 +1931,17 @@ export class Innovision extends Component { this.leaveTo('/admin/scan', true); return; } + if (pendingAction === 'ambassador') { + // GET MY CODE before signing in: back to the Campus Ambassador section (a jump, as for a link to it: a smooth + // scroll down the whole page would still be under way behind the dialog), with the code check running. + sessionStorage.removeItem('inv_pending_action'); + const resume = () => this.afterBoot(() => { + if (this.state.view === 'home') this.scrollHome(AMBASSADOR_SECTION); else this.goSection(AMBASSADOR_SECTION); + this.checkAmbassadorCode(); + }); + if (this.state.auth) this.closeAuth(resume); else resume(); + return; + } if (pendingAction === 'register') { if (typeof window !== 'undefined') sessionStorage.removeItem('inv_pending_action'); if (registration) { @@ -1903,6 +2039,7 @@ export class Innovision extends Component { adminOpen: false, profileOpen: false, phoneModalOpen: false, + ambDialog: null, authMode: 'register', step: 0, err: {}, @@ -2338,9 +2475,9 @@ export class Innovision extends Component { const no = () => this.toast('UPI ID: ' + t); try { if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(ok, no); else no(); } catch (e) { no(); } }; - /** Copies the registrant's own referral code from the pass. */ + /** Copies the code shown on the pass (shareCode). */ copyReferral = () => { - const code = this.state.registration?.referral_code; + const code = this.shareCode(); if (!code) return; const ok = () => { this.setState({ refCopied: true }); clearTimeout(this._refCopy); this._refCopy = setTimeout(() => this.setState({ refCopied: false }), 1600); }; const no = () => this.toast('Your referral code: ' + code); @@ -2425,17 +2562,26 @@ export class Innovision extends Component { else if (enrollment_no.length > 50 || (enrollment_no && !/^[A-Za-z0-9/_ -]+$/.test(enrollment_no))) { err.enrollment_no = 'Use only letters, numbers, spaces and / _ - (up to 50 characters).'; } - // Optional; whether the code belongs to someone is checked by the server on submit. + // Optional: a registrant's or a campus ambassador's code. Checked with the server before payment (and again on + // submit), so a wrong or deactivated code is fixed before anyone pays. const referred_by_code = normalizeReferralCode(v('referred_by_code')); - if (referred_by_code && !REFERRAL_CODE_PATTERN.test(referred_by_code)) { - err.referred_by_code = 'Referral codes are 6 letters and numbers. Check the code, or leave it empty.'; - } + if (referred_by_code && !isReferralCode(referred_by_code)) err.referred_by_code = REFERRAL_CODE_FORMAT_ERROR; if (this.fail(err)) return; this.reg = { name, college, email, phone, enrollment_no, gender, referred_by_code }; // Every student, NIT Rourkela included, continues to Step 1 (payment). - this.toStep(1); + if (!referred_by_code) { this.toStep(1); return; } + this.setState({ busyLbl: 'CHECKING CODE' }); + checkReferralCode(referred_by_code).then((res) => { + if (!this.alive) return; + this.setState({ busyLbl: '' }); + // Moved on meanwhile (closed the form, signed out). + if (!this.state.auth || this.state.authMode !== 'register' || this.state.step !== 0) return; + // A check that couldn't run (offline, busy) doesn't hold the student up: the submit checks the code again. + if ('valid' in res && !res.valid) { this.fail({ referred_by_code: res.error }); return; } + this.toStep(1); + }); } else if (s.step === 1) { this.toStep(2); } else if (s.step === 2) { @@ -2546,6 +2692,7 @@ export class Innovision extends Component { return; } + this.clearSharedRef(); this.pass = { name: savedReg.name || r.name, college: savedReg.college || r.college, @@ -2693,7 +2840,8 @@ export class Innovision extends Component { phone: user?.phone || R.phone || '', enrollment_no: user?.enrollment_no || s.registration?.enrollment_no || R.enrollment_no || '', gender: R.gender || s.registration?.gender || '', - referred_by_code: R.referred_by_code || '', + // What the student entered on the first step, else a share link's code. + referred_by_code: this.reg ? R.referred_by_code || '' : this.sharedRef, }; // @ts-ignore const up = (kind, ek, prompt) => { @@ -2753,8 +2901,8 @@ export class Innovision extends Component { : P.status === 'REJECTED' ? 'Your registration could not be approved. Please contact the helpdesk.' : 'Your payment is under review. Only the registration team can approve it. This page updates automatically while you are here.', - // The registrant's own referral code, shared from the pass (not once the registration was rejected). - refCode: String(P.status || '').toUpperCase() !== 'REJECTED' ? s.registration?.referral_code || '' : '', + // The code shared from the pass: an ambassador's CA code, else the registrant's own (shareCode). + refCode: this.shareCode(), refCodeLabel: s.registration?.ambassador_code ? 'Your Ambassador Code:' : 'Your Referral Code:', copyRefCode: this.copyReferral, refCodeLbl: s.refCopied ? 'COPIED' : 'COPY CODE', exploreFromPass: this.exploreFromPass, googleLogin: this.googleLogin, gBusy: s.gBusy, gErr: s.gErr, gLbl: s.gBusy ? 'Waiting for Google…' : 'Continue with Google', @@ -2827,6 +2975,8 @@ export class Innovision extends Component { menuExpanded: s.menu, menuLogin: (e: MouseEvent) => { this.setState({ menu: false }); this.loginClick(e); }, goHome: (e: MouseEvent) => { e.preventDefault(); this.goSection(null); }, + // Campus Ambassador section (HomeView): the Google Form ('' while it is a placeholder) and GET MY CODE. + ambassadorFormUrl: AMBASSADOR_FORM_URL, getAmbassadorCode: this.getAmbassadorCode, topNav: navLinks.filter((_, i) => !NAVBAR_HIDDEN.includes(LINKS[i][1])).map((l) => ({ label: l.label, labelCap: l.name, href: l.href, menuColor: l.dc, cur: l.cur === 'true', onClick: l.onClick })), linkGo: this.linkGo, menuVis: (s.menu ? 'visible' : 'hidden') as 'visible' | 'hidden', menuDelay: s.menu ? '0s' : '.9s', menuClip: s.menu ? 'circle(150% at 100% 0%)' : 'circle(0% at 100% 0%)', menuHidden: !s.menu, @@ -3047,6 +3197,14 @@ export class Innovision extends Component { onClose={() => this.setState({ phoneModalOpen: false })} userEmail={s.user?.email} /> + this.setState({ ambDialog: null })} + onRetry={this.checkAmbassadorCode} + onCreate={this.createAmbassadorCode} + onLogout={() => { this.setState({ ambDialog: null }); this.logout(); }} + /> {s.user && (s.user.role === 'admin' || s.user.role === 'it-team' || s.user.role === 'registration-team') && ( Promise; +} + +interface Ambassador { + rank: number | null; + id: string; + code: string; + name: string; + email: string; + /** From the ambassador's profile (the site asks every signed-in user for one). */ + phone: string | null; + college: string | null; + status: 'active' | 'revoked'; + /** When they first tapped GET MY CODE. */ + created_at: string; + confirmed_registrations: number; + pending_registrations: number; +} + +interface ReferredRegistration { + id: string; + registration_id: string; + name: string; + college: string; + status: 'pending' | 'confirmed' | 'rejected'; + created_at: string; +} + +const GOLD = 'oklch(0.8 0.12 85)'; +const BAD = 'oklch(0.85 0.12 35)'; +const th: React.CSSProperties = { padding: '10px 12px', textAlign: 'left', fontSize: '11px', fontWeight: 700, letterSpacing: '.14em', color: 'rgba(236,232,223,0.6)', borderBottom: '1px solid rgba(236,232,223,0.14)', whiteSpace: 'nowrap' }; +const td: React.CSSProperties = { padding: '10px 12px', fontSize: '13px', borderBottom: '1px solid rgba(236,232,223,0.08)', verticalAlign: 'top' }; +const num: React.CSSProperties = { ...td, textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 700 }; +const smallBtn: React.CSSProperties = { padding: '6px 12px', border: '1px solid rgba(236,232,223,0.35)', background: 'transparent', color: '#ECE8DF', fontSize: '11px', fontWeight: 700, letterSpacing: '.12em', cursor: 'pointer', whiteSpace: 'nowrap' }; +const dangerBtn: React.CSSProperties = { ...smallBtn, borderColor: 'oklch(0.74 0.15 35)', color: BAD }; +const input: React.CSSProperties = { minHeight: '40px', padding: '0 12px', background: 'rgba(236,232,223,0.06)', border: '1px solid rgba(236,232,223,0.2)', color: '#ECE8DF', fontSize: '14px' }; +const note: React.CSSProperties = { margin: 0, maxWidth: '760px', fontSize: '13px', lineHeight: 1.5, color: 'rgba(236,232,223,0.6)' }; +const contactLink: React.CSSProperties = { color: 'rgba(236,232,223,0.7)', textDecoration: 'none', wordBreak: 'break-all' }; + +/* + * Phones (up to 760px): the table becomes one card per ambassador. Name and contacts on top, then the counts, rank and + * code, college and join date in two columns, and the actions as full-size buttons; an opened row (edit, registrations) + * attaches below its card. The data-label of each cell titles it on a card. + */ +const RESPONSIVE_CSS = ` + .amb-tile { flex: 1 1 160px; min-width: 0; } + @media (max-width: 760px) { + .amb-note { font-size: 12px !important; } + .amb-tiles { gap: 8px !important; } + .amb-tile { flex: 1 1 calc(50% - 4px); padding: 10px 12px !important; } + .amb-tile-label { font-size: 10px !important; letter-spacing: .1em !important; } + .amb-tile-value { margin-top: 4px !important; font-size: 18px !important; } + .amb-head { flex-direction: column; align-items: stretch !important; } + .amb-head-actions { justify-content: stretch; } + .amb-head-actions > button { flex: 1 1 0; min-height: 44px; } + .amb-scroll { overflow: visible !important; border: 0 !important; } + .amb-table { min-width: 0 !important; display: block; } + .amb-table > thead { display: none; } + .amb-table > tbody { display: flex; flex-direction: column; gap: 12px; } + .amb-table > tbody > tr { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 4px 14px; padding: 14px; border: 1px solid rgba(236,232,223,0.14); background: rgba(236,232,223,0.03); } + .amb-table > tbody > tr.amb-sub { margin-top: -13px; border-top: 0; background: rgba(236,232,223,0.05); } + .amb-table > tbody > tr > td { display: block; padding: 6px 0 !important; border: 0 !important; text-align: left !important; min-width: 0; } + .amb-table > tbody > tr > td[data-label]::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 10px; font-weight: 700; letter-spacing: .14em; color: rgba(236,232,223,0.5); } + .amb-c-name { grid-column: 1 / -1; order: 1; font-size: 15px !important; } + .amb-c-confirmed { order: 2; font-size: 20px !important; } + .amb-c-pending { order: 3; font-size: 20px !important; } + .amb-c-rank { order: 4; } + .amb-c-code { order: 5; } + .amb-c-college { order: 6; } + .amb-c-joined { order: 7; } + .amb-c-actions { grid-column: 1 / -1; order: 8; padding-top: 10px !important; } + .amb-c-wide { grid-column: 1 / -1; } + .amb-actions > button { flex: 1 1 calc(50% - 6px); min-height: 44px; } + .amb-contact { display: inline-block; padding: 6px 0; font-size: 14px !important; } + .amb-edit > input { flex: 1 1 100% !important; min-height: 44px; } + .amb-edit > button { flex: 1 1 0; min-height: 44px; } + .amb-regs > thead { display: none; } + .amb-regs, .amb-regs > tbody { display: block; } + .amb-regs > tbody > tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 10px; padding: 10px 0; border-bottom: 1px solid rgba(236,232,223,0.1); } + .amb-regs > tbody > tr > td { display: block; padding: 0 !important; border: 0 !important; } + .amb-regs > tbody > tr > td:nth-child(1) { grid-column: 1; grid-row: 2; font-size: 12px; color: rgba(236,232,223,0.6); } + .amb-regs > tbody > tr > td:nth-child(2) { grid-column: 1; grid-row: 1; font-weight: 600; } + .amb-regs > tbody > tr > td:nth-child(3) { grid-column: 1 / -1; grid-row: 3; font-size: 12px; color: rgba(236,232,223,0.6); } + .amb-regs > tbody > tr > td:nth-child(4) { grid-column: 2; grid-row: 1; text-align: right; } + .amb-regs > tbody > tr > td:nth-child(5) { grid-column: 2; grid-row: 2; text-align: right; font-size: 12px; color: rgba(236,232,223,0.6); } + } +`; + +const shortDate = (iso: string) => new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }); +const statusColor = (s: string) => (s === 'confirmed' ? 'oklch(0.8 0.14 150)' : s === 'rejected' ? BAD : GOLD); + +export default function AmbassadorsManager({ getAuthToken }: AmbassadorsManagerProps) { + const [rows, setRows] = useState([]); + const [truncated, setTruncated] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [search, setSearch] = useState(''); + const [onlyActive, setOnlyActive] = useState(false); + const [busyId, setBusyId] = useState(null); + // Destructive actions ask once inline ("CONFIRM?") instead of a browser dialog. + const [confirming, setConfirming] = useState<{ id: string; action: 'revoke' | 'delete' } | null>(null); + const [openId, setOpenId] = useState(null); + const [referred, setReferred] = useState<{ id: string; list: ReferredRegistration[]; loading: boolean; error: string } | null>(null); + const [editing, setEditing] = useState<{ id: string; name: string; college: string } | null>(null); + const requestSeq = useRef(0); + // AdminDashboard recreates getAuthToken on every render; read it through a ref so loading isn't restarted each time. + const tokenRef = useRef(getAuthToken); + useEffect(() => { + tokenRef.current = getAuthToken; + }, [getAuthToken]); + + const call = useCallback(async (path: string, init: RequestInit = {}) => { + const token = await tokenRef.current(); + const res = await fetch(path, { + ...init, + headers: { ...(init.body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + credentials: 'include', + cache: 'no-store', + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(httpErrorMessage(res) || body.error || 'Request failed.'); + return body; + }, []); + + const load = useCallback(async () => { + const seq = ++requestSeq.current; + setLoading(true); + try { + const body = await call('/api/admin/ambassadors'); + if (seq !== requestSeq.current) return; // a newer request superseded this one + setRows(Array.isArray(body.ambassadors) ? body.ambassadors : []); + setTruncated(!!body.truncated); + setError(''); + } catch (err) { + if (seq === requestSeq.current) setError(err instanceof Error ? err.message : 'Failed to load ambassadors.'); + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, [call]); + + useEffect(() => { + const first = setTimeout(load, 0); + return () => clearTimeout(first); + }, [load]); + + useEffect(() => { + if (!confirming) return; + const t = setTimeout(() => setConfirming(null), 5000); + return () => clearTimeout(t); + }, [confirming]); + + const patch = async (id: string, payload: Record, done: string) => { + setBusyId(id); + setConfirming(null); + setError(''); + setNotice(''); + try { + await call('/api/admin/ambassadors', { method: 'PATCH', body: JSON.stringify({ id, ...payload }) }); + setNotice(done); + setEditing(null); + void load(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update the ambassador.'); + } finally { + setBusyId(null); + } + }; + + const remove = async (a: Ambassador) => { + setBusyId(a.id); + setConfirming(null); + setError(''); + setNotice(''); + try { + await call(`/api/admin/ambassadors?id=${encodeURIComponent(a.id)}`, { method: 'DELETE' }); + setNotice(`${a.name} was removed.`); + void load(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete the ambassador.'); + } finally { + setBusyId(null); + } + }; + + const toggleOpen = async (a: Ambassador) => { + if (openId === a.id) { setOpenId(null); return; } + setOpenId(a.id); + setReferred({ id: a.id, list: [], loading: true, error: '' }); + try { + const body = await call(`/api/admin/registrations?ambassador=${encodeURIComponent(a.id)}`); + setReferred({ id: a.id, list: Array.isArray(body.registrations) ? body.registrations : [], loading: false, error: '' }); + } catch (err) { + setReferred({ id: a.id, list: [], loading: false, error: err instanceof Error ? err.message : 'Failed to load registrations.' }); + } + }; + + const ask = (id: string, action: 'revoke' | 'delete') => setConfirming({ id, action }); + const isAsking = (id: string, action: string) => confirming?.id === id && confirming.action === action; + + const q = search.trim().toLowerCase(); + const shown = rows + .filter((r) => !onlyActive || r.confirmed_registrations + r.pending_registrations > 0) + .filter((r) => !q || [r.name, r.email, r.phone ?? '', r.college ?? '', r.code, formatReferralCode(r.code)].some((f) => f.toLowerCase().includes(q))); + const totals = rows.reduce( + (t, r) => ({ + active: t.active + (r.status === 'active' ? 1 : 0), + bringing: t.bringing + (r.confirmed_registrations > 0 ? 1 : 0), + confirmed: t.confirmed + r.confirmed_registrations, + pending: t.pending + r.pending_registrations, + }), + { active: 0, bringing: 0, confirmed: 0, pending: 0 } + ); + + const exportCsv = () => { + const headers = ['Rank', 'Name', 'Email', 'Phone', 'College', 'Code', 'Status', 'Confirmed', 'Pending', 'Joined At']; + const lines = rows.map((r) => [ + r.rank ?? '', r.name, r.email, r.phone ?? '', r.college ?? '', formatReferralCode(r.code), r.status, + r.confirmed_registrations, r.pending_registrations, r.created_at, + ].map(csvCell).join(',')); + const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `innovision_campus_ambassadors_${new Date().toISOString().slice(0, 10)}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + return ( +
    + +
    +

    + Students who took a campus ambassador code with GET MY CODE, and the registrations it brought in.{' '} + Confirmed counts registrations whose payment was approved and + decides the rank; Pending are still under review. Rejected ones + never count. There is no target or limit. +

    +
    + + +
    +
    + +
    + {([ + ['AMBASSADORS', totals.active, '#ECE8DF'], + ['WITH A CONFIRMED REGISTRATION', totals.bringing, '#ECE8DF'], + ['CONFIRMED REGISTRATIONS', totals.confirmed, GOLD], + ['PENDING REVIEW', totals.pending, '#ECE8DF'], + ] as const).map(([label, value, color]) => ( +
    +
    {label}
    +
    {value}
    +
    + ))} +
    + +
    + setSearch(e.target.value)} + placeholder="Search name, email, phone, college or code" + aria-label="Search ambassadors" + style={{ ...input, flex: '1 1 280px', boxSizing: 'border-box' }} + /> + +
    + + {error && ( +
    + {error} +
    + )} + {notice && !error &&
    {notice}
    } + {truncated &&
    Only the first 1000 ambassadors are listed.
    } + +
    + + + + + + + + + + + + + + + {shown.map((a) => { + const busy = busyId === a.id; + const revoked = a.status === 'revoked'; + return ( + + + + + + + + + + + + {editing?.id === a.id && ( + + + + )} + {openId === a.id && referred?.id === a.id && ( + + + + )} + + ); + })} + {!shown.length && ( + + + + )} + +
    RANKAMBASSADORCOLLEGECODEJOINEDCONFIRMEDPENDINGACTIONS
    0 && (a.rank ?? 99) <= 3 ? GOLD : '#ECE8DF' }}> + {revoked ? '—' : a.confirmed_registrations > 0 ? `#${a.rank}` : '—'} + +
    {a.name}{revoked && REVOKED}
    + +
    + {a.phone ? +91 {a.phone} : No phone yet} +
    +
    {a.college || '—'}{formatReferralCode(a.code)}{shortDate(a.created_at)}{a.confirmed_registrations}{a.pending_registrations} +
    + + + {revoked ? ( + + ) : ( + + )} + {a.confirmed_registrations + a.pending_registrations === 0 && ( + + )} +
    +
    +
    { + e.preventDefault(); + const changes: Record = {}; + if (editing.name.trim() !== a.name) changes.name = editing.name; + if (editing.college.trim() !== (a.college ?? '')) changes.college = editing.college.trim() || null; + if (!Object.keys(changes).length) { setEditing(null); return; } + void patch(a.id, changes, `${editing.name.trim() || a.name} was updated.`); + }} + className="amb-edit" + style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }} + > + setEditing({ ...editing, name: e.target.value })} style={{ ...input, flex: '1 1 180px' }} /> + setEditing({ ...editing, college: e.target.value })} placeholder="College" style={{ ...input, flex: '2 1 240px' }} /> + + + +
    + {referred.loading ? 'Loading registrations…' : referred.error ? {referred.error} : !referred.list.length ? 'Nobody has registered with this code yet.' : ( + + + + + + {referred.list.map((r) => ( + + + + + + + + ))} + +
    REGISTRATIONNAMECOLLEGESTATUSREGISTERED
    {r.registration_id}{r.name}{r.college}{r.status.toUpperCase()}{shortDate(r.created_at)}
    + )} +
    + {loading ? 'Loading…' : q || onlyActive ? 'No ambassadors match.' : 'No campus ambassadors yet. Students appear here once they tap GET MY CODE.'} +
    +
    +
    + ); +} diff --git a/src/config/ambassador.ts b/src/config/ambassador.ts new file mode 100644 index 0000000..6647a83 --- /dev/null +++ b/src/config/ambassador.ts @@ -0,0 +1,13 @@ +/** + * Campus Ambassador programme: the home page's Campus Ambassador section (APPLY and GET MY CODE), the CA- codes + * registrants may enter (supabase/schema.sql, section 16) and the admin dashboard's AMBASSADORS tab. + * + * AMBASSADOR_FORM_URL is the organisers' Google Form that APPLY opens. It only collects applications for the team's own + * records: GET MY CODE gives any signed-in student their code whether or not they filled it in, and nothing reads the + * responses. PLACEHOLDER: while it is empty, APPLY shows "APPLICATIONS OPEN SOON" (disabled); GET MY CODE works either + * way. When the organisers share the form, paste its link here (e.g. 'https://forms.gle/...'). + */ +export const AMBASSADOR_FORM_URL = ''; + +/** Home page section id (data-sec) and route: #/ambassador scrolls there. */ +export const AMBASSADOR_SECTION = 'ambassador'; diff --git a/src/lib/ambassadors.ts b/src/lib/ambassadors.ts new file mode 100644 index 0000000..dd22ccd --- /dev/null +++ b/src/lib/ambassadors.ts @@ -0,0 +1,104 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import { REFERRAL_CODE_FORMAT_ERROR, referralCodeKind } from '@/lib/security'; + +/** + * Campus ambassadors and the referral codes a registrant may enter (supabase/schema.sql, sections 14 and 16). + * Server-only: every caller is a route handler using the service role, after verifying the session. + */ + +/** Who is registering, from the verified session (never from the request body). */ +export interface Registrant { + userId: string; + /** Verified, lower-cased email. */ + email: string; +} + +export type ReferralResolution = + | { ok: true; kind: 'ambassador' | 'registrant'; referredBy: string | null; ambassadorId: string | null } + | { ok: false; status: 400 | 500; error: string }; + +export const AMBASSADOR_CODE_INACTIVE_ERROR = + 'This campus ambassador code is no longer active. Remove it or enter another code.'; +export const AMBASSADOR_CODE_OWN_ERROR = + "This is your own campus ambassador code, so it can't count for you. Leave the field empty or enter someone else's code."; +const UNKNOWN_AMBASSADOR_ERROR = + 'No campus ambassador has this code. Check it with the ambassador who shared it, or leave the field empty.'; +const UNKNOWN_REGISTRANT_ERROR = + 'No registration has this referral code. Check it with the friend who shared it, or leave the field empty.'; +const LOOKUP_FAILED_ERROR = 'Failed to check the referral code. Please try again.'; + +/** + * Who a normalized referral code credits, under the rules the database also enforces: + * - a registrant's 6-character code must belong to a registration that wasn't rejected; + * - a campus ambassador's code must be active and must not be the registrant's own (same email, or the account + * that claimed it). Any other ambassador's code is fine, from anyone, any number of times. + */ +export async function resolveReferralCode( + client: SupabaseClient, + code: string, + registrant: Registrant +): Promise { + const kind = referralCodeKind(code); + if (!kind) return { ok: false, status: 400, error: REFERRAL_CODE_FORMAT_ERROR }; + + if (kind === 'ambassador') { + const { data, error } = await client + .from('campus_ambassadors') + .select('id, email, user_id, status') + .eq('code', code) + .maybeSingle(); + if (error) { + console.error('Ambassador code lookup failed:', error); + return { ok: false, status: 500, error: LOOKUP_FAILED_ERROR }; + } + if (!data) return { ok: false, status: 400, error: UNKNOWN_AMBASSADOR_ERROR }; + if (data.email === registrant.email || data.user_id === registrant.userId) { + return { ok: false, status: 400, error: AMBASSADOR_CODE_OWN_ERROR }; + } + if (data.status !== 'active') return { ok: false, status: 400, error: AMBASSADOR_CODE_INACTIVE_ERROR }; + return { ok: true, kind, referredBy: null, ambassadorId: data.id }; + } + + const { data, error } = await client + .from('registrations') + .select('id, status') + .eq('referral_code', code) + .maybeSingle(); + if (error) { + console.error('Referral code lookup failed:', error); + return { ok: false, status: 500, error: LOOKUP_FAILED_ERROR }; + } + if (!data || data.status === 'rejected') return { ok: false, status: 400, error: UNKNOWN_REGISTRANT_ERROR }; + return { ok: true, kind, referredBy: data.id, ambassadorId: null }; +} + +/** + * The database refused an ambassador code while the registration was written (trg_guard_registration_ambassador), + * e.g. the ambassador was revoked after /api/register checked the code: the message for the referral field, or null. + */ +export function ambassadorGuardError(err: { message?: string } | null | undefined): string | null { + const message = err?.message ?? ''; + if (/own campus ambassador code/i.test(message)) return AMBASSADOR_CODE_OWN_ERROR; + if (/campus ambassador code is no longer active/i.test(message)) return AMBASSADOR_CODE_INACTIVE_ERROR; + return null; +} + +export interface OwnAmbassador { + id: string; + code: string; + name: string; + status: 'active' | 'revoked'; + user_id: string | null; +} + +/** The caller's own ambassador record, by verified email or by the account that claimed it; null when they're none. */ +export async function findOwnAmbassador( + client: SupabaseClient, + registrant: Registrant +): Promise<{ ambassador: OwnAmbassador | null; error: unknown }> { + const columns = 'id, code, name, status, user_id'; + const byEmail = await client.from('campus_ambassadors').select(columns).eq('email', registrant.email).maybeSingle(); + if (byEmail.error || byEmail.data) return { ambassador: byEmail.data as OwnAmbassador | null, error: byEmail.error }; + const byUser = await client.from('campus_ambassadors').select(columns).eq('user_id', registrant.userId).maybeSingle(); + return { ambassador: byUser.data as OwnAmbassador | null, error: byUser.error }; +} diff --git a/src/lib/security.ts b/src/lib/security.ts index 389746b..49207fd 100644 --- a/src/lib/security.ts +++ b/src/lib/security.ts @@ -247,6 +247,30 @@ export type RegistrationGender = (typeof REGISTRATION_GENDERS)[number]; /** Must match REFERRAL_CODE_PATTERN in validation.ts and registrations_referral_code_check in supabase/schema.sql. */ export const REFERRAL_CODE_PATTERN = /^[A-HJ-NP-Z2-9]{6}$/; +/** Must match AMBASSADOR_CODE_PATTERN in validation.ts and campus_ambassadors_code_check in supabase/schema.sql. */ +export const AMBASSADOR_CODE_PATTERN = /^CA[A-HJ-NP-Z2-9]{6}$/; + +/** Spaces and dashes of any kind, ignored in a typed code. Must match REFERRAL_CODE_NOISE in validation.ts. */ +const REFERRAL_CODE_NOISE = /[\s­​-‍⁠‐-―−﹘﹣--]+/g; + +/** + * A typed referral code, upper-cased without spaces or dashes ('' when empty). Same as normalizeReferralCode in + * validation.ts (kept here so this file stays free of `@/` imports): CA-K7QM2X, ca k7qm2x and CA–K7QM2X are CAK7QM2X. + */ +export function normalizeReferralCode(value: string): string { + return value.replace(REFERRAL_CODE_NOISE, '').toUpperCase(); +} + +/** Which kind of code a normalized code is, by its shape; null for neither. */ +export function referralCodeKind(code: string): 'ambassador' | 'registrant' | null { + if (AMBASSADOR_CODE_PATTERN.test(code)) return 'ambassador'; + if (REFERRAL_CODE_PATTERN.test(code)) return 'registrant'; + return null; +} + +/** Shown when a typed code has neither shape. Must match REFERRAL_CODE_FORMAT_ERROR in validation.ts. */ +export const REFERRAL_CODE_FORMAT_ERROR = + 'Referral codes are 6 letters and numbers, or CA- and 6 more for a campus ambassador. Check the code, or leave it empty.'; /** Form field a validation error belongs to, so the registration form can show it on the right step. */ export type RegistrationField = @@ -333,14 +357,11 @@ export function validateRegistrationInput( return { ok: false, error: 'Enrollment number contains invalid characters.', field: 'enrollment_no' }; } - // Optional. Same normalization as the form (normalizeReferralCode in validation.ts): case, spaces and dashes ignored. - const referred_by_code = (rawReferral || '').replace(/[\s-]+/g, '').toUpperCase(); - if (referred_by_code && !REFERRAL_CODE_PATTERN.test(referred_by_code)) { - return { - ok: false, - error: 'Referral codes are 6 letters and numbers. Check the code, or leave the field empty.', - field: 'referred_by_code', - }; + // Optional: a registrant's or a campus ambassador's code. Same normalization as the form: case, spaces and dashes + // ignored. + const referred_by_code = normalizeReferralCode(rawReferral || ''); + if (referred_by_code && !referralCodeKind(referred_by_code)) { + return { ok: false, error: REFERRAL_CODE_FORMAT_ERROR, field: 'referred_by_code' }; } if (!proofPath) { diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 5f40da6..269717d 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -43,6 +43,15 @@ export interface Registration { referral_code?: string; /** The registration whose referral code this registrant entered, if any. */ referred_by?: string | null; + /** The campus ambassador whose code this registrant entered, if any (never together with referred_by). */ + ambassador_id?: string | null; + /** + * Not a column: added by /api/register when the registrant is an active campus ambassador. Their permanent CA code, + * shown on the pass instead of referral_code. + */ + ambassador_code?: string | null; + /** Not a column: added by GET /api/admin/registrations, the code this registrant entered and whose it is. */ + referred_via?: { kind: 'registrant' | 'ambassador'; code: string; name: string } | null; created_at?: string; updated_at?: string; } @@ -635,7 +644,10 @@ export async function fetchUserRegistrationServer(userId: string, email?: string * Passes through /api/register which is protected by server auth middleware */ export async function createRegistration( - regData: Omit & { + regData: Omit< + Registration, + 'id' | 'created_at' | 'updated_at' | 'referral_code' | 'referred_by' | 'ambassador_id' | 'ambassador_code' | 'referred_via' + > & { /** Someone else's referral code entered in the form (optional). */ referred_by_code?: string; } @@ -699,3 +711,79 @@ export async function createRegistration( } } +/** POSTs JSON to one of the app's signed-in API routes, with the session's access token when the SDK has one. */ +async function postAsUser(path: string, body: unknown): Promise<{ res: Response; data: Record }> { + const { data: { session } } = await getSupabase().auth.getSession(); + const token = session?.access_token; + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + credentials: 'include', + body: JSON.stringify(body), + }); + // A rate limit or crash at the platform edge may answer with HTML, not our JSON. + const data = await res.json().catch(() => ({})); + return { res, data }; +} + +/** + * Checks the referral code typed in the registration form before payment (POST /api/register/check-code). + * `unchecked` when the check itself couldn't run (offline, rate limited, server error): the form then carries on, and + * POST /api/register checks the code again on submit. + */ +export async function checkReferralCode( + code: string +): Promise<{ valid: true; kind: 'ambassador' | 'registrant' } | { valid: false; error: string } | { unchecked: true }> { + try { + const { res, data } = await postAsUser('/api/register/check-code', { code }); + if (!res.ok) return { unchecked: true }; + if (data.valid === true) return { valid: true, kind: data.kind === 'ambassador' ? 'ambassador' : 'registrant' }; + return { valid: false, error: typeof data.error === 'string' ? data.error : 'This referral code is not valid.' }; + } catch { + return { unchecked: true }; + } +} + +/** + * GET MY CODE (POST /api/ambassador/code): the caller's campus ambassador code. Without `create`, a student who has + * none yet gets `ambassador: null` with their name and a suggested college for the dialog; `create` makes their code. + */ +export async function fetchAmbassadorCode(create?: { college: string }): Promise< + | { + ok: true; + email: string; + ambassador: { status: 'active'; code: string; name: string } | { status: 'revoked' } | null; + /** For a student who has no code yet: shown in the dialog that creates it. */ + name: string; + college: string; + } + | { ok: false; status?: number; error: string; field?: string } +> { + try { + const { res, data } = await postAsUser('/api/ambassador/code', create ? { create: true, college: create.college } : {}); + if (!res.ok) { + return { + ok: false, + status: res.status, + error: httpErrorMessage(res) || (typeof data.error === 'string' ? data.error : 'Could not get your ambassador code.'), + ...(typeof data.field === 'string' ? { field: data.field } : {}), + }; + } + const amb = data.ambassador as { status?: string; code?: string; name?: string } | null | undefined; + return { + ok: true, + email: typeof data.email === 'string' ? data.email : '', + ambassador: + amb?.status === 'active' && typeof amb.code === 'string' + ? { status: 'active', code: amb.code, name: String(amb.name ?? '') } + : amb?.status === 'revoked' + ? { status: 'revoked' } + : null, + name: typeof data.name === 'string' ? data.name : '', + college: typeof data.college === 'string' ? data.college : '', + }; + } catch { + return { ok: false, error: 'Network error. Please check your connection and try again.' }; + } +} + diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 4e7ba03..2c8bc27 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -31,9 +31,36 @@ export function isGender(value: unknown): value is Gender { */ export const REFERRAL_CODE_PATTERN = /^[A-HJ-NP-Z2-9]{6}$/; +/** + * Campus ambassador codes: 'CA' + 6 characters from the same alphabet (public.new_ambassador_code, section 16), shown + * as CA-K7QM2X. The length tells them apart from a registrant's code, so the one referral field takes either. + */ +export const AMBASSADOR_CODE_PATTERN = /^CA[A-HJ-NP-Z2-9]{6}$/; + +/** Shown when a typed code has neither shape. */ +export const REFERRAL_CODE_FORMAT_ERROR = + 'Referral codes are 6 letters and numbers, or CA- and 6 more for a campus ambassador. Check the code, or leave it empty.'; + +/** + * Spaces (including non-breaking and zero-width ones) and dashes of any kind (a hyphen, an en/em dash, a minus sign, + * a soft hyphen), as copying a code out of a chat app or a PDF tends to add them. + * Must match REFERRAL_CODE_NOISE in security.ts. + */ +const REFERRAL_CODE_NOISE = /[\s­​-‍⁠‐-―−﹘﹣--]+/g; + /** What a registrant typed as someone's referral code, upper-cased without spaces or dashes ('' when left empty). */ export function normalizeReferralCode(value: string): string { - return value.replace(/[\s-]+/g, '').toUpperCase(); + return value.replace(REFERRAL_CODE_NOISE, '').toUpperCase(); +} + +/** A normalized code of either shape: a registrant's or a campus ambassador's. */ +export function isReferralCode(code: string): boolean { + return REFERRAL_CODE_PATTERN.test(code) || AMBASSADOR_CODE_PATTERN.test(code); +} + +/** How a stored code is shown and shared: CAK7QM2X as CA-K7QM2X; a registrant's code as it is. */ +export function formatReferralCode(code: string): string { + return AMBASSADOR_CODE_PATTERN.test(code) ? `CA-${code.slice(2)}` : code; } /** diff --git a/src/proxy.ts b/src/proxy.ts index 6c42da6..2432621 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -34,9 +34,9 @@ const tooMany = () => * 1. CSRF: rejects cross-site POST/PATCH/PUT/DELETE requests to any /api route. Auth cookies are SameSite=Lax, * and this same-origin check closes the remaining gaps (sibling subdomains, `text/plain` JSON bodies). * 2. Abuse limits on /api: request body size cap and per-IP rate limits for writes and auth endpoints. - * 3. Optimistic auth gate for /register, /registration, /admin, /api/register and /api/admin: requests that carry - * no session at all are bounced early. This is NOT the authorization boundary; every route handler validates - * the token with Supabase and checks roles itself. + * 3. Optimistic auth gate for /register, /registration, /admin, /api/register, /api/ambassador and /api/admin: + * requests that carry no session at all are bounced early. This is NOT the authorization boundary; every route + * handler validates the token with Supabase and checks roles itself. */ export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; @@ -78,11 +78,13 @@ export function proxy(request: NextRequest) { const isRegisterApi = pathname === '/api/register' || pathname.startsWith('/api/register/'); + // GET MY CODE (campus ambassadors): signed-in callers only. + const isAmbassadorApi = pathname === '/api/ambassador' || pathname.startsWith('/api/ambassador/'); const isAdminPage = pathname === '/admin' || pathname.startsWith('/admin/'); const isAdminApi = pathname === '/api/admin' || pathname.startsWith('/api/admin/'); - if (isRegisterPage || isRegisterApi || isAdminPage || isAdminApi) { + if (isRegisterPage || isRegisterApi || isAmbassadorApi || isAdminPage || isAdminApi) { // 1. Check cookies for access_token or refresh_token const hasInnAccessToken = !!request.cookies.get('inn_access_token')?.value; const hasInnRefreshToken = !!request.cookies.get('inn_refresh_token')?.value; @@ -104,7 +106,7 @@ export function proxy(request: NextRequest) { hasInnAccessToken || hasInnRefreshToken || hasSupabaseCookie || hasAuthHeader; if (!isAuthenticated) { - if (isRegisterApi || isAdminApi) { + if (isRegisterApi || isAmbassadorApi || isAdminApi) { return NextResponse.json( { error: 'Authentication Required', diff --git a/supabase/manual/add_campus_ambassadors.sql b/supabase/manual/add_campus_ambassadors.sql new file mode 100644 index 0000000..6952e9c --- /dev/null +++ b/supabase/manual/add_campus_ambassadors.sql @@ -0,0 +1,193 @@ +-- ============================================================================== +-- MIGRATION (run once in the Supabase SQL editor): campus ambassadors. +-- +-- Why: any signed-in student can get a permanent CA- referral code (GET MY CODE on the home page) without +-- registering, registrants may enter it in the existing referral field, and admins / the registration team see each +-- ambassador's registrations in the admin dashboard's AMBASSADORS tab. +-- This adds the campus_ambassadors table (no client access at all), registrations.ambassador_id with its checks, +-- index and guard trigger, and the staff-only ambassador_leaderboard view. No existing column, policy or row changes. +-- +-- Run this before (or together with) deploying the code that uses it: until then GET MY CODE, CA codes and the +-- AMBASSADORS tab fail (registering without a CA code keeps working). +-- Requires add_referrals.sql (section 14) to have been run. Matches section 16 of supabase/schema.sql. Kept out of +-- supabase/migrations/ on purpose so the Supabase CLI never applies it automatically. Safe to re-run. +-- ============================================================================== + +-- Any signed-in student can become a campus ambassador: GET MY CODE on the home page (POST /api/ambassador/code) +-- creates their row, matched on the verified email of their session, the first time they ask. The section's APPLY +-- button only opens the organisers' Google Form (src/config/ambassador.ts); nothing here depends on it. +-- Each ambassador gets one permanent code, 'CA' + 6 characters from the referral alphabet (shown as CA-K7QM2X), that +-- any number of registrants may enter in the registration form's referral field. An ambassador doesn't have to +-- register. If they do, their registration also gets its own 6-character referral_code (section 14), which keeps the +-- normal referral rules (it counts on the referral leaderboard, never here) and never affects their ambassador code. +-- +-- A registration names at most one referrer: a registrant's code (referred_by) or an ambassador's (ambassador_id), +-- never both, never the registrant's own ambassador code and never a revoked one. ambassador_id never changes once +-- written, whoever writes (trg_guard_registration_ambassador: trg_enforce_registration_review only binds staff calling +-- the Supabase API, not the service role the app's routes use). +-- An ambassador's registrations count once confirmed (payment approved); pending ones are shown apart, rejected ones +-- never count. Staff can revoke an ambassador (abuse): their code is then refused for new registrations, and the ones +-- already made stay counted. An ambassador whose code has been used can't be deleted (ON DELETE RESTRICT). +-- +-- No client role can read or write any of this, an ambassador's own row included: every access goes through server +-- routes that verify the caller first (service role): POST /api/ambassador/code, POST /api/register/check-code, +-- POST /api/register and the admin dashboard's AMBASSADORS tab (/api/admin/ambassadors, admins and the registration +-- team only). +CREATE TABLE IF NOT EXISTS public.campus_ambassadors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + name TEXT NOT NULL, + college TEXT, + code TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + user_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +DO $$ +BEGIN + ALTER TABLE public.campus_ambassadors DROP CONSTRAINT IF EXISTS campus_ambassadors_email_check; + ALTER TABLE public.campus_ambassadors ADD CONSTRAINT campus_ambassadors_email_check + CHECK (email = lower(email) AND char_length(email) <= 254 AND email ~ '^[^@\s]+@[^@\s]+\.[^@\s]+$'); + ALTER TABLE public.campus_ambassadors DROP CONSTRAINT IF EXISTS campus_ambassadors_name_check; + ALTER TABLE public.campus_ambassadors ADD CONSTRAINT campus_ambassadors_name_check + CHECK (char_length(name) BETWEEN 1 AND 120); + ALTER TABLE public.campus_ambassadors DROP CONSTRAINT IF EXISTS campus_ambassadors_college_check; + ALTER TABLE public.campus_ambassadors ADD CONSTRAINT campus_ambassadors_college_check + CHECK (college IS NULL OR char_length(college) <= 200); + ALTER TABLE public.campus_ambassadors DROP CONSTRAINT IF EXISTS campus_ambassadors_code_check; + ALTER TABLE public.campus_ambassadors ADD CONSTRAINT campus_ambassadors_code_check + CHECK (code ~ '^CA[A-HJ-NP-Z2-9]{6}$'); + ALTER TABLE public.campus_ambassadors DROP CONSTRAINT IF EXISTS campus_ambassadors_status_check; + ALTER TABLE public.campus_ambassadors ADD CONSTRAINT campus_ambassadors_status_check + CHECK (status IN ('active', 'revoked')); +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_campus_ambassadors_email ON public.campus_ambassadors(email); +CREATE UNIQUE INDEX IF NOT EXISTS uq_campus_ambassadors_code ON public.campus_ambassadors(code); +CREATE UNIQUE INDEX IF NOT EXISTS uq_campus_ambassadors_user_id ON public.campus_ambassadors(user_id); + +CREATE OR REPLACE FUNCTION public.new_ambassador_code() RETURNS TEXT AS $$ +DECLARE + v_alphabet CONSTANT TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + v_bytes BYTEA; + v_code TEXT; +BEGIN + LOOP + -- Built like new_referral_code() (section 14): 6 uniform characters from CSPRNG bytes, after the 'CA' prefix. + v_bytes := uuid_send(gen_random_uuid()); + v_code := 'CA'; + FOR i IN 0..5 LOOP + v_code := v_code || substr(v_alphabet, 1 + get_byte(v_bytes, i) % 32, 1); + END LOOP; + EXIT WHEN NOT EXISTS (SELECT 1 FROM public.campus_ambassadors WHERE code = v_code); + END LOOP; + RETURN v_code; +END; +$$ LANGUAGE plpgsql VOLATILE SET search_path = public; + +-- The code is generated by the database on insert (a code sent with the insert is replaced) and never changes; the +-- email is stored trimmed and lower-cased. Changing the email unlinks the account that owned the old one. +CREATE OR REPLACE FUNCTION public.protect_campus_ambassador() RETURNS TRIGGER AS $$ +BEGIN + NEW.email := lower(btrim(NEW.email)); + IF TG_OP = 'INSERT' THEN + NEW.code := public.new_ambassador_code(); + NEW.created_at := now(); + ELSE + NEW.id := OLD.id; + NEW.code := OLD.code; + NEW.created_at := OLD.created_at; + IF NEW.email IS DISTINCT FROM OLD.email THEN + NEW.user_id := NULL; + END IF; + END IF; + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS trg_protect_campus_ambassador ON public.campus_ambassadors; +CREATE TRIGGER trg_protect_campus_ambassador + BEFORE INSERT OR UPDATE ON public.campus_ambassadors + FOR EACH ROW EXECUTE FUNCTION public.protect_campus_ambassador(); + +ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS ambassador_id UUID + REFERENCES public.campus_ambassadors(id) ON DELETE RESTRICT; + +DO $$ +BEGIN + ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_one_referrer_check; + ALTER TABLE public.registrations ADD CONSTRAINT registrations_one_referrer_check + CHECK (referred_by IS NULL OR ambassador_id IS NULL); +END $$; + +CREATE INDEX IF NOT EXISTS idx_registrations_ambassador_id ON public.registrations(ambassador_id); + +-- On insert: the ambassador must be active and must not be the registrant (same email or the same account). The row +-- is locked while the registration is written, so a revoke running at the same moment waits for it. +-- On update: ambassador_id can't change, for every role. +-- /api/register checks the same rules first and shows these errors on the referral field. +CREATE OR REPLACE FUNCTION public.guard_registration_ambassador() RETURNS TRIGGER AS $$ +DECLARE + v_amb public.campus_ambassadors%ROWTYPE; +BEGIN + IF TG_OP = 'UPDATE' THEN + IF NEW.ambassador_id IS DISTINCT FROM OLD.ambassador_id THEN + RAISE EXCEPTION 'The campus ambassador of registration % cannot be changed', OLD.registration_id + USING ERRCODE = '42501'; + END IF; + RETURN NEW; + END IF; + + IF NEW.ambassador_id IS NOT NULL THEN + SELECT * INTO v_amb FROM public.campus_ambassadors WHERE id = NEW.ambassador_id FOR SHARE; + IF FOUND AND v_amb.status <> 'active' THEN + RAISE EXCEPTION 'This campus ambassador code is no longer active' USING ERRCODE = '23514'; + END IF; + IF FOUND AND (v_amb.email = lower(NEW.email) OR v_amb.user_id = NEW.user_id) THEN + RAISE EXCEPTION 'A campus ambassador cannot use their own campus ambassador code' USING ERRCODE = '23514'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS trg_guard_registration_ambassador ON public.registrations; +CREATE TRIGGER trg_guard_registration_ambassador + BEFORE INSERT OR UPDATE ON public.registrations + FOR EACH ROW EXECUTE FUNCTION public.guard_registration_ambassador(); + +-- Staff leaderboard (GET /api/admin/ambassadors): one row per ambassador, revoked ones included but unranked. +-- rank counts confirmed registrations among active ambassadors (ties share a rank) and is computed over the whole +-- board, before any filter the API adds. No contact details: the API adds the email (this table) and the phone (the +-- ambassador's profile) for staff only. +-- security_invoker, and no privilege for any client role: only the service role (server routes) can read it. +DROP VIEW IF EXISTS public.ambassador_leaderboard; +CREATE VIEW public.ambassador_leaderboard WITH (security_invoker = true) AS +SELECT + (CASE WHEN a.status = 'active' + THEN rank() OVER (PARTITION BY a.status = 'active' ORDER BY COALESCE(c.confirmed, 0) DESC) END)::INT AS rank, + a.id, a.code, a.name, a.college, a.status, a.created_at, + COALESCE(c.confirmed, 0)::INT AS confirmed_registrations, + COALESCE(c.pending, 0)::INT AS pending_registrations +FROM public.campus_ambassadors a +LEFT JOIN ( + SELECT + ambassador_id, + count(*) FILTER (WHERE status = 'confirmed') AS confirmed, + count(*) FILTER (WHERE status = 'pending') AS pending + FROM public.registrations + WHERE ambassador_id IS NOT NULL + GROUP BY ambassador_id +) c ON c.ambassador_id = a.id; + +ALTER TABLE public.campus_ambassadors ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.campus_ambassadors FROM PUBLIC, anon, authenticated; +REVOKE ALL ON public.ambassador_leaderboard FROM PUBLIC, anon, authenticated; + +-- Internal: only the triggers above use these. +REVOKE EXECUTE ON FUNCTION public.new_ambassador_code() FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.protect_campus_ambassador() FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.guard_registration_ambassador() FROM PUBLIC, anon, authenticated, service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 440949c..79d2e25 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1459,3 +1459,184 @@ $$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; REVOKE ALL ON FUNCTION public.claim_pass_experience(UUID), public.acknowledge_pass_experience(UUID) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.claim_pass_experience(UUID), public.acknowledge_pass_experience(UUID) TO authenticated; + + +-- 16. CAMPUS AMBASSADORS +-- Any signed-in student can become a campus ambassador: GET MY CODE on the home page (POST /api/ambassador/code) +-- creates their row, matched on the verified email of their session, the first time they ask. The section's APPLY +-- button only opens the organisers' Google Form (src/config/ambassador.ts); nothing here depends on it. +-- Each ambassador gets one permanent code, 'CA' + 6 characters from the referral alphabet (shown as CA-K7QM2X), that +-- any number of registrants may enter in the registration form's referral field. An ambassador doesn't have to +-- register. If they do, their registration also gets its own 6-character referral_code (section 14), which keeps the +-- normal referral rules (it counts on the referral leaderboard, never here) and never affects their ambassador code. +-- +-- A registration names at most one referrer: a registrant's code (referred_by) or an ambassador's (ambassador_id), +-- never both, never the registrant's own ambassador code and never a revoked one. ambassador_id never changes once +-- written, whoever writes (trg_guard_registration_ambassador: trg_enforce_registration_review only binds staff calling +-- the Supabase API, not the service role the app's routes use). +-- An ambassador's registrations count once confirmed (payment approved); pending ones are shown apart, rejected ones +-- never count. Staff can revoke an ambassador (abuse): their code is then refused for new registrations, and the ones +-- already made stay counted. An ambassador whose code has been used can't be deleted (ON DELETE RESTRICT). +-- +-- No client role can read or write any of this, an ambassador's own row included: every access goes through server +-- routes that verify the caller first (service role): POST /api/ambassador/code, POST /api/register/check-code, +-- POST /api/register and the admin dashboard's AMBASSADORS tab (/api/admin/ambassadors, admins and the registration +-- team only). +CREATE TABLE IF NOT EXISTS public.campus_ambassadors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + name TEXT NOT NULL, + college TEXT, + code TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + user_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +DO $$ +BEGIN + ALTER TABLE public.campus_ambassadors DROP CONSTRAINT IF EXISTS campus_ambassadors_email_check; + ALTER TABLE public.campus_ambassadors ADD CONSTRAINT campus_ambassadors_email_check + CHECK (email = lower(email) AND char_length(email) <= 254 AND email ~ '^[^@\s]+@[^@\s]+\.[^@\s]+$'); + ALTER TABLE public.campus_ambassadors DROP CONSTRAINT IF EXISTS campus_ambassadors_name_check; + ALTER TABLE public.campus_ambassadors ADD CONSTRAINT campus_ambassadors_name_check + CHECK (char_length(name) BETWEEN 1 AND 120); + ALTER TABLE public.campus_ambassadors DROP CONSTRAINT IF EXISTS campus_ambassadors_college_check; + ALTER TABLE public.campus_ambassadors ADD CONSTRAINT campus_ambassadors_college_check + CHECK (college IS NULL OR char_length(college) <= 200); + ALTER TABLE public.campus_ambassadors DROP CONSTRAINT IF EXISTS campus_ambassadors_code_check; + ALTER TABLE public.campus_ambassadors ADD CONSTRAINT campus_ambassadors_code_check + CHECK (code ~ '^CA[A-HJ-NP-Z2-9]{6}$'); + ALTER TABLE public.campus_ambassadors DROP CONSTRAINT IF EXISTS campus_ambassadors_status_check; + ALTER TABLE public.campus_ambassadors ADD CONSTRAINT campus_ambassadors_status_check + CHECK (status IN ('active', 'revoked')); +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_campus_ambassadors_email ON public.campus_ambassadors(email); +CREATE UNIQUE INDEX IF NOT EXISTS uq_campus_ambassadors_code ON public.campus_ambassadors(code); +CREATE UNIQUE INDEX IF NOT EXISTS uq_campus_ambassadors_user_id ON public.campus_ambassadors(user_id); + +CREATE OR REPLACE FUNCTION public.new_ambassador_code() RETURNS TEXT AS $$ +DECLARE + v_alphabet CONSTANT TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + v_bytes BYTEA; + v_code TEXT; +BEGIN + LOOP + -- Built like new_referral_code() (section 14): 6 uniform characters from CSPRNG bytes, after the 'CA' prefix. + v_bytes := uuid_send(gen_random_uuid()); + v_code := 'CA'; + FOR i IN 0..5 LOOP + v_code := v_code || substr(v_alphabet, 1 + get_byte(v_bytes, i) % 32, 1); + END LOOP; + EXIT WHEN NOT EXISTS (SELECT 1 FROM public.campus_ambassadors WHERE code = v_code); + END LOOP; + RETURN v_code; +END; +$$ LANGUAGE plpgsql VOLATILE SET search_path = public; + +-- The code is generated by the database on insert (a code sent with the insert is replaced) and never changes; the +-- email is stored trimmed and lower-cased. Changing the email unlinks the account that owned the old one. +CREATE OR REPLACE FUNCTION public.protect_campus_ambassador() RETURNS TRIGGER AS $$ +BEGIN + NEW.email := lower(btrim(NEW.email)); + IF TG_OP = 'INSERT' THEN + NEW.code := public.new_ambassador_code(); + NEW.created_at := now(); + ELSE + NEW.id := OLD.id; + NEW.code := OLD.code; + NEW.created_at := OLD.created_at; + IF NEW.email IS DISTINCT FROM OLD.email THEN + NEW.user_id := NULL; + END IF; + END IF; + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS trg_protect_campus_ambassador ON public.campus_ambassadors; +CREATE TRIGGER trg_protect_campus_ambassador + BEFORE INSERT OR UPDATE ON public.campus_ambassadors + FOR EACH ROW EXECUTE FUNCTION public.protect_campus_ambassador(); + +ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS ambassador_id UUID + REFERENCES public.campus_ambassadors(id) ON DELETE RESTRICT; + +DO $$ +BEGIN + ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_one_referrer_check; + ALTER TABLE public.registrations ADD CONSTRAINT registrations_one_referrer_check + CHECK (referred_by IS NULL OR ambassador_id IS NULL); +END $$; + +CREATE INDEX IF NOT EXISTS idx_registrations_ambassador_id ON public.registrations(ambassador_id); + +-- On insert: the ambassador must be active and must not be the registrant (same email or the same account). The row +-- is locked while the registration is written, so a revoke running at the same moment waits for it. +-- On update: ambassador_id can't change, for every role. +-- /api/register checks the same rules first and shows these errors on the referral field. +CREATE OR REPLACE FUNCTION public.guard_registration_ambassador() RETURNS TRIGGER AS $$ +DECLARE + v_amb public.campus_ambassadors%ROWTYPE; +BEGIN + IF TG_OP = 'UPDATE' THEN + IF NEW.ambassador_id IS DISTINCT FROM OLD.ambassador_id THEN + RAISE EXCEPTION 'The campus ambassador of registration % cannot be changed', OLD.registration_id + USING ERRCODE = '42501'; + END IF; + RETURN NEW; + END IF; + + IF NEW.ambassador_id IS NOT NULL THEN + SELECT * INTO v_amb FROM public.campus_ambassadors WHERE id = NEW.ambassador_id FOR SHARE; + IF FOUND AND v_amb.status <> 'active' THEN + RAISE EXCEPTION 'This campus ambassador code is no longer active' USING ERRCODE = '23514'; + END IF; + IF FOUND AND (v_amb.email = lower(NEW.email) OR v_amb.user_id = NEW.user_id) THEN + RAISE EXCEPTION 'A campus ambassador cannot use their own campus ambassador code' USING ERRCODE = '23514'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS trg_guard_registration_ambassador ON public.registrations; +CREATE TRIGGER trg_guard_registration_ambassador + BEFORE INSERT OR UPDATE ON public.registrations + FOR EACH ROW EXECUTE FUNCTION public.guard_registration_ambassador(); + +-- Staff leaderboard (GET /api/admin/ambassadors): one row per ambassador, revoked ones included but unranked. +-- rank counts confirmed registrations among active ambassadors (ties share a rank) and is computed over the whole +-- board, before any filter the API adds. No contact details: the API adds the email (this table) and the phone (the +-- ambassador's profile) for staff only. +-- security_invoker, and no privilege for any client role: only the service role (server routes) can read it. +DROP VIEW IF EXISTS public.ambassador_leaderboard; +CREATE VIEW public.ambassador_leaderboard WITH (security_invoker = true) AS +SELECT + (CASE WHEN a.status = 'active' + THEN rank() OVER (PARTITION BY a.status = 'active' ORDER BY COALESCE(c.confirmed, 0) DESC) END)::INT AS rank, + a.id, a.code, a.name, a.college, a.status, a.created_at, + COALESCE(c.confirmed, 0)::INT AS confirmed_registrations, + COALESCE(c.pending, 0)::INT AS pending_registrations +FROM public.campus_ambassadors a +LEFT JOIN ( + SELECT + ambassador_id, + count(*) FILTER (WHERE status = 'confirmed') AS confirmed, + count(*) FILTER (WHERE status = 'pending') AS pending + FROM public.registrations + WHERE ambassador_id IS NOT NULL + GROUP BY ambassador_id +) c ON c.ambassador_id = a.id; + +ALTER TABLE public.campus_ambassadors ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.campus_ambassadors FROM PUBLIC, anon, authenticated; +REVOKE ALL ON public.ambassador_leaderboard FROM PUBLIC, anon, authenticated; + +-- Internal: only the triggers above use these. +REVOKE EXECUTE ON FUNCTION public.new_ambassador_code() FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.protect_campus_ambassador() FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.guard_registration_ambassador() FROM PUBLIC, anon, authenticated, service_role;