From 314b999627aa7e7bf882f9563d5db2a38998aed9 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Tue, 24 Mar 2026 00:13:47 +0800 Subject: [PATCH 01/14] feat: add admin layout, access control, and navigation --- src/hooks.server.ts | 10 +++++++- src/lib/NavBar.svelte | 37 ++++++++++++++++++++++++++-- src/routes/+layout.svelte | 2 +- src/routes/admin/+error.svelte | 20 +++++++++++++++ src/routes/admin/+layout.server.ts | 12 +++++++++ src/routes/admin/+layout.svelte | 39 ++++++++++++++++++++++++++++++ src/routes/admin/+page.svelte | 4 +++ 7 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 src/routes/admin/+error.svelte create mode 100644 src/routes/admin/+layout.server.ts create mode 100644 src/routes/admin/+layout.svelte create mode 100644 src/routes/admin/+page.svelte diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e4e5ede..f24bb74 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,5 @@ +import { type Handle, redirect } from '@sveltejs/kit'; import type { AppRole } from '$lib/server/auth'; -import { type Handle } from '@sveltejs/kit'; import { createServerClient } from '@supabase/ssr'; import { sequence } from '@sveltejs/kit/hooks'; @@ -63,6 +63,14 @@ const authGuard: Handle = async ({ event, resolve }) => { locals.user = user; locals.userRole = userRole; + const { pathname } = event.url; + const isPublicRoute = pathname === '/login' || pathname.startsWith('/login/'); + const isApiRoute = pathname.startsWith('/api/'); + + if (!session && !isPublicRoute && !isApiRoute) { + redirect(303, '/login'); + } + return resolve(event); }; diff --git a/src/lib/NavBar.svelte b/src/lib/NavBar.svelte index 7c24b49..ab4f5b3 100644 --- a/src/lib/NavBar.svelte +++ b/src/lib/NavBar.svelte @@ -1,11 +1,13 @@ @@ -46,6 +48,37 @@ {/each} + + {#if userRole === 'admin'} +
+ + Admin + +
+ {#each adminOptions as option, i (option)} + { + isNavBarOpen = false; + }} + > +
+ {#if option === 'Constiquiz Responses'} + + {:else} + + {/if} +
{option}
+
+
+ {/each} + {/if}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3fd9b28..1a51d6a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -93,7 +93,7 @@ class="fixed top-0 left-0 z-50 h-screen w-64 transition-transform duration-300 ease-in-out {isNavBarOpen ? 'translate-x-0' : '-translate-x-full'}" > - + {/if} diff --git a/src/routes/admin/+error.svelte b/src/routes/admin/+error.svelte new file mode 100644 index 0000000..1fafb9d --- /dev/null +++ b/src/routes/admin/+error.svelte @@ -0,0 +1,20 @@ + + +
+
+ +

Access Denied

+

+ {page.error?.message ?? 'You do not have permission to view this page.'} +

+ + Back to Dashboard + +
+
diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts new file mode 100644 index 0000000..887d054 --- /dev/null +++ b/src/routes/admin/+layout.server.ts @@ -0,0 +1,12 @@ +import type { LayoutServerLoad } from './$types'; +import { requireRole } from '$lib/server/auth'; + +export const load: LayoutServerLoad = event => { + const { user } = requireRole(event, 'admin'); + return { + adminUser: { + id: user.id, + email: user.email, + }, + }; +}; diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..f1f19da --- /dev/null +++ b/src/routes/admin/+layout.svelte @@ -0,0 +1,39 @@ + + +
+ + + {@render children()} +
diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte new file mode 100644 index 0000000..19bc863 --- /dev/null +++ b/src/routes/admin/+page.svelte @@ -0,0 +1,4 @@ +
+

Admin Dashboard

+

Dashboard content coming soon.

+
From 582370450647ff4edf5ddedd80c195f46b99b06b Mon Sep 17 00:00:00 2001 From: carlsalces Date: Tue, 24 Mar 2026 00:14:21 +0800 Subject: [PATCH 02/14] feat: set up Vitest testing infrastructure --- package.json | 7 +- pnpm-lock.yaml | 280 ++++++++++++++++++++++++++++ src/lib/server/__tests__/helpers.ts | 58 ++++++ vite.config.js | 9 +- 4 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 src/lib/server/__tests__/helpers.ts diff --git a/package.json b/package.json index 4ee68d2..4c81da9 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "lint:html": "linthtml src/**/*.html", "lint:css": "stylelint src/**/*.css", "lint:js": "eslint", - "lint:svelte": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + "lint:svelte": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest", + "test:run": "vitest run" }, "dependencies": { "@googleapis/drive": "^12.0.0", @@ -50,7 +52,8 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.31.1", "typescript-svelte-plugin": "^0.3.46", - "vite": "^6.3.3" + "vite": "^6.3.3", + "vitest": "^4.1.1" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5281e89..f5f9d7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: vite: specifier: ^6.3.3 version: 6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2) + vitest: + specifier: ^4.1.1 + version: 4.1.1(@types/node@22.15.3)(vite@6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2)) packages: @@ -382,6 +385,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -562,6 +568,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@supabase/auth-js@2.69.1': resolution: {integrity: sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==} @@ -713,9 +722,15 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} @@ -787,6 +802,35 @@ packages: resolution: {integrity: sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/expect@4.1.1': + resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==} + + '@vitest/mocker@4.1.1': + resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.1': + resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==} + + '@vitest/runner@4.1.1': + resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==} + + '@vitest/snapshot@4.1.1': + resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==} + + '@vitest/spy@4.1.1': + resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==} + + '@vitest/utils@4.1.1': + resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -842,6 +886,10 @@ packages: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -914,6 +962,10 @@ packages: resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} engines: {node: '>=14.16'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk-template@0.4.0: resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} engines: {node: '>=12'} @@ -989,6 +1041,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -1130,6 +1185,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1206,10 +1264,17 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1248,6 +1313,15 @@ packages: picomatch: optional: true + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -1702,6 +1776,9 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + map-obj@1.0.1: resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} engines: {node: '>=0.10.0'} @@ -1832,6 +1909,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1901,6 +1981,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1912,6 +1995,10 @@ packages: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + postcss-load-config@3.1.4: resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} @@ -2145,6 +2232,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -2184,6 +2274,12 @@ packages: spdx-license-ids@3.0.21: resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + stream-read-all@3.0.1: resolution: {integrity: sha512-EWZT9XOceBPlVJRrYcykW8jyRSZYbkb/0ZK36uLEmoWVO5gxBOnntNTseNzfREsqxqdfEGQrD8SXQ3QWbBmq8A==} engines: {node: '>=10'} @@ -2301,10 +2397,25 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.13: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -2439,6 +2550,41 @@ packages: vite: optional: true + vitest@4.1.1: + resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.1 + '@vitest/browser-preview': 4.1.1 + '@vitest/browser-webdriverio': 4.1.1 + '@vitest/ui': 4.1.1 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -2461,6 +2607,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2720,6 +2871,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -2887,6 +3040,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.40.1': optional: true + '@standard-schema/spec@1.1.0': {} + '@supabase/auth-js@2.69.1': dependencies: '@supabase/node-fetch': 2.6.15 @@ -3050,8 +3205,15 @@ snapshots: tailwindcss: 4.1.4 vite: 6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2) + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.7': {} '@types/json-schema@7.0.15': {} @@ -3149,6 +3311,47 @@ snapshots: '@typescript-eslint/types': 8.31.1 eslint-visitor-keys: 4.2.0 + '@vitest/expect@4.1.1': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.1(vite@6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2))': + dependencies: + '@vitest/spy': 4.1.1 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2) + + '@vitest/pretty-format@4.1.1': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.1': + dependencies: + '@vitest/utils': 4.1.1 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.1': + dependencies: + '@vitest/pretty-format': 4.1.1 + '@vitest/utils': 4.1.1 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.1': {} + + '@vitest/utils@4.1.1': + dependencies: + '@vitest/pretty-format': 4.1.1 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.14.1): dependencies: acorn: 8.14.1 @@ -3193,6 +3396,8 @@ snapshots: arrify@1.0.1: {} + assertion-error@2.0.1: {} + astral-regex@2.0.0: {} axobject-query@4.1.0: {} @@ -3274,6 +3479,8 @@ snapshots: camelcase@7.0.1: {} + chai@6.2.2: {} + chalk-template@0.4.0: dependencies: chalk: 4.1.2 @@ -3337,6 +3544,8 @@ snapshots: concat-map@0.0.1: {} + convert-source-map@2.0.0: {} + cookie@0.6.0: {} cookie@1.0.2: {} @@ -3456,6 +3665,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3586,8 +3797,14 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.7 + esutils@2.0.3: {} + expect-type@1.3.0: {} + extend@3.0.2: {} external-editor@3.1.0: @@ -3622,6 +3839,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -4078,6 +4299,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + map-obj@1.0.1: {} map-obj@4.3.0: {} @@ -4187,6 +4412,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -4262,12 +4489,16 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} picomatch@4.0.2: {} + picomatch@4.0.3: {} + postcss-load-config@3.1.4(postcss@8.5.3): dependencies: lilconfig: 2.1.0 @@ -4454,6 +4685,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -4490,6 +4723,10 @@ snapshots: spdx-license-ids@3.0.21: {} + stackback@0.0.2: {} + + std-env@4.0.0: {} + stream-read-all@3.0.1: {} string-width@4.2.3: @@ -4675,11 +4912,22 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + tinyglobby@0.2.13: dependencies: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.1.0: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -4769,6 +5017,33 @@ snapshots: optionalDependencies: vite: 6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2) + vitest@4.1.1(@types/node@22.15.3)(vite@6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2)): + dependencies: + '@vitest/expect': 4.1.1 + '@vitest/mocker': 4.1.1(vite@6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2)) + '@vitest/pretty-format': 4.1.1 + '@vitest/runner': 4.1.1 + '@vitest/snapshot': 4.1.1 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.15.3 + transitivePeerDependencies: + - msw + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -4790,6 +5065,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wordwrapjs@5.1.0: {} diff --git a/src/lib/server/__tests__/helpers.ts b/src/lib/server/__tests__/helpers.ts new file mode 100644 index 0000000..16b9f58 --- /dev/null +++ b/src/lib/server/__tests__/helpers.ts @@ -0,0 +1,58 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import { vi } from 'vitest'; + +interface MockQueryResult { + data: unknown; + error: null | { message: string }; + count?: number | null; +} + +/** + * Creates a mock Supabase client with a per-table response queue. + * + * Usage: + * const { client, mockFrom, whenFrom } = createMockSupabaseClient(); + * whenFrom('profiles').select({ data: [...], error: null }); + * const result = await client.from('profiles').select('*').eq('role', 'applicant'); + */ +export function createMockSupabaseClient() { + const tableResponses = new Map(); + + function whenFrom(table: string) { + return { + select(result: MockQueryResult) { + const queue = tableResponses.get(table) ?? []; + queue.push(result); + tableResponses.set(table, queue); + return this; + }, + }; + } + + const mockFrom = vi.fn((table: string) => { + const responses = tableResponses.get(table) ?? []; + const response = responses.shift() ?? { data: null, error: null }; + + const builder: Record = {}; + + builder.select = vi.fn().mockReturnValue(builder); + builder.eq = vi.fn().mockReturnValue(builder); + builder.in = vi.fn().mockReturnValue(builder); + builder.single = vi.fn().mockReturnValue(builder); + builder.maybeSingle = vi.fn().mockReturnValue(builder); + builder.order = vi.fn().mockReturnValue(builder); + builder.limit = vi.fn().mockReturnValue(builder); + builder.range = vi.fn().mockReturnValue(builder); + + // Make the builder thenable so `await` resolves to the response + builder.then = (resolve: (val: MockQueryResult) => void) => { + resolve(response); + }; + + return builder; + }); + + const client = { from: mockFrom } as unknown as SupabaseClient; + + return { client, mockFrom, whenFrom }; +} diff --git a/vite.config.js b/vite.config.js index aeec86f..9261fd7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,11 @@ -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; import { sveltekit } from '@sveltejs/kit/vite'; import tailwind from '@tailwindcss/vite'; -export default defineConfig({ plugins: [tailwind(), sveltekit()] }); +export default defineConfig({ + plugins: [tailwind(), sveltekit()], + test: { + include: ['src/**/*.{test,spec}.{js,ts}'], + }, +}); From 430366b25ad23167d63c9d2b27468d814ae577b5 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Tue, 24 Mar 2026 00:14:35 +0800 Subject: [PATCH 03/14] refactor: extract admin types and shared query functions --- src/lib/admin/types.ts | 63 +++ .../server/__tests__/admin-queries.test.ts | 375 ++++++++++++++++++ src/lib/server/admin-queries.ts | 171 ++++++++ src/routes/api/admin/applicants/+server.ts | 19 +- src/routes/api/admin/quiz-results/+server.ts | 51 +-- .../admin/quiz-results/[userId]/+server.ts | 34 +- .../api/admin/sigsheet-progress/+server.ts | 43 +- 7 files changed, 637 insertions(+), 119 deletions(-) create mode 100644 src/lib/admin/types.ts create mode 100644 src/lib/server/__tests__/admin-queries.test.ts create mode 100644 src/lib/server/admin-queries.ts diff --git a/src/lib/admin/types.ts b/src/lib/admin/types.ts new file mode 100644 index 0000000..add7fe2 --- /dev/null +++ b/src/lib/admin/types.ts @@ -0,0 +1,63 @@ +/** Profile row for an applicant, as returned by the admin applicants endpoint. */ +export interface ApplicantProfile { + id: string; + username: string; + full_name: string; + avatar_url: string | null; + role: string; +} + +/** Summary of a quiz submission with aggregated score. */ +export interface QuizResultSummary { + submission_id: string; + submitted_at: string; + user_id: string; + profile: { username: string; full_name: string }; + total_score: number; +} + +/** Detailed quiz answer with nested question and section info. */ +export interface QuizAnswerDetail { + answer_id: string; + question_id: number; + answer_text: string | null; + option_id: number | null; + points: number; + is_checked: boolean; + question: { + title: string; + point_value: number; + type: string; + section: { title: string }; + }; +} + +/** Full quiz result detail for a single user. */ +export interface QuizResultDetail { + profile: { id: string; username: string; full_name: string } | null; + submitted_at: string | null; + answers: QuizAnswerDetail[]; +} + +/** A single signature entry in the sigsheet. */ +export interface SigsheetSignatureDetail { + sig_id: string; + signed_at: string; + member_id: string; + member_name: string; +} + +/** Sigsheet progress summary for one applicant. */ +export interface SigsheetProgressSummary { + profile: { id: string; username: string; full_name: string }; + signatures: SigsheetSignatureDetail[]; + count: number; +} + +/** Grade input for an applicant (used in Plan 02). */ +export interface GradeInput { + user_id: string; + score: number; + max_score: number; + remarks?: string; +} diff --git a/src/lib/server/__tests__/admin-queries.test.ts b/src/lib/server/__tests__/admin-queries.test.ts new file mode 100644 index 0000000..b0bd66d --- /dev/null +++ b/src/lib/server/__tests__/admin-queries.test.ts @@ -0,0 +1,375 @@ +import { describe, expect, it } from 'vitest'; +import { createMockSupabaseClient } from './helpers'; +import { fetchApplicants } from '../admin-queries'; + +describe('fetchApplicants', () => { + it('returns applicant profiles when query succeeds', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + const mockApplicants = [ + { id: 'u1', username: 'alice', full_name: 'Alice A', avatar_url: null, role: 'applicant' }, + { + id: 'u2', + username: 'bob', + full_name: 'Bob B', + avatar_url: 'https://img.example.com/bob.jpg', + role: 'applicant', + }, + ]; + whenFrom('profiles').select({ data: mockApplicants, error: null }); + + const result = await fetchApplicants(client); + + expect(result).toEqual({ applicants: mockApplicants }); + }); + + it('throws when query fails', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('profiles').select({ data: null, error: { message: 'connection refused' } }); + + await expect(fetchApplicants(client)).rejects.toThrow('connection refused'); + }); + + it('returns empty array when no applicants exist', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('profiles').select({ data: [], error: null }); + + const result = await fetchApplicants(client); + + expect(result).toEqual({ applicants: [] }); + }); + + it('queries profiles table with correct parameters', async () => { + const { client, mockFrom, whenFrom } = createMockSupabaseClient(); + whenFrom('profiles').select({ data: [], error: null }); + + await fetchApplicants(client); + + expect(mockFrom).toHaveBeenCalledWith('profiles'); + }); +}); + +describe('fetchAllQuizResults', () => { + it('returns results with aggregated scores when submissions exist', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + + whenFrom('constiquiz-submissions').select({ + data: [ + { + submission_id: 's1', + submitted_at: '2025-01-01', + user_id: 'u1', + profiles: { username: 'alice', full_name: 'Alice' }, + }, + { + submission_id: 's2', + submitted_at: '2025-01-02', + user_id: 'u2', + profiles: { username: 'bob', full_name: 'Bob' }, + }, + ], + error: null, + }); + + whenFrom('constiquiz-answers').select({ + data: [ + { user_id: 'u1', points: 5 }, + { user_id: 'u1', points: 3 }, + { user_id: 'u2', points: 10 }, + ], + error: null, + }); + + const { fetchAllQuizResults } = await import('../admin-queries'); + const result = await fetchAllQuizResults(client); + + expect(result.results).toHaveLength(2); + expect(result.results[0]).toEqual({ + submission_id: 's1', + submitted_at: '2025-01-01', + user_id: 'u1', + profile: { username: 'alice', full_name: 'Alice' }, + total_score: 8, + }); + expect(result.results[1]).toEqual({ + submission_id: 's2', + submitted_at: '2025-01-02', + user_id: 'u2', + profile: { username: 'bob', full_name: 'Bob' }, + total_score: 10, + }); + }); + + it('returns empty results array when no submissions exist', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('constiquiz-submissions').select({ data: [], error: null }); + + const { fetchAllQuizResults } = await import('../admin-queries'); + const result = await fetchAllQuizResults(client); + + expect(result).toEqual({ results: [] }); + }); + + it('returns zero score for user with no answers', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('constiquiz-submissions').select({ + data: [ + { + submission_id: 's1', + submitted_at: '2025-01-01', + user_id: 'u1', + profiles: { username: 'alice', full_name: 'Alice' }, + }, + ], + error: null, + }); + whenFrom('constiquiz-answers').select({ data: [], error: null }); + + const { fetchAllQuizResults } = await import('../admin-queries'); + const result = await fetchAllQuizResults(client); + + expect(result.results[0]?.total_score).toBe(0); + }); + + it('handles null points in answers gracefully', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('constiquiz-submissions').select({ + data: [ + { + submission_id: 's1', + submitted_at: '2025-01-01', + user_id: 'u1', + profiles: { username: 'alice', full_name: 'Alice' }, + }, + ], + error: null, + }); + whenFrom('constiquiz-answers').select({ + data: [ + { user_id: 'u1', points: null }, + { user_id: 'u1', points: 5 }, + ], + error: null, + }); + + const { fetchAllQuizResults } = await import('../admin-queries'); + const result = await fetchAllQuizResults(client); + + expect(result.results[0]?.total_score).toBe(5); + }); + + it('throws when submissions query fails', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('constiquiz-submissions').select({ data: null, error: { message: 'db error' } }); + + const { fetchAllQuizResults } = await import('../admin-queries'); + await expect(fetchAllQuizResults(client)).rejects.toThrow('db error'); + }); + + it('throws when answers query fails', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('constiquiz-submissions').select({ + data: [ + { + submission_id: 's1', + submitted_at: '2025-01-01', + user_id: 'u1', + profiles: { username: 'alice', full_name: 'Alice' }, + }, + ], + error: null, + }); + whenFrom('constiquiz-answers').select({ data: null, error: { message: 'answers error' } }); + + const { fetchAllQuizResults } = await import('../admin-queries'); + await expect(fetchAllQuizResults(client)).rejects.toThrow('answers error'); + }); +}); + +describe('fetchQuizResultDetail', () => { + it('returns profile, submitted_at, and answers for a valid userId', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + + whenFrom('constiquiz-answers').select({ + data: [ + { + answer_id: 'a1', + question_id: 1, + answer_text: 'some text', + option_id: null, + points: 5, + is_checked: false, + question: { title: 'Q1', point_value: 5, type: 'short_text', section: { title: 'Section A' } }, + }, + ], + error: null, + }); + whenFrom('profiles').select({ + data: { id: 'u1', username: 'alice', full_name: 'Alice A' }, + error: null, + }); + whenFrom('constiquiz-submissions').select({ + data: { submitted_at: '2025-01-01T12:00:00Z' }, + error: null, + }); + + const { fetchQuizResultDetail } = await import('../admin-queries'); + const result = await fetchQuizResultDetail(client, 'u1'); + + expect(result.profile).toEqual({ id: 'u1', username: 'alice', full_name: 'Alice A' }); + expect(result.submitted_at).toBe('2025-01-01T12:00:00Z'); + expect(result.answers).toHaveLength(1); + expect(result.answers[0]?.question.section.title).toBe('Section A'); + }); + + it('returns null submitted_at when no submission exists', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('constiquiz-answers').select({ data: [], error: null }); + whenFrom('profiles').select({ data: { id: 'u1', username: 'alice', full_name: 'Alice A' }, error: null }); + whenFrom('constiquiz-submissions').select({ data: null, error: null }); + + const { fetchQuizResultDetail } = await import('../admin-queries'); + const result = await fetchQuizResultDetail(client, 'u1'); + + expect(result.submitted_at).toBeNull(); + }); + + it('throws when answers query fails', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('constiquiz-answers').select({ data: null, error: { message: 'answers failed' } }); + whenFrom('profiles').select({ data: null, error: null }); + whenFrom('constiquiz-submissions').select({ data: null, error: null }); + + const { fetchQuizResultDetail } = await import('../admin-queries'); + await expect(fetchQuizResultDetail(client, 'u1')).rejects.toThrow('answers failed'); + }); +}); + +describe('fetchSigsheetProgress', () => { + it('returns total members and grouped progress', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + + whenFrom('members').select({ data: null, error: null, count: 25 }); + whenFrom('sigsheet').select({ + data: [ + { + sig_id: 'sig1', + signed_at: '2025-01-01', + question: 'q', + answer: 'a', + member_id: 'm1', + member_name: 'Member One', + applicant: { id: 'u1', username: 'alice', full_name: 'Alice' }, + }, + { + sig_id: 'sig2', + signed_at: '2025-01-02', + question: 'q2', + answer: 'a2', + member_id: 'm2', + member_name: 'Member Two', + applicant: { id: 'u1', username: 'alice', full_name: 'Alice' }, + }, + { + sig_id: 'sig3', + signed_at: '2025-01-03', + question: 'q3', + answer: 'a3', + member_id: 'm3', + member_name: 'Member Three', + applicant: { id: 'u2', username: 'bob', full_name: 'Bob' }, + }, + ], + error: null, + }); + + const { fetchSigsheetProgress } = await import('../admin-queries'); + const result = await fetchSigsheetProgress(client); + + expect(result.total_members).toBe(25); + expect(result.progress).toHaveLength(2); + + const aliceProgress = result.progress.find(p => p.profile.id === 'u1'); + expect(aliceProgress?.count).toBe(2); + expect(aliceProgress?.signatures).toHaveLength(2); + + const bobProgress = result.progress.find(p => p.profile.id === 'u2'); + expect(bobProgress?.count).toBe(1); + }); + + it('returns empty progress when no sigsheet entries exist', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('members').select({ data: null, error: null, count: 10 }); + whenFrom('sigsheet').select({ data: [], error: null }); + + const { fetchSigsheetProgress } = await import('../admin-queries'); + const result = await fetchSigsheetProgress(client); + + expect(result.total_members).toBe(10); + expect(result.progress).toEqual([]); + }); + + it('throws when sigsheet query fails', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('members').select({ data: null, error: null, count: 10 }); + whenFrom('sigsheet').select({ data: null, error: { message: 'sigsheet error' } }); + + const { fetchSigsheetProgress } = await import('../admin-queries'); + await expect(fetchSigsheetProgress(client)).rejects.toThrow('sigsheet error'); + }); +}); + +describe('fetchSigsheetDetail', () => { + it('returns profile, signatures, count, and total_members for a userId', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + + whenFrom('members').select({ data: null, error: null, count: 25 }); + whenFrom('profiles').select({ + data: { id: 'u1', username: 'alice', full_name: 'Alice' }, + error: null, + }); + whenFrom('sigsheet').select({ + data: [ + { sig_id: 'sig1', signed_at: '2025-01-01', member_id: 'm1', member_name: 'Member One' }, + { sig_id: 'sig2', signed_at: '2025-01-02', member_id: 'm2', member_name: 'Member Two' }, + ], + error: null, + }); + + const { fetchSigsheetDetail } = await import('../admin-queries'); + const result = await fetchSigsheetDetail(client, 'u1'); + + expect(result.profile).toEqual({ id: 'u1', username: 'alice', full_name: 'Alice' }); + expect(result.signatures).toHaveLength(2); + expect(result.count).toBe(2); + expect(result.total_members).toBe(25); + }); + + it('returns empty signatures when applicant has none', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('members').select({ data: null, error: null, count: 10 }); + whenFrom('profiles').select({ + data: { id: 'u1', username: 'alice', full_name: 'Alice' }, + error: null, + }); + whenFrom('sigsheet').select({ data: [], error: null }); + + const { fetchSigsheetDetail } = await import('../admin-queries'); + const result = await fetchSigsheetDetail(client, 'u1'); + + expect(result.signatures).toEqual([]); + expect(result.count).toBe(0); + }); + + it('throws when sigsheet query fails', async () => { + const { client, whenFrom } = createMockSupabaseClient(); + whenFrom('members').select({ data: null, error: null, count: 10 }); + whenFrom('profiles').select({ + data: { id: 'u1', username: 'alice', full_name: 'Alice' }, + error: null, + }); + whenFrom('sigsheet').select({ data: null, error: { message: 'query failed' } }); + + const { fetchSigsheetDetail } = await import('../admin-queries'); + await expect(fetchSigsheetDetail(client, 'u1')).rejects.toThrow('query failed'); + }); +}); diff --git a/src/lib/server/admin-queries.ts b/src/lib/server/admin-queries.ts new file mode 100644 index 0000000..dbf8875 --- /dev/null +++ b/src/lib/server/admin-queries.ts @@ -0,0 +1,171 @@ +import type { + ApplicantProfile, + QuizAnswerDetail, + QuizResultDetail, + QuizResultSummary, + SigsheetProgressSummary, + SigsheetSignatureDetail, +} from '$lib/admin/types'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +export async function fetchApplicants(supabase: SupabaseClient): Promise<{ applicants: ApplicantProfile[] }> { + const { data, error } = await supabase + .from('profiles') + .select('id, username, full_name, avatar_url, role') + .eq('role', 'applicant'); + + if (error) { + throw new Error(error.message); + } + + return { applicants: (data ?? []) as ApplicantProfile[] }; +} + +export async function fetchAllQuizResults(supabase: SupabaseClient): Promise<{ results: QuizResultSummary[] }> { + const { data: submissions, error: subError } = await supabase.from('constiquiz-submissions').select(` + submission_id, + submitted_at, + user_id, + profiles!inner ( username, full_name ) + `); + + if (subError) { + throw new Error(subError.message); + } + + const userIds = + (submissions as Record[] | null)?.map(s => s.user_id as string).filter(Boolean) ?? []; + + if (userIds.length === 0) { + return { results: [] }; + } + + const { data: answers, error: ansError } = await supabase + .from('constiquiz-answers') + .select('user_id, points') + .in('user_id', userIds); + + if (ansError) { + throw new Error(ansError.message); + } + + const scoreMap: Record = {}; + for (const a of (answers as Record[] | null) ?? []) { + const uid = a.user_id as string; + scoreMap[uid] = (scoreMap[uid] ?? 0) + ((a.points as number | null) ?? 0); + } + + const results: QuizResultSummary[] = ((submissions as Record[] | null) ?? []).map(s => ({ + submission_id: s.submission_id as string, + submitted_at: s.submitted_at as string, + user_id: s.user_id as string, + profile: s.profiles as { username: string; full_name: string }, + total_score: scoreMap[s.user_id as string] ?? 0, + })); + + return { results }; +} + +export async function fetchQuizResultDetail(supabase: SupabaseClient, userId: string): Promise { + const [answersRes, profileRes, submissionRes] = await Promise.all([ + supabase + .from('constiquiz-answers') + .select( + ` + answer_id, question_id, answer_text, option_id, points, is_checked, + question:constiquiz-questions!inner ( + title, point_value, type, + section:constiquiz-sections!inner ( title ) + ) + `, + ) + .eq('user_id', userId), + supabase.from('profiles').select('id, username, full_name').eq('id', userId).single(), + supabase.from('constiquiz-submissions').select('submitted_at').eq('user_id', userId).maybeSingle(), + ]); + + if (answersRes.error) { + throw new Error(answersRes.error.message); + } + + return { + profile: profileRes.data as QuizResultDetail['profile'], + submitted_at: ((submissionRes.data as Record | null)?.submitted_at as string | null) ?? null, + answers: (answersRes.data ?? []) as unknown as QuizAnswerDetail[], + }; +} + +export async function fetchSigsheetProgress( + supabase: SupabaseClient, +): Promise<{ total_members: number | null; progress: SigsheetProgressSummary[] }> { + const { count: totalMembers } = await supabase.from('members').select('*', { count: 'exact', head: true }); + + const { data, error } = await supabase.from('sigsheet').select(` + sig_id, signed_at, question, answer, member_id, member_name, + applicant:profiles!inner ( id, username, full_name ) + `); + + if (error) { + throw new Error(error.message); + } + + type RawApplicant = { id: string; username: string; full_name: string }; + const byApplicant: Record = + {}; + + for (const row of (data as Record[] | null) ?? []) { + const applicant = row.applicant as unknown as RawApplicant; + const key = applicant.id; + if (!byApplicant[key]) { + byApplicant[key] = { profile: applicant, signatures: [], count: 0 }; + } + byApplicant[key]!.signatures.push({ + sig_id: row.sig_id as string, + signed_at: row.signed_at as string, + member_id: row.member_id as string, + member_name: row.member_name as string, + }); + byApplicant[key]!.count++; + } + + return { + total_members: totalMembers ?? null, + progress: Object.values(byApplicant), + }; +} + +export async function fetchSigsheetDetail( + supabase: SupabaseClient, + userId: string, +): Promise<{ + profile: { id: string; username: string; full_name: string } | null; + signatures: SigsheetSignatureDetail[]; + count: number; + total_members: number | null; +}> { + const [memberCountRes, profileRes, sigsheetRes] = await Promise.all([ + supabase.from('members').select('*', { count: 'exact', head: true }), + supabase.from('profiles').select('id, username, full_name').eq('id', userId).single(), + supabase.from('sigsheet').select('sig_id, signed_at, member_id, member_name').eq('applicant_id', userId), + ]); + + if (sigsheetRes.error) { + throw new Error(sigsheetRes.error.message); + } + + const signatures: SigsheetSignatureDetail[] = ((sigsheetRes.data as Record[] | null) ?? []).map( + row => ({ + sig_id: row.sig_id as string, + signed_at: row.signed_at as string, + member_id: row.member_id as string, + member_name: row.member_name as string, + }), + ); + + return { + profile: profileRes.data as { id: string; username: string; full_name: string } | null, + signatures, + count: signatures.length, + total_members: memberCountRes.count ?? null, + }; +} diff --git a/src/routes/api/admin/applicants/+server.ts b/src/routes/api/admin/applicants/+server.ts index a8744ba..889df7e 100644 --- a/src/routes/api/admin/applicants/+server.ts +++ b/src/routes/api/admin/applicants/+server.ts @@ -1,21 +1,16 @@ import { type RequestEvent, json } from '@sveltejs/kit'; +import { fetchApplicants } from '$lib/server/admin-queries'; import { getSupabaseAdmin } from '$lib/server/supabaseAdmin'; import { requireRole } from '$lib/server/auth'; -/** - * List all applicant profiles - */ export async function GET(event: RequestEvent) { requireRole(event, 'admin'); - const { data, error } = await getSupabaseAdmin() - .from('profiles') - .select('id, username, full_name, avatar_url, role') - .eq('role', 'applicant'); - - if (error) { - return json({ error: error.message }, { status: 500 }); + try { + const result = await fetchApplicants(getSupabaseAdmin()); + return json(result); + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error'; + return json({ error: message }, { status: 500 }); } - - return json({ applicants: data }); } diff --git a/src/routes/api/admin/quiz-results/+server.ts b/src/routes/api/admin/quiz-results/+server.ts index 29c214d..019d7ff 100644 --- a/src/routes/api/admin/quiz-results/+server.ts +++ b/src/routes/api/admin/quiz-results/+server.ts @@ -1,53 +1,16 @@ import { type RequestEvent, json } from '@sveltejs/kit'; +import { fetchAllQuizResults } from '$lib/server/admin-queries'; import { getSupabaseAdmin } from '$lib/server/supabaseAdmin'; import { requireRole } from '$lib/server/auth'; export async function GET(event: RequestEvent) { requireRole(event, 'admin'); - const supabaseAdmin = getSupabaseAdmin(); - - // Get all submissions with profile info - const { data: submissions, error: subError } = await supabaseAdmin.from('constiquiz-submissions').select(` - submission_id, - submitted_at, - user_id, - profiles!inner ( username, full_name ) - `); - - if (subError) { - return json({ error: subError.message }, { status: 500 }); - } - - // Get total scores per submitted user - const userIds = submissions?.map(s => s.user_id).filter(Boolean) ?? []; - - if (userIds.length === 0) { - return json({ results: [] }); - } - - const { data: answers, error: ansError } = await supabaseAdmin - .from('constiquiz-answers') - .select('user_id, points') - .in('user_id', userIds); - - if (ansError) { - return json({ error: ansError.message }, { status: 500 }); - } - - // Aggregate scores - const scoreMap: Record = {}; - for (const a of answers ?? []) { - scoreMap[a.user_id] = (scoreMap[a.user_id] ?? 0) + (a.points ?? 0); + try { + const result = await fetchAllQuizResults(getSupabaseAdmin()); + return json(result); + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error'; + return json({ error: message }, { status: 500 }); } - - const results = submissions?.map(s => ({ - submission_id: s.submission_id, - submitted_at: s.submitted_at, - user_id: s.user_id, - profile: s.profiles, - total_score: scoreMap[s.user_id] ?? 0, - })); - - return json({ results }); } diff --git a/src/routes/api/admin/quiz-results/[userId]/+server.ts b/src/routes/api/admin/quiz-results/[userId]/+server.ts index cde2070..4e344ee 100644 --- a/src/routes/api/admin/quiz-results/[userId]/+server.ts +++ b/src/routes/api/admin/quiz-results/[userId]/+server.ts @@ -1,4 +1,5 @@ import { type RequestEvent, json } from '@sveltejs/kit'; +import { fetchQuizResultDetail } from '$lib/server/admin-queries'; import { getSupabaseAdmin } from '$lib/server/supabaseAdmin'; import { requireRole } from '$lib/server/auth'; @@ -6,32 +7,11 @@ export async function GET(event: RequestEvent) { requireRole(event, 'admin'); const { userId } = event.params; - const supabaseAdmin = getSupabaseAdmin(); - - const [answersRes, profileRes, submissionRes] = await Promise.all([ - supabaseAdmin - .from('constiquiz-answers') - .select( - ` - answer_id, question_id, answer_text, option_id, points, is_checked, - question:constiquiz-questions!inner ( - title, point_value, type, - section:constiquiz-sections!inner ( title ) - ) - `, - ) - .eq('user_id', userId), - supabaseAdmin.from('profiles').select('id, username, full_name').eq('id', userId).single(), - supabaseAdmin.from('constiquiz-submissions').select('submitted_at').eq('user_id', userId).maybeSingle(), - ]); - - if (answersRes.error) { - return json({ error: answersRes.error.message }, { status: 500 }); + try { + const result = await fetchQuizResultDetail(getSupabaseAdmin(), userId!); + return json(result); + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error'; + return json({ error: message }, { status: 500 }); } - - return json({ - profile: profileRes.data, - submitted_at: submissionRes.data?.submitted_at ?? null, - answers: answersRes.data, - }); } diff --git a/src/routes/api/admin/sigsheet-progress/+server.ts b/src/routes/api/admin/sigsheet-progress/+server.ts index a7a6736..d628dc7 100644 --- a/src/routes/api/admin/sigsheet-progress/+server.ts +++ b/src/routes/api/admin/sigsheet-progress/+server.ts @@ -1,45 +1,16 @@ import { type RequestEvent, json } from '@sveltejs/kit'; +import { fetchSigsheetProgress } from '$lib/server/admin-queries'; import { getSupabaseAdmin } from '$lib/server/supabaseAdmin'; import { requireRole } from '$lib/server/auth'; export async function GET(event: RequestEvent) { requireRole(event, 'admin'); - const supabaseAdmin = getSupabaseAdmin(); - - // Get total member count for progress calculation - const { count: totalMembers } = await supabaseAdmin.from('members').select('*', { count: 'exact', head: true }); - - const { data, error } = await supabaseAdmin.from('sigsheet').select(` - sig_id, signed_at, question, answer, member_id, member_name, - applicant:profiles!inner ( id, username, full_name ) - `); - - if (error) { - return json({ error: error.message }, { status: 500 }); + try { + const result = await fetchSigsheetProgress(getSupabaseAdmin()); + return json(result); + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error'; + return json({ error: message }, { status: 500 }); } - - // Group by applicant - type ApplicantProfile = { id: string; username: string; full_name: string }; - type Signature = { sig_id: string; signed_at: string; member_id: string; member_name: string }; - const byApplicant: Record = {}; - for (const row of data ?? []) { - const applicant = row.applicant as unknown as ApplicantProfile; - const key = applicant.id; - if (!byApplicant[key]) { - byApplicant[key] = { profile: applicant, signatures: [], count: 0 }; - } - byApplicant[key]!.signatures.push({ - sig_id: row.sig_id, - signed_at: row.signed_at, - member_id: row.member_id, - member_name: row.member_name, - }); - byApplicant[key]!.count++; - } - - return json({ - total_members: totalMembers, - progress: Object.values(byApplicant), - }); } From 6c0f72279c9b9b8f41fdb9b1e209c410cbe1a997 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Tue, 24 Mar 2026 10:37:22 +0800 Subject: [PATCH 04/14] feat: add admin dashboard with pie charts and activity feed --- src/lib/NavBar.svelte | 79 +++++++++++++------------- src/lib/admin/PieChart.svelte | 50 ++++++++++++++++ src/routes/admin/+layout.svelte | 32 ----------- src/routes/admin/+page.server.ts | 97 ++++++++++++++++++++++++++++++++ src/routes/admin/+page.svelte | 85 +++++++++++++++++++++++++++- 5 files changed, 270 insertions(+), 73 deletions(-) create mode 100644 src/lib/admin/PieChart.svelte create mode 100644 src/routes/admin/+page.server.ts diff --git a/src/lib/NavBar.svelte b/src/lib/NavBar.svelte index ab4f5b3..489e0b7 100644 --- a/src/lib/NavBar.svelte +++ b/src/lib/NavBar.svelte @@ -1,10 +1,10 @@ + +
+
+ +
+ {#each segmentsWithPercent as segment (segment.label)} +
+ + + {segment.label}: {segment.percent}% ({segment.value}/{total}) + +
+ {/each} +
+
diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index f1f19da..3d5ff1f 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -1,39 +1,7 @@
- - {@render children()}
diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts new file mode 100644 index 0000000..7c2d3df --- /dev/null +++ b/src/routes/admin/+page.server.ts @@ -0,0 +1,97 @@ +import { fetchAllQuizResults, fetchApplicants, fetchSigsheetProgress } from '$lib/server/admin-queries'; +import type { PageServerLoad } from './$types'; +import { getSupabaseAdmin } from '$lib/server/supabaseAdmin'; + +export interface ActivityItem { + type: 'quiz' | 'sigsheet'; + name: string; + detail: string; + timestamp: string; +} + +export const load: PageServerLoad = async () => { + const supabase = getSupabaseAdmin(); + + const [applicantsRes, quizRes, sigsheetRes, quizAnswersRes, recentQuizRes, recentSigRes] = await Promise.all([ + fetchApplicants(supabase), + fetchAllQuizResults(supabase), + fetchSigsheetProgress(supabase), + supabase.from('constiquiz-answers').select('user_id'), + supabase + .from('constiquiz-submissions') + .select('submitted_at, user_id, profiles!inner(full_name)') + .order('submitted_at', { ascending: false }) + .limit(10), + supabase + .from('sigsheet') + .select('signed_at, member_name, applicant:profiles!inner(full_name)') + .order('signed_at', { ascending: false }) + .limit(10), + ]); + + const totalApplicants = applicantsRes.applicants.length; + const totalMembers = sigsheetRes.total_members ?? 0; + + // Sigsheet pie chart: Not started / In Progress / Met quota + let sigMetQuota = 0; + let sigInProgress = 0; + for (const entry of sigsheetRes.progress) { + if (totalMembers > 0 && entry.count >= totalMembers) { + sigMetQuota++; + } else { + sigInProgress++; + } + } + const sigNotStarted = totalApplicants - sigsheetRes.progress.length; + + // Quiz pie chart: Not started / In Progress / Completed + // Filter to only applicant user IDs so admins/other roles don't inflate counts + const applicantIds = new Set(applicantsRes.applicants.map(a => a.id)); + const submittedUserIds = new Set(quizRes.results.filter(r => applicantIds.has(r.user_id)).map(r => r.user_id)); + const answerUserIds = new Set( + ((quizAnswersRes.data as { user_id: string }[] | null) ?? []) + .filter(a => applicantIds.has(a.user_id)) + .map(a => a.user_id), + ); + const quizCompleted = submittedUserIds.size; + let quizInProgress = 0; + for (const uid of answerUserIds) { + if (!submittedUserIds.has(uid)) { + quizInProgress++; + } + } + const quizNotStarted = totalApplicants - quizCompleted - quizInProgress; + + // Activity feed: merge recent quiz submissions + sigsheet entries + const activities: ActivityItem[] = []; + + for (const row of (recentQuizRes.data as { submitted_at: string; profiles: { full_name: string } }[] | null) ?? + []) { + activities.push({ + type: 'quiz', + name: row.profiles.full_name, + detail: 'submitted the constitution quiz', + timestamp: row.submitted_at, + }); + } + + for (const row of (recentSigRes.data as + | { signed_at: string; member_name: string; applicant: { full_name: string } }[] + | null) ?? []) { + activities.push({ + type: 'sigsheet', + name: row.applicant.full_name, + detail: `got a signature from ${row.member_name}`, + timestamp: row.signed_at, + }); + } + + activities.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + return { + totalApplicants, + sigsheet: { notStarted: sigNotStarted, inProgress: sigInProgress, metQuota: sigMetQuota }, + quiz: { notStarted: quizNotStarted, inProgress: quizInProgress, completed: quizCompleted }, + activities: activities.slice(0, 20), + }; +}; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index 19bc863..d061365 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -1,4 +1,83 @@ -
-

Admin Dashboard

-

Dashboard content coming soon.

+ + +
+

Progress Summary

+ +
+
+

Signature Sheet

+

Progress

+
+ +
+
+ +
+

Constitution Quiz

+

Progress

+
+ +
+
+
+ +
+

Recent Activity

+ + {#if data.activities.length === 0} +

No recent activity

+ {:else} +
+ {#each data.activities as activity, i (activity.timestamp + activity.name + i)} +
+
+ + + {activity.name} + {activity.detail} + +
+ + {timeAgo(activity.timestamp)} + +
+ {/each} +
+ {/if} +
From 812648502c8738ce0ab4a54d0779affe026ac293 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Tue, 24 Mar 2026 10:37:31 +0800 Subject: [PATCH 05/14] feat: add shared admin list components --- src/lib/admin/FilterDropdown.svelte | 63 +++++++++++++ src/lib/admin/PaginatedTable.svelte | 139 ++++++++++++++++++++++++++++ src/lib/admin/Pagination.svelte | 41 ++++++++ src/lib/admin/SearchInput.svelte | 23 +++++ src/lib/admin/SortDropdown.svelte | 71 ++++++++++++++ src/lib/admin/StatusBadge.svelte | 17 ++++ 6 files changed, 354 insertions(+) create mode 100644 src/lib/admin/FilterDropdown.svelte create mode 100644 src/lib/admin/PaginatedTable.svelte create mode 100644 src/lib/admin/Pagination.svelte create mode 100644 src/lib/admin/SearchInput.svelte create mode 100644 src/lib/admin/SortDropdown.svelte create mode 100644 src/lib/admin/StatusBadge.svelte diff --git a/src/lib/admin/FilterDropdown.svelte b/src/lib/admin/FilterDropdown.svelte new file mode 100644 index 0000000..6e40f8a --- /dev/null +++ b/src/lib/admin/FilterDropdown.svelte @@ -0,0 +1,63 @@ + + + + +
+ + + {#if open} +
+ {#each options as option (option)} + + {/each} +
+ {/if} +
diff --git a/src/lib/admin/PaginatedTable.svelte b/src/lib/admin/PaginatedTable.svelte new file mode 100644 index 0000000..3603b82 --- /dev/null +++ b/src/lib/admin/PaginatedTable.svelte @@ -0,0 +1,139 @@ + + +
+ +
+ + + + {#each columns as col (col.key)} + + {/each} + + + + {#each paginatedData as row, i (i)} + onRowClick?.(row)} + > + {#each columns as column (column.key)} + + {/each} + + {/each} + + {#if paginatedData.length === 0} + + + + {/if} + +
+ {col.header} +
+ {#if cell} + {@render cell({ row, column, value: row[column.key] })} + {:else} + {String(row[column.key] ?? '')} + {/if} +
+ No results found +
+
+ + + +
diff --git a/src/lib/admin/Pagination.svelte b/src/lib/admin/Pagination.svelte new file mode 100644 index 0000000..cd2f047 --- /dev/null +++ b/src/lib/admin/Pagination.svelte @@ -0,0 +1,41 @@ + + +{#if totalPages > 1} +
+ + + + Page {currentPage} of {totalPages} + + + +
+{/if} diff --git a/src/lib/admin/SearchInput.svelte b/src/lib/admin/SearchInput.svelte new file mode 100644 index 0000000..f8d6c99 --- /dev/null +++ b/src/lib/admin/SearchInput.svelte @@ -0,0 +1,23 @@ + + +
+ + +
diff --git a/src/lib/admin/SortDropdown.svelte b/src/lib/admin/SortDropdown.svelte new file mode 100644 index 0000000..e24c6d2 --- /dev/null +++ b/src/lib/admin/SortDropdown.svelte @@ -0,0 +1,71 @@ + + + + +
+ + {#if open} +
+ {#each columns as col (col.key)} + + {/each} +
+ {/if} +
diff --git a/src/lib/admin/StatusBadge.svelte b/src/lib/admin/StatusBadge.svelte new file mode 100644 index 0000000..4da53dd --- /dev/null +++ b/src/lib/admin/StatusBadge.svelte @@ -0,0 +1,17 @@ + + + + {status} + From 39236fbd76761f6c99c80799c5503d62d259a2d0 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Tue, 24 Mar 2026 11:05:10 +0800 Subject: [PATCH 06/14] fix: filter applicant profiles in progress calculations for sigsheet and quiz charts --- src/routes/admin/+page.server.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts index 7c2d3df..915c735 100644 --- a/src/routes/admin/+page.server.ts +++ b/src/routes/admin/+page.server.ts @@ -33,20 +33,21 @@ export const load: PageServerLoad = async () => { const totalMembers = sigsheetRes.total_members ?? 0; // Sigsheet pie chart: Not started / In Progress / Met quota + // Filter to only applicant profiles so non-applicants don't inflate counts + const applicantIds = new Set(applicantsRes.applicants.map(a => a.id)); + const applicantProgress = sigsheetRes.progress.filter(entry => applicantIds.has(entry.profile.id)); let sigMetQuota = 0; let sigInProgress = 0; - for (const entry of sigsheetRes.progress) { + for (const entry of applicantProgress) { if (totalMembers > 0 && entry.count >= totalMembers) { sigMetQuota++; } else { sigInProgress++; } } - const sigNotStarted = totalApplicants - sigsheetRes.progress.length; + const sigNotStarted = totalApplicants - applicantProgress.length; // Quiz pie chart: Not started / In Progress / Completed - // Filter to only applicant user IDs so admins/other roles don't inflate counts - const applicantIds = new Set(applicantsRes.applicants.map(a => a.id)); const submittedUserIds = new Set(quizRes.results.filter(r => applicantIds.has(r.user_id)).map(r => r.user_id)); const answerUserIds = new Set( ((quizAnswersRes.data as { user_id: string }[] | null) ?? []) From 7f9faba1700c5009c9fdf68779a6674aad217d9a Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 8 Apr 2026 21:09:51 +0800 Subject: [PATCH 07/14] feat: add fetchQuizRespondents query and QuizRespondent type Adds a new admin-queries function that returns all applicants (including those with no quiz activity), each tagged with a Not Started / In Progress / Completed status derived from constiquiz-submissions and constiquiz-answers, plus the quiz's max possible score (sum of question point_values). Used by the upcoming /admin/constiquiz respondent list page. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/admin/types.ts | 9 +++++ src/lib/server/admin-queries.ts | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/lib/admin/types.ts b/src/lib/admin/types.ts index add7fe2..48457fe 100644 --- a/src/lib/admin/types.ts +++ b/src/lib/admin/types.ts @@ -61,3 +61,12 @@ export interface GradeInput { max_score: number; remarks?: string; } + +/** Row shape for the constiquiz respondent list page (P02-001). */ +export interface QuizRespondent { + user_id: string; + full_name: string; + username: string; + status: 'Not Started' | 'In Progress' | 'Completed'; + current_score: number; +} diff --git a/src/lib/server/admin-queries.ts b/src/lib/server/admin-queries.ts index dbf8875..1053c70 100644 --- a/src/lib/server/admin-queries.ts +++ b/src/lib/server/admin-queries.ts @@ -1,6 +1,7 @@ import type { ApplicantProfile, QuizAnswerDetail, + QuizRespondent, QuizResultDetail, QuizResultSummary, SigsheetProgressSummary, @@ -134,6 +135,72 @@ export async function fetchSigsheetProgress( }; } +export async function fetchQuizRespondents( + supabase: SupabaseClient, +): Promise<{ respondents: QuizRespondent[]; max_score: number }> { + const [applicantsRes, submissionsRes, answersRes, questionsRes] = await Promise.all([ + supabase.from('profiles').select('id, username, full_name').eq('role', 'applicant'), + supabase.from('constiquiz-submissions').select('user_id'), + supabase.from('constiquiz-answers').select('user_id, points'), + supabase.from('constiquiz-questions').select('point_value'), + ]); + + if (applicantsRes.error) { + throw new Error(applicantsRes.error.message); + } + if (submissionsRes.error) { + throw new Error(submissionsRes.error.message); + } + if (answersRes.error) { + throw new Error(answersRes.error.message); + } + if (questionsRes.error) { + throw new Error(questionsRes.error.message); + } + + const submittedUserIds = new Set(); + for (const row of (submissionsRes.data as Record[] | null) ?? []) { + const uid = row.user_id as string | null; + if (uid) { + submittedUserIds.add(uid); + } + } + + const scoreByUser: Record = {}; + const hasAnswerByUser = new Set(); + for (const row of (answersRes.data as Record[] | null) ?? []) { + const uid = row.user_id as string | null; + if (!uid) continue; + scoreByUser[uid] = (scoreByUser[uid] ?? 0) + ((row.points as number | null) ?? 0); + hasAnswerByUser.add(uid); + } + + const max_score = ((questionsRes.data as Record[] | null) ?? []).reduce( + (sum, q) => sum + ((q.point_value as number | null) ?? 0), + 0, + ); + + const respondents: QuizRespondent[] = ((applicantsRes.data as Record[] | null) ?? []).map(p => { + const uid = p.id as string; + let status: QuizRespondent['status'] = 'Not Started'; + if (submittedUserIds.has(uid)) { + status = 'Completed'; + } else if (hasAnswerByUser.has(uid)) { + status = 'In Progress'; + } + + return { + user_id: uid, + full_name: (p.full_name as string | null) ?? '', + username: (p.username as string | null) ?? '', + status, + current_score: scoreByUser[uid] ?? 0, + }; + }); + + return { respondents, max_score }; +} + export async function fetchSigsheetDetail( supabase: SupabaseClient, userId: string, From 6a1a40806a198804e9980d0d04f29c44a7d07c23 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 8 Apr 2026 21:09:58 +0800 Subject: [PATCH 08/14] feat: add shared AdminListView component Encapsulates the list-page layout (heading + search + filter + sort + paginated table) and the four state variables those controls share. Pages just supply the title, data, columns, filter key, row-click handler, and a cell snippet for custom rendering. Will be reused by the constiquiz and sigsheet admin list pages. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/admin/AdminListView.svelte | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/lib/admin/AdminListView.svelte diff --git a/src/lib/admin/AdminListView.svelte b/src/lib/admin/AdminListView.svelte new file mode 100644 index 0000000..b1d5b94 --- /dev/null +++ b/src/lib/admin/AdminListView.svelte @@ -0,0 +1,70 @@ + + +
+

{title}

+ +
+
+ +
+ + +
+ + +
From e292d1d371d2547e264740b5e03b66eb059c6091 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 8 Apr 2026 21:10:03 +0800 Subject: [PATCH 09/14] feat: add constiquiz respondent list page Implements P02-001. The /admin/constiquiz route lists every applicant with their status badge, current score over max possible score, and a "View response" link to the (future) P02-002 detail page. Loader calls fetchQuizRespondents and the page composes AdminListView with custom cell rendering for the status badge, score format, and link column. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/constiquiz/+page.server.ts | 8 ++++ src/routes/admin/constiquiz/+page.svelte | 47 +++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/routes/admin/constiquiz/+page.server.ts create mode 100644 src/routes/admin/constiquiz/+page.svelte diff --git a/src/routes/admin/constiquiz/+page.server.ts b/src/routes/admin/constiquiz/+page.server.ts new file mode 100644 index 0000000..51f8053 --- /dev/null +++ b/src/routes/admin/constiquiz/+page.server.ts @@ -0,0 +1,8 @@ +import type { PageServerLoad } from './$types'; +import { fetchQuizRespondents } from '$lib/server/admin-queries'; +import { getSupabaseAdmin } from '$lib/server/supabaseAdmin'; + +export const load: PageServerLoad = async () => { + const supabase = getSupabaseAdmin(); + return await fetchQuizRespondents(supabase); +}; diff --git a/src/routes/admin/constiquiz/+page.svelte b/src/routes/admin/constiquiz/+page.svelte new file mode 100644 index 0000000..7b8b7e2 --- /dev/null +++ b/src/routes/admin/constiquiz/+page.svelte @@ -0,0 +1,47 @@ + + +[]} + {columns} + filterKey="status" + onRowClick={goToDetail} +> + {#snippet cell({ row, column })} + {#if column.key === 'status'} + + {:else if column.key === 'current_score'} + + {row.current_score} / {data.max_score} + + {:else if column.key === 'responses'} + e.stopPropagation()} + > + View response + + {:else} + {String(row[column.key] ?? '')} + {/if} + {/snippet} + From c5e25314cbb151ff3ee8ee5eb406db845d265e9a Mon Sep 17 00:00:00 2001 From: carlsalces Date: Thu, 9 Apr 2026 00:19:23 +0800 Subject: [PATCH 10/14] fix: remove username field from constiquiz respondent list --- src/lib/admin/types.ts | 1 - src/lib/server/admin-queries.ts | 3 +-- src/routes/admin/constiquiz/+page.svelte | 8 ++++---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/lib/admin/types.ts b/src/lib/admin/types.ts index 48457fe..0a94875 100644 --- a/src/lib/admin/types.ts +++ b/src/lib/admin/types.ts @@ -66,7 +66,6 @@ export interface GradeInput { export interface QuizRespondent { user_id: string; full_name: string; - username: string; status: 'Not Started' | 'In Progress' | 'Completed'; current_score: number; } diff --git a/src/lib/server/admin-queries.ts b/src/lib/server/admin-queries.ts index 1053c70..cf4f023 100644 --- a/src/lib/server/admin-queries.ts +++ b/src/lib/server/admin-queries.ts @@ -139,7 +139,7 @@ export async function fetchQuizRespondents( supabase: SupabaseClient, ): Promise<{ respondents: QuizRespondent[]; max_score: number }> { const [applicantsRes, submissionsRes, answersRes, questionsRes] = await Promise.all([ - supabase.from('profiles').select('id, username, full_name').eq('role', 'applicant'), + supabase.from('profiles').select('id, full_name').eq('role', 'applicant'), supabase.from('constiquiz-submissions').select('user_id'), supabase.from('constiquiz-answers').select('user_id, points'), supabase.from('constiquiz-questions').select('point_value'), @@ -192,7 +192,6 @@ export async function fetchQuizRespondents( return { user_id: uid, full_name: (p.full_name as string | null) ?? '', - username: (p.username as string | null) ?? '', status, current_score: scoreByUser[uid] ?? 0, }; diff --git a/src/routes/admin/constiquiz/+page.svelte b/src/routes/admin/constiquiz/+page.svelte index 7b8b7e2..388408f 100644 --- a/src/routes/admin/constiquiz/+page.svelte +++ b/src/routes/admin/constiquiz/+page.svelte @@ -6,10 +6,9 @@ const { data } = $props(); const columns = [ - { key: 'full_name', header: 'Applicant Name', searchable: true }, - { key: 'username', header: 'Username', searchable: true }, - { key: 'status', header: 'Status' }, - { key: 'current_score', header: 'Current Score' }, + { key: 'full_name', header: 'Applicant Name', searchable: true, sortable: true }, + { key: 'status', header: 'Status', sortable: true }, + { key: 'current_score', header: 'Current Score', sortable: true }, { key: 'responses', header: 'Responses' }, ]; @@ -22,6 +21,7 @@ title="Constitution Quiz Respondents" data={data.respondents as unknown as Record[]} {columns} + rowKey="user_id" filterKey="status" onRowClick={goToDetail} > From c6392f21f53beae7ebaa91478b2ae6eaffeb3bf1 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Thu, 9 Apr 2026 00:19:39 +0800 Subject: [PATCH 11/14] fix: add page padding and larger heading to AdminListView --- src/lib/admin/AdminListView.svelte | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/lib/admin/AdminListView.svelte b/src/lib/admin/AdminListView.svelte index b1d5b94..d9b154d 100644 --- a/src/lib/admin/AdminListView.svelte +++ b/src/lib/admin/AdminListView.svelte @@ -3,12 +3,13 @@ import PaginatedTable from './PaginatedTable.svelte'; import SearchInput from './SearchInput.svelte'; import type { Snippet } from 'svelte'; - import SortDropdown from './SortDropdown.svelte'; + import { fly } from 'svelte/transition'; interface Column { key: string; header: string; searchable?: boolean; + sortable?: boolean; } interface CellContext { @@ -22,6 +23,7 @@ title, data, columns, + rowKey, filterKey = '', filterOptions = ['all', 'Not Started', 'In Progress', 'Completed'], searchPlaceholder = 'Search applicant', @@ -31,6 +33,7 @@ title: string; data: Record[]; columns: Column[]; + rowKey: string; filterKey?: string; filterOptions?: string[]; searchPlaceholder?: string; @@ -41,30 +44,17 @@ let searchTerm = $state(''); let filterValue = $state('all'); - let sortKey = $state(''); - let sortDirection = $state<'asc' | 'desc'>('asc'); -
-

{title}

+
+

{title}

-
- +
From 82aa2de1b1a9151713364eed0b9210741c7d7f54 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Thu, 9 Apr 2026 00:20:16 +0800 Subject: [PATCH 12/14] feat: add pagination-related components --- src/lib/admin/PaginatedTable.svelte | 62 ++++++++++++++++++++++++----- src/lib/admin/Pagination.svelte | 10 +++-- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/src/lib/admin/PaginatedTable.svelte b/src/lib/admin/PaginatedTable.svelte index 3603b82..54a33b8 100644 --- a/src/lib/admin/PaginatedTable.svelte +++ b/src/lib/admin/PaginatedTable.svelte @@ -1,11 +1,14 @@ {#if totalPages > 1} -
+
- + Page {currentPage} of {totalPages} From 7f32e130cc62957f6e7119096c5398574fd9682e Mon Sep 17 00:00:00 2001 From: carlsalces Date: Thu, 9 Apr 2026 00:20:34 +0800 Subject: [PATCH 13/14] feat: implement dropdown components --- src/lib/admin/FilterDropdown.svelte | 9 +++- src/lib/admin/SortDropdown.svelte | 71 ----------------------------- 2 files changed, 7 insertions(+), 73 deletions(-) delete mode 100644 src/lib/admin/SortDropdown.svelte diff --git a/src/lib/admin/FilterDropdown.svelte b/src/lib/admin/FilterDropdown.svelte index 6e40f8a..1c2b897 100644 --- a/src/lib/admin/FilterDropdown.svelte +++ b/src/lib/admin/FilterDropdown.svelte @@ -1,4 +1,6 @@ - - - -
- - {#if open} -
- {#each columns as col (col.key)} - - {/each} -
- {/if} -
From 7977f619d91a09935a7d0992a20afff15ede83d7 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Thu, 9 Apr 2026 00:20:53 +0800 Subject: [PATCH 14/14] chore: modify .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1d41d45..86b310e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ CLAUDE.md .agents/ .claude/ skills-lock.json +.DS_Store \ No newline at end of file