diff --git a/.gitignore b/.gitignore index 38b1ec3..f872be1 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ next-env.d.ts # bank statements: never commit /STATEMENT*.xlsx + +# registration exports (students' personal data): never commit +/innovision_registrations_*.csv diff --git a/src/app/api/admin/seats/route.ts b/src/app/api/admin/seats/route.ts index 9fbf242..c32510c 100644 --- a/src/app/api/admin/seats/route.ts +++ b/src/app/api/admin/seats/route.ts @@ -27,6 +27,9 @@ async function readCounter(supabase: SupabaseClient) { .maybeSingle(); updatedBy = profile?.full_name || profile?.email || null; } + // The card just leaves out the female line if this fails. + const female = await supabase.rpc('female_seats_status'); + if (female.error) console.error('/api/admin/seats female seats read failed:', female.error); return { error: null, counter: { @@ -37,6 +40,7 @@ async function readCounter(supabase: SupabaseClient) { closedAt: data.closed_at, updatedAt: data.updated_at, updatedBy, + female: female.data ? { cap: female.data.cap, registered: female.data.registered, full: female.data.full } : null, }, }; } diff --git a/src/app/api/register/female-access/route.ts b/src/app/api/register/female-access/route.ts new file mode 100644 index 0000000..c3bbe42 --- /dev/null +++ b/src/app/api/register/female-access/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUser } from '@/lib/auth-server'; +import { getSupabaseAdmin } from '@/lib/supabase'; +import { createRateLimiter } from '@/lib/security'; + +export const runtime = 'nodejs'; + +// The form asks once each time it opens while the cap is reached, so this only stops abuse. +const accessLimiter = createRateLimiter({ limit: 60, windowMs: 10 * 60 * 1000 }); + +/** + * GET /api/register/female-access + * Whether this student may still choose Female or Others at the cap, because they may already have paid + * (female_seat_kept, supabase/schema.sql section 21): { keepsAccess }. Display only: start-payment and the database + * make the same check themselves. + */ +export async function GET(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; + + if (!accessLimiter.hit(user.id)) { + return NextResponse.json( + { error: 'Too many attempts. Please wait a few minutes and try again.' }, + { status: 429, headers: { 'Retry-After': String(accessLimiter.retryAfterSeconds(user.id)) } } + ); + } + + const { data, error } = await getSupabaseAdmin(token).rpc('female_seats_status', { p_user_id: user.id }); + if (error || !data) { + console.error('/api/register/female-access failed:', error); + return NextResponse.json({ error: 'Could not check your registration.' }, { status: 500 }); + } + return NextResponse.json({ keepsAccess: data.keeps_access === true }); + } catch (err: unknown) { + console.error('API /api/register/female-access 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 14e3d85..13572f0 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -15,7 +15,7 @@ import { PHONE_IN_USE_ERROR_MESSAGE, } from '@/lib/validation'; import { ATTEMPTS_USED_UP_ERROR, attemptsLeft, isAttemptsUsedUpError } from '@/lib/rejections'; -import { REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; +import { FEMALE_SEATS_FULL_MESSAGE, isFemaleSeatsFullError, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; import { classifyStudentType, createRateLimiter, @@ -349,6 +349,10 @@ export async function POST(req: NextRequest) { if (isIterSoaRefusal(regError)) { return NextResponse.json({ error: ITER_SOA_ERROR_MESSAGE, field: 'college' }, { status: 403 }); } + // The female seat cap (trg_guard_female_seats): the database is the only check here, so it holds under a rush. + if (isFemaleSeatsFullError(regError)) { + return NextResponse.json({ code: 'female_seats_full', error: FEMALE_SEATS_FULL_MESSAGE, field: 'gender' }, { status: 409 }); + } 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). diff --git a/src/app/api/register/start-payment/route.ts b/src/app/api/register/start-payment/route.ts index 19b1f3c..a057129 100644 --- a/src/app/api/register/start-payment/route.ts +++ b/src/app/api/register/start-payment/route.ts @@ -3,8 +3,8 @@ import { getAuthenticatedUser } from '@/lib/auth-server'; import { getSupabaseAdmin } from '@/lib/supabase'; import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate'; import { ATTEMPTS_USED_UP_ERROR, attemptsLeft } from '@/lib/rejections'; -import { REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; -import { createRateLimiter, readJsonObject } from '@/lib/security'; +import { FEMALE_SEATS_FULL_MESSAGE, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; +import { createRateLimiter, readJsonObject, REGISTRATION_GENDERS } from '@/lib/security'; export const runtime = 'nodejs'; @@ -12,12 +12,13 @@ export const runtime = 'nodejs'; const startLimiter = createRateLimiter({ limit: 30, windowMs: 10 * 60 * 1000 }); /** - * POST /api/register/start-payment { fresh? } + * POST /api/register/start-payment { gender, fresh? } * Called before the form shows any payment details: a live read of the database, never a cached count. Answers with what - * the student may do (issue_payment_pass, supabase/schema.sql section 20): + * the student may do (start_payment_for_gender, supabase/schema.sql sections 20 and 21): * { state: 'pay', amount, currentFee } show the payment details at this fee * { state: 'already_paid', amount, currentFee } no payment details: continue only if already paid * 409 { code: 'registrations_closed', error } no payment details at all + * 409 { code: 'female_seats_full', error, field: 'gender' } no payment details for this gender * The fee is locked here: the student may submit at it even if the phase closes or the fee rises meanwhile. `fresh` means * the student hasn't paid and wants the current fee instead of an older one they were quoted. */ @@ -40,6 +41,12 @@ export async function POST(req: NextRequest) { const body = await readJsonObject(req); if (!body.ok) return NextResponse.json({ error: body.error }, { status: body.status }); + // Required, so the seat cap is always checked before anyone is shown a way to pay. + const { gender } = body.value; + if (!(REGISTRATION_GENDERS as readonly unknown[]).includes(gender)) { + return NextResponse.json({ error: 'Select your gender.', field: 'gender' }, { status: 400 }); + } + // Nobody who can't submit should be shown a way to pay. const client = getSupabaseAdmin(token); const [active, rejected] = await Promise.all([ @@ -57,12 +64,20 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 }); } - const { data, error } = await client.rpc('issue_payment_pass', { p_user_id: user.id, p_fresh: body.value.fresh === true }); + const { data, error } = await client.rpc('start_payment_for_gender', { + p_user_id: user.id, + p_fresh: body.value.fresh === true, + p_gender: gender, + }); if (error || !data) { console.error('/api/register/start-payment pass failed:', error); return NextResponse.json({ error: 'Could not start the payment. Please try again.' }, { status: 500 }); } + if (data.state === 'female_full') { + return NextResponse.json({ code: 'female_seats_full', error: FEMALE_SEATS_FULL_MESSAGE, field: 'gender' }, { status: 409 }); + } + if (data.state === 'closed') { return NextResponse.json({ code: 'registrations_closed', error: REGISTRATIONS_FULL_MESSAGE }, { status: 409 }); } diff --git a/src/app/api/seats/route.ts b/src/app/api/seats/route.ts index bd610eb..39981f8 100644 --- a/src/app/api/seats/route.ts +++ b/src/app/api/seats/route.ts @@ -10,12 +10,12 @@ export const runtime = 'nodejs'; const CACHE_TTL_MS = 5 * 1000; const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=5, s-maxage=5, stale-while-revalidate=10' }; const NO_STORE = { 'Cache-Control': 'no-store' }; -let cached: { body: { total: number; left: number; closed: boolean }; expires: number } | null = null; +let cached: { body: { total: number; left: number; closed: boolean; femaleFull: boolean }; expires: number } | null = null; /** * GET /api/seats - * The seat counter on the registration form: { total, left, closed } (supabase/schema.sql, sections 17 and 20). Only - * these, nothing about any registration. 503 while the counter isn't set up: the form then hides it. + * The seat counter on the registration form: { total, left, closed, femaleFull } (supabase/schema.sql, sections 17, 20 + * and 21). Only these, nothing about any registration. 503 while the counter isn't set up: the form then hides it. */ export async function GET() { try { @@ -26,17 +26,24 @@ export async function GET() { 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, closed_at') - .eq('id', 1) - .maybeSingle(); + const client = getSupabaseAdmin(); + const [{ data, error }, female] = await Promise.all([ + client.from('registration_counter').select('total_seats, seats_left, closed_at').eq('id', 1).maybeSingle(), + client.rpc('female_seats_status'), + ]); if (error || !data) { if (error) console.error('Seat counter query failed:', error); return NextResponse.json({ error: 'Seat counter unavailable' }, { status: 503, headers: NO_STORE }); } + // Display only: a failed lookup leaves the options open, and start-payment and the database still enforce the cap. + if (female.error) console.error('Female seats query failed:', female.error); - const body = { total: data.total_seats, left: data.seats_left, closed: data.closed_at !== null }; + const body = { + total: data.total_seats, + left: data.seats_left, + closed: data.closed_at !== null, + femaleFull: female.data?.full === true, + }; cached = { body, expires: Date.now() + CACHE_TTL_MS }; return NextResponse.json(body, { headers: CACHE_HEADERS }); } catch (err: unknown) { diff --git a/src/components/innovision/AuthOverlay.tsx b/src/components/innovision/AuthOverlay.tsx index 6b581e8..464ac26 100644 --- a/src/components/innovision/AuthOverlay.tsx +++ b/src/components/innovision/AuthOverlay.tsx @@ -2,14 +2,15 @@ // @ts-nocheck "use client"; /* eslint-disable @next/next/no-img-element -- decorative/user-uploaded images not suited for next/image */ -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useDialogFocus } from './useDialogFocus'; import type { V } from './types'; import { Logo } from './icons'; import EntryPassCard from './EntryPassCard'; -import SeatCounter from './SeatCounter'; +import SeatCounter, { useFemaleAccess, useSeatCount } from './SeatCounter'; import styles from './AuthOverlay.module.css'; import { isIterSoaCollege, isIterSoaEmail, ITER_SOA_ERROR_MESSAGE } from '@/lib/validation'; +import { FEMALE_SEATS_FULL_MESSAGE, genderChoices } from '@/lib/seats'; /* ── Payment QR lightbox state lives here, outside JSX ── */ @@ -72,6 +73,21 @@ export default function AuthOverlay({ v }: { v: V }) { const offer = v.payOffer; const payOpen = v.payCheck !== 'busy' && v.payCheck !== 'failed' && offer?.state === 'pay'; + // Only polled while the registration form is open. At the female seat cap, Female and Others are marked FULL unless the + // server says this student keeps them (or until it answers: the server has the last word either way). + const seats = useSeatCount(!!v.showSeats); + const femaleFull = !!seats?.femaleFull; + const femaleAccess = useFemaleAccess(femaleFull, v.user?.id, !!v.showSeats); + const genderCapped = femaleFull && femaleAccess !== true; + const genderFieldset = useRef(null); + // A disabled radio that was already chosen still counts as chosen, so clear it once it's known to be closed to them. + useEffect(() => { + if (!femaleFull || femaleAccess !== false) return; + genderFieldset.current?.querySelectorAll('input:disabled').forEach((input) => { + input.checked = false; + }); + }, [femaleFull, femaleAccess]); + return ( <>
@@ -109,7 +125,7 @@ export default function AuthOverlay({ v }: { v: V }) {

{v.authTitle}

{v.authSub}

- {v.showSeats ? : null} + {v.showSeats ? : null}
    {v.prog.map((p, pI) => ( @@ -160,17 +176,20 @@ export default function AuthOverlay({ v }: { v: V }) { {v.err.name ? ({v.err.name}) : null} -
    +
    GENDER - {v.genderOptions.map((g) => ( - - {v.err.gender ? ({v.err.gender}) : null} + {genderCapped ? ({FEMALE_SEATS_FULL_MESSAGE}) : null} + {/* Not the cap's message a second time: a stale form can get it from the server before its next refresh. */} + {v.err.gender && !(genderCapped && v.err.gender === FEMALE_SEATS_FULL_MESSAGE) ? ({v.err.gender}) : null}
    diff --git a/src/components/innovision/Innovision.tsx b/src/components/innovision/Innovision.tsx index 8e6b199..3c643e9 100644 --- a/src/components/innovision/Innovision.tsx +++ b/src/components/innovision/Innovision.tsx @@ -1748,9 +1748,15 @@ export class Innovision extends Component { if (this.state.payCheck === 'busy') return; if (REGISTRATION_PREVIEW_ENABLED && this.state.user?.id === 'dev-preview-user') return; this.setState({ payCheck: 'busy' }); - const pay = await startPayment(fresh); + const pay = await startPayment(this.reg?.gender ?? '', fresh); if (!this.alive) return; if (pay.offer) { this.setState({ payOffer: pay.offer, payCheck: 'idle' }); return; } + if (pay.field === 'gender') { + this.setState({ payOffer: null, payCheck: 'idle' }); + this.toStep(0); + this.setState({ err: { gender: pay.error } }); + return; + } if (/closed/i.test(pay.error ?? '')) { this.setState({ payOffer: null, payCheck: 'idle' }); this.toStep(0); @@ -2640,7 +2646,7 @@ export class Innovision extends Component { err.college = ITER_SOA_ERROR_MESSAGE; } if (!/^[6-9]\d{9}$/.test(phone)) err.phone = 'Use a 10-digit Indian mobile number.'; - if (!enrollment_no) err.enrollment_no = 'Enter your college roll / enrollment number.'; + if (!enrollment_no) err.enrollment_no = 'Enter your college roll / enrollment number or employee ID.'; 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).'; } @@ -2662,7 +2668,7 @@ export class Innovision extends Component { const preview = REGISTRATION_PREVIEW_ENABLED && user.id === 'dev-preview-user'; const started = preview ? Promise.resolve({ offer: { state: 'pay' as const, amount: PREVIEW_REGISTRATION_FEE, currentFee: PREVIEW_REGISTRATION_FEE } }) - : startPayment(); + : startPayment(gender); this.setState({ busyLbl: referred_by_code ? 'CHECKING CODE' : 'CHECKING' }); Promise.all([started, referred_by_code ? checkReferralCode(referred_by_code) : null]).then(([pay, res]) => { if (!this.alive) return; @@ -2671,7 +2677,7 @@ export class Innovision extends Component { 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 (res && 'valid' in res && !res.valid) { this.fail({ referred_by_code: res.error }); return; } - if (!pay.offer) { this.fail({ form: pay.error }); return; } + if (!pay.offer) { this.fail(pay.field === 'gender' ? { gender: pay.error } : { form: pay.error }); return; } this.setState({ payOffer: pay.offer, payCheck: 'idle' }, () => this.toStep(1)); }); } else if (s.step === 1) { diff --git a/src/components/innovision/ProfileOverlay.tsx b/src/components/innovision/ProfileOverlay.tsx index 9ab4b39..422974e 100644 --- a/src/components/innovision/ProfileOverlay.tsx +++ b/src/components/innovision/ProfileOverlay.tsx @@ -352,7 +352,7 @@ export default function ProfileOverlay({ {registration?.enrollment_no && (
    - ENROLLMENT / ROLL NO + ENROLLMENT / ROLL NO / EMPLOYEE ID
    {registration.enrollment_no} diff --git a/src/components/innovision/SeatCounter.tsx b/src/components/innovision/SeatCounter.tsx index e6cc284..1535922 100644 --- a/src/components/innovision/SeatCounter.tsx +++ b/src/components/innovision/SeatCounter.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; import { fetchSeatCount, formatSeats, type SeatCount } from '@/lib/seats'; +import { fetchFemaleAccess } from '@/lib/supabase'; const POLL_MS = 30_000; const FEW_LEFT = 100; @@ -8,13 +9,14 @@ const GOLD = 'oklch(0.78 0.075 85)'; const WARN = 'oklch(0.76 0.14 35)'; /** - * "1,042 / 1,300 seats left" on the registration form (GET /api/seats), refreshed every 30 seconds while the tab is - * visible, so students watch it go down. Renders nothing until the count is known, or when it can't be read. + * The public count (GET /api/seats), refreshed every 30 seconds while `enabled` and the tab is visible. Null until the + * count is known, or when it can't be read. */ -export default function SeatCounter() { +export function useSeatCount(enabled: boolean): SeatCount | null { const [seats, setSeats] = useState(null); useEffect(() => { + if (!enabled) return; let cancelled = false; let busy = false; const refresh = async () => { @@ -35,8 +37,35 @@ export default function SeatCounter() { window.removeEventListener('focus', refresh); document.removeEventListener('visibilitychange', refresh); }; - }, []); + }, [enabled]); + return seats; +} + +/** + * While the female seat cap is reached: whether this student keeps Female and Others, because they may already have paid + * (GET /api/register/female-access). True, false, or null until known or when it can't be read. Asked again each time the + * form opens, so a payment staff have just allowed shows up. + */ +export function useFemaleAccess(femaleFull: boolean, userId: string | undefined, enabled: boolean): boolean | null { + const [access, setAccess] = useState(null); + + useEffect(() => { + if (!femaleFull || !userId || !enabled) return; + let cancelled = false; + void fetchFemaleAccess().then((next) => { + if (!cancelled) setAccess(next); + }); + return () => { + cancelled = true; + }; + }, [femaleFull, userId, enabled]); + + return access; +} + +/** "1,042 / 1,300 seats left" on the registration form, so students watch it go down. Nothing until the count is known. */ +export default function SeatCounter({ seats }: { seats: SeatCount | null }) { if (!seats) return null; const full = seats.closed; const few = !full && seats.left <= FEW_LEFT; diff --git a/src/components/innovision/admin/SeatCounterCard.tsx b/src/components/innovision/admin/SeatCounterCard.tsx index 1446cc4..c1e1e32 100644 --- a/src/components/innovision/admin/SeatCounterCard.tsx +++ b/src/components/innovision/admin/SeatCounterCard.tsx @@ -29,6 +29,8 @@ interface Counter { closedAt: string | null; updatedAt: string | null; updatedBy: string | null; + /** The female seat cap; null if it couldn't be read. `full` also counts students on the payment step. */ + female: { cap: number; registered: number; full: boolean } | null; } const GOLD = 'oklch(0.8 0.12 85)'; @@ -197,6 +199,11 @@ export default function SeatCounterCard({ getAuthToken, canEdit, canAllowPayment PHASE {counter.phase} · ₹{formatSeats(counter.fee)} · {closed ? 'CLOSED' : 'OPEN'} + {counter.female ? ( + + FEMALE {formatSeats(counter.female.registered)} / {formatSeats(counter.female.cap)} · {counter.female.full ? (counter.female.registered < counter.female.cap ? 'FULL (SEATS HELD WHILE PAYING)' : 'FULL') : 'OPEN'} + + ) : null} )}
    diff --git a/src/lib/seats.ts b/src/lib/seats.ts index 98cd486..926987e 100644 --- a/src/lib/seats.ts +++ b/src/lib/seats.ts @@ -16,6 +16,8 @@ export interface SeatCount { total: number; left: number; closed: boolean; + /** The female seat cap is reached (supabase/schema.sql, section 21): Female and Others show as FULL on the form. */ + femaleFull: boolean; } /** @@ -24,14 +26,28 @@ export interface SeatCount { */ export const REGISTRATIONS_FULL_MESSAGE = "Registrations are closed. Already paid? Don't pay again: contact us with your UTR."; +/** Under the Gender options at the cap, and the server's refusal. The same rule as above about what it may not say. */ +export const FEMALE_SEATS_FULL_MESSAGE = + "Female and Others seats are full right now. A seat may open up if a registration is not approved, so check back later. Already paid? Don't pay again: contact us with your UTR."; + +/** The refusal from trg_guard_female_seats (supabase/schema.sql, section 21). */ +export function isFemaleSeatsFullError(err: { message?: string } | null | undefined): boolean { + return /Female and Others seats are full/i.test(err?.message ?? ''); +} + +/** The Gender options as the form shows them: at the cap, Female and Others are marked full. */ +export function genderChoices(options: readonly T[], capped: boolean): (T & { full: boolean })[] { + return options.map((o) => ({ ...o, full: capped && (o.value === 'female' || o.value === 'others') })); +} + 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, closed }), or null if it isn't a valid one. */ +/** The counter from a JSON body ({ total, left, closed, femaleFull }), 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, closed } = value as Record; + const { total, left, closed, femaleFull } = value as Record; if (!isSeatNumber(total) || !isSeatNumber(left) || left > total) return null; - return { total, left, closed: closed === true || left === 0 }; + return { total, left, closed: closed === true || left === 0, femaleFull: femaleFull === true }; } /** Why an admin's new numbers can't be saved, or null if they can. */ diff --git a/src/lib/security.ts b/src/lib/security.ts index 98edbbf..0698d8a 100644 --- a/src/lib/security.ts +++ b/src/lib/security.ts @@ -400,10 +400,10 @@ export function validateRegistrationInput( const enrollment_no = enrollment ? enrollment.trim() : ''; if (!enrollment_no) { - return { ok: false, error: 'Enter your college roll / enrollment number.', field: 'enrollment_no' }; + return { ok: false, error: 'Enter your college roll / enrollment number or employee ID.', field: 'enrollment_no' }; } if (enrollment_no.length > 50 || (enrollment_no && !/^[A-Za-z0-9/_ -]+$/.test(enrollment_no))) { - return { ok: false, error: 'Enrollment number contains invalid characters.', field: 'enrollment_no' }; + return { ok: false, error: 'Roll / enrollment number or employee ID contains invalid characters.', field: 'enrollment_no' }; } // Optional: a registrant's or a campus ambassador's code. Same normalization as the form: case, spaces and dashes diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 21e518d..aeea2ce 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -662,9 +662,10 @@ export async function fetchUserRegistrationServer(userId: string, email?: string /** * Asks the server what this student may do on the payment step (POST /api/register/start-payment). Without an `offer` - * the form must not show payment details: `error` says why, and it applies to a failed request too. + * the form must not show payment details: `error` says why, and it applies to a failed request too. `field` is the form + * field the server blamed, if any. */ -export async function startPayment(fresh = false): Promise<{ offer?: PaymentOffer; error?: string }> { +export async function startPayment(gender: string, fresh = false): Promise<{ offer?: PaymentOffer; error?: string; field?: string }> { const failed = 'Could not check registrations. Please check your connection and try again.'; try { const { data: { session } } = await getSupabase().auth.getSession(); @@ -672,10 +673,15 @@ export async function startPayment(fresh = false): Promise<{ offer?: PaymentOffe const res = await fetch('/api/register/start-payment', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, - body: JSON.stringify({ fresh }), + body: JSON.stringify({ gender, fresh }), }); const result = await res.json().catch(() => ({})); - if (!res.ok) return { error: httpErrorMessage(res) || result.error || failed }; + if (!res.ok) { + return { + error: httpErrorMessage(res) || result.error || failed, + ...(typeof result.field === 'string' ? { field: result.field } : {}), + }; + } const offer = parsePaymentOffer(result); return offer ? { offer } : { error: failed }; } catch { @@ -683,6 +689,23 @@ export async function startPayment(fresh = false): Promise<{ offer?: PaymentOffe } } +/** + * Whether this student may still choose Female or Others at the cap (GET /api/register/female-access), or null when it + * couldn't be read. For display only: the form then keeps them marked full, and the server decides on continuing. + */ +export async function fetchFemaleAccess(): Promise { + try { + const { data: { session } } = await getSupabase().auth.getSession(); + const token = session?.access_token; + const res = await fetch('/api/register/female-access', { headers: token ? { Authorization: `Bearer ${token}` } : {} }); + if (!res.ok) return null; + const result = await res.json(); + return result.keepsAccess === true; + } catch { + return null; + } +} + /** * Create a new registration * Passes through /api/register which is protected by server auth middleware diff --git a/supabase/manual/female_seat_cap.sql b/supabase/manual/female_seat_cap.sql new file mode 100644 index 0000000..4c1eb75 --- /dev/null +++ b/supabase/manual/female_seat_cap.sql @@ -0,0 +1,162 @@ +-- ============================================================================== +-- MIGRATION (run once in the Supabase SQL editor): female seat cap. +-- +-- Why: female registrations are capped at 350. At the cap the form shows Female and Others as FULL and the server refuses +-- them before any payment details are shown. A UPI payment can't be refunded, so a student who may already have paid +-- keeps access (see female_seat_kept below), which can take the count slightly over the cap in rare cases. +-- This adds section 21 of supabase/schema.sql. Existing registrations are not changed. +-- +-- Run this BEFORE deploying the code that uses it: the new form's payment step fails without it. The code deployed now +-- keeps working once this has run. +-- Kept out of supabase/migrations/ on purpose so the Supabase CLI never applies it automatically. Safe to re-run: it never +-- changes the cap once set. +-- +-- To change the cap: UPDATE public.registration_counter SET female_cap = 400 WHERE id = 1; +-- To see the count: SELECT public.female_seats_status(); +-- ============================================================================== + +-- 21. FEMALE SEAT CAP +-- Female registrations that are pending or confirmed, in any phase, count towards registration_counter.female_cap. +-- Others doesn't count towards it, but is closed along with Female at the cap. Separate from the seat counter: Male +-- registrations are never affected. A rejection frees the seat. Only the server reads and writes these. +ALTER TABLE public.registration_counter ADD COLUMN IF NOT EXISTS female_cap INTEGER NOT NULL DEFAULT 350; + +-- The gender a payment pass was issued for. Set only while Female and Others were open to this student: 'female' holds a +-- seat until pay_by, and 'female' or 'others' lets the student submit at the cap until submit_by. +ALTER TABLE public.payment_passes ADD COLUMN IF NOT EXISTS gender TEXT; + +DO $$ +BEGIN + ALTER TABLE public.registration_counter DROP CONSTRAINT IF EXISTS registration_counter_female_cap_check; + ALTER TABLE public.registration_counter ADD CONSTRAINT registration_counter_female_cap_check + CHECK (female_cap BETWEEN 0 AND 10000); + ALTER TABLE public.payment_passes DROP CONSTRAINT IF EXISTS payment_passes_gender_check; + ALTER TABLE public.payment_passes ADD CONSTRAINT payment_passes_gender_check + CHECK (gender IS NULL OR gender IN ('male', 'female', 'others')); +END $$; + +-- Seats taken: female registrations, plus female students on the payment step (without this hold, everyone paying at 349 +-- would get in). p_user_id leaves out that student's own hold. +CREATE OR REPLACE FUNCTION public.female_seats_taken(p_user_id UUID DEFAULT NULL) +RETURNS INTEGER AS $$ + SELECT (SELECT count(*) FROM public.registrations WHERE gender = 'female' AND status IN ('pending', 'confirmed'))::INT + + (SELECT count(*) FROM public.payment_passes + WHERE gender = 'female' AND used_at IS NULL AND pay_by > now() AND user_id IS DISTINCT FROM p_user_id)::INT; +$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public; + +-- Who may still choose Female or Others at the cap, because they may already have paid: a live pass issued for one of +-- them before the cap, a payment staff allowed, or a registration rejected for a reason that doesn't question the payment. +CREATE OR REPLACE FUNCTION public.female_seat_kept(p_user_id UUID) +RETURNS BOOLEAN AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.payment_passes + WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now() + AND (issued_by IS NOT NULL OR gender IN ('female', 'others')) + ) OR COALESCE(( + SELECT r.status = 'rejected' AND r.gender IN ('female', 'others') + AND r.rejection_reason IN ('unclear_screenshot', 'utr_mismatch') AND r.amount > 0 + FROM public.registrations r WHERE r.user_id = p_user_id ORDER BY r.created_at DESC LIMIT 1 + ), false); +$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public; + +-- GET /api/seats, GET /api/admin/seats and GET /api/register/female-access. +CREATE OR REPLACE FUNCTION public.female_seats_status(p_user_id UUID DEFAULT NULL) +RETURNS JSONB AS $$ + SELECT jsonb_build_object( + 'cap', c.female_cap, + 'registered', (SELECT count(*) FROM public.registrations WHERE gender = 'female' AND status IN ('pending', 'confirmed')), + 'full', public.female_seats_taken() >= c.female_cap, + 'keeps_access', p_user_id IS NOT NULL AND public.female_seat_kept(p_user_id)) + FROM public.registration_counter c WHERE c.id = 1; +$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public; + +-- POST /api/register/start-payment: issue_payment_pass (section 20) with the cap in front of it. Adds one answer: +-- state 'female_full': no pass and no payment details for this gender. +-- FOR UPDATE, for Female and Others only, makes students reaching for the last seat take turns. +CREATE OR REPLACE FUNCTION public.start_payment_for_gender(p_user_id UUID, p_fresh BOOLEAN, p_gender TEXT) +RETURNS JSONB AS $$ +DECLARE + c public.registration_counter%ROWTYPE; + p public.payment_passes%ROWTYPE; + r public.registrations%ROWTYPE; + v_capped BOOLEAN := COALESCE(p_gender IN ('female', 'others'), false); + v_result JSONB; +BEGIN + IF v_capped THEN + SELECT * INTO c FROM public.registration_counter WHERE id = 1 FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'registration_counter has no row'; + END IF; + END IF; + + SELECT * INTO p FROM public.payment_passes + WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now() + ORDER BY created_at DESC LIMIT 1; + + IF v_capped AND public.female_seats_taken(p_user_id) >= c.female_cap THEN + IF public.female_seat_kept(p_user_id) THEN + -- "I haven't paid" while the seats are full: nothing to honour. + IF p_fresh AND c.closed_at IS NULL THEN + RETURN jsonb_build_object('state', 'female_full'); + END IF; + -- May already have paid, so they may continue with their proof, but aren't shown a way to pay again. + IF p.id IS NOT NULL AND p.pay_by <= now() AND p.issued_by IS NULL THEN + RETURN jsonb_build_object('state', 'already_paid', 'amount', p.amount, 'phase', p.phase, 'pay_seconds', 0, 'current_fee', NULL); + END IF; + IF p.id IS NULL THEN + SELECT * INTO r FROM public.registrations WHERE user_id = p_user_id ORDER BY created_at DESC LIMIT 1; + INSERT INTO public.payment_passes (user_id, phase, amount, pay_by, submit_by, gender) + VALUES (p_user_id, r.phase, r.amount::INT, now(), now() + interval '24 hours', p_gender) + RETURNING * INTO p; + RETURN jsonb_build_object('state', 'already_paid', 'amount', p.amount, 'phase', p.phase, 'pay_seconds', 0, 'current_fee', NULL); + END IF; + -- With no phase open and no pass, the closed answer below is the one that matters. + ELSIF c.closed_at IS NULL OR p.id IS NOT NULL THEN + RETURN jsonb_build_object('state', 'female_full'); + END IF; + END IF; + + v_result := public.issue_payment_pass(p_user_id, p_fresh); + + IF v_capped THEN + UPDATE public.payment_passes SET gender = p_gender + WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now(); + -- Another gender chosen on a renewed pass: a student who paid and picked wrongly must still be able to go back. + ELSIF NOT p_fresh AND p.gender IN ('female', 'others') THEN + UPDATE public.payment_passes SET gender = p.gender + WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now() AND gender IS NULL; + END IF; + RETURN v_result; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- The last word, whoever inserts. /api/register looks for this message. +CREATE OR REPLACE FUNCTION public.guard_female_seats() RETURNS TRIGGER AS $$ +DECLARE + v_cap INTEGER; +BEGIN + IF NEW.user_id IS NULL OR NEW.gender IS NULL OR NEW.gender NOT IN ('female', 'others') + OR NEW.status NOT IN ('pending', 'confirmed') THEN + RETURN NEW; + END IF; + SELECT female_cap INTO v_cap FROM public.registration_counter WHERE id = 1 FOR UPDATE; + IF public.female_seats_taken(NEW.user_id) >= v_cap AND NOT public.female_seat_kept(NEW.user_id) THEN + RAISE EXCEPTION 'Female and Others seats are full' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS trg_guard_female_seats ON public.registrations; +CREATE TRIGGER trg_guard_female_seats + BEFORE INSERT ON public.registrations + FOR EACH ROW EXECUTE FUNCTION public.guard_female_seats(); + +-- Only the server may call these; the helpers and the trigger need no grant. +REVOKE EXECUTE ON FUNCTION public.guard_female_seats() FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.female_seats_taken(UUID) FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.female_seat_kept(UUID) FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.female_seats_status(UUID) FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.start_payment_for_gender(UUID, BOOLEAN, TEXT) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.female_seats_status(UUID) TO service_role; +GRANT EXECUTE ON FUNCTION public.start_payment_for_gender(UUID, BOOLEAN, TEXT) TO service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 974f0f8..4692c45 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -2225,3 +2225,152 @@ REVOKE EXECUTE ON FUNCTION public.start_registration_phase(INTEGER, INTEGER, INT GRANT EXECUTE ON FUNCTION public.issue_payment_pass(UUID, BOOLEAN) TO service_role; GRANT EXECUTE ON FUNCTION public.resolve_registration_payment(UUID) TO service_role; GRANT EXECUTE ON FUNCTION public.start_registration_phase(INTEGER, INTEGER, INTEGER, UUID) TO service_role; + + +-- ============================================================================== +-- 21. FEMALE SEAT CAP +-- ============================================================================== +-- Female registrations that are pending or confirmed, in any phase, count towards registration_counter.female_cap. +-- Others doesn't count towards it, but is closed along with Female at the cap. Separate from the seat counter: Male +-- registrations are never affected. A rejection frees the seat. Only the server reads and writes these. +ALTER TABLE public.registration_counter ADD COLUMN IF NOT EXISTS female_cap INTEGER NOT NULL DEFAULT 350; + +-- The gender a payment pass was issued for. Set only while Female and Others were open to this student: 'female' holds a +-- seat until pay_by, and 'female' or 'others' lets the student submit at the cap until submit_by. +ALTER TABLE public.payment_passes ADD COLUMN IF NOT EXISTS gender TEXT; + +DO $$ +BEGIN + ALTER TABLE public.registration_counter DROP CONSTRAINT IF EXISTS registration_counter_female_cap_check; + ALTER TABLE public.registration_counter ADD CONSTRAINT registration_counter_female_cap_check + CHECK (female_cap BETWEEN 0 AND 10000); + ALTER TABLE public.payment_passes DROP CONSTRAINT IF EXISTS payment_passes_gender_check; + ALTER TABLE public.payment_passes ADD CONSTRAINT payment_passes_gender_check + CHECK (gender IS NULL OR gender IN ('male', 'female', 'others')); +END $$; + +-- Seats taken: female registrations, plus female students on the payment step (without this hold, everyone paying at 349 +-- would get in). p_user_id leaves out that student's own hold. +CREATE OR REPLACE FUNCTION public.female_seats_taken(p_user_id UUID DEFAULT NULL) +RETURNS INTEGER AS $$ + SELECT (SELECT count(*) FROM public.registrations WHERE gender = 'female' AND status IN ('pending', 'confirmed'))::INT + + (SELECT count(*) FROM public.payment_passes + WHERE gender = 'female' AND used_at IS NULL AND pay_by > now() AND user_id IS DISTINCT FROM p_user_id)::INT; +$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public; + +-- Who may still choose Female or Others at the cap, because they may already have paid: a live pass issued for one of +-- them before the cap, a payment staff allowed, or a registration rejected for a reason that doesn't question the payment. +CREATE OR REPLACE FUNCTION public.female_seat_kept(p_user_id UUID) +RETURNS BOOLEAN AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.payment_passes + WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now() + AND (issued_by IS NOT NULL OR gender IN ('female', 'others')) + ) OR COALESCE(( + SELECT r.status = 'rejected' AND r.gender IN ('female', 'others') + AND r.rejection_reason IN ('unclear_screenshot', 'utr_mismatch') AND r.amount > 0 + FROM public.registrations r WHERE r.user_id = p_user_id ORDER BY r.created_at DESC LIMIT 1 + ), false); +$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public; + +-- GET /api/seats, GET /api/admin/seats and GET /api/register/female-access. +CREATE OR REPLACE FUNCTION public.female_seats_status(p_user_id UUID DEFAULT NULL) +RETURNS JSONB AS $$ + SELECT jsonb_build_object( + 'cap', c.female_cap, + 'registered', (SELECT count(*) FROM public.registrations WHERE gender = 'female' AND status IN ('pending', 'confirmed')), + 'full', public.female_seats_taken() >= c.female_cap, + 'keeps_access', p_user_id IS NOT NULL AND public.female_seat_kept(p_user_id)) + FROM public.registration_counter c WHERE c.id = 1; +$$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public; + +-- POST /api/register/start-payment: issue_payment_pass (section 20) with the cap in front of it. Adds one answer: +-- state 'female_full': no pass and no payment details for this gender. +-- FOR UPDATE, for Female and Others only, makes students reaching for the last seat take turns. +CREATE OR REPLACE FUNCTION public.start_payment_for_gender(p_user_id UUID, p_fresh BOOLEAN, p_gender TEXT) +RETURNS JSONB AS $$ +DECLARE + c public.registration_counter%ROWTYPE; + p public.payment_passes%ROWTYPE; + r public.registrations%ROWTYPE; + v_capped BOOLEAN := COALESCE(p_gender IN ('female', 'others'), false); + v_result JSONB; +BEGIN + IF v_capped THEN + SELECT * INTO c FROM public.registration_counter WHERE id = 1 FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'registration_counter has no row'; + END IF; + END IF; + + SELECT * INTO p FROM public.payment_passes + WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now() + ORDER BY created_at DESC LIMIT 1; + + IF v_capped AND public.female_seats_taken(p_user_id) >= c.female_cap THEN + IF public.female_seat_kept(p_user_id) THEN + -- "I haven't paid" while the seats are full: nothing to honour. + IF p_fresh AND c.closed_at IS NULL THEN + RETURN jsonb_build_object('state', 'female_full'); + END IF; + -- May already have paid, so they may continue with their proof, but aren't shown a way to pay again. + IF p.id IS NOT NULL AND p.pay_by <= now() AND p.issued_by IS NULL THEN + RETURN jsonb_build_object('state', 'already_paid', 'amount', p.amount, 'phase', p.phase, 'pay_seconds', 0, 'current_fee', NULL); + END IF; + IF p.id IS NULL THEN + SELECT * INTO r FROM public.registrations WHERE user_id = p_user_id ORDER BY created_at DESC LIMIT 1; + INSERT INTO public.payment_passes (user_id, phase, amount, pay_by, submit_by, gender) + VALUES (p_user_id, r.phase, r.amount::INT, now(), now() + interval '24 hours', p_gender) + RETURNING * INTO p; + RETURN jsonb_build_object('state', 'already_paid', 'amount', p.amount, 'phase', p.phase, 'pay_seconds', 0, 'current_fee', NULL); + END IF; + -- With no phase open and no pass, the closed answer below is the one that matters. + ELSIF c.closed_at IS NULL OR p.id IS NOT NULL THEN + RETURN jsonb_build_object('state', 'female_full'); + END IF; + END IF; + + v_result := public.issue_payment_pass(p_user_id, p_fresh); + + IF v_capped THEN + UPDATE public.payment_passes SET gender = p_gender + WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now(); + -- Another gender chosen on a renewed pass: a student who paid and picked wrongly must still be able to go back. + ELSIF NOT p_fresh AND p.gender IN ('female', 'others') THEN + UPDATE public.payment_passes SET gender = p.gender + WHERE user_id = p_user_id AND used_at IS NULL AND submit_by > now() AND gender IS NULL; + END IF; + RETURN v_result; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- The last word, whoever inserts. /api/register looks for this message. +CREATE OR REPLACE FUNCTION public.guard_female_seats() RETURNS TRIGGER AS $$ +DECLARE + v_cap INTEGER; +BEGIN + IF NEW.user_id IS NULL OR NEW.gender IS NULL OR NEW.gender NOT IN ('female', 'others') + OR NEW.status NOT IN ('pending', 'confirmed') THEN + RETURN NEW; + END IF; + SELECT female_cap INTO v_cap FROM public.registration_counter WHERE id = 1 FOR UPDATE; + IF public.female_seats_taken(NEW.user_id) >= v_cap AND NOT public.female_seat_kept(NEW.user_id) THEN + RAISE EXCEPTION 'Female and Others seats are full' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS trg_guard_female_seats ON public.registrations; +CREATE TRIGGER trg_guard_female_seats + BEFORE INSERT ON public.registrations + FOR EACH ROW EXECUTE FUNCTION public.guard_female_seats(); + +-- Only the server may call these; the helpers and the trigger need no grant. +REVOKE EXECUTE ON FUNCTION public.guard_female_seats() FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.female_seats_taken(UUID) FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.female_seat_kept(UUID) FROM PUBLIC, anon, authenticated, service_role; +REVOKE EXECUTE ON FUNCTION public.female_seats_status(UUID) FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.start_payment_for_gender(UUID, BOOLEAN, TEXT) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.female_seats_status(UUID) TO service_role; +GRANT EXECUTE ON FUNCTION public.start_payment_for_gender(UUID, BOOLEAN, TEXT) TO service_role;