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
6 changes: 3 additions & 3 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ const nextConfig: NextConfig = {
],
},
{
// API responses carry personal data and tokens: never let a browser or CDN cache them. The two public,
// anonymous endpoints (events, gallery) are excluded and set their own cache headers.
source: "/api/:path((?!events$|gallery$).*)",
// API responses carry personal data and tokens: never let a browser or CDN cache them. The three public,
// anonymous endpoints (events, gallery, seats) are excluded and set their own cache headers.
source: "/api/:path((?!events$|gallery$|seats$).*)",
headers: [{ key: "Cache-Control", value: "private, no-store, max-age=0" }],
},
{
Expand Down
52 changes: 50 additions & 2 deletions src/app/api/admin/registrations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import type { SupabaseClient } from '@supabase/supabase-js';
import { verifyRegistrationStaff } from '@/lib/auth-server';
import { isUuid, readJsonObject, sanitizeFilterValue } from '@/lib/security';
import { isRejectionReason } from '@/lib/rejections';

export const runtime = 'nodejs';

Expand All @@ -27,6 +28,42 @@ async function rowsById(supabase: SupabaseClient, table: string, columns: string
return { rows, error: null };
}

/**
* attempt for every row (1 = the user's first registration, 2 = made again after one rejection, ...) and
* previous_rejection_reason, why the user's registration before it was rejected. Every earlier registration of a user
* is a rejected one (only one may be pending or confirmed), so the user's rejected rows are enough, whatever the
* filters list.
*/
async function withAttempts(supabase: SupabaseClient, registrations: Record<string, unknown>[]) {
const userIds = [...new Set(registrations.map((r) => r.user_id).filter((v): v is string => typeof v === 'string'))];
const rejected: Record<string, string>[] = [];
for (let i = 0; i < userIds.length; i += LOOKUP_CHUNK) {
// '*' rather than naming rejection_reason, so the list keeps loading on a database without section 17 yet.
const { data, error } = await supabase
.from('registrations')
.select('*')
.eq('status', 'rejected')
.in('user_id', userIds.slice(i, i + LOOKUP_CHUNK));
if (error) return { error, registrations };
rejected.push(...((data ?? []) as Record<string, string>[]));
}
const byUser = new Map<string, Record<string, string>[]>();
for (const r of rejected) byUser.set(r.user_id, [...(byUser.get(r.user_id) ?? []), r]);
return {
error: null,
registrations: registrations.map((r) => {
const earlier = (byUser.get(r.user_id as string) ?? [])
.filter((x) => x.id !== r.id && x.created_at < (r.created_at as string))
.sort((a, b) => (a.created_at < b.created_at ? -1 : 1));
return {
...r,
attempt: earlier.length + 1,
previous_rejection_reason: earlier.at(-1)?.rejection_reason ?? null,
};
}),
};
}

/** referred_via for every row: the referrer's code and name, looked up whether or not the filters list the referrer. */
async function withReferredVia(supabase: SupabaseClient, registrations: Record<string, unknown>[]) {
const ids = (key: string) => [...new Set(registrations.map((r) => r[key]).filter((v): v is string => typeof v === 'string'))];
Expand Down Expand Up @@ -133,7 +170,12 @@ export async function GET(req: NextRequest) {
console.error('/api/admin/registrations referrer lookup error:', enriched.error);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
return NextResponse.json({ success: true, registrations: enriched.registrations });
const counted = await withAttempts(supabase, enriched.registrations);
if (counted.error) {
console.error('/api/admin/registrations attempts lookup error:', counted.error);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}
return NextResponse.json({ success: true, registrations: counted.registrations });
} catch (err: unknown) {
console.error('/api/admin/registrations error:', err);
const message = 'Failed to fetch registrations';
Expand All @@ -156,7 +198,7 @@ export async function PATCH(req: NextRequest) {
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: parsed.status });
}
const { registrationId, status } = parsed.value;
const { registrationId, status, reason } = parsed.value;

if (!registrationId || !status) {
return NextResponse.json(
Expand All @@ -176,6 +218,11 @@ export async function PATCH(req: NextRequest) {
);
}

// Every rejection says why: the student sees it on their pass before registering again.
if (status === 'rejected' && !isRejectionReason(reason)) {
return NextResponse.json({ error: 'Choose why this registration is rejected.' }, { status: 400 });
}

const { supabase, user } = auth;

// Fetch existing registration to verify status
Expand Down Expand Up @@ -214,6 +261,7 @@ export async function PATCH(req: NextRequest) {
.from('registrations')
.update({
status,
...(status === 'rejected' ? { rejection_reason: reason } : {}),
reviewed_by: user.id,
reviewed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
Expand Down
102 changes: 102 additions & 0 deletions src/app/api/admin/seats/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server';
import type { SupabaseClient } from '@supabase/supabase-js';
import { verifyAdmin, verifyRegistrationStaff } from '@/lib/auth-server';
import { readJsonObject } from '@/lib/security';
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. */
async function readCounter(supabase: SupabaseClient) {
const { data, error } = await supabase
.from('registration_counter')
.select('total_seats, seats_left, updated_by, 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;
}
return {
error: null,
counter: { total: data.total_seats, left: data.seats_left, 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.';

/**
* GET /api/admin/seats
* The seat counter for the admin dashboard (admins and the registration team).
*/
export async function GET(req: NextRequest) {
try {
const auth = await verifyRegistrationStaff(req);
if ('error' in auth) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}

const { error, counter } = await readCounter(auth.supabase);
if (error) {
console.error('/api/admin/seats read failed:', error);
return NextResponse.json({ error: UNAVAILABLE }, { status: 500 });
}
return NextResponse.json({ success: true, counter });
} catch (err: unknown) {
console.error('/api/admin/seats GET error:', err);
return NextResponse.json({ error: 'Failed to load the seat counter' }, { status: 500 });
}
}

/**
* 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.
*/
export async function PATCH(req: NextRequest) {
try {
// verifyAdmin's 403 message is about user roles; a refusal here says what this route needs instead.
const auth = await verifyAdmin(req);
if ('error' in auth) {
return NextResponse.json(
{ error: auth.status === 403 ? 'Only admins can change the seat counter.' : auth.error },
{ status: auth.status }
);
}

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

const { error: writeError } = await auth.supabase
.from('registration_counter')
.upsert(
{ id: 1, total_seats: total, seats_left: left, updated_by: auth.user.id, updated_at: new Date().toISOString() },
{ onConflict: 'id' }
);
if (writeError) {
console.error('/api/admin/seats write failed:', writeError);
return NextResponse.json({ error: UNAVAILABLE }, { status: 500 });
}

const { error, counter } = await readCounter(auth.supabase);
if (error) {
console.error('/api/admin/seats read after write failed:', error);
return NextResponse.json({ error: 'Saved, but the new counter could not be read back. Refresh.' }, { status: 500 });
}
return NextResponse.json({ success: true, counter });
} catch (err: unknown) {
console.error('/api/admin/seats PATCH error:', err);
return NextResponse.json({ error: 'Failed to update the seat counter' }, { status: 500 });
}
}
12 changes: 11 additions & 1 deletion src/app/api/pass/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getSupabaseAsUser } from '@/lib/supabase';
import { createRateLimiter } from '@/lib/security';
import { decryptEntryToken, encryptEntryToken, entryPassKey, mintEntryToken } from '@/lib/entry-pass';
import type { ParticipantPass } from '@/lib/pass-experience';
import { attemptsLeft } from '@/lib/rejections';

export const runtime = 'nodejs';

Expand Down Expand Up @@ -46,7 +47,8 @@ export async function POST(req: NextRequest) {
}
const mine = data as MyEntryPass;

const details = await db.from('registrations').select('college')
// '*' rather than naming rejection_reason, so the pass keeps loading on a database without section 17 yet.
const details = await db.from('registrations').select('*')
.eq('user_id', auth.user.id).eq('registration_id', mine.registration_id ?? '').maybeSingle();
if (details.error) return NextResponse.json({ error: 'Could not load your registration.' }, { status: 500 });
const pass: ParticipantPass = {
Expand All @@ -58,6 +60,14 @@ export async function POST(req: NextRequest) {
checkedIn: !!mine.checked_in,
token: null as string | null,
};
if (mine.registration_status === 'rejected') {
// The reason staff gave, and whether the student may register again (REGISTER AGAIN on the pass).
const rejected = await db.from('registrations').select('id', { count: 'exact', head: true })
.eq('user_id', auth.user.id).eq('status', 'rejected');
if (rejected.error) return NextResponse.json({ error: 'Could not load your registration.' }, { status: 500 });
pass.rejectionReason = details.data?.rejection_reason ?? null;
pass.attemptsLeft = attemptsLeft(rejected.count ?? 0);
}
if (mine.registration_status !== 'confirmed' || mine.pass_status !== 'active' || !mine.pass) {
return NextResponse.json({ success: true, pass }, { headers: { 'Cache-Control': 'private, no-store' } });
}
Expand Down
72 changes: 58 additions & 14 deletions src/app/api/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { paymentProofExists } from '@/lib/payment-proofs';
import { ambassadorGuardError, findOwnAmbassador, resolveReferralCode, type Registrant } from '@/lib/ambassadors';
import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate';
import { isDuplicatePhoneError, PHONE_IN_USE_ERROR_MESSAGE } from '@/lib/validation';
import { ATTEMPTS_USED_UP_ERROR, attemptsLeft, isAttemptsUsedUpError } from '@/lib/rejections';
import {
classifyStudentType,
createRateLimiter,
Expand All @@ -22,18 +23,32 @@ const registerLimiter = createRateLimiter({ limit: 10, windowMs: 10 * 60 * 1000
/** Added to a referral-code error at submit: the form goes back to the code with the payment details still filled in. */
const PAYMENT_KEPT_NOTE = 'Nothing was submitted and your payment details are kept: fix or clear the code, then submit again.';

/** Latest registration owned by this user (by id, or by their verified email for legacy rows). */
async function findExistingRegistration(client: SupabaseClient, user: User, verifiedEmail: string) {
/**
* Latest registration owned by this user (by id, or by their verified email for legacy rows). `activeOnly` leaves out
* rejected ones: a rejected registration doesn't stop the user from registering again.
*/
async function findExistingRegistration(
client: SupabaseClient,
user: User,
verifiedEmail: string,
{ activeOnly = false } = {}
) {
const filter = isFilterSafeEmail(verifiedEmail)
? `user_id.eq.${user.id},email.eq.${verifiedEmail}`
: `user_id.eq.${user.id}`;
return client
let query = client.from('registrations').select('*').or(filter);
if (activeOnly) query = query.neq('status', 'rejected');
return query.order('created_at', { ascending: false }).limit(1).maybeSingle();
}

/** How many of this user's registrations staff rejected (each one uses up an attempt). */
async function countRejected(client: SupabaseClient, userId: string) {
const { count, error } = await client
.from('registrations')
.select('*')
.or(filter)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('status', 'rejected');
return { count: count ?? 0, error };
}

/**
Expand All @@ -57,6 +72,7 @@ async function utrConflictResponse(client: SupabaseClient, userId: string) {
.from('registrations')
.select('*')
.eq('user_id', userId)
.neq('status', 'rejected')
.limit(1)
.maybeSingle();
if (error) console.warn('Registration lookup after a UTR conflict failed:', error);
Expand Down Expand Up @@ -99,12 +115,26 @@ export async function GET(req: NextRequest) {
console.warn('Error fetching registration in /api/register GET:', error);
return NextResponse.json({ success: false, error: 'Failed to fetch registration.' }, { status: 500 });
}
if (!registration) return NextResponse.json({ success: true, registration: null });

// A rejected registration also says whether the user may register again (the pass shows REGISTER AGAIN).
let extra = {};
if (registration.status === 'rejected') {
const rejected = await countRejected(adminClient, user.id);
if (rejected.error) {
console.warn('Rejected registrations count failed in /api/register GET:', rejected.error);
return NextResponse.json({ success: false, error: 'Failed to fetch registration.' }, { status: 500 });
}
extra = { attempts_left: attemptsLeft(rejected.count) };
}

return NextResponse.json({
success: true,
registration: registration
? await withAmbassadorCode(adminClient, registration, { userId: user.id, email: verifiedEmail })
: null,
registration: await withAmbassadorCode(
adminClient,
{ ...registration, ...extra },
{ userId: user.id, email: verifiedEmail }
),
});
} catch (err: unknown) {
console.error('API /api/register GET error:', err);
Expand Down Expand Up @@ -165,8 +195,9 @@ export async function POST(req: NextRequest) {

const adminClient = getSupabaseAdmin(token);

// 3. Prevent duplicate registrations for the same authenticated user
const { data: existingReg } = await findExistingRegistration(adminClient, user, verifiedEmail);
// 3. Prevent duplicate registrations for the same authenticated user. A rejected one doesn't count: the user may
// register again, up to MAX_REGISTRATION_ATTEMPTS registrations in all (also enforced by the database).
const { data: existingReg } = await findExistingRegistration(adminClient, user, verifiedEmail, { activeOnly: true });

if (existingReg) {
return NextResponse.json(
Expand All @@ -180,6 +211,15 @@ export async function POST(req: NextRequest) {
);
}

const rejected = await countRejected(adminClient, user.id);
if (rejected.error) {
console.error('Rejected registrations count failed in /api/register POST:', rejected.error);
return NextResponse.json({ error: 'Failed to record registration. Please try again.' }, { status: 500 });
}
if (attemptsLeft(rejected.count) === 0) {
return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 });
}

// A UPI transaction can only pay for one registration.
const { data: utrOwner } = await adminClient
.from('registrations')
Expand Down Expand Up @@ -261,6 +301,10 @@ export async function POST(req: NextRequest) {
if (guardError) {
return NextResponse.json({ error: `${guardError} ${PAYMENT_KEPT_NOTE}`, field: 'referred_by_code' }, { status: 400 });
}
// Rejected for the last time between the check above and this insert (trg_guard_registration_attempts).
if (isAttemptsUsedUpError(regError)) {
return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 });
}
if (regError.code !== '23505') break;
// Unique violation on utr: another registration claimed this UTR between the pre-check and the insert (possibly
// this user's own concurrent submit, when the UTR index is checked before the user_id index).
Expand All @@ -272,7 +316,7 @@ export async function POST(req: NextRequest) {
}
// Unique violation on user_id (concurrent double submit): return the registration that won.
if (!/registration_id|referral_code/i.test(`${regError.message} ${regError.details ?? ''}`)) {
const { data: winner } = await findExistingRegistration(adminClient, user, verifiedEmail);
const { data: winner } = await findExistingRegistration(adminClient, user, verifiedEmail, { activeOnly: true });
if (winner) {
return NextResponse.json({
success: true,
Expand Down
Loading
Loading