Skip to content

Commit c4eefae

Browse files
authored
Merge pull request #80 from OpencodeNIT-R/development
Development
2 parents 70871da + 6fe21f4 commit c4eefae

22 files changed

Lines changed: 390 additions & 88 deletions

File tree

‎src/app/api/admin/entry/overview/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { badRequest, entryAuthFailure, entryRateLimited, entryRpcFailure } from
77

88
export const runtime = 'nodejs';
99

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

1313
const PAGE_SIZE = 30;

‎src/app/api/admin/registrations/route.ts‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,30 @@ async function rowsById(supabase: SupabaseClient, table: string, columns: string
3535
* is a rejected one (only one may be pending or confirmed), so the user's rejected rows are enough, whatever the
3636
* filters list.
3737
*/
38-
async function withAttempts(supabase: SupabaseClient, registrations: Record<string, unknown>[]) {
38+
async function withAttempts(supabase: SupabaseClient, registrations: Record<string, unknown>[], isWholeTable: boolean) {
3939
const userIds = [...new Set(registrations.map((r) => r.user_id).filter((v): v is string => typeof v === 'string'))];
4040
const rejected: Record<string, string>[] = [];
41-
for (let i = 0; i < userIds.length; i += LOOKUP_CHUNK) {
42-
// '*' rather than naming rejection_reason, so the list keeps loading on a database without section 17 yet.
43-
const { data, error } = await supabase
44-
.from('registrations')
45-
.select('*')
46-
.eq('status', 'rejected')
47-
.in('user_id', userIds.slice(i, i + LOOKUP_CHUNK));
48-
if (error) return { error, registrations };
49-
rejected.push(...((data ?? []) as Record<string, string>[]));
41+
if (isWholeTable) {
42+
// The dashboard's list: every rejected row is already here, so this costs no request.
43+
rejected.push(...(registrations.filter((r) => r.status === 'rejected' && typeof r.user_id === 'string') as Record<string, string>[]));
44+
} else {
45+
for (let i = 0; i < userIds.length; i += LOOKUP_CHUNK) {
46+
// '*' rather than naming rejection_reason, so the list keeps loading on a database without section 17 yet.
47+
const { data, error } = await supabase
48+
.from('registrations')
49+
.select('*')
50+
.eq('status', 'rejected')
51+
.in('user_id', userIds.slice(i, i + LOOKUP_CHUNK));
52+
if (error) return { error, registrations };
53+
rejected.push(...((data ?? []) as Record<string, string>[]));
54+
}
5055
}
5156
const byUser = new Map<string, Record<string, string>[]>();
52-
for (const r of rejected) byUser.set(r.user_id, [...(byUser.get(r.user_id) ?? []), r]);
57+
for (const r of rejected) {
58+
const mine = byUser.get(r.user_id);
59+
if (mine) mine.push(r);
60+
else byUser.set(r.user_id, [r]);
61+
}
5362
return {
5463
error: null,
5564
registrations: registrations.map((r) => {
@@ -68,12 +77,14 @@ async function withAttempts(supabase: SupabaseClient, registrations: Record<stri
6877
/** referred_via for every row: the referrer's code and name, looked up whether or not the filters list the referrer. */
6978
async function withReferredVia(supabase: SupabaseClient, registrations: Record<string, unknown>[]) {
7079
const ids = (key: string) => [...new Set(registrations.map((r) => r[key]).filter((v): v is string => typeof v === 'string'))];
80+
// A referrer already in this list needs no lookup, which on the dashboard's unfiltered list is all of them.
81+
const listed = new Map(registrations.map((r) => [r.id as string, r as Record<string, string>]));
7182
const [friends, ambassadors] = await Promise.all([
72-
rowsById(supabase, 'registrations', 'id, referral_code, name', ids('referred_by')),
83+
rowsById(supabase, 'registrations', 'id, referral_code, name', ids('referred_by').filter((id) => !listed.has(id))),
7384
rowsById(supabase, 'campus_ambassadors', 'id, code, name', ids('ambassador_id')),
7485
]);
7586
if (friends.error || ambassadors.error) return { error: friends.error || ambassadors.error, registrations };
76-
const friendById = new Map(friends.rows.map((f) => [f.id, f]));
87+
const friendById = new Map([...listed, ...friends.rows.map((f) => [f.id, f] as const)]);
7788
const ambassadorById = new Map(ambassadors.rows.map((a) => [a.id, a]));
7889
return {
7990
error: null,
@@ -189,7 +200,9 @@ export async function GET(req: NextRequest) {
189200
console.error('/api/admin/registrations referrer lookup error:', enriched.error);
190201
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });
191202
}
192-
const counted = await withAttempts(supabase, enriched.registrations);
203+
const isWholeTable =
204+
(!status || status === 'all') && (!studentType || studentType === 'all') && !ambassador && !query;
205+
const counted = await withAttempts(supabase, enriched.registrations, isWholeTable);
193206
if (counted.error) {
194207
console.error('/api/admin/registrations attempts lookup error:', counted.error);
195208
return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 });

‎src/app/api/admin/users/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextRequest, NextResponse } from 'next/server';
2-
import { verifyAdmin } from '@/lib/auth-server';
2+
import { forgetCachedRole, verifyAdmin } from '@/lib/auth-server';
33
import { isUuid, readJsonObject } from '@/lib/security';
44

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

120+
forgetCachedRole(userId);
120121
return NextResponse.json({ success: true, user: updated });
121122
} catch (err: unknown) {
122123
console.error('/api/admin/users error:', err);

‎src/app/api/auth/session/route.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { getSupabaseAdmin } from '@/lib/supabase';
33
import { safeHttpUrl } from '@/lib/validation';
44
import { hashSessionToken, readJsonObject } from '@/lib/security';
5+
import { isTokenExpired } from '@/lib/auth-server';
56

67
export const runtime = 'nodejs';
78

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

27-
if (accessToken) {
28+
// An expired token goes straight to the refresh below: Supabase Auth could only refuse it.
29+
if (accessToken && !isTokenExpired(accessToken)) {
2830
const userResult = await supabase.auth.getUser(accessToken);
2931
if (userResult.data?.user) {
3032
activeUser = userResult.data.user;

‎src/app/api/events/route.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { PUBLIC_EVENT_COLUMNS } from '@/lib/events';
55
export const runtime = 'nodejs';
66

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

1314
// Public GET route to fetch all published events

‎src/app/api/gallery/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export const runtime = 'nodejs';
66
// Public, unauthenticated endpoint: serve from a short in-process cache and let the CDN cache it too, so floods of
77
// anonymous requests don't each become a service-role database query.
88
const CACHE_TTL_MS = 30 * 1000;
9-
const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=30, s-maxage=60, stale-while-revalidate=300' };
9+
const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=30, s-maxage=300, stale-while-revalidate=300' };
1010
let cached: { body: unknown; expires: number } | null = null;
1111

1212
// Public GET route to fetch all gallery photos

‎src/app/api/pass/route.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ interface MyEntryPass {
2828
*/
2929
export async function POST(req: NextRequest) {
3030
try {
31-
const auth = await getAuthenticatedUser(req);
31+
// Polled while MY PASS is open. A POST only because the first call after approval writes.
32+
const auth = await getAuthenticatedUser(req, { cached: true });
3233
if (!auth) {
3334
return NextResponse.json({ error: 'Your session has expired. Please log in again.' }, { status: 401 });
3435
}
@@ -48,8 +49,9 @@ export async function POST(req: NextRequest) {
4849
const mine = data as MyEntryPass;
4950

5051
// '*' rather than naming rejection_reason, so the pass keeps loading on a database without section 17 yet.
51-
const details = await db.from('registrations').select('*')
52-
.eq('user_id', auth.user.id).eq('registration_id', mine.registration_id ?? '').maybeSingle();
52+
const details = mine.registration_id
53+
? await db.from('registrations').select('*').eq('user_id', auth.user.id).eq('registration_id', mine.registration_id).maybeSingle()
54+
: { data: null, error: null };
5355
if (details.error) return NextResponse.json({ error: 'Could not load your registration.' }, { status: 500 });
5456
const pass: ParticipantPass = {
5557
registrationStatus: mine.registration_status,

‎src/app/api/seats/route.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ export const runtime = 'nodejs';
55

66
// Public, unauthenticated endpoint polled by every open registration form: serve from a short in-process cache and let
77
// the CDN cache it too, so a crowd watching the counter doesn't turn into one database query each. Short enough that
8-
// the number still visibly moves while someone is on the form. Display only: POST /api/register/start-payment reads the
8+
// the number still visibly moves on the form's 30-second poll. Display only: POST /api/register/start-payment reads the
99
// database live to decide who may pay. Excluded from the global no-store API header in next.config.ts.
10-
const CACHE_TTL_MS = 5 * 1000;
11-
const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=5, s-maxage=5, stale-while-revalidate=10' };
10+
const CACHE_TTL_MS = 15 * 1000;
11+
const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=5, s-maxage=15, stale-while-revalidate=15' };
1212
const NO_STORE = { 'Cache-Control': 'no-store' };
1313
let cached: { body: { total: number; left: number; closed: boolean; femaleFull: boolean }; expires: number } | null = null;
1414

‎src/components/innovision/AdminDashboard.tsx‎

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
'use client';
22

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

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

22+
/** A filter or search change reloads the list in the background only if it was last loaded longer ago than this. */
23+
const REGS_FRESH_MS = 60_000;
24+
2225
/** Roles an admin can give from the USERS tab (/api/admin/users); 'admin' is set in the database only. */
2326
type AssignableRole = 'user' | 'it-team' | 'registration-team' | 'viewer';
2427

@@ -47,7 +50,10 @@ export default function AdminDashboard({
4750

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

89+
const registrations = useMemo(() => {
90+
const search = searchTerm(searchReg);
91+
return allRegistrations.filter(
92+
(r) =>
93+
(statusFilter === 'all' || (loadedStatus.get(r.id ?? '') ?? r.status) === statusFilter) &&
94+
(typeFilter === 'all' || r.student_type === typeFilter) &&
95+
(!search || matchesRegistrationSearch(r, search))
96+
);
97+
}, [allRegistrations, loadedStatus, statusFilter, typeFilter, searchReg]);
98+
8399
// Pagination states (30 items per page)
84100
const [regPage, setRegPage] = useState<number>(1);
85101
const [userPage, setUserPage] = useState<number>(1);
@@ -156,39 +172,48 @@ export default function AdminDashboard({
156172
}
157173
};
158174

159-
// Fetch registrations
160-
const fetchRegistrations = useCallback(async () => {
161-
if (!canViewRegistrations) return;
175+
const regsRequest = useRef(0);
176+
const regsLoading = useRef(false);
177+
const regsLoadedAt = useRef(0);
178+
179+
// Fetch every registration. `background` keeps the table on screen; `opening`: the SEAT COUNTER card loads by itself as
180+
// the dashboard opens, so it needn't be told to reload.
181+
const fetchRegistrations = useCallback(async ({ background = false, opening = false } = {}) => {
182+
if (!canViewRegistrations || (background && regsLoading.current)) return;
183+
const request = ++regsRequest.current;
184+
regsLoading.current = true;
162185
try {
163-
setLoadingRegs(true);
186+
if (!background) setLoadingRegs(true);
164187
setErrorMsg('');
165188
const token = await getAuthToken();
166189

167-
const params = new URLSearchParams();
168-
if (statusFilter !== 'all') params.set('status', statusFilter);
169-
if (typeFilter !== 'all') params.set('student_type', typeFilter);
170-
if (searchReg.trim()) params.set('q', searchReg.trim());
171-
172190
const headers: Record<string, string> = {};
173191
if (token) {
174192
headers['Authorization'] = `Bearer ${token}`;
175193
}
176194

177-
const res = await fetch(`/api/admin/registrations?${params.toString()}`, {
195+
const res = await fetch('/api/admin/registrations', {
178196
headers,
179197
credentials: 'include',
180198
});
181199
const data = await res.json().catch(() => ({}));
200+
if (request !== regsRequest.current) return; // a newer request superseded this one
182201
if (!res.ok) throw new Error(httpErrorMessage(res) || data.error || 'Failed to fetch registrations');
183202
setRegistrations(data.registrations || []);
184-
setSeatsKey((k) => k + 1);
203+
setLoadedStatus(new Map((data.registrations || []).map((r: Registration) => [r.id ?? '', r.status])));
204+
regsLoadedAt.current = Date.now();
205+
if (!opening) setSeatsKey((k) => k + 1);
185206
} catch (err: unknown) {
207+
if (request !== regsRequest.current) return;
186208
const message = err instanceof Error ? err.message : 'Error loading registrations';
187209
setErrorMsg(message);
188210
} finally {
189-
setLoadingRegs(false);
211+
if (request === regsRequest.current) {
212+
regsLoading.current = false;
213+
setLoadingRegs(false);
214+
}
190215
}
191-
}, [statusFilter, typeFilter, searchReg, canViewRegistrations]);
216+
}, [canViewRegistrations]);
192217

193218
// Fetch users (Admin only: IT-Team cannot access users)
194219
const fetchUsers = useCallback(async () => {
@@ -221,11 +246,33 @@ export default function AdminDashboard({
221246
useEffect(() => {
222247
if (!isOpen) return;
223248
const timer = setTimeout(() => {
224-
if (canViewRegistrations) fetchRegistrations();
225-
if (isAdmin) fetchUsers();
249+
if (canViewRegistrations) fetchRegistrations({ opening: true });
250+
}, 0);
251+
return () => clearTimeout(timer);
252+
}, [isOpen, fetchRegistrations, canViewRegistrations]);
253+
254+
// Filtering costs no request, but changing a filter used to bring in other staff's reviews too: still does, if the
255+
// list is more than a minute old.
256+
useEffect(() => {
257+
if (!isOpen || !regsLoadedAt.current || Date.now() - regsLoadedAt.current < REGS_FRESH_MS) return;
258+
const timer = setTimeout(() => fetchRegistrations({ background: true }), 0);
259+
return () => clearTimeout(timer);
260+
}, [isOpen, statusFilter, typeFilter, searchReg, fetchRegistrations]);
261+
262+
// The user directory loads when the USERS tab first opens, and again the next time the dashboard is opened.
263+
const usersLoaded = useRef(false);
264+
useEffect(() => {
265+
if (!isOpen) {
266+
usersLoaded.current = false;
267+
return;
268+
}
269+
if (!isAdmin || tab !== 'users' || usersLoaded.current) return;
270+
const timer = setTimeout(() => {
271+
usersLoaded.current = true;
272+
fetchUsers();
226273
}, 0);
227274
return () => clearTimeout(timer);
228-
}, [isOpen, fetchRegistrations, fetchUsers, isAdmin, canViewRegistrations]);
275+
}, [isOpen, isAdmin, tab, fetchUsers]);
229276

230277
// Active tab: 'users' is strictly admin-only; staff have their own tabs
231278
let activeTab = tab;
@@ -1109,7 +1156,7 @@ export default function AdminDashboard({
11091156
cursor: 'pointer',
11101157
}}
11111158
>
1112-
USERS & ROLES ({users.length})
1159+
USERS & ROLES{users.length > 0 ? ` (${users.length})` : ''}
11131160
</button>
11141161
)}
11151162

@@ -1375,7 +1422,7 @@ export default function AdminDashboard({
13751422

13761423
<button
13771424
type="button"
1378-
onClick={fetchRegistrations}
1425+
onClick={() => fetchRegistrations()}
13791426
style={{
13801427
height: '40px',
13811428
padding: '0 16px',

‎src/components/innovision/AuthOverlay.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,9 @@ export default function AuthOverlay({ v }: { v: V }) {
230230
</label>
231231
<label data-s-in="" style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
232232
<span style={{ fontSize: "12px", fontWeight: "700", letterSpacing: ".16em", color: "rgba(236,232,223,.86)" }}>
233-
COLLEGE ROLL / ENROLLMENT NUMBER / EMPLOYEE ID
233+
ROLL NUMBER / EMPLOYEE ID
234234
</span>
235-
<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)" />
235+
<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)" />
236236
{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}
237237
</label>
238238
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>

0 commit comments

Comments
 (0)