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
2 changes: 1 addition & 1 deletion src/app/api/admin/entry/overview/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { badRequest, entryAuthFailure, entryRateLimited, entryRpcFailure } from

export const runtime = 'nodejs';

// The dashboard polls every ~12 s; this leaves room for searching and paging on top.
// The dashboard polls every 30 s; this leaves room for searching and paging on top.
const overviewLimiter = createRateLimiter({ limit: 60, windowMs: 60 * 1000 });

const PAGE_SIZE = 30;
Expand Down
41 changes: 27 additions & 14 deletions src/app/api/admin/registrations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,30 @@ async function rowsById(supabase: SupabaseClient, table: string, columns: string
* 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>[]) {
async function withAttempts(supabase: SupabaseClient, registrations: Record<string, unknown>[], isWholeTable: boolean) {
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>[]));
if (isWholeTable) {
// The dashboard's list: every rejected row is already here, so this costs no request.
rejected.push(...(registrations.filter((r) => r.status === 'rejected' && typeof r.user_id === 'string') as Record<string, string>[]));
} else {
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]);
for (const r of rejected) {
const mine = byUser.get(r.user_id);
if (mine) mine.push(r);
else byUser.set(r.user_id, [r]);
}
return {
error: null,
registrations: registrations.map((r) => {
Expand All @@ -68,12 +77,14 @@ async function withAttempts(supabase: SupabaseClient, registrations: Record<stri
/** 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'))];
// A referrer already in this list needs no lookup, which on the dashboard's unfiltered list is all of them.
const listed = new Map(registrations.map((r) => [r.id as string, r as Record<string, string>]));
const [friends, ambassadors] = await Promise.all([
rowsById(supabase, 'registrations', 'id, referral_code, name', ids('referred_by')),
rowsById(supabase, 'registrations', 'id, referral_code, name', ids('referred_by').filter((id) => !listed.has(id))),
rowsById(supabase, 'campus_ambassadors', 'id, code, name', ids('ambassador_id')),
]);
if (friends.error || ambassadors.error) return { error: friends.error || ambassadors.error, registrations };
const friendById = new Map(friends.rows.map((f) => [f.id, f]));
const friendById = new Map([...listed, ...friends.rows.map((f) => [f.id, f] as const)]);
const ambassadorById = new Map(ambassadors.rows.map((a) => [a.id, a]));
return {
error: null,
Expand Down Expand Up @@ -189,7 +200,9 @@ 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 });
}
const counted = await withAttempts(supabase, enriched.registrations);
const isWholeTable =
(!status || status === 'all') && (!studentType || studentType === 'all') && !ambassador && !query;
const counted = await withAttempts(supabase, enriched.registrations, isWholeTable);
if (counted.error) {
console.error('/api/admin/registrations attempts lookup error:', counted.error);
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/admin/users/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdmin } from '@/lib/auth-server';
import { forgetCachedRole, verifyAdmin } from '@/lib/auth-server';
import { isUuid, readJsonObject } from '@/lib/security';

export const runtime = 'nodejs';
Expand Down Expand Up @@ -117,6 +117,7 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
}

forgetCachedRole(userId);
return NextResponse.json({ success: true, user: updated });
} catch (err: unknown) {
console.error('/api/admin/users error:', err);
Expand Down
4 changes: 3 additions & 1 deletion src/app/api/auth/session/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 { getSupabaseAdmin } from '@/lib/supabase';
import { safeHttpUrl } from '@/lib/validation';
import { hashSessionToken, readJsonObject } from '@/lib/security';
import { isTokenExpired } from '@/lib/auth-server';

export const runtime = 'nodejs';

Expand All @@ -24,7 +25,8 @@ export async function GET(req: NextRequest) {
let currentAccessToken = accessToken;
let currentRefreshToken = refreshToken;

if (accessToken) {
// An expired token goes straight to the refresh below: Supabase Auth could only refuse it.
if (accessToken && !isTokenExpired(accessToken)) {
const userResult = await supabase.auth.getUser(accessToken);
if (userResult.data?.user) {
activeUser = userResult.data.user;
Expand Down
5 changes: 3 additions & 2 deletions src/app/api/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { PUBLIC_EVENT_COLUMNS } from '@/lib/events';
export const runtime = 'nodejs';

// Public, unauthenticated endpoint: serve from a short in-process cache and let the CDN cache it too, so floods of
// anonymous requests don't each become a service-role database query.
// anonymous requests don't each become a service-role database query. The CDN keeps it as long as the /events pages
// keep theirs (revalidate = 300).
const CACHE_TTL_MS = 30 * 1000;
const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=30, s-maxage=60, stale-while-revalidate=300' };
const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=30, s-maxage=300, stale-while-revalidate=300' };
let cached: { body: unknown; expires: number } | null = null;

// Public GET route to fetch all published events
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/gallery/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const runtime = 'nodejs';
// Public, unauthenticated endpoint: serve from a short in-process cache and let the CDN cache it too, so floods of
// anonymous requests don't each become a service-role database query.
const CACHE_TTL_MS = 30 * 1000;
const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=30, s-maxage=60, stale-while-revalidate=300' };
const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=30, s-maxage=300, stale-while-revalidate=300' };
let cached: { body: unknown; expires: number } | null = null;

// Public GET route to fetch all gallery photos
Expand Down
8 changes: 5 additions & 3 deletions src/app/api/pass/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ interface MyEntryPass {
*/
export async function POST(req: NextRequest) {
try {
const auth = await getAuthenticatedUser(req);
// Polled while MY PASS is open. A POST only because the first call after approval writes.
const auth = await getAuthenticatedUser(req, { cached: true });
if (!auth) {
return NextResponse.json({ error: 'Your session has expired. Please log in again.' }, { status: 401 });
}
Expand All @@ -48,8 +49,9 @@ export async function POST(req: NextRequest) {
const mine = data as MyEntryPass;

// '*' 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();
const details = mine.registration_id
? await db.from('registrations').select('*').eq('user_id', auth.user.id).eq('registration_id', mine.registration_id).maybeSingle()
: { data: null, error: null };
if (details.error) return NextResponse.json({ error: 'Could not load your registration.' }, { status: 500 });
const pass: ParticipantPass = {
registrationStatus: mine.registration_status,
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/seats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ 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. Display only: POST /api/register/start-payment reads the
// the number still visibly moves on the form's 30-second poll. 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 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;

Expand Down
89 changes: 68 additions & 21 deletions src/components/innovision/AdminDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
'use client';

import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import type { UserProfile, Registration } from '@/lib/supabase';
import { getSupabase, fetchSessionFromDatabase, getCookie } from '@/lib/supabase';
import { csvCell, formatReferralCode, GENDER_OPTIONS, safeHttpUrl } from '@/lib/validation';
import { csvCell, formatReferralCode, GENDER_OPTIONS, matchesRegistrationSearch, safeHttpUrl, searchTerm } from '@/lib/validation';

const genderLabel = (value: string) => GENDER_OPTIONS.find((g) => g.value === value)?.label ?? value;
import EventsManager from './admin/EventsManager';
Expand All @@ -19,6 +19,9 @@ import { ID_CARD_REUPLOAD_REASONS, idCardReuploadLabel, type IdCardReuploadReaso
/** A registration that still needs its college ID card (supabase/schema.sql section 19); rejected ones never do. */
const idCardMissing = (r: Registration) => r.status !== 'rejected' && !r.id_card_path;

/** A filter or search change reloads the list in the background only if it was last loaded longer ago than this. */
const REGS_FRESH_MS = 60_000;

/** Roles an admin can give from the USERS tab (/api/admin/users); 'admin' is set in the database only. */
type AssignableRole = 'user' | 'it-team' | 'registration-team' | 'viewer';

Expand Down Expand Up @@ -47,7 +50,10 @@ export default function AdminDashboard({

const defaultTab = canViewRegistrations ? 'registrations' : 'events';
const [tab, setTab] = useState<'registrations' | 'referrals' | 'ambassadors' | 'events' | 'gallery' | 'users' | 'entry'>(defaultTab);
const [registrations, setRegistrations] = useState<Registration[]>([]);
// Every registration, loaded once: the filters and search below work over it without asking the server again.
const [allRegistrations, setRegistrations] = useState<Registration[]>([]);
// Each row's status when loaded: the status filter uses it, so a row just reviewed stays in view until the next load.
const [loadedStatus, setLoadedStatus] = useState<Map<string, string>>(new Map());
const [users, setUsers] = useState<UserProfile[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [loadingUsers, setLoadingUsers] = useState(false);
Expand Down Expand Up @@ -80,6 +86,16 @@ export default function AdminDashboard({
const [searchReg, setSearchReg] = useState<string>('');
const [searchUser, setSearchUser] = useState<string>('');

const registrations = useMemo(() => {
const search = searchTerm(searchReg);
return allRegistrations.filter(
(r) =>
(statusFilter === 'all' || (loadedStatus.get(r.id ?? '') ?? r.status) === statusFilter) &&
(typeFilter === 'all' || r.student_type === typeFilter) &&
(!search || matchesRegistrationSearch(r, search))
);
}, [allRegistrations, loadedStatus, statusFilter, typeFilter, searchReg]);

// Pagination states (30 items per page)
const [regPage, setRegPage] = useState<number>(1);
const [userPage, setUserPage] = useState<number>(1);
Expand Down Expand Up @@ -156,39 +172,48 @@ export default function AdminDashboard({
}
};

// Fetch registrations
const fetchRegistrations = useCallback(async () => {
if (!canViewRegistrations) return;
const regsRequest = useRef(0);
const regsLoading = useRef(false);
const regsLoadedAt = useRef(0);

// Fetch every registration. `background` keeps the table on screen; `opening`: the SEAT COUNTER card loads by itself as
// the dashboard opens, so it needn't be told to reload.
const fetchRegistrations = useCallback(async ({ background = false, opening = false } = {}) => {
if (!canViewRegistrations || (background && regsLoading.current)) return;
const request = ++regsRequest.current;
regsLoading.current = true;
try {
setLoadingRegs(true);
if (!background) setLoadingRegs(true);
setErrorMsg('');
const token = await getAuthToken();

const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
if (typeFilter !== 'all') params.set('student_type', typeFilter);
if (searchReg.trim()) params.set('q', searchReg.trim());

const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}

const res = await fetch(`/api/admin/registrations?${params.toString()}`, {
const res = await fetch('/api/admin/registrations', {
headers,
credentials: 'include',
});
const data = await res.json().catch(() => ({}));
if (request !== regsRequest.current) return; // a newer request superseded this one
if (!res.ok) throw new Error(httpErrorMessage(res) || data.error || 'Failed to fetch registrations');
setRegistrations(data.registrations || []);
setSeatsKey((k) => k + 1);
setLoadedStatus(new Map((data.registrations || []).map((r: Registration) => [r.id ?? '', r.status])));
regsLoadedAt.current = Date.now();
if (!opening) setSeatsKey((k) => k + 1);
} catch (err: unknown) {
if (request !== regsRequest.current) return;
const message = err instanceof Error ? err.message : 'Error loading registrations';
setErrorMsg(message);
} finally {
setLoadingRegs(false);
if (request === regsRequest.current) {
regsLoading.current = false;
setLoadingRegs(false);
}
}
}, [statusFilter, typeFilter, searchReg, canViewRegistrations]);
}, [canViewRegistrations]);

// Fetch users (Admin only: IT-Team cannot access users)
const fetchUsers = useCallback(async () => {
Expand Down Expand Up @@ -221,11 +246,33 @@ export default function AdminDashboard({
useEffect(() => {
if (!isOpen) return;
const timer = setTimeout(() => {
if (canViewRegistrations) fetchRegistrations();
if (isAdmin) fetchUsers();
if (canViewRegistrations) fetchRegistrations({ opening: true });
}, 0);
return () => clearTimeout(timer);
}, [isOpen, fetchRegistrations, canViewRegistrations]);

// Filtering costs no request, but changing a filter used to bring in other staff's reviews too: still does, if the
// list is more than a minute old.
useEffect(() => {
if (!isOpen || !regsLoadedAt.current || Date.now() - regsLoadedAt.current < REGS_FRESH_MS) return;
const timer = setTimeout(() => fetchRegistrations({ background: true }), 0);
return () => clearTimeout(timer);
}, [isOpen, statusFilter, typeFilter, searchReg, fetchRegistrations]);

// The user directory loads when the USERS tab first opens, and again the next time the dashboard is opened.
const usersLoaded = useRef(false);
useEffect(() => {
if (!isOpen) {
usersLoaded.current = false;
return;
}
if (!isAdmin || tab !== 'users' || usersLoaded.current) return;
const timer = setTimeout(() => {
usersLoaded.current = true;
fetchUsers();
}, 0);
return () => clearTimeout(timer);
}, [isOpen, fetchRegistrations, fetchUsers, isAdmin, canViewRegistrations]);
}, [isOpen, isAdmin, tab, fetchUsers]);

// Active tab: 'users' is strictly admin-only; staff have their own tabs
let activeTab = tab;
Expand Down Expand Up @@ -1109,7 +1156,7 @@ export default function AdminDashboard({
cursor: 'pointer',
}}
>
USERS & ROLES ({users.length})
USERS & ROLES{users.length > 0 ? ` (${users.length})` : ''}
</button>
)}

Expand Down Expand Up @@ -1375,7 +1422,7 @@ export default function AdminDashboard({

<button
type="button"
onClick={fetchRegistrations}
onClick={() => fetchRegistrations()}
style={{
height: '40px',
padding: '0 16px',
Expand Down
4 changes: 2 additions & 2 deletions src/components/innovision/AuthOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,9 @@ export default function AuthOverlay({ v }: { v: V }) {
</label>
<label data-s-in="" style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<span style={{ fontSize: "12px", fontWeight: "700", letterSpacing: ".16em", color: "rgba(236,232,223,.86)" }}>
COLLEGE ROLL / ENROLLMENT NUMBER / EMPLOYEE ID
ROLL NUMBER / EMPLOYEE ID
</span>
<input name="enrollment_no" defaultValue={v.regVals.enrollment_no} autoComplete="off" required placeholder="Your roll / enrollment number or employee ID"aria-invalid={v.inv.enrollment_no} style={{ height: "54px", padding: "0 16px", borderRadius: "0", background: "rgba(236,232,223,.04)", fontSize: "16px", color: "#ECE8DF", outline: "none", transition: "border-color .3s,background-color .3s", border: `1.5px solid ${v.bc.enrollment_no}` }} style-focus="border-color:oklch(0.78 0.075 85);background:rgba(236,232,223,.08)" />
<input name="enrollment_no" defaultValue={v.regVals.enrollment_no} autoComplete="off" required placeholder="Your roll number or employee ID" aria-invalid={v.inv.enrollment_no} style={{ height: "54px", padding: "0 16px", borderRadius: "0", background: "rgba(236,232,223,.04)", fontSize: "16px", color: "#ECE8DF", outline: "none", transition: "border-color .3s,background-color .3s", border: `1.5px solid ${v.bc.enrollment_no}` }} style-focus="border-color:oklch(0.78 0.075 85);background:rgba(236,232,223,.08)" />
{v.err.enrollment_no ? (<span role="alert" style={{ fontSize: "14px", lineHeight: "1.4", color: "oklch(0.76 0.14 35)" }}>{v.err.enrollment_no}</span>) : null}
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
Expand Down
Loading
Loading