diff --git a/.gitignore b/.gitignore index f872be1..98b8b40 100644 --- a/.gitignore +++ b/.gitignore @@ -57,8 +57,23 @@ next-env.d.ts # Isolated pass component test bundle .pass-test/ -# bank statements: never commit -/STATEMENT*.xlsx - -# registration exports (students' personal data): never commit -/innovision_registrations_*.csv +# spreadsheets in the repo root (bank statements, registration exports with +# students' personal data, ad-hoc sheets): never commit +/*.xlsx +/*.xls +/*.csv + +# Office lock files +~$* + +# OS and editor clutter +Thumbs.db +desktop.ini +.idea/ +.vscode/ +*.swp + +# stray logs and temp files +*.log +*.tmp +*.bak diff --git a/src/app/api/admin/seats/accommodation/route.ts b/src/app/api/admin/seats/accommodation/route.ts new file mode 100644 index 0000000..ac9d2b2 --- /dev/null +++ b/src/app/api/admin/seats/accommodation/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyAdmin } from '@/lib/auth-server'; +import { readJsonObject } from '@/lib/security'; + +export const runtime = 'nodejs'; + +/** + * POST /api/admin/seats/accommodation { open } + * Admins only: whether Male students may choose accommodation (supabase/schema.sql, section 22). Reversible at any time. + * Existing registrations don't change, and a student already quoted accommodation keeps it. + */ +export async function POST(req: NextRequest) { + try { + const auth = await verifyAdmin(req); + if ('error' in auth) { + return NextResponse.json( + { error: auth.status === 403 ? 'Only admins can change male accommodation.' : auth.error }, + { status: auth.status } + ); + } + + const parsed = await readJsonObject(req); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + const { open } = parsed.value; + if (typeof open !== 'boolean') { + return NextResponse.json({ error: 'Say whether male accommodation is open.' }, { status: 400 }); + } + + const { error } = await auth.supabase + .from('registration_counter') + .update({ + male_accommodation_open: open, + male_accommodation_updated_by: auth.user.id, + male_accommodation_updated_at: new Date().toISOString(), + }) + .eq('id', 1); + if (error) { + console.error('/api/admin/seats/accommodation failed:', error); + return NextResponse.json({ error: 'Failed to change male accommodation.' }, { status: 500 }); + } + return NextResponse.json({ success: true }); + } catch (err: unknown) { + console.error('/api/admin/seats/accommodation error:', err); + return NextResponse.json({ error: 'Failed to change male accommodation.' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/seats/route.ts b/src/app/api/admin/seats/route.ts index c32510c..5ab139e 100644 --- a/src/app/api/admin/seats/route.ts +++ b/src/app/api/admin/seats/route.ts @@ -13,20 +13,17 @@ export const runtime = 'nodejs'; async function readCounter(supabase: SupabaseClient) { const { data, error } = await supabase .from('registration_counter') - .select('total_seats, seats_left, phase, fee_inr, closed_at, updated_by, updated_at') + .select('total_seats, seats_left, phase, fee_inr, closed_at, updated_by, updated_at, male_accommodation_open, male_accommodation_updated_by, male_accommodation_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; - } + const staffName = async (id: string | null) => { + if (!id) return null; + const { data: profile } = await supabase.from('profiles').select('full_name, email').eq('id', id).maybeSingle(); + return profile?.full_name || profile?.email || null; + }; + const [updatedBy, maleUpdatedBy] = await Promise.all([staffName(data.updated_by), staffName(data.male_accommodation_updated_by)]); // 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); @@ -41,12 +38,17 @@ async function readCounter(supabase: SupabaseClient) { updatedAt: data.updated_at, updatedBy, female: female.data ? { cap: female.data.cap, registered: female.data.registered, full: female.data.full } : null, + maleAccommodation: { + open: data.male_accommodation_open, + updatedAt: data.male_accommodation_updated_at, + updatedBy: maleUpdatedBy, + }, }, }; } const UNAVAILABLE = - 'The seat counter is not set up yet. Run supabase/manual/seat_counter_and_reregistration.sql and registration_phases.sql in Supabase.'; + 'The seat counter is not set up yet. Run supabase/manual/seat_counter_and_reregistration.sql, registration_phases.sql and no_accommodation.sql in Supabase.'; /** * GET /api/admin/seats diff --git a/src/app/api/pass/route.ts b/src/app/api/pass/route.ts index b014287..4d960cc 100644 --- a/src/app/api/pass/route.ts +++ b/src/app/api/pass/route.ts @@ -59,6 +59,7 @@ export async function POST(req: NextRequest) { registrationId: mine.registration_id ?? null, name: mine.name ?? null, college: details.data?.college ?? null, + accommodation: details.data?.accommodation !== false, checkedIn: !!mine.checked_in, token: null as string | null, }; diff --git a/src/app/api/register/female-access/route.ts b/src/app/api/register/female-access/route.ts deleted file mode 100644 index c3bbe42..0000000 --- a/src/app/api/register/female-access/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 13572f0..8d55185 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 { FEMALE_SEATS_FULL_MESSAGE, isFemaleSeatsFullError, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; +import { accommodationForSubmit, FORM_OUT_OF_DATE_MESSAGE, isAccommodationRefusal, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; import { classifyStudentType, createRateLimiter, @@ -246,6 +246,13 @@ export async function POST(req: NextRequest) { return NextResponse.json({ code: 'registrations_closed', error: REGISTRATIONS_FULL_MESSAGE }, { status: 409 }); } + // Saved when the student started paying (start-payment), never taken from the body alone. Null: this form never told + // them whether accommodation is included. + const accommodation = accommodationForSubmit(input.gender, input.no_accommodation, payment); + if (accommodation === null) { + return NextResponse.json({ code: 'form_out_of_date', error: FORM_OUT_OF_DATE_MESSAGE, field: 'no_accommodation' }, { status: 409 }); + } + // A UPI transaction can only pay for one registration. const { data: utrOwner } = await adminClient .from('registrations') @@ -308,6 +315,7 @@ export async function POST(req: NextRequest) { phone: input.phone, enrollment_no: input.enrollment_no, gender: input.gender, + accommodation, student_type: studentType, // Only the private storage object path is stored, never a URL or the image itself. payment_proof_path: input.payment_proof_path, @@ -349,9 +357,9 @@ 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 }); + // Accommodation was closed between the check above and this insert (trg_guard_registration_accommodation). + if (isAccommodationRefusal(regError)) { + return NextResponse.json({ code: 'form_out_of_date', error: FORM_OUT_OF_DATE_MESSAGE, field: 'no_accommodation' }, { status: 409 }); } if (regError.code !== '23505') break; // Unique violation on utr: another registration claimed this UTR between the pre-check and the insert (possibly diff --git a/src/app/api/register/start-payment/route.ts b/src/app/api/register/start-payment/route.ts index a057129..2db8f78 100644 --- a/src/app/api/register/start-payment/route.ts +++ b/src/app/api/register/start-payment/route.ts @@ -3,7 +3,7 @@ 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 { FEMALE_SEATS_FULL_MESSAGE, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; +import { FORM_OUT_OF_DATE_MESSAGE, MALE_ACCOMMODATION_CLOSED_MESSAGE, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; import { createRateLimiter, readJsonObject, REGISTRATION_GENDERS } from '@/lib/security'; export const runtime = 'nodejs'; @@ -12,15 +12,17 @@ export const runtime = 'nodejs'; const startLimiter = createRateLimiter({ limit: 30, windowMs: 10 * 60 * 1000 }); /** - * POST /api/register/start-payment { gender, fresh? } + * POST /api/register/start-payment { gender, no_accommodation, 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 (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. + * the student may do (start_payment_with_accommodation, supabase/schema.sql sections 20 and 22): + * { state: 'pay', amount, currentFee, accommodation } show the payment details at this fee + * { state: 'already_paid', amount, currentFee, accommodation } no payment details: continue only if already paid + * 409 { code: 'registrations_closed', error } no payment details at all + * 409 { code: 'male_accommodation_closed', error, field } only without accommodation + * 409 { code: 'form_out_of_date', error, field } the form must be refreshed first + * The fee and accommodation are locked here: the student may submit with them even if the phase closes, the fee rises or + * Male accommodation closes meanwhile. `fresh` means the student hasn't paid and wants the current fee instead of an older + * one they were quoted. */ export async function POST(req: NextRequest) { try { @@ -41,11 +43,15 @@ 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; + // Required, so accommodation is always decided before anyone is shown a way to pay. + const { gender, no_accommodation: noAccommodation } = body.value; if (!(REGISTRATION_GENDERS as readonly unknown[]).includes(gender)) { return NextResponse.json({ error: 'Select your gender.', field: 'gender' }, { status: 400 }); } + // A form that doesn't send the box never told the student about accommodation: nobody pays without having seen it. + if (typeof noAccommodation !== 'boolean' || (gender !== 'male' && !noAccommodation)) { + return NextResponse.json({ code: 'form_out_of_date', error: FORM_OUT_OF_DATE_MESSAGE, field: 'no_accommodation' }, { status: 409 }); + } // Nobody who can't submit should be shown a way to pay. const client = getSupabaseAdmin(token); @@ -64,18 +70,22 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 }); } - const { data, error } = await client.rpc('start_payment_for_gender', { + const { data, error } = await client.rpc('start_payment_with_accommodation', { p_user_id: user.id, p_fresh: body.value.fresh === true, p_gender: gender, + p_no_accommodation: noAccommodation, }); 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 === 'accommodation_closed') { + return NextResponse.json( + { code: 'male_accommodation_closed', error: MALE_ACCOMMODATION_CLOSED_MESSAGE, field: 'no_accommodation' }, + { status: 409 } + ); } if (data.state === 'closed') { @@ -85,6 +95,7 @@ export async function POST(req: NextRequest) { state: data.state, amount: data.amount, currentFee: data.current_fee ?? null, + accommodation: data.accommodation, }); } catch (err: unknown) { console.error('API /api/register/start-payment error:', err); diff --git a/src/app/api/seats/route.ts b/src/app/api/seats/route.ts index b88cbf8..398275d 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 = 15 * 1000; const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=5, s-maxage=15, stale-while-revalidate=15' }; const NO_STORE = { 'Cache-Control': 'no-store' }; -let cached: { body: { total: number; left: number; closed: boolean; femaleFull: boolean }; expires: number } | null = null; +let cached: { body: { total: number; left: number; closed: boolean; maleAccommodationOpen: boolean }; expires: number } | null = null; /** * GET /api/seats - * 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. + * The seat counter on the registration form: { total, left, closed, maleAccommodationOpen } (supabase/schema.sql, sections + * 17, 20 and 22). Only these, nothing about any registration. 503 while the counter isn't set up: the form then hides it. */ export async function GET() { try { @@ -27,22 +27,22 @@ export async function GET() { } 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'), - ]); + const { data, error } = await client + .from('registration_counter') + .select('total_seats, seats_left, closed_at, male_accommodation_open') + .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 }); } - // 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, - femaleFull: female.data?.full === true, + // Display only: start-payment and the database decide. + maleAccommodationOpen: data.male_accommodation_open !== false, }; cached = { body, expires: Date.now() + CACHE_TTL_MS }; return NextResponse.json(body, { headers: CACHE_HEADERS }); diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 9b423a4..e81be0b 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -25,7 +25,7 @@ export default function PrivacyPage() {

What we collect