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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,6 @@ next-env.d.ts

# Isolated pass component test bundle
.pass-test/

# bank statements: never commit
/STATEMENT*.xlsx
102 changes: 102 additions & 0 deletions src/app/api/admin/registrations/payment-pass/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyRegistrationStaff } from '@/lib/auth-server';
import { isFilterSafeEmail, readJsonObject } from '@/lib/security';
import { isFee, MAX_FEE } from '@/lib/seats';

export const runtime = 'nodejs';

const MAX_NOTE_LENGTH = 300;
const ALLOWED_FOR_MS = 7 * 24 * 60 * 60 * 1000;

/**
* POST /api/admin/registrations/payment-pass { email, amount, note }
* Registration-Team/Admin only. The staff route for a student who paid when they shouldn't have been able to (a saved
* QR or the UPI ID while registrations were closed), after staff have checked the bank statement. It doesn't create a
* registration: the student still fills in the form and submits their proof, at `amount`, and it is reviewed as usual.
*/
export async function POST(req: NextRequest) {
try {
const auth = await verifyRegistrationStaff(req);
if ('error' in auth) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}

const parsed = await readJsonObject(req);
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status });
const { email, amount, note } = parsed.value;

const normalized = typeof email === 'string' ? email.toLowerCase().trim() : '';
if (!normalized || !isFilterSafeEmail(normalized)) {
return NextResponse.json({ error: 'Enter the email address the student signs in with.' }, { status: 400 });
}
if (!isFee(amount)) {
return NextResponse.json({ error: `The amount must be a whole number of rupees from 1 to ${MAX_FEE}.` }, { status: 400 });
}
const reason = typeof note === 'string' ? note.trim() : '';
if (!reason || reason.length > MAX_NOTE_LENGTH) {
return NextResponse.json({ error: `Add a note with the UTR you checked (up to ${MAX_NOTE_LENGTH} characters).` }, { status: 400 });
}

const { supabase, user } = auth;
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('id')
.eq('email', normalized)
.maybeSingle();
if (profileError) {
console.error('/api/admin/registrations/payment-pass profile lookup failed:', profileError);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
if (!profile) {
return NextResponse.json(
{ error: 'No account with that email. Ask the student to sign in on the site once, then try again.' },
{ status: 404 }
);
}
if (profile.id === user.id) {
return NextResponse.json({ error: 'You cannot allow your own payment. Ask another staff member.' }, { status: 403 });
}

const [active, counter] = await Promise.all([
supabase.from('registrations').select('registration_id').eq('user_id', profile.id).neq('status', 'rejected').limit(1).maybeSingle(),
supabase.from('registration_counter').select('phase').eq('id', 1).maybeSingle(),
]);
if (active.error || counter.error || !counter.data) {
console.error('/api/admin/registrations/payment-pass lookup failed:', active.error || counter.error);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
if (active.data) {
return NextResponse.json({ error: `This student already has registration ${active.data.registration_id}.` }, { status: 409 });
}

// Only one pass counts at a time: the newest.
const now = new Date();
const { error: retireError } = await supabase
.from('payment_passes')
.update({ used_at: now.toISOString() })
.eq('user_id', profile.id)
.is('used_at', null);
if (retireError) {
console.error('/api/admin/registrations/payment-pass retire failed:', retireError);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
// pay_by is now: staff have seen the payment, so the form skips the payment details and goes to the proof.
const { error: insertError } = await supabase.from('payment_passes').insert({
user_id: profile.id,
phase: counter.data.phase,
amount,
pay_by: now.toISOString(),
submit_by: new Date(now.getTime() + ALLOWED_FOR_MS).toISOString(),
issued_by: user.id,
note: reason,
});
if (insertError) {
console.error('/api/admin/registrations/payment-pass insert failed:', insertError);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (err: unknown) {
console.error('/api/admin/registrations/payment-pass error:', err);
return NextResponse.json({ error: 'Failed to allow the payment.' }, { status: 500 });
}
}
51 changes: 51 additions & 0 deletions src/app/api/admin/seats/phase/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdmin } from '@/lib/auth-server';
import { readJsonObject } from '@/lib/security';
import { nextPhaseError } from '@/lib/seats';

export const runtime = 'nodejs';

/**
* POST /api/admin/seats/phase { fee, total, left }
* Admins only: starts the next registration phase, the only way to reopen registrations (start_registration_phase,
* supabase/schema.sql section 20). Refused while a phase is open. Students already quoted an older fee keep 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 start a registration phase.' : auth.error },
{ status: auth.status }
);
}

const parsed = await readJsonObject(req);
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status });
const { fee, total, left } = parsed.value;
const invalid = nextPhaseError(fee, total, left);
if (invalid) return NextResponse.json({ error: invalid }, { status: 400 });

const { error } = await auth.supabase.rpc('start_registration_phase', {
p_fee: fee,
p_total: total,
p_left: left,
p_admin: auth.user.id,
});
if (error) {
// 55000: the current phase hasn't closed (trg_close_registration_phase_at_zero closes it at 0).
if (error.code === '55000') {
return NextResponse.json(
{ error: 'The current phase is still open. Set seats left to 0 to close it first.' },
{ status: 409 }
);
}
console.error('/api/admin/seats/phase failed:', error);
return NextResponse.json({ error: 'Failed to start the next phase.' }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (err: unknown) {
console.error('/api/admin/seats/phase error:', err);
return NextResponse.json({ error: 'Failed to start the next phase.' }, { status: 500 });
}
}
21 changes: 16 additions & 5 deletions src/app/api/admin/seats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import { seatCountError } from '@/lib/seats';

export const runtime = 'nodejs';

/** The counter as staff see it: the two numbers, and who last set them by hand and when. */
/**
* The counter as staff see it: the numbers, the phase and its fee, whether it is closed, and who last set the numbers
* by hand and when.
*/
async function readCounter(supabase: SupabaseClient) {
const { data, error } = await supabase
.from('registration_counter')
.select('total_seats, seats_left, updated_by, updated_at')
.select('total_seats, seats_left, phase, fee_inr, closed_at, updated_by, updated_at')
.eq('id', 1)
.maybeSingle();
if (error || !data) return { error: error ?? new Error('registration_counter has no row'), counter: null };
Expand All @@ -26,12 +29,20 @@ async function readCounter(supabase: SupabaseClient) {
}
return {
error: null,
counter: { total: data.total_seats, left: data.seats_left, updatedAt: data.updated_at, updatedBy },
counter: {
total: data.total_seats,
left: data.seats_left,
phase: data.phase,
fee: data.fee_inr,
closedAt: data.closed_at,
updatedAt: data.updated_at,
updatedBy,
},
};
}

const UNAVAILABLE =
'The seat counter is not set up yet. Run supabase/manual/seat_counter_and_reregistration.sql in Supabase.';
'The seat counter is not set up yet. Run supabase/manual/seat_counter_and_reregistration.sql and registration_phases.sql in Supabase.';

/**
* GET /api/admin/seats
Expand Down Expand Up @@ -59,7 +70,7 @@ export async function GET(req: NextRequest) {
/**
* PATCH /api/admin/seats { total, left }
* Admins only: sets both numbers. The database keeps moving `left` from there as registrations come in and are
* rejected.
* rejected. Setting `left` to 0 closes the phase. Nothing here reopens a closed one: only POST /api/admin/seats/phase.
*/
export async function PATCH(req: NextRequest) {
try {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/id-card/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
export const runtime = 'nodejs';

const TOO_LARGE = 'That photo is larger than 2 MB. Please choose a smaller photo of your ID card.';
const ALREADY_UPLOADED = 'Your college ID card is already uploaded. Contact the registration helpdesk to change it.';
const ALREADY_UPLOADED = 'Your college ID card is already uploaded. Contact us to change it.';

// Allow retries but stop bulk abuse of storage.
const idCardLimiter = createRateLimiter({ limit: 20, windowMs: 10 * 60 * 1000 });
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/pass/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export async function POST(req: NextRequest) {
pass.token = decryptEntryToken(ciphertext, mine.pass, key);
if (!pass.token) {
console.error('/api/pass: stored token does not decrypt (wrong ENTRY_PASS_ENCRYPTION_KEY?)');
return NextResponse.json({ error: 'Your pass could not be loaded. Please contact the help desk.' }, { status: 500 });
return NextResponse.json({ error: 'Your pass could not be loaded. Please contact us.' }, { status: 500 });
}
return NextResponse.json({ success: true, pass }, { headers: { 'Cache-Control': 'private, no-store' } });
} catch (err: unknown) {
Expand Down
29 changes: 26 additions & 3 deletions src/app/api/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ import {
PHONE_IN_USE_ERROR_MESSAGE,
} from '@/lib/validation';
import { ATTEMPTS_USED_UP_ERROR, attemptsLeft, isAttemptsUsedUpError } from '@/lib/rejections';
import { REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
import {
classifyStudentType,
createRateLimiter,
generateRegistrationId,
isFilterSafeEmail,
NEW_REGISTRATION_STATUS,
readJsonObject,
REGISTRATION_FEE_INR,
validateRegistrationInput,
} from '@/lib/security';

Expand Down Expand Up @@ -234,6 +234,18 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 });
}

// What this submit is charged, and whether it may go ahead: the fee quoted when the student started paying (even if
// the phase has closed or the fee has risen since), or the current fee while a phase is open. Otherwise refused: they
// may have paid with a saved QR or the UPI ID, so staff check the bank statement and allow it by hand.
const { data: payment, error: paymentError } = await adminClient.rpc('resolve_registration_payment', { p_user_id: user.id });
if (paymentError || !payment) {
console.error('Payment lookup failed in /api/register POST:', paymentError);
return NextResponse.json({ error: 'Failed to record registration. Please try again.' }, { status: 500 });
}
if (!payment.allowed) {
return NextResponse.json({ code: 'registrations_closed', error: REGISTRATIONS_FULL_MESSAGE }, { status: 409 });
}

// A UPI transaction can only pay for one registration.
const { data: utrOwner } = await adminClient
.from('registrations')
Expand Down Expand Up @@ -285,7 +297,7 @@ export async function POST(req: NextRequest) {
ambassadorId = referral.ambassadorId;
}

// 4. Status and fee are server constants, the same for every registrant (no free tier): any status, amount or
// 4. Status and fee are decided here, by the same rules for every registrant (no free tier): any status, amount or
// student_type in the body is ignored. Staff confirm or reject after checking the payment.
// 5. Insert Registration into Database (retrying if the random IV26-XXXX id or the generated referral code collides)
const basePayload = {
Expand All @@ -303,7 +315,9 @@ export async function POST(req: NextRequest) {
id_card_path: input.id_card_path,
id_card_uploaded_at: new Date().toISOString(),
utr: input.utr,
amount: REGISTRATION_FEE_INR,
amount: payment.amount,
phase: payment.phase,
allowed_by: payment.allowed_by ?? null,
status: NEW_REGISTRATION_STATUS,
// The registrant's own referral_code is generated by the database (trg_assign_referral_code).
referred_by: referredBy,
Expand Down Expand Up @@ -367,6 +381,15 @@ export async function POST(req: NextRequest) {
);
}

// The pass is spent. If this fails the active registration still blocks a second submit.
if (payment.pass_id) {
const { error: passError } = await adminClient
.from('payment_passes')
.update({ used_at: new Date().toISOString() })
.eq('id', payment.pass_id);
if (passError) console.warn('Marking the payment pass used failed:', passError);
}

// Update profile with enrollment_no & phone
await adminClient
.from('profiles')
Expand Down
78 changes: 78 additions & 0 deletions src/app/api/register/start-payment/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
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 { REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats';
import { createRateLimiter, readJsonObject } from '@/lib/security';

export const runtime = 'nodejs';

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

/**
* POST /api/register/start-payment { 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 (issue_payment_pass, supabase/schema.sql section 20):
* { 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
* 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.
*/
export async function POST(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 (!registrationsOpen()) return registrationsClosedResponse();

if (!startLimiter.hit(user.id)) {
return NextResponse.json(
{ error: 'Too many attempts. Please wait a few minutes and try again.' },
{ status: 429, headers: { 'Retry-After': String(startLimiter.retryAfterSeconds(user.id)) } }
);
}

const body = await readJsonObject(req);
if (!body.ok) return NextResponse.json({ error: body.error }, { status: body.status });

// Nobody who can't submit should be shown a way to pay.
const client = getSupabaseAdmin(token);
const [active, rejected] = await Promise.all([
client.from('registrations').select('id').eq('user_id', user.id).neq('status', 'rejected').limit(1).maybeSingle(),
client.from('registrations').select('id', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'rejected'),
]);
if (active.error || rejected.error) {
console.error('/api/register/start-payment registration lookup failed:', active.error || rejected.error);
return NextResponse.json({ error: 'Could not start the payment. Please try again.' }, { status: 500 });
}
if (active.data) {
return NextResponse.json({ error: 'You have already registered for Innovision 2026.' }, { status: 409 });
}
if (attemptsLeft(rejected.count ?? 0) === 0) {
return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 });
}

const { data, error } = await client.rpc('issue_payment_pass', { p_user_id: user.id, p_fresh: body.value.fresh === true });
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 === 'closed') {
return NextResponse.json({ code: 'registrations_closed', error: REGISTRATIONS_FULL_MESSAGE }, { status: 409 });
}
return NextResponse.json({
state: data.state,
amount: data.amount,
currentFee: data.current_fee ?? null,
});
} catch (err: unknown) {
console.error('API /api/register/start-payment error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Loading
Loading