Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ next-env.d.ts

# bank statements: never commit
/STATEMENT*.xlsx

# registration exports (students' personal data): never commit
/innovision_registrations_*.csv
4 changes: 4 additions & 0 deletions src/app/api/admin/seats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
},
};
}
Expand Down
42 changes: 42 additions & 0 deletions src/app/api/register/female-access/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
6 changes: 5 additions & 1 deletion src/app/api/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down
25 changes: 20 additions & 5 deletions src/app/api/register/start-payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@ 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';

// One call each time a student leaves the details step, so this only stops abuse.
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.
*/
Expand All @@ -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([
Expand 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 });
}
Expand Down
25 changes: 16 additions & 9 deletions src/app/api/seats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
39 changes: 29 additions & 10 deletions src/components/innovision/AuthOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ── */

Expand Down Expand Up @@ -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<HTMLFieldSetElement>(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<HTMLInputElement>('input:disabled').forEach((input) => {
input.checked = false;
});
}, [femaleFull, femaleAccess]);

return (
<>
<div data-auth-root="" aria-hidden={v.authHidden} style={{ position: "fixed", inset: "0", zIndex: "62", visibility: "hidden", pointerEvents: "none" }}>
Expand Down Expand Up @@ -109,7 +125,7 @@ export default function AuthOverlay({ v }: { v: V }) {
<div data-a-in="">
<h2 data-m-in="" style={{ margin: "0", fontFamily: "var(--font-display)", fontWeight: "400", fontSize: "var(--auth-title-size)", lineHeight: ".98", letterSpacing: ".01em", textWrap: "balance" }}>{v.authTitle}</h2>
<p data-m-in="" style={{ margin: "18px 0 0", maxWidth: "34ch", fontSize: "clamp(15px,1.2vw,18px)", lineHeight: "1.55", color: "rgba(236,232,223,.78)", textWrap: "pretty" }}>{v.authSub}</p>
{v.showSeats ? <SeatCounter /> : null}
{v.showSeats ? <SeatCounter seats={seats} /> : null}
</div>
<ol data-a-in="" aria-label="Registration progress" style={{ display: "none", flexDirection: "column", margin: "0", padding: "0", listStyle: "none" }}>
{v.prog.map((p, pI) => (
Expand Down Expand Up @@ -160,17 +176,20 @@ export default function AuthOverlay({ v }: { v: V }) {
<input name="name" defaultValue={v.regVals.name} autoComplete="name" placeholder="As on your college ID" aria-invalid={v.inv.name} style={{ height: "54px", padding: "0 16px", borderRadius: "0", background: "rgba(236,232,223,.04)", fontSize: "16px", color: "#ECE8DF", outline: "none", transition: "border-color .3s,background-color .3s", border: `1.5px solid ${v.bc.name}` }} style-focus="border-color:oklch(0.78 0.075 85);background:rgba(236,232,223,.08)" />
{v.err.name ? (<span role="alert" style={{ fontSize: "14px", lineHeight: "1.4", color: "oklch(0.76 0.14 35)" }}>{v.err.name}</span>) : null}
</label>
<fieldset data-s-in="" aria-invalid={v.inv.gender} style={{ display: "flex", flexDirection: "column", gap: "8px", margin: "0", padding: "0", border: "0", minWidth: "0" }}>
<fieldset ref={genderFieldset} data-s-in="" aria-invalid={v.inv.gender} style={{ display: "flex", flexDirection: "column", gap: "8px", margin: "0", padding: "0", border: "0", minWidth: "0" }}>
<legend style={{ padding: "0", marginBottom: "8px", fontSize: "12px", fontWeight: "700", letterSpacing: ".16em", color: "rgba(236,232,223,.86)" }}>GENDER</legend>
<span role="radiogroup" aria-label="Gender" style={{ display: "grid", gridTemplateColumns: "repeat(3,minmax(0,1fr))", gap: "8px" }}>
{v.genderOptions.map((g) => (
<label key={g.value} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "10px", minHeight: "54px", padding: "0 12px", border: `1.5px solid ${v.bc.gender}`, background: "rgba(236,232,223,.04)", fontSize: "15px", fontWeight: "500", color: "#ECE8DF", cursor: "pointer", transition: "border-color .3s,background-color .3s" }} style-hover="border-color:oklch(0.78 0.075 85)">
<input type="radio" name="gender" value={g.value} defaultChecked={v.regVals.gender === g.value} style={{ width: "16px", height: "16px", margin: "0", accentColor: "oklch(0.78 0.075 85)", cursor: "pointer" }} />
{genderChoices(v.genderOptions, genderCapped).map((g) => (
<label key={g.value} style={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "center", gap: "4px 10px", minHeight: "54px", padding: "6px 12px", border: `1.5px solid ${g.full ? "rgba(236,232,223,.16)" : v.bc.gender}`, background: g.full ? "transparent" : "rgba(236,232,223,.04)", fontSize: "15px", fontWeight: "500", color: g.full ? "rgba(236,232,223,.5)" : "#ECE8DF", cursor: g.full ? "not-allowed" : "pointer", transition: "border-color .3s,background-color .3s" }} style-hover={g.full ? undefined : "border-color:oklch(0.78 0.075 85)"}>
<input type="radio" name="gender" value={g.value} disabled={g.full} defaultChecked={!g.full && v.regVals.gender === g.value} style={{ width: "16px", height: "16px", margin: "0", accentColor: "oklch(0.78 0.075 85)", cursor: g.full ? "not-allowed" : "pointer" }} />
{g.label}
{g.full ? <span style={{ fontSize: "11px", fontWeight: "700", letterSpacing: ".12em", color: "oklch(0.76 0.14 35)" }}>FULL</span> : null}
</label>
))}
</span>
{v.err.gender ? (<span role="alert" style={{ fontSize: "14px", lineHeight: "1.4", color: "oklch(0.76 0.14 35)" }}>{v.err.gender}</span>) : null}
{genderCapped ? (<span role="status" style={{ fontSize: "14px", lineHeight: "1.45", color: "rgba(236,232,223,.86)" }}>{FEMALE_SEATS_FULL_MESSAGE}</span>) : 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) ? (<span role="alert" style={{ fontSize: "14px", lineHeight: "1.4", color: "oklch(0.76 0.14 35)" }}>{v.err.gender}</span>) : null}
</fieldset>
<label data-s-in="" style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<span style={{ fontSize: "12px", fontWeight: "700", letterSpacing: ".16em", color: "rgba(236,232,223,.86)" }}>COLLEGE NAME</span>
Expand Down Expand Up @@ -211,9 +230,9 @@ export default function AuthOverlay({ v }: { v: V }) {
</label>
<label data-s-in="" style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<span style={{ fontSize: "12px", fontWeight: "700", letterSpacing: ".16em", color: "rgba(236,232,223,.86)" }}>
COLLEGE ROLL / ENROLLMENT NUMBER
COLLEGE ROLL / ENROLLMENT NUMBER / EMPLOYEE ID
</span>
<input name="enrollment_no" defaultValue={v.regVals.enrollment_no} autoComplete="off" required placeholder="Your college roll / enrollment number" aria-invalid={v.inv.enrollment_no} style={{ height: "54px", padding: "0 16px", borderRadius: "0", background: "rgba(236,232,223,.04)", fontSize: "16px", color: "#ECE8DF", outline: "none", transition: "border-color .3s,background-color .3s", border: `1.5px solid ${v.bc.enrollment_no}` }} style-focus="border-color:oklch(0.78 0.075 85);background:rgba(236,232,223,.08)" />
<input name="enrollment_no" defaultValue={v.regVals.enrollment_no} autoComplete="off" required placeholder="Your roll / enrollment number or employee ID"aria-invalid={v.inv.enrollment_no} style={{ height: "54px", padding: "0 16px", borderRadius: "0", background: "rgba(236,232,223,.04)", fontSize: "16px", color: "#ECE8DF", outline: "none", transition: "border-color .3s,background-color .3s", border: `1.5px solid ${v.bc.enrollment_no}` }} style-focus="border-color:oklch(0.78 0.075 85);background:rgba(236,232,223,.08)" />
{v.err.enrollment_no ? (<span role="alert" style={{ fontSize: "14px", lineHeight: "1.4", color: "oklch(0.76 0.14 35)" }}>{v.err.enrollment_no}</span>) : null}
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
Expand Down
Loading
Loading