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
From Google sign-in: your name, email address and profile photo. We never see your Google password.
-
When you register: your name, gender, college, enrolment or roll number and phone number, and a photo of the front of your college ID card that you upload.
+
When you register: your name, gender, whether you need accommodation, college, enrolment or roll number and phone number, and a photo of the front of your college ID card that you upload.
For the fee: the UPI transaction ID (UTR) and the screenshot of your payment that you upload. We do not collect card, bank or UPI PIN details.
Referral codes: if you enter a friend's or a campus ambassador's code when you register, we record whose code it was.
diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx
index e005538..adc61fa 100644
--- a/src/app/terms/page.tsx
+++ b/src/app/terms/page.tsx
@@ -31,6 +31,10 @@ export default function TermsPage() {
The registration fee is the amount shown on the registration form when you start paying, paid by UPI to the
account shown there. It may be higher in later registration phases.
+
+ Whether your registration includes accommodation is shown on the registration form before you pay, and on your
+ pass. The fee is the same with or without it.
+
Registrations may close without notice. Don't pay while registrations are closed.
After paying, you upload a screenshot of the payment and enter its UPI transaction ID (UTR).