Skip to content

Commit 2350e55

Browse files
authored
Merge pull request #82 from OpencodeNIT-R/development
Development
2 parents c4eefae + 969a27b commit 2350e55

27 files changed

Lines changed: 752 additions & 190 deletions

‎.gitignore‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,23 @@ next-env.d.ts
5757
# Isolated pass component test bundle
5858
.pass-test/
5959

60-
# bank statements: never commit
61-
/STATEMENT*.xlsx
62-
63-
# registration exports (students' personal data): never commit
64-
/innovision_registrations_*.csv
60+
# spreadsheets in the repo root (bank statements, registration exports with
61+
# students' personal data, ad-hoc sheets): never commit
62+
/*.xlsx
63+
/*.xls
64+
/*.csv
65+
66+
# Office lock files
67+
~$*
68+
69+
# OS and editor clutter
70+
Thumbs.db
71+
desktop.ini
72+
.idea/
73+
.vscode/
74+
*.swp
75+
76+
# stray logs and temp files
77+
*.log
78+
*.tmp
79+
*.bak
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { verifyAdmin } from '@/lib/auth-server';
3+
import { readJsonObject } from '@/lib/security';
4+
5+
export const runtime = 'nodejs';
6+
7+
/**
8+
* POST /api/admin/seats/accommodation { open }
9+
* Admins only: whether Male students may choose accommodation (supabase/schema.sql, section 22). Reversible at any time.
10+
* Existing registrations don't change, and a student already quoted accommodation keeps it.
11+
*/
12+
export async function POST(req: NextRequest) {
13+
try {
14+
const auth = await verifyAdmin(req);
15+
if ('error' in auth) {
16+
return NextResponse.json(
17+
{ error: auth.status === 403 ? 'Only admins can change male accommodation.' : auth.error },
18+
{ status: auth.status }
19+
);
20+
}
21+
22+
const parsed = await readJsonObject(req);
23+
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status });
24+
const { open } = parsed.value;
25+
if (typeof open !== 'boolean') {
26+
return NextResponse.json({ error: 'Say whether male accommodation is open.' }, { status: 400 });
27+
}
28+
29+
const { error } = await auth.supabase
30+
.from('registration_counter')
31+
.update({
32+
male_accommodation_open: open,
33+
male_accommodation_updated_by: auth.user.id,
34+
male_accommodation_updated_at: new Date().toISOString(),
35+
})
36+
.eq('id', 1);
37+
if (error) {
38+
console.error('/api/admin/seats/accommodation failed:', error);
39+
return NextResponse.json({ error: 'Failed to change male accommodation.' }, { status: 500 });
40+
}
41+
return NextResponse.json({ success: true });
42+
} catch (err: unknown) {
43+
console.error('/api/admin/seats/accommodation error:', err);
44+
return NextResponse.json({ error: 'Failed to change male accommodation.' }, { status: 500 });
45+
}
46+
}

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,17 @@ export const runtime = 'nodejs';
1313
async function readCounter(supabase: SupabaseClient) {
1414
const { data, error } = await supabase
1515
.from('registration_counter')
16-
.select('total_seats, seats_left, phase, fee_inr, closed_at, updated_by, updated_at')
16+
.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')
1717
.eq('id', 1)
1818
.maybeSingle();
1919
if (error || !data) return { error: error ?? new Error('registration_counter has no row'), counter: null };
2020

21-
let updatedBy: string | null = null;
22-
if (data.updated_by) {
23-
const { data: profile } = await supabase
24-
.from('profiles')
25-
.select('full_name, email')
26-
.eq('id', data.updated_by)
27-
.maybeSingle();
28-
updatedBy = profile?.full_name || profile?.email || null;
29-
}
21+
const staffName = async (id: string | null) => {
22+
if (!id) return null;
23+
const { data: profile } = await supabase.from('profiles').select('full_name, email').eq('id', id).maybeSingle();
24+
return profile?.full_name || profile?.email || null;
25+
};
26+
const [updatedBy, maleUpdatedBy] = await Promise.all([staffName(data.updated_by), staffName(data.male_accommodation_updated_by)]);
3027
// The card just leaves out the female line if this fails.
3128
const female = await supabase.rpc('female_seats_status');
3229
if (female.error) console.error('/api/admin/seats female seats read failed:', female.error);
@@ -41,12 +38,17 @@ async function readCounter(supabase: SupabaseClient) {
4138
updatedAt: data.updated_at,
4239
updatedBy,
4340
female: female.data ? { cap: female.data.cap, registered: female.data.registered, full: female.data.full } : null,
41+
maleAccommodation: {
42+
open: data.male_accommodation_open,
43+
updatedAt: data.male_accommodation_updated_at,
44+
updatedBy: maleUpdatedBy,
45+
},
4446
},
4547
};
4648
}
4749

4850
const UNAVAILABLE =
49-
'The seat counter is not set up yet. Run supabase/manual/seat_counter_and_reregistration.sql and registration_phases.sql in Supabase.';
51+
'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.';
5052

5153
/**
5254
* GET /api/admin/seats

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export async function POST(req: NextRequest) {
5959
registrationId: mine.registration_id ?? null,
6060
name: mine.name ?? null,
6161
college: details.data?.college ?? null,
62+
accommodation: details.data?.accommodation !== false,
6263
checkedIn: !!mine.checked_in,
6364
token: null as string | null,
6465
};

‎src/app/api/register/female-access/route.ts‎

Lines changed: 0 additions & 42 deletions
This file was deleted.

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

Lines changed: 12 additions & 4 deletions
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 { FEMALE_SEATS_FULL_MESSAGE, isFemaleSeatsFullError, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
18+
import { accommodationForSubmit, FORM_OUT_OF_DATE_MESSAGE, isAccommodationRefusal, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
1919
import {
2020
classifyStudentType,
2121
createRateLimiter,
@@ -246,6 +246,13 @@ export async function POST(req: NextRequest) {
246246
return NextResponse.json({ code: 'registrations_closed', error: REGISTRATIONS_FULL_MESSAGE }, { status: 409 });
247247
}
248248

249+
// Saved when the student started paying (start-payment), never taken from the body alone. Null: this form never told
250+
// them whether accommodation is included.
251+
const accommodation = accommodationForSubmit(input.gender, input.no_accommodation, payment);
252+
if (accommodation === null) {
253+
return NextResponse.json({ code: 'form_out_of_date', error: FORM_OUT_OF_DATE_MESSAGE, field: 'no_accommodation' }, { status: 409 });
254+
}
255+
249256
// A UPI transaction can only pay for one registration.
250257
const { data: utrOwner } = await adminClient
251258
.from('registrations')
@@ -308,6 +315,7 @@ export async function POST(req: NextRequest) {
308315
phone: input.phone,
309316
enrollment_no: input.enrollment_no,
310317
gender: input.gender,
318+
accommodation,
311319
student_type: studentType,
312320
// Only the private storage object path is stored, never a URL or the image itself.
313321
payment_proof_path: input.payment_proof_path,
@@ -349,9 +357,9 @@ export async function POST(req: NextRequest) {
349357
if (isIterSoaRefusal(regError)) {
350358
return NextResponse.json({ error: ITER_SOA_ERROR_MESSAGE, field: 'college' }, { status: 403 });
351359
}
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 });
360+
// Accommodation was closed between the check above and this insert (trg_guard_registration_accommodation).
361+
if (isAccommodationRefusal(regError)) {
362+
return NextResponse.json({ code: 'form_out_of_date', error: FORM_OUT_OF_DATE_MESSAGE, field: 'no_accommodation' }, { status: 409 });
355363
}
356364
if (regError.code !== '23505') break;
357365
// Unique violation on utr: another registration claimed this UTR between the pre-check and the insert (possibly

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

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ 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 { FEMALE_SEATS_FULL_MESSAGE, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
6+
import { FORM_OUT_OF_DATE_MESSAGE, MALE_ACCOMMODATION_CLOSED_MESSAGE, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
77
import { createRateLimiter, readJsonObject, REGISTRATION_GENDERS } from '@/lib/security';
88

99
export const runtime = 'nodejs';
@@ -12,15 +12,17 @@ export const runtime = 'nodejs';
1212
const startLimiter = createRateLimiter({ limit: 30, windowMs: 10 * 60 * 1000 });
1313

1414
/**
15-
* POST /api/register/start-payment { gender, fresh? }
15+
* POST /api/register/start-payment { gender, no_accommodation, 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 (start_payment_for_gender, supabase/schema.sql sections 20 and 21):
18-
* { state: 'pay', amount, currentFee } show the payment details at this fee
19-
* { state: 'already_paid', amount, currentFee } no payment details: continue only if already paid
20-
* 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
22-
* The fee is locked here: the student may submit at it even if the phase closes or the fee rises meanwhile. `fresh` means
23-
* the student hasn't paid and wants the current fee instead of an older one they were quoted.
17+
* the student may do (start_payment_with_accommodation, supabase/schema.sql sections 20 and 22):
18+
* { state: 'pay', amount, currentFee, accommodation } show the payment details at this fee
19+
* { state: 'already_paid', amount, currentFee, accommodation } no payment details: continue only if already paid
20+
* 409 { code: 'registrations_closed', error } no payment details at all
21+
* 409 { code: 'male_accommodation_closed', error, field } only without accommodation
22+
* 409 { code: 'form_out_of_date', error, field } the form must be refreshed first
23+
* The fee and accommodation are locked here: the student may submit with them even if the phase closes, the fee rises or
24+
* Male accommodation closes meanwhile. `fresh` means the student hasn't paid and wants the current fee instead of an older
25+
* one they were quoted.
2426
*/
2527
export async function POST(req: NextRequest) {
2628
try {
@@ -41,11 +43,15 @@ export async function POST(req: NextRequest) {
4143
const body = await readJsonObject(req);
4244
if (!body.ok) return NextResponse.json({ error: body.error }, { status: body.status });
4345

44-
// Required, so the seat cap is always checked before anyone is shown a way to pay.
45-
const { gender } = body.value;
46+
// Required, so accommodation is always decided before anyone is shown a way to pay.
47+
const { gender, no_accommodation: noAccommodation } = body.value;
4648
if (!(REGISTRATION_GENDERS as readonly unknown[]).includes(gender)) {
4749
return NextResponse.json({ error: 'Select your gender.', field: 'gender' }, { status: 400 });
4850
}
51+
// A form that doesn't send the box never told the student about accommodation: nobody pays without having seen it.
52+
if (typeof noAccommodation !== 'boolean' || (gender !== 'male' && !noAccommodation)) {
53+
return NextResponse.json({ code: 'form_out_of_date', error: FORM_OUT_OF_DATE_MESSAGE, field: 'no_accommodation' }, { status: 409 });
54+
}
4955

5056
// Nobody who can't submit should be shown a way to pay.
5157
const client = getSupabaseAdmin(token);
@@ -64,18 +70,22 @@ export async function POST(req: NextRequest) {
6470
return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 });
6571
}
6672

67-
const { data, error } = await client.rpc('start_payment_for_gender', {
73+
const { data, error } = await client.rpc('start_payment_with_accommodation', {
6874
p_user_id: user.id,
6975
p_fresh: body.value.fresh === true,
7076
p_gender: gender,
77+
p_no_accommodation: noAccommodation,
7178
});
7279
if (error || !data) {
7380
console.error('/api/register/start-payment pass failed:', error);
7481
return NextResponse.json({ error: 'Could not start the payment. Please try again.' }, { status: 500 });
7582
}
7683

77-
if (data.state === 'female_full') {
78-
return NextResponse.json({ code: 'female_seats_full', error: FEMALE_SEATS_FULL_MESSAGE, field: 'gender' }, { status: 409 });
84+
if (data.state === 'accommodation_closed') {
85+
return NextResponse.json(
86+
{ code: 'male_accommodation_closed', error: MALE_ACCOMMODATION_CLOSED_MESSAGE, field: 'no_accommodation' },
87+
{ status: 409 }
88+
);
7989
}
8090

8191
if (data.state === 'closed') {
@@ -85,6 +95,7 @@ export async function POST(req: NextRequest) {
8595
state: data.state,
8696
amount: data.amount,
8797
currentFee: data.current_fee ?? null,
98+
accommodation: data.accommodation,
8899
});
89100
} catch (err: unknown) {
90101
console.error('API /api/register/start-payment error:', err);

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

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

1515
/**
1616
* GET /api/seats
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.
17+
* The seat counter on the registration form: { total, left, closed, maleAccommodationOpen } (supabase/schema.sql, sections
18+
* 17, 20 and 22). 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 {
@@ -27,22 +27,22 @@ export async function GET() {
2727
}
2828

2929
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-
]);
30+
const { data, error } = await client
31+
.from('registration_counter')
32+
.select('total_seats, seats_left, closed_at, male_accommodation_open')
33+
.eq('id', 1)
34+
.maybeSingle();
3435
if (error || !data) {
3536
if (error) console.error('Seat counter query failed:', error);
3637
return NextResponse.json({ error: 'Seat counter unavailable' }, { status: 503, headers: NO_STORE });
3738
}
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);
4039

4140
const body = {
4241
total: data.total_seats,
4342
left: data.seats_left,
4443
closed: data.closed_at !== null,
45-
femaleFull: female.data?.full === true,
44+
// Display only: start-payment and the database decide.
45+
maleAccommodationOpen: data.male_accommodation_open !== false,
4646
};
4747
cached = { body, expires: Date.now() + CACHE_TTL_MS };
4848
return NextResponse.json(body, { headers: CACHE_HEADERS });

‎src/app/privacy/page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export default function PrivacyPage() {
2525
<h2>What we collect</h2>
2626
<ul>
2727
<li><b>From Google sign-in:</b> your name, email address and profile photo. We never see your Google password.</li>
28-
<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>
28+
<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>
2929
<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>
3030
<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>
3131
<li>

‎src/app/terms/page.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export default function TermsPage() {
3131
The registration fee is the amount shown on the registration form when you start paying, paid by UPI to the
3232
account shown there. It may be higher in later registration phases.
3333
</li>
34+
<li>
35+
Whether your registration includes accommodation is shown on the registration form before you pay, and on your
36+
pass. The fee is the same with or without it.
37+
</li>
3438
<li>Registrations may close without notice. Don&apos;t pay while registrations are closed.</li>
3539
<li>After paying, you upload a screenshot of the payment and enter its UPI transaction ID (UTR).</li>
3640
<li>

0 commit comments

Comments
 (0)