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
25 changes: 20 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
46 changes: 46 additions & 0 deletions src/app/api/admin/seats/accommodation/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
24 changes: 13 additions & 11 deletions src/app/api/admin/seats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/app/api/pass/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
42 changes: 0 additions & 42 deletions src/app/api/register/female-access/route.ts

This file was deleted.

16 changes: 12 additions & 4 deletions 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 { 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,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
39 changes: 25 additions & 14 deletions src/app/api/register/start-payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand All @@ -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);
Expand All @@ -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') {
Expand All @@ -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);
Expand Down
20 changes: 10 additions & 10 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 = 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 {
Expand All @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default function PrivacyPage() {
<h2>What we collect</h2>
<ul>
<li><b>From Google sign-in:</b> your name, email address and profile photo. We never see your Google password.</li>
<li><b>When you register:</b> 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.</li>
<li><b>When you register:</b> 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.</li>
<li><b>For the fee:</b> the UPI transaction ID (UTR) and the screenshot of your payment that you upload. We do not collect card, bank or UPI PIN details.</li>
<li><b>Referral codes:</b> if you enter a friend&apos;s or a campus ambassador&apos;s code when you register, we record whose code it was.</li>
<li>
Expand Down
4 changes: 4 additions & 0 deletions src/app/terms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</li>
<li>
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.
</li>
<li>Registrations may close without notice. Don&apos;t pay while registrations are closed.</li>
<li>After paying, you upload a screenshot of the payment and enter its UPI transaction ID (UTR).</li>
<li>
Expand Down
3 changes: 3 additions & 0 deletions src/components/entry/EntryScanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface Details {
college: string;
phone: string;
utr: string | null;
accommodation?: boolean;
}
interface CheckInInfo {
registration_id?: string;
Expand Down Expand Up @@ -833,6 +834,8 @@ function DetailsList({ details }: { details: Details }) {
<dd>{details.college}</dd>
<dt>Phone</dt>
<dd>{details.phone}</dd>
<dt>Accommodation</dt>
<dd>{details.accommodation === false ? 'Not included' : 'Included'}</dd>
<dt>UTR</dt>
<dd>{details.utr || '—'}</dd>
<dt>Reg. ID</dt>
Expand Down
Loading
Loading