From 1ed7d8e1ed5ad81c6224d26358f822dcb3e6d64d Mon Sep 17 00:00:00 2001 From: Krish Agarwal Date: Mon, 2 Nov 2026 17:21:31 +0530 Subject: [PATCH 1/3] reg counter fixes' --- .gitignore | 3 + .../admin/registrations/payment-pass/route.ts | 102 +++++ src/app/api/admin/registrations/route.ts | 11 + .../api/admin/registrations/top-up/route.ts | 100 +++++ src/app/api/admin/seats/phase/route.ts | 51 +++ src/app/api/admin/seats/route.ts | 29 +- src/app/api/pass/route.ts | 4 + src/app/api/register/route.ts | 31 +- src/app/api/register/start-payment/route.ts | 79 ++++ src/app/api/seats/route.ts | 18 +- src/app/terms/page.tsx | 7 +- src/components/innovision/AdminDashboard.tsx | 51 ++- src/components/innovision/AuthOverlay.tsx | 51 ++- src/components/innovision/EntryPassCard.tsx | 8 + src/components/innovision/Innovision.tsx | 54 ++- src/components/innovision/ProfileOverlay.tsx | 3 + src/components/innovision/SeatCounter.tsx | 11 +- .../innovision/admin/SeatCounterCard.tsx | 151 +++++++- .../innovision/admin/TopUpControls.tsx | 90 +++++ src/config/payment.ts | 22 +- src/lib/pass-experience.ts | 2 + src/lib/rejections.ts | 2 +- src/lib/seats.ts | 71 +++- src/lib/security.ts | 6 +- src/lib/supabase.ts | 34 ++ supabase/manual/registration_phases.sql | 348 ++++++++++++++++++ .../seat_counter_and_reregistration.sql | 1 + supabase/schema.sql | 336 +++++++++++++++++ 28 files changed, 1597 insertions(+), 79 deletions(-) create mode 100644 src/app/api/admin/registrations/payment-pass/route.ts create mode 100644 src/app/api/admin/registrations/top-up/route.ts create mode 100644 src/app/api/admin/seats/phase/route.ts create mode 100644 src/app/api/register/start-payment/route.ts create mode 100644 src/components/innovision/admin/TopUpControls.tsx create mode 100644 supabase/manual/registration_phases.sql diff --git a/.gitignore b/.gitignore index eb21281..38b1ec3 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ next-env.d.ts # Isolated pass component test bundle .pass-test/ + +# bank statements: never commit +/STATEMENT*.xlsx diff --git a/src/app/api/admin/registrations/payment-pass/route.ts b/src/app/api/admin/registrations/payment-pass/route.ts new file mode 100644 index 0000000..f05080a --- /dev/null +++ b/src/app/api/admin/registrations/payment-pass/route.ts @@ -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 helpdesk 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 }); + } +} diff --git a/src/app/api/admin/registrations/route.ts b/src/app/api/admin/registrations/route.ts index b4eb4da..df72a7f 100644 --- a/src/app/api/admin/registrations/route.ts +++ b/src/app/api/admin/registrations/route.ts @@ -15,6 +15,8 @@ const MAX_BATCHES = 20; // Ids per `in.(...)` lookup, keeping the request URL short. const LOOKUP_CHUNK = 150; +const AMOUNT_DUE_ERROR = 'Part of the fee is still due. Record the top-up UTR before approving.'; + /** Who brought a registration in: the registrant's code they entered, or a campus ambassador's (never both). */ type ReferredVia = { kind: 'registrant' | 'ambassador'; code: string; name: string } | null; @@ -292,6 +294,11 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ error: ID_CARD_REQUIRED_ERROR }, { status: 409 }); } + // No approval while part of the fee is due (also refused by trg_require_amount_paid_for_approval). Rejecting is fine. + if (status === 'confirmed' && existing.amount_due > 0) { + return NextResponse.json({ error: AMOUNT_DUE_ERROR }, { status: 409 }); + } + const { data: updated, error: updateErr } = await supabase .from('registrations') .update({ @@ -312,6 +319,10 @@ export async function PATCH(req: NextRequest) { if (isIdCardRequiredError(updateErr)) { return NextResponse.json({ error: ID_CARD_REQUIRED_ERROR }, { status: 409 }); } + // A difference was asked for between the check above and this update. + if (/amount due before approval/i.test(updateErr.message ?? '')) { + return NextResponse.json({ error: AMOUNT_DUE_ERROR }, { status: 409 }); + } console.error('/api/admin/registrations database error:', updateErr); return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); } diff --git a/src/app/api/admin/registrations/top-up/route.ts b/src/app/api/admin/registrations/top-up/route.ts new file mode 100644 index 0000000..a1be95b --- /dev/null +++ b/src/app/api/admin/registrations/top-up/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { isUuid, readJsonObject } from '@/lib/security'; +import { isFee, MAX_FEE } from '@/lib/seats'; + +export const runtime = 'nodejs'; + +const UTR_TAKEN = 'That UTR is already used by another registration or top-up.'; + +/** + * POST /api/admin/registrations/top-up + * { registrationId, action: 'ask', amountDue } Asks for the rest of the fee: MY PASS shows it, approval is blocked. + * { registrationId, action: 'record', utr } The rest has arrived: stores its UTR, approval is possible again. + * Registration-Team/Admin only, on a pending registration that isn't the caller's own. For someone who paid too little: + * payments can't be refunded, so they pay the difference instead of the whole fee again. `amount` already holds the full + * fee owed and doesn't change. + */ +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 { registrationId, action, amountDue, utr } = parsed.value; + if (!isUuid(registrationId)) { + return NextResponse.json({ error: 'Invalid registrationId' }, { status: 400 }); + } + + const { supabase, user } = auth; + const { data: existing, error: fetchErr } = await supabase + .from('registrations') + .select('id, user_id, registration_id, status, amount_due') + .eq('id', registrationId) + .maybeSingle(); + if (fetchErr) { + console.error('/api/admin/registrations/top-up database error:', fetchErr); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + if (!existing) { + return NextResponse.json({ error: 'Registration not found' }, { status: 404 }); + } + if (existing.user_id === user.id) { + return NextResponse.json({ error: 'You cannot review your own registration. Ask another staff member.' }, { status: 403 }); + } + if (existing.status !== 'pending') { + return NextResponse.json( + { error: `Registration ${existing.registration_id} is already ${existing.status} and cannot be altered.` }, + { status: 400 } + ); + } + + let changes: Record; + if (action === 'ask') { + if (!isFee(amountDue)) { + return NextResponse.json({ error: `The amount due must be a whole number of rupees from 1 to ${MAX_FEE}.` }, { status: 400 }); + } + changes = { amount_due: amountDue }; + } else if (action === 'record') { + if (existing.amount_due <= 0) { + return NextResponse.json({ error: `No amount is due on ${existing.registration_id}.` }, { status: 409 }); + } + if (typeof utr !== 'string' || !/^\d{12}$/.test(utr)) { + return NextResponse.json({ error: 'UTR numbers are 12 digits.' }, { status: 400 }); + } + changes = { + amount_due: 0, + top_up_utr: utr, + top_up_recorded_by: user.id, + top_up_recorded_at: new Date().toISOString(), + }; + } else { + return NextResponse.json({ error: "Invalid action. Must be 'ask' or 'record'" }, { status: 400 }); + } + + const { data: updated, error: updateErr } = await supabase + .from('registrations') + .update({ ...changes, updated_at: new Date().toISOString() }) + .eq('id', existing.id) + // Only while still pending, so a review at the same moment wins cleanly. + .eq('status', 'pending') + .select() + .maybeSingle(); + if (updateErr) { + // uq_registrations_top_up_utr or trg_guard_top_up_utr. + if (updateErr.code === '23505') return NextResponse.json({ error: UTR_TAKEN }, { status: 409 }); + console.error('/api/admin/registrations/top-up update failed:', updateErr); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + if (!updated) { + return NextResponse.json({ error: `Registration ${existing.registration_id} was just reviewed.` }, { status: 409 }); + } + return NextResponse.json({ success: true, registration: updated }); + } catch (err: unknown) { + console.error('/api/admin/registrations/top-up error:', err); + return NextResponse.json({ error: 'Failed to update the registration.' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/seats/phase/route.ts b/src/app/api/admin/seats/phase/route.ts new file mode 100644 index 0000000..716080b --- /dev/null +++ b/src/app/api/admin/seats/phase/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/seats/route.ts b/src/app/api/admin/seats/route.ts index 6ab61ea..1c3c3aa 100644 --- a/src/app/api/admin/seats/route.ts +++ b/src/app/api/admin/seats/route.ts @@ -6,15 +6,25 @@ 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, how many students are on the + * payment step right now (they may still submit after it closes), 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 }; + const { count, error: countError } = await supabase + .from('payment_passes') + .select('id', { count: 'exact', head: true }) + .is('used_at', null) + .gt('pay_by', new Date().toISOString()); + if (countError) return { error: countError, counter: null }; + let updatedBy: string | null = null; if (data.updated_by) { const { data: profile } = await supabase @@ -26,12 +36,21 @@ 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, + paymentsInProgress: count ?? 0, + 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 @@ -59,7 +78,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 { diff --git a/src/app/api/pass/route.ts b/src/app/api/pass/route.ts index b096e68..2f66230 100644 --- a/src/app/api/pass/route.ts +++ b/src/app/api/pass/route.ts @@ -73,6 +73,10 @@ export async function POST(req: NextRequest) { pass.idCard = details.data.id_card_path ? 'uploaded' : 'pending'; pass.idCardReuploadReason = details.data.id_card_path ? null : details.data.id_card_reupload_reason ?? null; } + // Staff asked for the rest of the fee (section 20): MY PASS says how much. + if (mine.registration_status === 'pending' && details.data?.amount_due > 0) { + pass.amountDue = details.data.amount_due; + } if (mine.registration_status !== 'confirmed' || mine.pass_status !== 'active' || !mine.pass) { return NextResponse.json({ success: true, pass }, { headers: { 'Cache-Control': 'private, no-store' } }); } diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index 5872b55..60f1aa5 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -15,6 +15,7 @@ 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, @@ -22,7 +23,6 @@ import { isFilterSafeEmail, NEW_REGISTRATION_STATUS, readJsonObject, - REGISTRATION_FEE_INR, validateRegistrationInput, } from '@/lib/security'; @@ -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 the helpdesk checks the bank statement and allows 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') @@ -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 = { @@ -303,7 +315,11 @@ 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, + submitted_late: payment.late === true, + pay_by: payment.pay_by ?? null, + 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, @@ -367,6 +383,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') diff --git a/src/app/api/register/start-payment/route.ts b/src/app/api/register/start-payment/route.ts new file mode 100644 index 0000000..9756ff3 --- /dev/null +++ b/src/app/api/register/start-payment/route.ts @@ -0,0 +1,79 @@ +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 leaving the details step and one each time the time to pay runs out, 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, paySeconds, currentFee } show the payment details for paySeconds, at this fee + * { state: 'already_paid', amount, paySeconds: 0, 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, + paySeconds: data.pay_seconds, + 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 }); + } +} diff --git a/src/app/api/seats/route.ts b/src/app/api/seats/route.ts index 79e82e9..bd610eb 100644 --- a/src/app/api/seats/route.ts +++ b/src/app/api/seats/route.ts @@ -5,17 +5,17 @@ export const runtime = 'nodejs'; // Public, unauthenticated endpoint polled by every open registration form: serve from a short in-process cache and let // the CDN cache it too, so a crowd watching the counter doesn't turn into one database query each. Short enough that -// the number still visibly moves while someone is on the form. Excluded from the global no-store API header in -// next.config.ts. -const CACHE_TTL_MS = 10 * 1000; -const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=10, s-maxage=15, stale-while-revalidate=30' }; +// the number still visibly moves while someone is on the form. Display only: POST /api/register/start-payment reads the +// database live to decide who may pay. Excluded from the global no-store API header in next.config.ts. +const CACHE_TTL_MS = 5 * 1000; +const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=5, s-maxage=5, stale-while-revalidate=10' }; const NO_STORE = { 'Cache-Control': 'no-store' }; -let cached: { body: { total: number; left: number }; expires: number } | null = null; +let cached: { body: { total: number; left: number; closed: boolean }; expires: number } | null = null; /** * GET /api/seats - * The seat counter on the registration form: { total, left } (supabase/schema.sql, section 17). Only these two numbers, - * 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 } (supabase/schema.sql, sections 17 and 20). Only + * these, nothing about any registration. 503 while the counter isn't set up: the form then hides it. */ export async function GET() { try { @@ -28,7 +28,7 @@ export async function GET() { const { data, error } = await getSupabaseAdmin() .from('registration_counter') - .select('total_seats, seats_left') + .select('total_seats, seats_left, closed_at') .eq('id', 1) .maybeSingle(); if (error || !data) { @@ -36,7 +36,7 @@ export async function GET() { return NextResponse.json({ error: 'Seat counter unavailable' }, { status: 503, headers: NO_STORE }); } - const body = { total: data.total_seats, left: data.seats_left }; + const body = { total: data.total_seats, left: data.seats_left, closed: data.closed_at !== null }; cached = { body, expires: Date.now() + CACHE_TTL_MS }; return NextResponse.json(body, { headers: CACHE_HEADERS }); } catch (err: unknown) { diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx index caaa429..e005538 100644 --- a/src/app/terms/page.tsx +++ b/src/app/terms/page.tsx @@ -1,6 +1,5 @@ import Link from 'next/link'; import LegalPage, { LegalContacts } from '@/components/legal/LegalPage'; -import { REGISTRATION_FEE } from '@/config/payment'; import { pageMetadata } from '@/lib/seo'; export const metadata = pageMetadata({ @@ -28,7 +27,11 @@ export default function TermsPage() {

Fee and payment