diff --git a/next.config.ts b/next.config.ts index 1a701e5..bff5630 100644 --- a/next.config.ts +++ b/next.config.ts @@ -68,9 +68,9 @@ const nextConfig: NextConfig = { ], }, { - // API responses carry personal data and tokens: never let a browser or CDN cache them. The two public, - // anonymous endpoints (events, gallery) are excluded and set their own cache headers. - source: "/api/:path((?!events$|gallery$).*)", + // API responses carry personal data and tokens: never let a browser or CDN cache them. The three public, + // anonymous endpoints (events, gallery, seats) are excluded and set their own cache headers. + source: "/api/:path((?!events$|gallery$|seats$).*)", headers: [{ key: "Cache-Control", value: "private, no-store, max-age=0" }], }, { diff --git a/src/app/api/admin/registrations/route.ts b/src/app/api/admin/registrations/route.ts index 589a7b7..f3d6b17 100644 --- a/src/app/api/admin/registrations/route.ts +++ b/src/app/api/admin/registrations/route.ts @@ -2,6 +2,7 @@ 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'; +import { isRejectionReason } from '@/lib/rejections'; export const runtime = 'nodejs'; @@ -27,6 +28,42 @@ async function rowsById(supabase: SupabaseClient, table: string, columns: string return { rows, error: null }; } +/** + * attempt for every row (1 = the user's first registration, 2 = made again after one rejection, ...) and + * previous_rejection_reason, why the user's registration before it was rejected. Every earlier registration of a user + * is a rejected one (only one may be pending or confirmed), so the user's rejected rows are enough, whatever the + * filters list. + */ +async function withAttempts(supabase: SupabaseClient, registrations: Record[]) { + const userIds = [...new Set(registrations.map((r) => r.user_id).filter((v): v is string => typeof v === 'string'))]; + const rejected: Record[] = []; + for (let i = 0; i < userIds.length; i += LOOKUP_CHUNK) { + // '*' rather than naming rejection_reason, so the list keeps loading on a database without section 17 yet. + const { data, error } = await supabase + .from('registrations') + .select('*') + .eq('status', 'rejected') + .in('user_id', userIds.slice(i, i + LOOKUP_CHUNK)); + if (error) return { error, registrations }; + rejected.push(...((data ?? []) as Record[])); + } + const byUser = new Map[]>(); + for (const r of rejected) byUser.set(r.user_id, [...(byUser.get(r.user_id) ?? []), r]); + return { + error: null, + registrations: registrations.map((r) => { + const earlier = (byUser.get(r.user_id as string) ?? []) + .filter((x) => x.id !== r.id && x.created_at < (r.created_at as string)) + .sort((a, b) => (a.created_at < b.created_at ? -1 : 1)); + return { + ...r, + attempt: earlier.length + 1, + previous_rejection_reason: earlier.at(-1)?.rejection_reason ?? 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'))]; @@ -133,7 +170,12 @@ export async function GET(req: NextRequest) { 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 }); + const counted = await withAttempts(supabase, enriched.registrations); + if (counted.error) { + console.error('/api/admin/registrations attempts lookup error:', counted.error); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + return NextResponse.json({ success: true, registrations: counted.registrations }); } catch (err: unknown) { console.error('/api/admin/registrations error:', err); const message = 'Failed to fetch registrations'; @@ -156,7 +198,7 @@ export async function PATCH(req: NextRequest) { if (!parsed.ok) { return NextResponse.json({ error: parsed.error }, { status: parsed.status }); } - const { registrationId, status } = parsed.value; + const { registrationId, status, reason } = parsed.value; if (!registrationId || !status) { return NextResponse.json( @@ -176,6 +218,11 @@ export async function PATCH(req: NextRequest) { ); } + // Every rejection says why: the student sees it on their pass before registering again. + if (status === 'rejected' && !isRejectionReason(reason)) { + return NextResponse.json({ error: 'Choose why this registration is rejected.' }, { status: 400 }); + } + const { supabase, user } = auth; // Fetch existing registration to verify status @@ -214,6 +261,7 @@ export async function PATCH(req: NextRequest) { .from('registrations') .update({ status, + ...(status === 'rejected' ? { rejection_reason: reason } : {}), reviewed_by: user.id, reviewed_at: new Date().toISOString(), updated_at: new Date().toISOString(), diff --git a/src/app/api/admin/seats/route.ts b/src/app/api/admin/seats/route.ts new file mode 100644 index 0000000..b9eac1b --- /dev/null +++ b/src/app/api/admin/seats/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import { verifyAdmin, verifyRegistrationStaff } from '@/lib/auth-server'; +import { readJsonObject } from '@/lib/security'; +import { seatCountError } from '@/lib/seats'; + +export const runtime = 'nodejs'; + +/** The counter as staff see it: the two numbers, and who last set them by hand and when. */ +async function readCounter(supabase: SupabaseClient) { + const { data, error } = await supabase + .from('registration_counter') + .select('total_seats, seats_left, updated_by, updated_at') + .eq('id', 1) + .maybeSingle(); + if (error || !data) return { error: error ?? new Error('registration_counter has no row'), counter: null }; + + let updatedBy: string | null = null; + if (data.updated_by) { + const { data: profile } = await supabase + .from('profiles') + .select('full_name, email') + .eq('id', data.updated_by) + .maybeSingle(); + updatedBy = profile?.full_name || profile?.email || null; + } + return { + error: null, + counter: { total: data.total_seats, left: data.seats_left, updatedAt: data.updated_at, updatedBy }, + }; +} + +const UNAVAILABLE = + 'The seat counter is not set up yet. Run supabase/manual/seat_counter_and_reregistration.sql in Supabase.'; + +/** + * GET /api/admin/seats + * The seat counter for the admin dashboard (admins and the registration team). + */ +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 { error, counter } = await readCounter(auth.supabase); + if (error) { + console.error('/api/admin/seats read failed:', error); + return NextResponse.json({ error: UNAVAILABLE }, { status: 500 }); + } + return NextResponse.json({ success: true, counter }); + } catch (err: unknown) { + console.error('/api/admin/seats GET error:', err); + return NextResponse.json({ error: 'Failed to load the seat counter' }, { status: 500 }); + } +} + +/** + * PATCH /api/admin/seats { total, left } + * Admins only: sets both numbers. The database keeps moving `left` from there as registrations come in and are + * rejected. + */ +export async function PATCH(req: NextRequest) { + try { + // verifyAdmin's 403 message is about user roles; a refusal here says what this route needs instead. + const auth = await verifyAdmin(req); + if ('error' in auth) { + return NextResponse.json( + { error: auth.status === 403 ? 'Only admins can change the seat counter.' : auth.error }, + { status: auth.status } + ); + } + + const parsed = await readJsonObject(req); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + const { total, left } = parsed.value; + const invalid = seatCountError(total, left); + if (invalid) return NextResponse.json({ error: invalid }, { status: 400 }); + + const { error: writeError } = await auth.supabase + .from('registration_counter') + .upsert( + { id: 1, total_seats: total, seats_left: left, updated_by: auth.user.id, updated_at: new Date().toISOString() }, + { onConflict: 'id' } + ); + if (writeError) { + console.error('/api/admin/seats write failed:', writeError); + return NextResponse.json({ error: UNAVAILABLE }, { status: 500 }); + } + + const { error, counter } = await readCounter(auth.supabase); + if (error) { + console.error('/api/admin/seats read after write failed:', error); + return NextResponse.json({ error: 'Saved, but the new counter could not be read back. Refresh.' }, { status: 500 }); + } + return NextResponse.json({ success: true, counter }); + } catch (err: unknown) { + console.error('/api/admin/seats PATCH error:', err); + return NextResponse.json({ error: 'Failed to update the seat counter' }, { status: 500 }); + } +} diff --git a/src/app/api/pass/route.ts b/src/app/api/pass/route.ts index 4313fba..292ed8d 100644 --- a/src/app/api/pass/route.ts +++ b/src/app/api/pass/route.ts @@ -4,6 +4,7 @@ import { getSupabaseAsUser } from '@/lib/supabase'; import { createRateLimiter } from '@/lib/security'; import { decryptEntryToken, encryptEntryToken, entryPassKey, mintEntryToken } from '@/lib/entry-pass'; import type { ParticipantPass } from '@/lib/pass-experience'; +import { attemptsLeft } from '@/lib/rejections'; export const runtime = 'nodejs'; @@ -46,7 +47,8 @@ export async function POST(req: NextRequest) { } const mine = data as MyEntryPass; - const details = await db.from('registrations').select('college') + // '*' rather than naming rejection_reason, so the pass keeps loading on a database without section 17 yet. + const details = await db.from('registrations').select('*') .eq('user_id', auth.user.id).eq('registration_id', mine.registration_id ?? '').maybeSingle(); if (details.error) return NextResponse.json({ error: 'Could not load your registration.' }, { status: 500 }); const pass: ParticipantPass = { @@ -58,6 +60,14 @@ export async function POST(req: NextRequest) { checkedIn: !!mine.checked_in, token: null as string | null, }; + if (mine.registration_status === 'rejected') { + // The reason staff gave, and whether the student may register again (REGISTER AGAIN on the pass). + const rejected = await db.from('registrations').select('id', { count: 'exact', head: true }) + .eq('user_id', auth.user.id).eq('status', 'rejected'); + if (rejected.error) return NextResponse.json({ error: 'Could not load your registration.' }, { status: 500 }); + pass.rejectionReason = details.data?.rejection_reason ?? null; + pass.attemptsLeft = attemptsLeft(rejected.count ?? 0); + } if (mine.registration_status !== 'confirmed' || mine.pass_status !== 'active' || !mine.pass) { return NextResponse.json({ success: true, pass }, { headers: { 'Cache-Control': 'private, no-store' } }); } diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index 07a1d1f..cf18d81 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -6,6 +6,7 @@ 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 { ATTEMPTS_USED_UP_ERROR, attemptsLeft, isAttemptsUsedUpError } from '@/lib/rejections'; import { classifyStudentType, createRateLimiter, @@ -22,18 +23,32 @@ 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) { +/** + * Latest registration owned by this user (by id, or by their verified email for legacy rows). `activeOnly` leaves out + * rejected ones: a rejected registration doesn't stop the user from registering again. + */ +async function findExistingRegistration( + client: SupabaseClient, + user: User, + verifiedEmail: string, + { activeOnly = false } = {} +) { const filter = isFilterSafeEmail(verifiedEmail) ? `user_id.eq.${user.id},email.eq.${verifiedEmail}` : `user_id.eq.${user.id}`; - return client + let query = client.from('registrations').select('*').or(filter); + if (activeOnly) query = query.neq('status', 'rejected'); + return query.order('created_at', { ascending: false }).limit(1).maybeSingle(); +} + +/** How many of this user's registrations staff rejected (each one uses up an attempt). */ +async function countRejected(client: SupabaseClient, userId: string) { + const { count, error } = await client .from('registrations') - .select('*') - .or(filter) - .order('created_at', { ascending: false }) - .limit(1) - .maybeSingle(); + .select('id', { count: 'exact', head: true }) + .eq('user_id', userId) + .eq('status', 'rejected'); + return { count: count ?? 0, error }; } /** @@ -57,6 +72,7 @@ async function utrConflictResponse(client: SupabaseClient, userId: string) { .from('registrations') .select('*') .eq('user_id', userId) + .neq('status', 'rejected') .limit(1) .maybeSingle(); if (error) console.warn('Registration lookup after a UTR conflict failed:', error); @@ -99,12 +115,26 @@ export async function GET(req: NextRequest) { console.warn('Error fetching registration in /api/register GET:', error); return NextResponse.json({ success: false, error: 'Failed to fetch registration.' }, { status: 500 }); } + if (!registration) return NextResponse.json({ success: true, registration: null }); + + // A rejected registration also says whether the user may register again (the pass shows REGISTER AGAIN). + let extra = {}; + if (registration.status === 'rejected') { + const rejected = await countRejected(adminClient, user.id); + if (rejected.error) { + console.warn('Rejected registrations count failed in /api/register GET:', rejected.error); + return NextResponse.json({ success: false, error: 'Failed to fetch registration.' }, { status: 500 }); + } + extra = { attempts_left: attemptsLeft(rejected.count) }; + } return NextResponse.json({ success: true, - registration: registration - ? await withAmbassadorCode(adminClient, registration, { userId: user.id, email: verifiedEmail }) - : null, + registration: await withAmbassadorCode( + adminClient, + { ...registration, ...extra }, + { userId: user.id, email: verifiedEmail } + ), }); } catch (err: unknown) { console.error('API /api/register GET error:', err); @@ -165,8 +195,9 @@ export async function POST(req: NextRequest) { const adminClient = getSupabaseAdmin(token); - // 3. Prevent duplicate registrations for the same authenticated user - const { data: existingReg } = await findExistingRegistration(adminClient, user, verifiedEmail); + // 3. Prevent duplicate registrations for the same authenticated user. A rejected one doesn't count: the user may + // register again, up to MAX_REGISTRATION_ATTEMPTS registrations in all (also enforced by the database). + const { data: existingReg } = await findExistingRegistration(adminClient, user, verifiedEmail, { activeOnly: true }); if (existingReg) { return NextResponse.json( @@ -180,6 +211,15 @@ export async function POST(req: NextRequest) { ); } + const rejected = await countRejected(adminClient, user.id); + if (rejected.error) { + console.error('Rejected registrations count failed in /api/register POST:', rejected.error); + return NextResponse.json({ error: 'Failed to record registration. Please try again.' }, { status: 500 }); + } + if (attemptsLeft(rejected.count) === 0) { + return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 }); + } + // A UPI transaction can only pay for one registration. const { data: utrOwner } = await adminClient .from('registrations') @@ -261,6 +301,10 @@ export async function POST(req: NextRequest) { if (guardError) { return NextResponse.json({ error: `${guardError} ${PAYMENT_KEPT_NOTE}`, field: 'referred_by_code' }, { status: 400 }); } + // Rejected for the last time between the check above and this insert (trg_guard_registration_attempts). + if (isAttemptsUsedUpError(regError)) { + return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 }); + } 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). @@ -272,7 +316,7 @@ export async function POST(req: NextRequest) { } // Unique violation on user_id (concurrent double submit): return the registration that won. if (!/registration_id|referral_code/i.test(`${regError.message} ${regError.details ?? ''}`)) { - const { data: winner } = await findExistingRegistration(adminClient, user, verifiedEmail); + const { data: winner } = await findExistingRegistration(adminClient, user, verifiedEmail, { activeOnly: true }); if (winner) { return NextResponse.json({ success: true, diff --git a/src/app/api/seats/route.ts b/src/app/api/seats/route.ts new file mode 100644 index 0000000..79e82e9 --- /dev/null +++ b/src/app/api/seats/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { getSupabaseAdmin, isSupabaseAdminConfigured } from '@/lib/supabase'; + +export const runtime = 'nodejs'; + +// Public, unauthenticated endpoint polled by every open registration form: serve from a short in-process cache and let +// the CDN cache it too, so a crowd watching the counter doesn't turn into one database query each. Short enough that +// the number still visibly moves while someone is on the form. Excluded from the global no-store API header in +// next.config.ts. +const CACHE_TTL_MS = 10 * 1000; +const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=10, s-maxage=15, stale-while-revalidate=30' }; +const NO_STORE = { 'Cache-Control': 'no-store' }; +let cached: { body: { total: number; left: number }; expires: number } | null = null; + +/** + * GET /api/seats + * The seat counter on the registration form: { total, left } (supabase/schema.sql, section 17). Only these two numbers, + * nothing about any registration. 503 while the counter isn't set up: the form then hides it. + */ +export async function GET() { + try { + if (cached && cached.expires > Date.now()) { + return NextResponse.json(cached.body, { headers: CACHE_HEADERS }); + } + if (!isSupabaseAdminConfigured()) { + return NextResponse.json({ error: 'Seat counter unavailable' }, { status: 503, headers: NO_STORE }); + } + + const { data, error } = await getSupabaseAdmin() + .from('registration_counter') + .select('total_seats, seats_left') + .eq('id', 1) + .maybeSingle(); + if (error || !data) { + if (error) console.error('Seat counter query failed:', error); + return NextResponse.json({ error: 'Seat counter unavailable' }, { status: 503, headers: NO_STORE }); + } + + const body = { total: data.total_seats, left: data.seats_left }; + cached = { body, expires: Date.now() + CACHE_TTL_MS }; + return NextResponse.json(body, { headers: CACHE_HEADERS }); + } catch (err: unknown) { + console.error('Seat counter route error:', err); + return NextResponse.json({ error: 'Seat counter unavailable' }, { status: 503, headers: NO_STORE }); + } +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index ef8c144..7e34133 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -3,6 +3,7 @@ import { getAuthenticatedUser } from '@/lib/auth-server'; import { getSupabaseAdmin } from '@/lib/supabase'; import { uploadPaymentProof } from '@/lib/payment-proofs'; import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate'; +import { ATTEMPTS_USED_UP_ERROR, attemptsLeft } from '@/lib/rejections'; import { buildPaymentProofPath, createRateLimiter, @@ -80,22 +81,26 @@ export async function POST(req: NextRequest) { } // No new proofs once registered (a submitted proof can't be swapped). Formerly part of the Storage INSERT policy. - const { data: registration, error: regErr } = await getSupabaseAdmin(auth.token) - .from('registrations') - .select('id') - .eq('user_id', auth.user.id) - .limit(1) - .maybeSingle(); - if (regErr) { - console.error('Payment proof upload: registration lookup failed:', regErr); + // A rejected registration doesn't count: its owner may register again with a new screenshot, while they have + // attempts left. + const client = getSupabaseAdmin(auth.token); + const [active, rejected] = await Promise.all([ + client.from('registrations').select('id').eq('user_id', auth.user.id).neq('status', 'rejected').limit(1).maybeSingle(), + client.from('registrations').select('id', { count: 'exact', head: true }).eq('user_id', auth.user.id).eq('status', 'rejected'), + ]); + if (active.error || rejected.error) { + console.error('Payment proof upload: registration lookup failed:', active.error || rejected.error); return NextResponse.json({ error: 'Failed to upload file. Please try again.' }, { status: 500 }); } - if (registration) { + if (active.data) { return NextResponse.json( { error: 'You have already registered. Your payment screenshot can no longer be changed.' }, { status: 409 } ); } + if (attemptsLeft(rejected.count ?? 0) === 0) { + return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 409 }); + } // Server-chosen path `/.`: the client's filename never reaches storage. const path = buildPaymentProofPath(auth.user.id, type); diff --git a/src/components/innovision/AdminDashboard.tsx b/src/components/innovision/AdminDashboard.tsx index 7505a44..2405a9e 100644 --- a/src/components/innovision/AdminDashboard.tsx +++ b/src/components/innovision/AdminDashboard.tsx @@ -11,7 +11,9 @@ import GalleryManager from './admin/GalleryManager'; import EntryManager from './admin/EntryManager'; import ReferralLeaderboard from './admin/ReferralLeaderboard'; import AmbassadorsManager from './admin/AmbassadorsManager'; +import SeatCounterCard from './admin/SeatCounterCard'; import { httpErrorMessage } from '@/lib/api-errors'; +import { MAX_REGISTRATION_ATTEMPTS, ordinal, REJECTION_REASONS, rejectionLabel, type RejectionReason } from '@/lib/rejections'; interface AdminDashboardProps { isOpen: boolean; @@ -48,8 +50,12 @@ export default function AdminDashboard({ message: string; actionText: string; actionType: 'approve' | 'reject' | 'role' | 'default'; - onConfirm: () => void; + /** A rejection: the confirm button stays disabled until a reason is picked, and gets it. */ + onConfirm: (reason?: RejectionReason) => void; } | null>(null); + const [rejectReason, setRejectReason] = useState(''); + // Bumped whenever the seat count may have moved (list reloaded, registration reviewed): the SEAT COUNTER card reloads. + const [seatsKey, setSeatsKey] = useState(0); // Filters const [statusFilter, setStatusFilter] = useState('all'); @@ -157,6 +163,7 @@ export default function AdminDashboard({ const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(httpErrorMessage(res) || data.error || 'Failed to fetch registrations'); setRegistrations(data.registrations || []); + setSeatsKey((k) => k + 1); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Error loading registrations'; setErrorMsg(message); @@ -219,19 +226,27 @@ export default function AdminDashboard({ } const isApprove = newStatus === 'confirmed'; + // What a rejection means for the student: they see the reason and may register again, unless this was their last + // attempt. + const attempt = reg.attempt ?? 1; + const afterReject = + attempt >= MAX_REGISTRATION_ATTEMPTS + ? `This is their ${ordinal(attempt)} and last attempt: after this rejection they can't register again.` + : `They will see the reason on their pass and can register again (${MAX_REGISTRATION_ATTEMPTS - attempt} more ${MAX_REGISTRATION_ATTEMPTS - attempt === 1 ? 'attempt' : 'attempts'}).`; + setRejectReason(''); setConfirmModal({ isOpen: true, title: isApprove ? 'Approve Registration' : 'Reject Registration', message: isApprove ? `Are you sure you want to APPROVE registration for ${reg.name} (${reg.registration_id})? This will generate their confirmed boarding pass.` - : `Are you sure you want to REJECT registration for ${reg.name} (${reg.registration_id})? This action cannot be altered once confirmed.`, + : `REJECT registration for ${reg.name} (${reg.registration_id})? It cannot be altered once done. ${afterReject} Their seat goes back to the counter.`, actionText: isApprove ? 'APPROVE REGISTRATION' : 'REJECT REGISTRATION', actionType: isApprove ? 'approve' : 'reject', - onConfirm: () => executeUpdateStatus(reg, newStatus), + onConfirm: (reason) => executeUpdateStatus(reg, newStatus, reason), }); }; - const executeUpdateStatus = async (reg: Registration, newStatus: 'confirmed' | 'rejected') => { + const executeUpdateStatus = async (reg: Registration, newStatus: 'confirmed' | 'rejected', reason?: RejectionReason) => { if (!canReviewRegistrations) return; try { setActionBusyId(reg.id || reg.registration_id); @@ -251,6 +266,7 @@ export default function AdminDashboard({ body: JSON.stringify({ registrationId: reg.id, status: newStatus, + ...(newStatus === 'rejected' ? { reason } : {}), }), }); @@ -260,8 +276,11 @@ export default function AdminDashboard({ showToast(`Registration ${reg.registration_id} marked as ${newStatus.toUpperCase()}`, 'success'); // Update local state setRegistrations((prev) => - prev.map((r) => (r.id === reg.id ? { ...r, status: newStatus } : r)) + prev.map((r) => + r.id === reg.id ? { ...r, status: newStatus, rejection_reason: newStatus === 'rejected' ? reason ?? null : null } : r + ) ); + setSeatsKey((k) => k + 1); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to update status'; showToast(message, 'error'); @@ -333,6 +352,8 @@ export default function AdminDashboard({ 'Enrollment No', 'Student Type', 'Status', + 'Rejection Reason', + 'Attempt', 'Amount (INR)', 'UPI UTR', 'Payment Screenshot (private ref)', @@ -353,6 +374,8 @@ export default function AdminDashboard({ r.enrollment_no, r.student_type, r.status, + rejectionLabel(r.rejection_reason), + r.attempt ?? 1, r.amount, r.utr, r.payment_proof_path, @@ -720,6 +743,40 @@ export default function AdminDashboard({

{confirmModal.message}

+ {confirmModal.actionType === 'reject' && ( +
+ + WHY? (THE STUDENT SEES THIS) + +
+ {REJECTION_REASONS.map((r) => ( + + ))} +
+
+ )}
} {/* Clean minimal footer underneath the ticket */} diff --git a/src/components/innovision/EntryPassCard.tsx b/src/components/innovision/EntryPassCard.tsx index 8914cdf..9f947c6 100644 --- a/src/components/innovision/EntryPassCard.tsx +++ b/src/components/innovision/EntryPassCard.tsx @@ -6,20 +6,23 @@ import { passRequest } from './pass-client'; import PassIssued from './PassIssued'; import styles from './PassIssued.module.css'; import { REGISTRATION_PREVIEW_ENABLED } from '@/config/launch'; +import { rejectionMessage } from '@/lib/rejections'; interface EntryPassCardProps { onContinue?: () => void; previewPass?: ParticipantPass; + /** REGISTER AGAIN on a rejected pass with attempts left; left out while registrations are closed. */ + onRegisterAgain?: () => void | Promise; } -export default function EntryPassCard({ onContinue, previewPass }: EntryPassCardProps) { +export default function EntryPassCard({ onContinue, previewPass, onRegisterAgain }: EntryPassCardProps) { if (REGISTRATION_PREVIEW_ENABLED && previewPass) { return ; } - return ; + return ; } -function AuthenticatedEntryPassCard({ onContinue }: EntryPassCardProps) { +function AuthenticatedEntryPassCard({ onContinue, onRegisterAgain }: EntryPassCardProps) { const [pass, setPass] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(true); @@ -100,14 +103,29 @@ function AuthenticatedEntryPassCard({ onContinue }: EntryPassCardProps) { } if (!pass) return null; - return ; + return ; } -function PassState({ pass, onContinue, preview = false }: { pass: ParticipantPass; onContinue?: () => void; preview?: boolean }) { +function PassState({ + pass, + onContinue, + onRegisterAgain, + preview = false, +}: { + pass: ParticipantPass; + onContinue?: () => void; + onRegisterAgain?: () => void | Promise; + preview?: boolean; +}) { + const [opening, setOpening] = useState(false); + // Unapproved states: Pending review or Rejected if (!isApprovedPass(pass)) { const isPending = pass.registrationStatus === 'pending'; const isRejected = pass.registrationStatus === 'rejected'; + const reason = isRejected ? rejectionMessage(pass.rejectionReason) : ''; + const left = isRejected ? pass.attemptsLeft ?? 0 : 0; + const canRetry = left > 0 && !!onRegisterAgain; return (
@@ -131,9 +149,38 @@ function PassState({ pass, onContinue, preview = false }: { pass: ParticipantPas

- {passStatusMessage(pass)} + {isRejected && canRetry + ? 'Your payment was not approved, but you can register again with the right details.' + : passStatusMessage(pass)}

+ {reason ? ( +

+ WHY + {reason} +

+ ) : null} + + {canRetry ? ( + <> + + + {left === 1 ? 'This is your last attempt.' : `${left} attempts left.`} A new payment screenshot and UTR are + needed; the same UTR is fine if that payment was real. + + + ) : null} + {isPending && (
diff --git a/src/components/innovision/Innovision.tsx b/src/components/innovision/Innovision.tsx index 738e43e..283a4b8 100644 --- a/src/components/innovision/Innovision.tsx +++ b/src/components/innovision/Innovision.tsx @@ -55,6 +55,7 @@ import { type GalleryPhoto, } from '@/lib/supabase'; import { REGISTRATIONS_OPEN, REGISTRATIONS_SOON_PATH, MERCH_PATH, REGISTRATION_PREVIEW_ENABLED } from '@/config/launch'; +import { fetchSeatCount, SEATS_FULL_MESSAGE } from '@/lib/seats'; 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'; @@ -1697,6 +1698,37 @@ export class Innovision extends Component { if (r?.ambassador_code) return formatReferralCode(r.ambassador_code); return String(P.status || '').toUpperCase() !== 'REJECTED' ? r?.referral_code || '' : ''; } + /** + * A rejected registration with attempts left (attempts_left, from GET /api/register) doesn't keep its owner out of + * the form: REGISTER AGAIN on the pass opens it. Never while registrations are closed. + */ + canRegisterAgain(): boolean { + const r = this.state.registration; + return REGISTRATIONS_OPEN && r?.status === 'rejected' && (r.attempts_left ?? 0) > 0; + } + /** + * REGISTER AGAIN on a rejected pass (the registration overlay's or the profile's): a fresh form (new screenshot and + * UTR) with the details pre-filled from the rejected registration. Checks the server first: the pass and this + * component poll separately, so one may be ahead. + */ + registerAgain = async () => { + const user = this.state.user; + if (!user || this.state.busyLbl) return; + if (!this.canRegisterAgain()) { + const fresh = await fetchUserRegistration(user.id, user.email); + if (!this.alive || this.state.user?.id !== user.id) return; + this.syncRegistration(fresh); + if (!this.canRegisterAgain()) { + this.toast('You can’t register again right now. Please contact the registration helpdesk.'); + return; + } + } + this.dropFiles(); + this.reg = null; + this.setState({ files: {}, step: 0, err: {} }, () => { + if (this.state.auth) this.switchMode('register'); else this.openAuth('register'); + }); + }; /** Leaves for the "registrations opening soon" page (/register while REGISTRATIONS_OPEN is false). */ showRegistrationsSoon(replace?: boolean) { if (typeof window === 'undefined') return; @@ -2157,8 +2189,9 @@ export class Innovision extends Component { this.toast('Please log in to register.'); } - // If user is already registered, always show their pass - never show registration - if (mode === 'register' && (this.state.registration || this.pass)) { + // If user is already registered, always show their pass - never show registration (unless they were rejected + // and may register again) + if (mode === 'register' && (this.state.registration || this.pass) && !this.canRegisterAgain()) { mode = 'pass'; } @@ -2287,8 +2320,9 @@ export class Innovision extends Component { const s = this.state; let m = typeof mode === 'string' ? mode : (s.authMode === 'login' ? 'register' : 'login'); - // Never switch to register if user is already registered - show their pass - if (m === 'register' && (s.registration || this.pass)) { + // Never switch to register if user is already registered - show their pass (unless they were rejected and may + // register again) + if (m === 'register' && (s.registration || this.pass) && !this.canRegisterAgain()) { m = 'pass'; } @@ -2570,16 +2604,17 @@ export class Innovision extends Component { 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). - if (!referred_by_code) { this.toStep(1); return; } - this.setState({ busyLbl: 'CHECKING CODE' }); - checkReferralCode(referred_by_code).then((res) => { + // Every student, NIT Rourkela included, continues to Step 1 (payment), unless every seat is taken: nobody should + // pay for a seat that isn't there. A count that can't be read doesn't hold the student up. + this.setState({ busyLbl: referred_by_code ? 'CHECKING CODE' : 'CHECKING SEATS' }); + Promise.all([fetchSeatCount(), referred_by_code ? checkReferralCode(referred_by_code) : null]).then(([seats, 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; + if (seats && seats.left === 0) { this.fail({ form: SEATS_FULL_MESSAGE }); 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; } + if (res && 'valid' in res && !res.valid) { this.fail({ referred_by_code: res.error }); return; } this.toStep(1); }); } else if (s.step === 1) { @@ -2867,7 +2902,11 @@ export class Innovision extends Component { authTitle: reg ? 'Claim your seat' : am === 'login' ? 'Welcome back' : P.status === 'CONFIRMED' ? 'Your place in the odyssey' : P.status === 'REJECTED' ? 'Payment not approved' : 'Registration received', authSub: reg ? 'Three short stops to register for Innovision 2026 at NIT Rourkela.' - : am === 'login' ? 'Sign in or create your account with Google.' : P.status === 'CONFIRMED' ? 'Your payment is verified. Your INNO pass is always available here.' : P.status === 'REJECTED' ? 'Please contact the registration helpdesk for assistance.' : 'Your payment is under review by the registration team. Your pass appears here after approval.', + : am === 'login' ? 'Sign in or create your account with Google.' : P.status === 'CONFIRMED' ? 'Your payment is verified. Your INNO pass is always available here.' : P.status === 'REJECTED' ? (this.canRegisterAgain() ? 'See why below, then register again with the right details.' : 'Please contact the registration helpdesk for assistance.') : 'Your payment is under review by the registration team. Your pass appears here after approval.', + // The seat counter (GET /api/seats) beside the registration form. + showSeats: reg, + // REGISTER AGAIN on a rejected pass; the pass itself decides whether to show it (attempts left). + registerAgain: REGISTRATIONS_OPEN ? this.registerAgain : undefined, railD: show(showRail && !s.narrow), hprogD: showRail && s.narrow ? 'grid' : 'none', prog: railSteps.map(([label, hint, short], k) => { const done = k < st, cur = k === st && reg, back = done && reg; @@ -3186,6 +3225,7 @@ export class Innovision extends Component { registration={s.registration} onOpenPass={() => this.openAuth('pass')} onOpenRegister={() => this.openAuth('register')} + onRegisterAgain={REGISTRATIONS_OPEN ? this.registerAgain : undefined} onOpenAdmin={() => this.setState({ adminOpen: true, profileOpen: false })} onLogout={this.logout} onUpdatePhone={this.handleUpdatePhone} diff --git a/src/components/innovision/PassIssued.module.css b/src/components/innovision/PassIssued.module.css index 34e5c33..52a424a 100644 --- a/src/components/innovision/PassIssued.module.css +++ b/src/components/innovision/PassIssued.module.css @@ -614,6 +614,24 @@ padding: 4px 8px; border-radius: 3px; border: 1px solid rgba(239,68,68,0.25); } +/* Why a registration was rejected, and REGISTER AGAIN (status boxes sit outside .experience: no variables here). */ +.statusReason { + margin: 14px 0 0; padding: 10px 14px; + border-left: 2px solid #f87171; background: rgba(239,68,68,0.06); + font-size: 13.5px; line-height: 1.5; color: #ECE8DF; +} +.statusReasonLabel { display: block; margin-bottom: 2px; font: 700 10.5px monospace; letter-spacing: .12em; color: #f87171; } +.statusAction { + width: 100%; min-height: 48px; margin-top: 16px; padding: 12px 20px; + border: 1px solid #c8b17a; border-radius: 3px; background: rgba(200,177,122,0.12); + color: #e4d3a4; font-size: 13px; font-weight: 700; letter-spacing: .1em; cursor: pointer; + transition: background-color 0.2s; +} +.statusAction:hover { background: rgba(200,177,122,0.22); } +.statusAction:focus-visible { outline: 2px solid #c8b17a; outline-offset: 3px; } +.statusAction:disabled { opacity: 0.6; cursor: progress; } +.statusHint { display: block; margin-top: 8px; font-size: 12.5px; text-align: center; color: rgba(236,232,223,0.6); } + .experience button:focus-visible, .experience a:focus-visible { outline: 2px solid var(--gold); diff --git a/src/components/innovision/ProfileOverlay.tsx b/src/components/innovision/ProfileOverlay.tsx index a8eb158..c3569b6 100644 --- a/src/components/innovision/ProfileOverlay.tsx +++ b/src/components/innovision/ProfileOverlay.tsx @@ -13,6 +13,8 @@ interface ProfileOverlayProps { registration: Registration | null; onOpenPass: () => void; onOpenRegister: () => void; + /** REGISTER AGAIN on a rejected pass with attempts left; left out while registrations are closed. */ + onRegisterAgain?: () => void | Promise; onOpenAdmin: () => void; onLogout: () => void; onUpdatePhone: (newPhone: string) => Promise; @@ -25,6 +27,7 @@ export default function ProfileOverlay({ registration, onOpenPass, onOpenRegister, + onRegisterAgain, onOpenAdmin, onLogout, onUpdatePhone, @@ -412,6 +415,8 @@ export default function ProfileOverlay({ ? 'Your registration is confirmed. Download your entry pass below and show the QR with your college ID at the registration desk.' : registration.status === 'pending' ? 'Your payment is being verified and your registration will be approved soon. Keep checking MY PASS: your entry QR will appear there once approved.' + : onRegisterAgain && (registration.attempts_left ?? 0) > 0 + ? 'Your registration could not be verified. See why below, then register again with the right details.' : 'Your registration could not be verified. Please visit the helpdesk or contact the event administrators.'}

@@ -436,7 +441,7 @@ export default function ProfileOverlay({ passStatus: registration.status === 'confirmed' ? 'active' : 'none', registrationId: registration.registration_id, name: registration.name, college: registration.college, checkedIn: false, token: null, - } : undefined} /> + } : undefined} onRegisterAgain={onRegisterAgain ? async () => { onClose(); await onRegisterAgain(); } : undefined} /> + ) : null} + + {canEdit && editing ? ( +
+ + + + + {formError ? ( + {formError} + ) : null} +
+ ) : null} + + ); +} diff --git a/src/lib/pass-experience.ts b/src/lib/pass-experience.ts index 280b9a0..5681b1b 100644 --- a/src/lib/pass-experience.ts +++ b/src/lib/pass-experience.ts @@ -8,6 +8,10 @@ export interface ParticipantPass { checkedIn: boolean; token: string | null; qrUnavailable?: string; + /** Rejected registrations only: the reason staff gave (src/lib/rejections.ts), if any. */ + rejectionReason?: string | null; + /** Rejected registrations only: how many more times the student may register. */ + attemptsLeft?: number; } export function isApprovedPass(pass: ParticipantPass): boolean { diff --git a/src/lib/rejections.ts b/src/lib/rejections.ts new file mode 100644 index 0000000..99764ce --- /dev/null +++ b/src/lib/rejections.ts @@ -0,0 +1,70 @@ +/** + * Rejections and registering again (supabase/schema.sql, section 17). Client & server safe, and free of `@/` imports + * so it can be unit tested directly with `node --test`. + * + * Staff pick a reason with every rejection; the student sees it on their pass with a REGISTER AGAIN button, until the + * account has used MAX_REGISTRATION_ATTEMPTS registrations. + */ + +/** Registrations one account may make in all (matches guard_registration_attempts in supabase/schema.sql). */ +export const MAX_REGISTRATION_ATTEMPTS = 3; + +/** The values match registrations_rejection_reason_check in supabase/schema.sql. */ +export const REJECTION_REASONS = [ + { + value: 'unclear_screenshot', + label: 'Unclear screenshot', + message: 'The payment screenshot was unclear or did not show the payment. Upload a clear screenshot of the payment success screen.', + }, + { + value: 'utr_mismatch', + label: "UTR doesn't match", + message: "The UTR you entered doesn't match the payment. Copy the 12-digit UTR from the payment details in your UPI app.", + }, + { + value: 'wrong_amount', + label: 'Wrong amount', + message: 'The amount paid was not the registration fee. Pay the exact fee shown on the payment step.', + }, + { + value: 'duplicate_payment', + label: 'Duplicate payment', + message: 'This payment was already used for another registration. Each registration needs its own payment.', + }, +] as const; + +export type RejectionReason = (typeof REJECTION_REASONS)[number]['value']; + +export function isRejectionReason(value: unknown): value is RejectionReason { + return REJECTION_REASONS.some((r) => r.value === value); +} + +/** Short label for staff (admin dashboard). */ +export function rejectionLabel(reason: string | null | undefined): string { + return REJECTION_REASONS.find((r) => r.value === reason)?.label ?? ''; +} + +/** What the student is told to fix, or '' for a rejection without a reason (made before reasons existed). */ +export function rejectionMessage(reason: string | null | undefined): string { + return REJECTION_REASONS.find((r) => r.value === reason)?.message ?? ''; +} + +/** Registrations still allowed after `rejected` rejections (never negative). */ +export function attemptsLeft(rejected: number): number { + return Math.max(MAX_REGISTRATION_ATTEMPTS - rejected, 0); +} + +export const ATTEMPTS_USED_UP_ERROR = + `This account has been rejected ${MAX_REGISTRATION_ATTEMPTS} times and can't register again. Please contact the registration helpdesk.`; + +/** The database refused a registration because the account has no attempts left (trg_guard_registration_attempts). */ +export function isAttemptsUsedUpError(err: { message?: string } | null | undefined): boolean { + return /registration attempts used up/i.test(err?.message ?? ''); +} + +/** "2nd", "3rd": the attempt number staff see on a registration made again after a rejection. */ +export function ordinal(n: number): string { + const tens = n % 100; + if (tens >= 11 && tens <= 13) return `${n}th`; + return `${n}${({ 1: 'st', 2: 'nd', 3: 'rd' } as Record)[n % 10] ?? 'th'}`; +} diff --git a/src/lib/seats.ts b/src/lib/seats.ts new file mode 100644 index 0000000..e379ec1 --- /dev/null +++ b/src/lib/seats.ts @@ -0,0 +1,55 @@ +/** + * The seat counter on the registration form (supabase/schema.sql, section 17). Client & server safe, and free of `@/` + * imports so it can be unit tested directly with `node --test`. + * + * Admins set both numbers in the admin dashboard; after that the database moves `left` by itself (a registration + * takes a seat, a rejection gives it back). The form stops before the payment step once `left` is 0. + */ + +/** Largest total an admin may set (matches registration_counter_seats_check). */ +export const MAX_SEATS = 10000; + +export interface SeatCount { + total: number; + left: number; +} + +export const SEATS_FULL_MESSAGE = + 'Registrations are full: every seat has been taken. Nothing was charged; please keep checking this page in case seats open up.'; + +const isSeatNumber = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 0 && (n as number) <= MAX_SEATS; + +/** The counter from a JSON body ({ total, left }), or null if it isn't a valid one. */ +export function parseSeatCount(value: unknown): SeatCount | null { + if (!value || typeof value !== 'object') return null; + const { total, left } = value as Record; + if (!isSeatNumber(total) || !isSeatNumber(left) || left > total) return null; + return { total, left }; +} + +/** Why an admin's new numbers can't be saved, or null if they can. */ +export function seatCountError(total: unknown, left: unknown): string | null { + if (!isSeatNumber(total)) return `Total seats must be a whole number from 0 to ${MAX_SEATS}.`; + if (!isSeatNumber(left)) return `Seats left must be a whole number from 0 to ${MAX_SEATS}.`; + if (left > total) return 'Seats left cannot be more than the total.'; + return null; +} + +/** 1300 → "1,300" (Indian digit grouping, the same as the rest of the site). */ +export function formatSeats(n: number): string { + return n.toLocaleString('en-IN'); +} + +/** + * The public count (GET /api/seats), or null when it couldn't be read (offline, server error, or the counter isn't + * set up yet): the form then hides the counter and lets the student carry on. + */ +export async function fetchSeatCount(): Promise { + try { + const res = await fetch('/api/seats'); + if (!res.ok) return null; + return parseSeatCount(await res.json()); + } catch { + return null; + } +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 269717d..2b6153b 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,5 +1,6 @@ import { createClient, SupabaseClient, Session, User } from '@supabase/supabase-js'; import { httpErrorMessage } from './api-errors'; +import type { RejectionReason } from './rejections'; import { SITE_URL } from '@/config/site'; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ''; @@ -52,6 +53,14 @@ export interface Registration { 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; + /** Why staff rejected it (src/lib/rejections.ts); null for other statuses and for rejections made before reasons. */ + rejection_reason?: RejectionReason | null; + /** Not a column: added by GET /api/register to a rejected registration, the registrations the user may still make. */ + attempts_left?: number; + /** Not a column: added by GET /api/admin/registrations, 1 for a first registration, 2 after one rejection, ... */ + attempt?: number; + /** Not a column: added by GET /api/admin/registrations, why the user's previous registration was rejected. */ + previous_rejection_reason?: RejectionReason | null; created_at?: string; updated_at?: string; } diff --git a/supabase/manual/seat_counter_and_reregistration.sql b/supabase/manual/seat_counter_and_reregistration.sql new file mode 100644 index 0000000..27eebf6 --- /dev/null +++ b/supabase/manual/seat_counter_and_reregistration.sql @@ -0,0 +1,160 @@ +-- ============================================================================== +-- MIGRATION (run once in the Supabase SQL editor): seat counter, and registering again after a rejection. +-- +-- Why: the registration form shows how many seats are left (1300 to start, minus the registrations that already hold +-- a seat), admins can change both numbers in the dashboard, and a rejected student can register again (up to 3 +-- registrations in all), seeing the reason staff gave. +-- This changes section 5 of supabase/schema.sql (the review trigger keeps the rejection reason; one ACTIVE registration +-- per user instead of one per user, ever) and adds section 17: registrations.rejection_reason, the attempts guard and +-- the registration_counter table with its trigger. No existing row changes. +-- +-- Run this BEFORE deploying the code that uses it: until then the seat counter stays hidden, the admin SEAT COUNTER +-- card shows an error, rejecting a registration fails (it needs the rejection_reason column) and registering again is +-- refused by the old one-registration-per-user index. +-- Kept out of supabase/migrations/ on purpose so the Supabase CLI never applies it automatically. Safe to re-run: it +-- never resets the counter once it exists. +-- ============================================================================== + +-- Section 5: staff reviewing through the Supabase API may only move a PENDING registration to confirmed/rejected +-- ("once done cannot be altered"); every other column stays as submitted, except the reason given with a rejection. +CREATE OR REPLACE FUNCTION public.enforce_registration_review() RETURNS TRIGGER AS $$ +DECLARE + v_status TEXT; + v_reason TEXT; +BEGIN + IF COALESCE(auth.role(), '') <> 'authenticated' THEN + RETURN NEW; + END IF; + + IF OLD.user_id = auth.uid() THEN + RAISE EXCEPTION 'Staff cannot review their own registration' USING ERRCODE = '42501'; + END IF; + + IF OLD.status <> 'pending' OR NEW.status NOT IN ('confirmed', 'rejected') THEN + RAISE EXCEPTION 'Registration % is already % and cannot be altered', OLD.registration_id, OLD.status + USING ERRCODE = '42501'; + END IF; + + v_status := NEW.status; + v_reason := CASE WHEN NEW.status = 'rejected' THEN NEW.rejection_reason END; + NEW := OLD; + NEW.status := v_status; + NEW.rejection_reason := v_reason; + NEW.reviewed_by := auth.uid(); + NEW.reviewed_at := now(); + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- Section 5: one active (pending or confirmed) registration per user. Rejected registrations don't count, so a +-- rejected student may register again. Replaces uq_registrations_user_id (one registration per user, ever). +DROP INDEX IF EXISTS public.uq_registrations_user_id; +DO $$ +BEGIN + CREATE UNIQUE INDEX IF NOT EXISTS uq_registrations_active_user_id ON public.registrations(user_id) + WHERE status <> 'rejected'; +EXCEPTION WHEN unique_violation THEN + RAISE NOTICE 'uq_registrations_active_user_id not created: a user has two active registrations'; +END $$; + +-- Section 17: +-- Seat counter: the registration form shows " / seats left" (public GET /api/seats). Admins +-- set both numbers in the admin dashboard (PATCH /api/admin/seats). The count then moves by itself: every new +-- registration takes a seat (pending ones too: they have already paid), a rejection gives it back, approving changes +-- nothing. It never goes below 0 or above total_seats. When it reaches 0 the form stops before the payment step; a +-- submit from someone who was already paying is still accepted (it leaves the count at 0). +-- One row (id = 1). No client role can read or write it: the routes above use the service role, and the trigger below +-- is SECURITY DEFINER, so the count moves whoever changes a registration (the app, the Supabase API or the SQL editor). +CREATE TABLE IF NOT EXISTS public.registration_counter ( + id SMALLINT PRIMARY KEY DEFAULT 1, + total_seats INTEGER NOT NULL, + seats_left INTEGER NOT NULL, + -- The admin who last set the numbers by hand, and when (automatic moves don't change these). + updated_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +DO $$ +BEGIN + ALTER TABLE public.registration_counter DROP CONSTRAINT IF EXISTS registration_counter_single_row_check; + ALTER TABLE public.registration_counter ADD CONSTRAINT registration_counter_single_row_check CHECK (id = 1); + -- 10000 matches MAX_SEATS in src/lib/seats.ts. + ALTER TABLE public.registration_counter DROP CONSTRAINT IF EXISTS registration_counter_seats_check; + ALTER TABLE public.registration_counter ADD CONSTRAINT registration_counter_seats_check + CHECK (total_seats BETWEEN 0 AND 10000 AND seats_left BETWEEN 0 AND total_seats); +END $$; + +-- First run: 1300 seats, less the registrations that already hold one. Re-running keeps the current numbers. +INSERT INTO public.registration_counter (id, total_seats, seats_left) +SELECT 1, 1300, GREATEST(1300 - count(*), 0)::INT +FROM public.registrations WHERE status IN ('pending', 'confirmed') +ON CONFLICT (id) DO NOTHING; + +-- A registration holds a seat while it is pending or confirmed. The count moves when that changes: insert (-1), +-- rejection (+1), a rejection undone in the SQL editor (-1), a deleted registration that held a seat (+1). +CREATE OR REPLACE FUNCTION public.move_registration_counter() RETURNS TRIGGER AS $$ +DECLARE + v_held BOOLEAN := false; + v_holds BOOLEAN := false; +BEGIN + IF TG_OP <> 'INSERT' THEN + v_held := COALESCE(OLD.status IN ('pending', 'confirmed'), false); + END IF; + IF TG_OP <> 'DELETE' THEN + v_holds := COALESCE(NEW.status IN ('pending', 'confirmed'), false); + END IF; + IF v_holds AND NOT v_held THEN + UPDATE public.registration_counter SET seats_left = GREATEST(seats_left - 1, 0) WHERE id = 1; + ELSIF v_held AND NOT v_holds THEN + UPDATE public.registration_counter SET seats_left = LEAST(seats_left + 1, total_seats) WHERE id = 1; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS trg_move_registration_counter ON public.registrations; +CREATE TRIGGER trg_move_registration_counter + AFTER INSERT OR UPDATE OF status OR DELETE ON public.registrations + FOR EACH ROW EXECUTE FUNCTION public.move_registration_counter(); + +ALTER TABLE public.registration_counter ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.registration_counter FROM PUBLIC, anon, authenticated; + +-- Registering again: a rejected student may submit a new registration (uq_registrations_active_user_id, section 5, +-- only allows one pending or confirmed registration per user), up to 3 registrations in all: after a third rejection +-- the account can't register again. The rejected rows stay, with who rejected them and when. +-- Staff give a reason with every rejection made in the app (PATCH /api/admin/registrations); the student sees it on +-- their pass. Older rejections have none. The values match REJECTION_REASONS in src/lib/rejections.ts. +ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS rejection_reason TEXT; + +DO $$ +BEGIN + ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_rejection_reason_check; + ALTER TABLE public.registrations ADD CONSTRAINT registrations_rejection_reason_check + CHECK (rejection_reason IS NULL OR ( + status = 'rejected' + AND rejection_reason IN ('unclear_screenshot', 'utr_mismatch', 'wrong_amount', 'duplicate_payment') + )); +END $$; + +-- Refuses a fourth registration, whoever inserts. 3 matches MAX_REGISTRATION_ATTEMPTS in src/lib/rejections.ts, and +-- /api/register looks for this message. +CREATE OR REPLACE FUNCTION public.guard_registration_attempts() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.user_id IS NOT NULL + AND (SELECT count(*) FROM public.registrations WHERE user_id = NEW.user_id AND status = 'rejected') >= 3 THEN + RAISE EXCEPTION 'Registration attempts used up: this account has been rejected 3 times' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS trg_guard_registration_attempts ON public.registrations; +CREATE TRIGGER trg_guard_registration_attempts + BEFORE INSERT ON public.registrations + FOR EACH ROW EXECUTE FUNCTION public.guard_registration_attempts(); + +-- Internal: only the triggers above use these. +REVOKE EXECUTE ON FUNCTION public.move_registration_counter() FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.guard_registration_attempts() FROM PUBLIC, anon, authenticated, service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 79d2e25..313e960 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -396,10 +396,12 @@ CREATE TRIGGER trg_enforce_registration_insert FOR EACH ROW EXECUTE FUNCTION public.enforce_registration_insert(); -- Staff reviewing through the Supabase API may only move a PENDING registration to confirmed/rejected --- ("once done cannot be altered"); every other column stays as submitted. +-- ("once done cannot be altered"); every other column stays as submitted, except the reason given with a rejection +-- (rejection_reason, section 17). CREATE OR REPLACE FUNCTION public.enforce_registration_review() RETURNS TRIGGER AS $$ DECLARE v_status TEXT; + v_reason TEXT; BEGIN IF COALESCE(auth.role(), '') <> 'authenticated' THEN RETURN NEW; @@ -415,8 +417,10 @@ BEGIN END IF; v_status := NEW.status; + v_reason := CASE WHEN NEW.status = 'rejected' THEN NEW.rejection_reason END; NEW := OLD; NEW.status := v_status; + NEW.rejection_reason := v_reason; NEW.reviewed_by := auth.uid(); NEW.reviewed_at := now(); NEW.updated_at := now(); @@ -429,13 +433,17 @@ CREATE TRIGGER trg_enforce_registration_review BEFORE UPDATE ON public.registrations FOR EACH ROW EXECUTE FUNCTION public.enforce_registration_review(); --- One registration per user, and one registration per UPI transaction (UTR). +-- One active (pending or confirmed) registration per user, and one registration per UPI transaction (UTR). Rejected +-- registrations are left out of both: a rejected student may register again, with a new screenshot and the same or a +-- new UTR (section 17 caps the attempts). Replaces uq_registrations_user_id (one registration per user, ever). -- Wrapped so the script still runs if legacy duplicate rows exist; clean them up and re-run to add the index. +DROP INDEX IF EXISTS public.uq_registrations_user_id; DO $$ BEGIN - CREATE UNIQUE INDEX IF NOT EXISTS uq_registrations_user_id ON public.registrations(user_id); + CREATE UNIQUE INDEX IF NOT EXISTS uq_registrations_active_user_id ON public.registrations(user_id) + WHERE status <> 'rejected'; EXCEPTION WHEN unique_violation THEN - RAISE NOTICE 'uq_registrations_user_id not created: duplicate user_id rows exist in registrations'; + RAISE NOTICE 'uq_registrations_active_user_id not created: a user has two active registrations'; END $$; DO $$ @@ -662,7 +670,8 @@ ON CONFLICT (id) DO UPDATE SET -- Uploads: NO INSERT policy for users. A signed-in user's JWT plus the public key could otherwise upload straight to -- Storage, skipping /api/upload's rate limit, magic-byte check and per-user limits (unbounded junk files that the -- owner can't even delete). /api/upload writes with the service role after all its checks: server-chosen --- '/.' name, and only before the user has registered. Dropped here so re-running this script +-- '/.' name, and only while the user has no active registration (none yet, or only rejected ones +-- with attempts left, section 17). Dropped here so re-running this script -- also removes the policy from databases created by an earlier version. -- No UPDATE/DELETE policies either: a submitted proof can't be replaced or removed. DROP POLICY IF EXISTS "Payment proofs: owners upload into their own folder" ON storage.objects; @@ -1640,3 +1649,105 @@ REVOKE ALL ON public.ambassador_leaderboard FROM PUBLIC, anon, authenticated; 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; + + +-- 17. SEAT COUNTER AND REGISTERING AGAIN AFTER A REJECTION +-- Seat counter: the registration form shows " / seats left" (public GET /api/seats). Admins +-- set both numbers in the admin dashboard (PATCH /api/admin/seats). The count then moves by itself: every new +-- registration takes a seat (pending ones too: they have already paid), a rejection gives it back, approving changes +-- nothing. It never goes below 0 or above total_seats. When it reaches 0 the form stops before the payment step; a +-- submit from someone who was already paying is still accepted (it leaves the count at 0). +-- One row (id = 1). No client role can read or write it: the routes above use the service role, and the trigger below +-- is SECURITY DEFINER, so the count moves whoever changes a registration (the app, the Supabase API or the SQL editor). +CREATE TABLE IF NOT EXISTS public.registration_counter ( + id SMALLINT PRIMARY KEY DEFAULT 1, + total_seats INTEGER NOT NULL, + seats_left INTEGER NOT NULL, + -- The admin who last set the numbers by hand, and when (automatic moves don't change these). + updated_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +DO $$ +BEGIN + ALTER TABLE public.registration_counter DROP CONSTRAINT IF EXISTS registration_counter_single_row_check; + ALTER TABLE public.registration_counter ADD CONSTRAINT registration_counter_single_row_check CHECK (id = 1); + -- 10000 matches MAX_SEATS in src/lib/seats.ts. + ALTER TABLE public.registration_counter DROP CONSTRAINT IF EXISTS registration_counter_seats_check; + ALTER TABLE public.registration_counter ADD CONSTRAINT registration_counter_seats_check + CHECK (total_seats BETWEEN 0 AND 10000 AND seats_left BETWEEN 0 AND total_seats); +END $$; + +-- First run: 1300 seats, less the registrations that already hold one. Re-running keeps the current numbers. +INSERT INTO public.registration_counter (id, total_seats, seats_left) +SELECT 1, 1300, GREATEST(1300 - count(*), 0)::INT +FROM public.registrations WHERE status IN ('pending', 'confirmed') +ON CONFLICT (id) DO NOTHING; + +-- A registration holds a seat while it is pending or confirmed. The count moves when that changes: insert (-1), +-- rejection (+1), a rejection undone in the SQL editor (-1), a deleted registration that held a seat (+1). +CREATE OR REPLACE FUNCTION public.move_registration_counter() RETURNS TRIGGER AS $$ +DECLARE + v_held BOOLEAN := false; + v_holds BOOLEAN := false; +BEGIN + IF TG_OP <> 'INSERT' THEN + v_held := COALESCE(OLD.status IN ('pending', 'confirmed'), false); + END IF; + IF TG_OP <> 'DELETE' THEN + v_holds := COALESCE(NEW.status IN ('pending', 'confirmed'), false); + END IF; + IF v_holds AND NOT v_held THEN + UPDATE public.registration_counter SET seats_left = GREATEST(seats_left - 1, 0) WHERE id = 1; + ELSIF v_held AND NOT v_holds THEN + UPDATE public.registration_counter SET seats_left = LEAST(seats_left + 1, total_seats) WHERE id = 1; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS trg_move_registration_counter ON public.registrations; +CREATE TRIGGER trg_move_registration_counter + AFTER INSERT OR UPDATE OF status OR DELETE ON public.registrations + FOR EACH ROW EXECUTE FUNCTION public.move_registration_counter(); + +ALTER TABLE public.registration_counter ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.registration_counter FROM PUBLIC, anon, authenticated; + +-- Registering again: a rejected student may submit a new registration (uq_registrations_active_user_id, section 5, +-- only allows one pending or confirmed registration per user), up to 3 registrations in all: after a third rejection +-- the account can't register again. The rejected rows stay, with who rejected them and when. +-- Staff give a reason with every rejection made in the app (PATCH /api/admin/registrations); the student sees it on +-- their pass. Older rejections have none. The values match REJECTION_REASONS in src/lib/rejections.ts. +ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS rejection_reason TEXT; + +DO $$ +BEGIN + ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_rejection_reason_check; + ALTER TABLE public.registrations ADD CONSTRAINT registrations_rejection_reason_check + CHECK (rejection_reason IS NULL OR ( + status = 'rejected' + AND rejection_reason IN ('unclear_screenshot', 'utr_mismatch', 'wrong_amount', 'duplicate_payment') + )); +END $$; + +-- Refuses a fourth registration, whoever inserts. 3 matches MAX_REGISTRATION_ATTEMPTS in src/lib/rejections.ts, and +-- /api/register looks for this message. +CREATE OR REPLACE FUNCTION public.guard_registration_attempts() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.user_id IS NOT NULL + AND (SELECT count(*) FROM public.registrations WHERE user_id = NEW.user_id AND status = 'rejected') >= 3 THEN + RAISE EXCEPTION 'Registration attempts used up: this account has been rejected 3 times' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS trg_guard_registration_attempts ON public.registrations; +CREATE TRIGGER trg_guard_registration_attempts + BEFORE INSERT ON public.registrations + FOR EACH ROW EXECUTE FUNCTION public.guard_registration_attempts(); + +-- Internal: only the triggers above use these. +REVOKE EXECUTE ON FUNCTION public.move_registration_counter() FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.guard_registration_attempts() FROM PUBLIC, anon, authenticated, service_role;