Skip to content

Commit c012a79

Browse files
authored
Merge pull request #77 from Krish-2118/development
female reg counter
2 parents c21c705 + 6a8fc55 commit c012a79

16 files changed

Lines changed: 529 additions & 43 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,6 @@ next-env.d.ts
5959

6060
# bank statements: never commit
6161
/STATEMENT*.xlsx
62+
63+
# registration exports (students' personal data): never commit
64+
/innovision_registrations_*.csv

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ async function readCounter(supabase: SupabaseClient) {
2727
.maybeSingle();
2828
updatedBy = profile?.full_name || profile?.email || null;
2929
}
30+
// The card just leaves out the female line if this fails.
31+
const female = await supabase.rpc('female_seats_status');
32+
if (female.error) console.error('/api/admin/seats female seats read failed:', female.error);
3033
return {
3134
error: null,
3235
counter: {
@@ -37,6 +40,7 @@ async function readCounter(supabase: SupabaseClient) {
3740
closedAt: data.closed_at,
3841
updatedAt: data.updated_at,
3942
updatedBy,
43+
female: female.data ? { cap: female.data.cap, registered: female.data.registered, full: female.data.full } : null,
4044
},
4145
};
4246
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getAuthenticatedUser } from '@/lib/auth-server';
3+
import { getSupabaseAdmin } from '@/lib/supabase';
4+
import { createRateLimiter } from '@/lib/security';
5+
6+
export const runtime = 'nodejs';
7+
8+
// The form asks once each time it opens while the cap is reached, so this only stops abuse.
9+
const accessLimiter = createRateLimiter({ limit: 60, windowMs: 10 * 60 * 1000 });
10+
11+
/**
12+
* GET /api/register/female-access
13+
* Whether this student may still choose Female or Others at the cap, because they may already have paid
14+
* (female_seat_kept, supabase/schema.sql section 21): { keepsAccess }. Display only: start-payment and the database
15+
* make the same check themselves.
16+
*/
17+
export async function GET(req: NextRequest) {
18+
try {
19+
const auth = await getAuthenticatedUser(req);
20+
if (!auth || !auth.user.email) {
21+
return NextResponse.json({ error: 'Please log in to continue.' }, { status: 401 });
22+
}
23+
const { user, token } = auth;
24+
25+
if (!accessLimiter.hit(user.id)) {
26+
return NextResponse.json(
27+
{ error: 'Too many attempts. Please wait a few minutes and try again.' },
28+
{ status: 429, headers: { 'Retry-After': String(accessLimiter.retryAfterSeconds(user.id)) } }
29+
);
30+
}
31+
32+
const { data, error } = await getSupabaseAdmin(token).rpc('female_seats_status', { p_user_id: user.id });
33+
if (error || !data) {
34+
console.error('/api/register/female-access failed:', error);
35+
return NextResponse.json({ error: 'Could not check your registration.' }, { status: 500 });
36+
}
37+
return NextResponse.json({ keepsAccess: data.keeps_access === true });
38+
} catch (err: unknown) {
39+
console.error('API /api/register/female-access error:', err);
40+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
41+
}
42+
}

‎src/app/api/register/route.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
PHONE_IN_USE_ERROR_MESSAGE,
1616
} from '@/lib/validation';
1717
import { ATTEMPTS_USED_UP_ERROR, attemptsLeft, isAttemptsUsedUpError } from '@/lib/rejections';
18-
import { REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
18+
import { FEMALE_SEATS_FULL_MESSAGE, isFemaleSeatsFullError, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
1919
import {
2020
classifyStudentType,
2121
createRateLimiter,
@@ -349,6 +349,10 @@ export async function POST(req: NextRequest) {
349349
if (isIterSoaRefusal(regError)) {
350350
return NextResponse.json({ error: ITER_SOA_ERROR_MESSAGE, field: 'college' }, { status: 403 });
351351
}
352+
// The female seat cap (trg_guard_female_seats): the database is the only check here, so it holds under a rush.
353+
if (isFemaleSeatsFullError(regError)) {
354+
return NextResponse.json({ code: 'female_seats_full', error: FEMALE_SEATS_FULL_MESSAGE, field: 'gender' }, { status: 409 });
355+
}
352356
if (regError.code !== '23505') break;
353357
// Unique violation on utr: another registration claimed this UTR between the pre-check and the insert (possibly
354358
// this user's own concurrent submit, when the UTR index is checked before the user_id index).

‎src/app/api/register/start-payment/route.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,22 @@ import { getAuthenticatedUser } from '@/lib/auth-server';
33
import { getSupabaseAdmin } from '@/lib/supabase';
44
import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate';
55
import { ATTEMPTS_USED_UP_ERROR, attemptsLeft } from '@/lib/rejections';
6-
import { REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
7-
import { createRateLimiter, readJsonObject } from '@/lib/security';
6+
import { FEMALE_SEATS_FULL_MESSAGE, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
7+
import { createRateLimiter, readJsonObject, REGISTRATION_GENDERS } from '@/lib/security';
88

99
export const runtime = 'nodejs';
1010

1111
// One call each time a student leaves the details step, so this only stops abuse.
1212
const startLimiter = createRateLimiter({ limit: 30, windowMs: 10 * 60 * 1000 });
1313

1414
/**
15-
* POST /api/register/start-payment { fresh? }
15+
* POST /api/register/start-payment { gender, fresh? }
1616
* Called before the form shows any payment details: a live read of the database, never a cached count. Answers with what
17-
* the student may do (issue_payment_pass, supabase/schema.sql section 20):
17+
* the student may do (start_payment_for_gender, supabase/schema.sql sections 20 and 21):
1818
* { state: 'pay', amount, currentFee } show the payment details at this fee
1919
* { state: 'already_paid', amount, currentFee } no payment details: continue only if already paid
2020
* 409 { code: 'registrations_closed', error } no payment details at all
21+
* 409 { code: 'female_seats_full', error, field: 'gender' } no payment details for this gender
2122
* The fee is locked here: the student may submit at it even if the phase closes or the fee rises meanwhile. `fresh` means
2223
* the student hasn't paid and wants the current fee instead of an older one they were quoted.
2324
*/
@@ -40,6 +41,12 @@ export async function POST(req: NextRequest) {
4041
const body = await readJsonObject(req);
4142
if (!body.ok) return NextResponse.json({ error: body.error }, { status: body.status });
4243

44+
// Required, so the seat cap is always checked before anyone is shown a way to pay.
45+
const { gender } = body.value;
46+
if (!(REGISTRATION_GENDERS as readonly unknown[]).includes(gender)) {
47+
return NextResponse.json({ error: 'Select your gender.', field: 'gender' }, { status: 400 });
48+
}
49+
4350
// Nobody who can't submit should be shown a way to pay.
4451
const client = getSupabaseAdmin(token);
4552
const [active, rejected] = await Promise.all([
@@ -57,12 +64,20 @@ export async function POST(req: NextRequest) {
5764
return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 });
5865
}
5966

60-
const { data, error } = await client.rpc('issue_payment_pass', { p_user_id: user.id, p_fresh: body.value.fresh === true });
67+
const { data, error } = await client.rpc('start_payment_for_gender', {
68+
p_user_id: user.id,
69+
p_fresh: body.value.fresh === true,
70+
p_gender: gender,
71+
});
6172
if (error || !data) {
6273
console.error('/api/register/start-payment pass failed:', error);
6374
return NextResponse.json({ error: 'Could not start the payment. Please try again.' }, { status: 500 });
6475
}
6576

77+
if (data.state === 'female_full') {
78+
return NextResponse.json({ code: 'female_seats_full', error: FEMALE_SEATS_FULL_MESSAGE, field: 'gender' }, { status: 409 });
79+
}
80+
6681
if (data.state === 'closed') {
6782
return NextResponse.json({ code: 'registrations_closed', error: REGISTRATIONS_FULL_MESSAGE }, { status: 409 });
6883
}

‎src/app/api/seats/route.ts‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ export const runtime = 'nodejs';
1010
const CACHE_TTL_MS = 5 * 1000;
1111
const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=5, s-maxage=5, stale-while-revalidate=10' };
1212
const NO_STORE = { 'Cache-Control': 'no-store' };
13-
let cached: { body: { total: number; left: number; closed: boolean }; expires: number } | null = null;
13+
let cached: { body: { total: number; left: number; closed: boolean; femaleFull: boolean }; expires: number } | null = null;
1414

1515
/**
1616
* GET /api/seats
17-
* The seat counter on the registration form: { total, left, closed } (supabase/schema.sql, sections 17 and 20). Only
18-
* these, nothing about any registration. 503 while the counter isn't set up: the form then hides it.
17+
* The seat counter on the registration form: { total, left, closed, femaleFull } (supabase/schema.sql, sections 17, 20
18+
* and 21). Only these, nothing about any registration. 503 while the counter isn't set up: the form then hides it.
1919
*/
2020
export async function GET() {
2121
try {
@@ -26,17 +26,24 @@ export async function GET() {
2626
return NextResponse.json({ error: 'Seat counter unavailable' }, { status: 503, headers: NO_STORE });
2727
}
2828

29-
const { data, error } = await getSupabaseAdmin()
30-
.from('registration_counter')
31-
.select('total_seats, seats_left, closed_at')
32-
.eq('id', 1)
33-
.maybeSingle();
29+
const client = getSupabaseAdmin();
30+
const [{ data, error }, female] = await Promise.all([
31+
client.from('registration_counter').select('total_seats, seats_left, closed_at').eq('id', 1).maybeSingle(),
32+
client.rpc('female_seats_status'),
33+
]);
3434
if (error || !data) {
3535
if (error) console.error('Seat counter query failed:', error);
3636
return NextResponse.json({ error: 'Seat counter unavailable' }, { status: 503, headers: NO_STORE });
3737
}
38+
// Display only: a failed lookup leaves the options open, and start-payment and the database still enforce the cap.
39+
if (female.error) console.error('Female seats query failed:', female.error);
3840

39-
const body = { total: data.total_seats, left: data.seats_left, closed: data.closed_at !== null };
41+
const body = {
42+
total: data.total_seats,
43+
left: data.seats_left,
44+
closed: data.closed_at !== null,
45+
femaleFull: female.data?.full === true,
46+
};
4047
cached = { body, expires: Date.now() + CACHE_TTL_MS };
4148
return NextResponse.json(body, { headers: CACHE_HEADERS });
4249
} catch (err: unknown) {

‎src/components/innovision/AuthOverlay.tsx‎

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
// @ts-nocheck
33
"use client";
44
/* eslint-disable @next/next/no-img-element -- decorative/user-uploaded images not suited for next/image */
5-
import React, { useRef, useState } from 'react';
5+
import React, { useEffect, useRef, useState } from 'react';
66
import { useDialogFocus } from './useDialogFocus';
77
import type { V } from './types';
88
import { Logo } from './icons';
99
import EntryPassCard from './EntryPassCard';
10-
import SeatCounter from './SeatCounter';
10+
import SeatCounter, { useFemaleAccess, useSeatCount } from './SeatCounter';
1111
import styles from './AuthOverlay.module.css';
1212
import { isIterSoaCollege, isIterSoaEmail, ITER_SOA_ERROR_MESSAGE } from '@/lib/validation';
13+
import { FEMALE_SEATS_FULL_MESSAGE, genderChoices } from '@/lib/seats';
1314

1415
/* ── Payment QR lightbox state lives here, outside JSX ── */
1516

@@ -72,6 +73,21 @@ export default function AuthOverlay({ v }: { v: V }) {
7273
const offer = v.payOffer;
7374
const payOpen = v.payCheck !== 'busy' && v.payCheck !== 'failed' && offer?.state === 'pay';
7475

76+
// Only polled while the registration form is open. At the female seat cap, Female and Others are marked FULL unless the
77+
// server says this student keeps them (or until it answers: the server has the last word either way).
78+
const seats = useSeatCount(!!v.showSeats);
79+
const femaleFull = !!seats?.femaleFull;
80+
const femaleAccess = useFemaleAccess(femaleFull, v.user?.id, !!v.showSeats);
81+
const genderCapped = femaleFull && femaleAccess !== true;
82+
const genderFieldset = useRef<HTMLFieldSetElement>(null);
83+
// A disabled radio that was already chosen still counts as chosen, so clear it once it's known to be closed to them.
84+
useEffect(() => {
85+
if (!femaleFull || femaleAccess !== false) return;
86+
genderFieldset.current?.querySelectorAll<HTMLInputElement>('input:disabled').forEach((input) => {
87+
input.checked = false;
88+
});
89+
}, [femaleFull, femaleAccess]);
90+
7591
return (
7692
<>
7793
<div data-auth-root="" aria-hidden={v.authHidden} style={{ position: "fixed", inset: "0", zIndex: "62", visibility: "hidden", pointerEvents: "none" }}>
@@ -109,7 +125,7 @@ export default function AuthOverlay({ v }: { v: V }) {
109125
<div data-a-in="">
110126
<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>
111127
<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>
112-
{v.showSeats ? <SeatCounter /> : null}
128+
{v.showSeats ? <SeatCounter seats={seats} /> : null}
113129
</div>
114130
<ol data-a-in="" aria-label="Registration progress" style={{ display: "none", flexDirection: "column", margin: "0", padding: "0", listStyle: "none" }}>
115131
{v.prog.map((p, pI) => (
@@ -160,17 +176,20 @@ export default function AuthOverlay({ v }: { v: V }) {
160176
<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)" />
161177
{v.err.name ? (<span role="alert" style={{ fontSize: "14px", lineHeight: "1.4", color: "oklch(0.76 0.14 35)" }}>{v.err.name}</span>) : null}
162178
</label>
163-
<fieldset data-s-in="" aria-invalid={v.inv.gender} style={{ display: "flex", flexDirection: "column", gap: "8px", margin: "0", padding: "0", border: "0", minWidth: "0" }}>
179+
<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" }}>
164180
<legend style={{ padding: "0", marginBottom: "8px", fontSize: "12px", fontWeight: "700", letterSpacing: ".16em", color: "rgba(236,232,223,.86)" }}>GENDER</legend>
165181
<span role="radiogroup" aria-label="Gender" style={{ display: "grid", gridTemplateColumns: "repeat(3,minmax(0,1fr))", gap: "8px" }}>
166-
{v.genderOptions.map((g) => (
167-
<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)">
168-
<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" }} />
182+
{genderChoices(v.genderOptions, genderCapped).map((g) => (
183+
<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)"}>
184+
<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" }} />
169185
{g.label}
186+
{g.full ? <span style={{ fontSize: "11px", fontWeight: "700", letterSpacing: ".12em", color: "oklch(0.76 0.14 35)" }}>FULL</span> : null}
170187
</label>
171188
))}
172189
</span>
173-
{v.err.gender ? (<span role="alert" style={{ fontSize: "14px", lineHeight: "1.4", color: "oklch(0.76 0.14 35)" }}>{v.err.gender}</span>) : null}
190+
{genderCapped ? (<span role="status" style={{ fontSize: "14px", lineHeight: "1.45", color: "rgba(236,232,223,.86)" }}>{FEMALE_SEATS_FULL_MESSAGE}</span>) : null}
191+
{/* Not the cap's message a second time: a stale form can get it from the server before its next refresh. */}
192+
{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}
174193
</fieldset>
175194
<label data-s-in="" style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
176195
<span style={{ fontSize: "12px", fontWeight: "700", letterSpacing: ".16em", color: "rgba(236,232,223,.86)" }}>COLLEGE NAME</span>
@@ -211,9 +230,9 @@ export default function AuthOverlay({ v }: { v: V }) {
211230
</label>
212231
<label data-s-in="" style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
213232
<span style={{ fontSize: "12px", fontWeight: "700", letterSpacing: ".16em", color: "rgba(236,232,223,.86)" }}>
214-
COLLEGE ROLL / ENROLLMENT NUMBER
233+
COLLEGE ROLL / ENROLLMENT NUMBER / EMPLOYEE ID
215234
</span>
216-
<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)" />
235+
<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)" />
217236
{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}
218237
</label>
219238
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>

0 commit comments

Comments
 (0)