diff --git a/src/app/api/admin/entry/overview/route.ts b/src/app/api/admin/entry/overview/route.ts index 4b7eb5f..ba68f91 100644 --- a/src/app/api/admin/entry/overview/route.ts +++ b/src/app/api/admin/entry/overview/route.ts @@ -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; diff --git a/src/app/api/admin/registrations/route.ts b/src/app/api/admin/registrations/route.ts index b4eb4da..fd84ff4 100644 --- a/src/app/api/admin/registrations/route.ts +++ b/src/app/api/admin/registrations/route.ts @@ -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[]) { +async function withAttempts(supabase: SupabaseClient, registrations: Record[], isWholeTable: boolean) { const userIds = [...new Set(registrations.map((r) => r.user_id).filter((v): v is string => typeof v === 'string'))]; const rejected: Record[] = []; - 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[])); + 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[])); + } 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[])); + } } const byUser = new Map[]>(); - 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) => { @@ -68,12 +77,14 @@ async function withAttempts(supabase: SupabaseClient, registrations: Record[]) { 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])); 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, @@ -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 }); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 42d3151..0272197 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -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'; @@ -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); diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 8aadfb0..664de67 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -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'; @@ -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; diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index d8b3175..f9262e2 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -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 diff --git a/src/app/api/gallery/route.ts b/src/app/api/gallery/route.ts index 58d2ab1..33ec8eb 100644 --- a/src/app/api/gallery/route.ts +++ b/src/app/api/gallery/route.ts @@ -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 diff --git a/src/app/api/pass/route.ts b/src/app/api/pass/route.ts index d0bd359..b014287 100644 --- a/src/app/api/pass/route.ts +++ b/src/app/api/pass/route.ts @@ -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 }); } @@ -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, diff --git a/src/app/api/seats/route.ts b/src/app/api/seats/route.ts index 39981f8..b88cbf8 100644 --- a/src/app/api/seats/route.ts +++ b/src/app/api/seats/route.ts @@ -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; diff --git a/src/components/innovision/AdminDashboard.tsx b/src/components/innovision/AdminDashboard.tsx index 4091066..27fa8e4 100644 --- a/src/components/innovision/AdminDashboard.tsx +++ b/src/components/innovision/AdminDashboard.tsx @@ -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'; @@ -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'; @@ -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([]); + // Every registration, loaded once: the filters and search below work over it without asking the server again. + const [allRegistrations, setRegistrations] = useState([]); + // 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>(new Map()); const [users, setUsers] = useState([]); const [loadingRegs, setLoadingRegs] = useState(false); const [loadingUsers, setLoadingUsers] = useState(false); @@ -80,6 +86,16 @@ export default function AdminDashboard({ const [searchReg, setSearchReg] = useState(''); const [searchUser, setSearchUser] = useState(''); + 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(1); const [userPage, setUserPage] = useState(1); @@ -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 = {}; 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 () => { @@ -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; @@ -1109,7 +1156,7 @@ export default function AdminDashboard({ cursor: 'pointer', }} > - USERS & ROLES ({users.length}) + USERS & ROLES{users.length > 0 ? ` (${users.length})` : ''} )} @@ -1375,7 +1422,7 @@ export default function AdminDashboard({