From 0d6c58e727abd8526344d285701569dbc0308683 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 21:46:38 +0800 Subject: [PATCH 01/28] docs: add CLAUDE.md and admin roles implementation plan Add project guidance for Claude Code, a detailed implementation plan for the admin role system, and simplified data flow diagrams. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 77 +++ docs/1-admin-roles-implementation-plan.md | 744 ++++++++++++++++++++++ docs/2-admin-roles-data-flow-diagrams.md | 209 ++++++ 3 files changed, 1030 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/1-admin-roles-implementation-plan.md create mode 100644 docs/2-admin-roles-data-flow-diagrams.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f9e39a0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +UP CSI member application processing web app built with **SvelteKit 2** (Svelte 5), **TypeScript**, **Supabase** (auth + PostgreSQL), and **Google Drive API** (file storage). Uses **pnpm** as package manager. + +## Commands + +```bash +pnpm dev # Start dev server +pnpm build # Production build +pnpm preview # Preview production build + +pnpm fmt # Check formatting (Prettier) +pnpm fmt:fix # Auto-fix formatting +pnpm lint # Run all linters in parallel (html, css, js, svelte) +pnpm lint:js # ESLint only +pnpm lint:svelte # Svelte type checking only +``` + +## Architecture + +### Routing (SvelteKit file-based) + +- `/` — Dashboard/home +- `/login` — Google OAuth login (restricted to `@up.edu.ph` emails, checked against Supabase `whitelist` table) +- `/login/callback` — OAuth callback with domain + whitelist validation +- `/sigsheet` — Signature sheet feature (member grid with modals) +- `/consti-quiz` — Constitution quiz (multiple question types: radio, checkbox, short/long text) +- `/api/answers` — POST quiz answers (upsert pattern) +- `/api/get_gdrive_folder` — Create/get Google Drive folders +- `/api/upload` — Upload files to Google Drive + +### Auth Flow + +Supabase SSR OAuth with Google. Server hook (`hooks.server.ts`) creates a Supabase client per request with cookie management. Browser client uses `createBrowserClient()`. `safeGetSession()` helper for auth state. Layout's `onAuthStateChange` invalidates data on auth changes. + +### Data Layer + +- **Database**: Supabase PostgREST — tables include `sigsheet`, `constiquiz-sections`, `constiquiz-questions`, `constiquiz-options`, `constiquiz-answers`, `constiquiz-submissions`, `constiquiz-availability`, `profiles`, `whitelist`, `pic-folders` +- **File storage**: Google Drive API with JWT service account auth +- **Client state**: Svelte writable stores in `$lib/shared.ts` (`uuid`, `username`, `gdrive_folder_id`, `filledSigsheet`, `applicant_names_list`) +- **Data loading**: Parallel fetching with `Promise.all()` in `+layout.ts`, dependency tracking with `depends()` + +### Key Files + +- `src/hooks.server.ts` — Server hook creating Supabase client per request +- `src/lib/supabaseClient.js` — Browser-side Supabase client +- `src/lib/shared.ts` — Global Svelte stores +- `src/routes/+layout.ts` — Root data loader (auth, quiz data) +- `src/routes/consti-quiz/constiquiz-types.ts` — Quiz question type definitions (discriminated unions) + +### Environment Variables + +``` +PUBLIC_SUPABASE_URL +PUBLIC_SUPABASE_ANON_KEY +PUBLIC_SUPABASE_SERVICE_KEY +PUBLIC_GOOGLE_SERVICE_EMAIL +PUBLIC_GOOGLE_PRIVATE_KEY +``` + +## Code Conventions + +- **Svelte 5 runes**: Uses `$props()`, `$state()`, snippet types for component composition +- **TypeScript strict mode** with `noUncheckedIndexedAccess` and `noImplicitOverride` +- **Prettier**: 4-space indentation, 120 char width, single quotes, Tailwind class sorting plugin +- **Tailwind CSS**: Custom CSI brand colors defined in `tailwind.config.ts` (`csi-blue: #00C6D7`, `csi-black: #212121`, `csi-yellow: #F7CF2F`, plus committee colors) +- **ESLint**: No unused vars, no console (warning), prefer const + +## Deployment + +- Docker multi-stage build (Node.js Alpine), port 3000 +- CI runs on PRs/push to main: install → format check → lint → build +- Deploy triggers on push to `production` branch → builds Docker image → pushes to GitHub Container Registry diff --git a/docs/1-admin-roles-implementation-plan.md b/docs/1-admin-roles-implementation-plan.md new file mode 100644 index 0000000..5213df0 --- /dev/null +++ b/docs/1-admin-roles-implementation-plan.md @@ -0,0 +1,744 @@ +# Admin Roles Implementation Plan + +> **Scope:** Database, auth, and backend changes only. No frontend. +> **Goal:** Add an "admin" role so org leaders can view/check applicant data (quiz responses, sigsheet progress, profiles). + +--- + +## Current State Summary + +- **Auth:** Google OAuth → `@up.edu.ph` domain check → `whitelist` table check → session created. All users treated identically as applicants. +- **Database:** `profiles` table has no role column. No RLS policies on any table. +- **Backend:** Two API endpoints (`/api/get_gdrive_folder`, `/api/upload`) have no auth checks and use a browser-side Supabase client on the server. Other endpoints check `safeGetSession()` but have no role awareness. +- **Hooks:** `hooks.server.ts` has a commented-out `authGuard`. The `sequence()` only runs the `supabase` handle. +- **Service key:** `PUBLIC_SUPABASE_SERVICE_KEY` exists in CI env vars but is never imported in code. The `PUBLIC_` prefix is wrong — it would expose the key to the browser. + +--- + +## Phase 1: Database Schema Changes + +### 1.1 Add role to profiles + +Run this migration in Supabase SQL Editor (or via Supabase CLI migration): + +```sql +-- Create enum type for roles (extensible: add 'member' later with ALTER TYPE) +CREATE TYPE public.app_role AS ENUM ('applicant', 'admin'); + +-- Add role column — all existing users default to 'applicant' +ALTER TABLE public.profiles + ADD COLUMN role public.app_role NOT NULL DEFAULT 'applicant'; + +-- Index for fast role lookups (used on every request in hooks) +CREATE INDEX idx_profiles_role ON public.profiles (role); +``` + +**Design decision:** Role on `profiles` directly (not a separate table) because it's a 1:1 relationship with two values. If multi-role is needed later, migrate to a join table then. + +### 1.2 Seed admin users + +```sql +UPDATE public.profiles +SET role = 'admin' +WHERE id IN ( + SELECT id FROM auth.users + WHERE email IN ( + 'admin1@up.edu.ph', + 'admin2@up.edu.ph' + -- Replace with actual admin emails + ) +); +``` + +Ensure these emails are also in the `whitelist` table: + +```sql +INSERT INTO public.whitelist (email) +VALUES ('admin1@up.edu.ph'), ('admin2@up.edu.ph') +ON CONFLICT (email) DO NOTHING; +``` + +### 1.3 Verify + +```sql +SELECT p.id, u.email, p.role +FROM public.profiles p +JOIN auth.users u ON p.id = u.id +ORDER BY p.role, u.email; +``` + +--- + +## Phase 2: Server-Side Auth Infrastructure + +### 2.1 Rename service key env var + +The service key must NOT have the `PUBLIC_` prefix — SvelteKit exposes `PUBLIC_` vars to the browser. + +**`.env`:** Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` + +**`.github/workflows/deploy.yml`:** Update all references: +```yaml +# Change these lines: +PUBLIC_SUPABASE_SERVICE_KEY: ${{ vars.PUBLIC_SUPABASE_SERVICE_KEY }} +# To: +SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} +``` + +**`.github/workflows/ci.yml`:** Same rename. Also update the GitHub Actions repo settings to use the new variable name (or move to a secret since it's a sensitive key). + +### 2.2 Create service-role Supabase client + +**New file: `src/lib/server/supabaseAdmin.ts`** + +```typescript +import { SUPABASE_SERVICE_KEY } from '$env/static/private'; +import { PUBLIC_SUPABASE_URL } from '$env/static/public'; +import { createClient } from '@supabase/supabase-js'; + +/** + * Service-role Supabase client. Bypasses RLS. + * ONLY use in server-side code for admin operations. + */ +export const supabaseAdmin = createClient(PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_KEY); +``` + +The `src/lib/server/` directory is a SvelteKit convention — files here can only be imported from server-side code (`+server.ts`, `+page.server.ts`, `hooks.server.ts`). This prevents accidental browser exposure. + +### 2.3 Create auth helper utilities + +**New file: `src/lib/server/auth.ts`** + +```typescript +import { error } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; + +export type AppRole = 'applicant' | 'admin'; + +/** + * Requires authenticated user. Throws 401 if not logged in. + */ +export function requireAuth(event: RequestEvent) { + const { user, session } = event.locals; + if (!session || !user) { + throw error(401, 'Authentication required'); + } + return { user, session }; +} + +/** + * Requires a specific role. Throws 401 if not authenticated, 403 if wrong role. + */ +export function requireRole(event: RequestEvent, role: AppRole) { + const { user, session } = requireAuth(event); + if (event.locals.userRole !== role) { + throw error(403, 'Insufficient permissions'); + } + return { user, session }; +} + +/** + * Check if current user is admin. Does not throw. + */ +export function isAdmin(event: RequestEvent): boolean { + return event.locals.userRole === 'admin'; +} +``` + +--- + +## Phase 3: Auth Flow — Role Resolution in Hooks + +### 3.1 Update TypeScript types + +**File: `src/app.d.ts`** + +```typescript +import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; +import type { Database } from './database.types.ts'; +import type { AppRole } from '$lib/server/auth'; + +declare global { + namespace App { + interface Locals { + supabase: SupabaseClient; + safeGetSession: () => Promise<{ session: Session | null; user: User | null }>; + session: Session | null; + user: User | null; + userRole: AppRole | null; + } + interface PageData { + session: Session | null; + userRole: AppRole | null; + } + } +} + +export {}; +``` + +### 3.2 Add authGuard to hooks + +**File: `src/hooks.server.ts`** + +Replace the commented-out `authGuard` and update the `sequence()` export: + +```typescript +import { type Handle } from '@sveltejs/kit'; +import { createServerClient } from '@supabase/ssr'; +import { sequence } from '@sveltejs/kit/hooks'; +import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; +import type { AppRole } from '$lib/server/auth'; + +const supabase: Handle = ({ event, resolve }) => { + // ... KEEP EXISTING CODE (lines 8-48) EXACTLY AS-IS ... +}; + +const authGuard: Handle = async ({ event, resolve }) => { + const { session, user } = await event.locals.safeGetSession(); + event.locals.session = session; + event.locals.user = user; + event.locals.userRole = null; + + if (user) { + const { data: profile } = await event.locals.supabase + .from('profiles') + .select('role') + .eq('id', user.id) + .single(); + + event.locals.userRole = (profile?.role as AppRole) ?? 'applicant'; + } + + return resolve(event); +}; + +export const handle: Handle = sequence(supabase, authGuard); +``` + +**What this does:** On every request, after the Supabase client is set up, fetch the user's role from `profiles` and attach it to `event.locals.userRole`. All downstream handlers (layout loads, API endpoints) can read `event.locals.userRole` without an extra DB query. + +### 3.3 Pass role to page data + +**File: `src/routes/+layout.server.ts`** + +```typescript +export const load = async ({ locals: { safeGetSession, userRole }, cookies }) => { + const { session } = await safeGetSession(); + return { + session, + cookies: cookies.getAll(), + userRole: userRole ?? null, + }; +}; +``` + +### 3.4 Login callback — no changes needed + +Admins log in via the same Google OAuth flow. They must be in the `whitelist` table. Their role is determined from `profiles.role`, not the login flow. + +### 3.5 Verify + +1. Add `console.log('userRole:', event.locals.userRole)` temporarily in the `authGuard` +2. Log in as a seeded admin → should print `'admin'` +3. Log in as a regular applicant → should print `'applicant'` +4. Access any page without logging in → should print `null` + +--- + +## Phase 4: Fix Existing Unprotected Endpoints + +Two endpoints currently have no auth checks and use the browser Supabase client singleton on the server. + +### 4.1 Fix `/api/get_gdrive_folder` + +**File: `src/routes/api/get_gdrive_folder/+server.js`** + +Changes: +1. Remove `import { supabase } from '$lib/supabaseClient';` +2. Add auth check using `locals.safeGetSession()` (or import `requireAuth` if converting to `.ts`) +3. Use `locals.supabase` instead of the browser singleton +4. Use `user.id` from auth instead of `uuid` from request body (prevents spoofing) + +```javascript +// Before: +import { supabase } from '$lib/supabaseClient'; +export async function POST({ request }) { + const { uuid, username } = await request.json(); + // ... uses uuid from body + +// After: +export async function POST({ request, locals }) { + const { user } = await locals.safeGetSession(); + if (!user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + const uuid = user.id; // Use authenticated user's ID + const { username } = await request.json(); + // ... replace all `supabase.` calls with `locals.supabase.` +``` + +### 4.2 Fix `/api/upload` + +**File: `src/routes/api/upload/+server.js`** + +Same pattern: +1. Remove `import { supabase } from '../../../lib/supabaseClient';` +2. Add auth check +3. Use `locals.supabase` for DB operations +4. Use `user.id` instead of `formData.get('uuid')` + +```javascript +// Before: +import { supabase } from '../../../lib/supabaseClient'; +export async function POST({ request }) { + const formData = await request.formData(); + const uuid = formData.get('uuid'); + +// After: +export async function POST({ request, locals }) { + const { user } = await locals.safeGetSession(); + if (!user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + const uuid = user.id; + const formData = await request.formData(); + // ... replace all `supabase.` calls with `locals.supabase.` +``` + +### 4.3 Verify + +- Call `POST /api/get_gdrive_folder` without a session cookie → expect 401 +- Call `POST /api/upload` without a session cookie → expect 401 +- Call both while logged in → should work as before + +--- + +## Phase 5: Admin API Endpoints + +All new endpoints go under `src/routes/api/admin/`. Every endpoint: +- Calls `requireRole(event, 'admin')` as the first line +- Uses `supabaseAdmin` (service-role client) to bypass RLS for cross-user reads +- Returns JSON + +### 5.1 List all applicant profiles + +**New file: `src/routes/api/admin/applicants/+server.ts`** + +```typescript +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; + +export async function GET(event) { + requireRole(event, 'admin'); + + const { data, error } = await supabaseAdmin + .from('profiles') + .select('id, username, full_name, avatar_url, role') + .eq('role', 'applicant'); + + if (error) { + return json({ error: error.message }, { status: 500 }); + } + + return json({ applicants: data }); +} +``` + +### 5.2 All quiz submissions with scores + +**New file: `src/routes/api/admin/quiz-results/+server.ts`** + +```typescript +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; + +export async function GET(event) { + requireRole(event, 'admin'); + + // 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); + } + + 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 }); +} +``` + +### 5.3 Single applicant's detailed quiz answers + +**New file: `src/routes/api/admin/quiz-results/[userId]/+server.ts`** + +```typescript +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; + +export async function GET(event) { + requireRole(event, 'admin'); + const userId = event.params.userId; + + 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 }); + } + + return json({ + profile: profileRes.data, + submitted_at: submissionRes.data?.submitted_at ?? null, + answers: answersRes.data, + }); +} +``` + +### 5.4 All applicants' sigsheet progress + +**New file: `src/routes/api/admin/sigsheet-progress/+server.ts`** + +```typescript +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; + +export async function GET(event) { + requireRole(event, 'admin'); + + // 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 }); + } + + // Group by applicant + const byApplicant: Record = {}; + for (const row of data ?? []) { + const key = row.applicant.id; + if (!byApplicant[key]) { + byApplicant[key] = { profile: row.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), + }); +} +``` + +### Endpoint summary + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/admin/applicants` | List all applicant profiles | +| GET | `/api/admin/quiz-results` | All submissions with total scores | +| GET | `/api/admin/quiz-results/[userId]` | Single applicant's detailed answers | +| GET | `/api/admin/sigsheet-progress` | All sigsheet progress grouped by applicant | + +### Verify each endpoint + +For each: unauthenticated → 401, applicant → 403, admin → 200 with data. + +--- + +## Phase 6: Row Level Security (RLS) + +RLS is defense-in-depth. Even if application code has a bug, the database enforces access rules. The `supabaseAdmin` client (service role) bypasses RLS by design — admin endpoints still work. + +### 6.1 Create helper function + +```sql +CREATE OR REPLACE FUNCTION public.get_user_role() +RETURNS public.app_role +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT role FROM public.profiles WHERE id = auth.uid() +$$; +``` + +`SECURITY DEFINER` lets it read `profiles` even when RLS is enabled. `STABLE` allows caching within a transaction. + +### 6.2 Enable RLS on all tables + +```sql +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.whitelist ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-answers" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-submissions" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-sections" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-questions" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-options" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."constiquiz-availability" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.sigsheet ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.members ENABLE ROW LEVEL SECURITY; +ALTER TABLE public."pic-folders" ENABLE ROW LEVEL SECURITY; +``` + +> **Note:** Table names with hyphens must be double-quoted in SQL. + +### 6.3 Policies — User-scoped tables + +**profiles:** +```sql +CREATE POLICY "Users can view own profile" + ON public.profiles FOR SELECT + USING (auth.uid() = id); + +CREATE POLICY "Users can update own profile" + ON public.profiles FOR UPDATE + USING (auth.uid() = id); + +CREATE POLICY "Admins can view all profiles" + ON public.profiles FOR SELECT + USING (public.get_user_role() = 'admin'); +``` + +**constiquiz-answers:** +```sql +CREATE POLICY "Users can view own answers" + ON public."constiquiz-answers" FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own answers" + ON public."constiquiz-answers" FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own answers" + ON public."constiquiz-answers" FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "Admins can view all answers" + ON public."constiquiz-answers" FOR SELECT + USING (public.get_user_role() = 'admin'); +``` + +**constiquiz-submissions:** +```sql +CREATE POLICY "Users can view own submissions" + ON public."constiquiz-submissions" FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own submissions" + ON public."constiquiz-submissions" FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Admins can view all submissions" + ON public."constiquiz-submissions" FOR SELECT + USING (public.get_user_role() = 'admin'); +``` + +**sigsheet:** +```sql +CREATE POLICY "Users can view own sigsheet" + ON public.sigsheet FOR SELECT + USING (auth.uid() = applicant_id); + +CREATE POLICY "Users can insert own sigsheet" + ON public.sigsheet FOR INSERT + WITH CHECK (auth.uid() = applicant_id); + +CREATE POLICY "Admins can view all sigsheet" + ON public.sigsheet FOR SELECT + USING (public.get_user_role() = 'admin'); +``` + +**pic-folders:** +```sql +CREATE POLICY "Users can view own folder" + ON public."pic-folders" FOR SELECT + USING (auth.uid() = applicant_uuid); + +CREATE POLICY "Users can insert own folder" + ON public."pic-folders" FOR INSERT + WITH CHECK (auth.uid() = applicant_uuid); + +CREATE POLICY "Admins can view all folders" + ON public."pic-folders" FOR SELECT + USING (public.get_user_role() = 'admin'); +``` + +### 6.4 Policies — Read-only reference tables + +These are shared data all authenticated users can read: + +```sql +CREATE POLICY "Authenticated can view sections" + ON public."constiquiz-sections" FOR SELECT + USING (auth.uid() IS NOT NULL); + +CREATE POLICY "Authenticated can view questions" + ON public."constiquiz-questions" FOR SELECT + USING (auth.uid() IS NOT NULL); + +CREATE POLICY "Authenticated can view options" + ON public."constiquiz-options" FOR SELECT + USING (auth.uid() IS NOT NULL); + +CREATE POLICY "Authenticated can view availability" + ON public."constiquiz-availability" FOR SELECT + USING (auth.uid() IS NOT NULL); + +CREATE POLICY "Authenticated can view members" + ON public.members FOR SELECT + USING (auth.uid() IS NOT NULL); + +CREATE POLICY "Authenticated can check whitelist" + ON public.whitelist FOR SELECT + USING (auth.uid() IS NOT NULL); +``` + +### 6.5 Verify RLS + +1. Open browser DevTools as an applicant. Try querying another user's answers via the Supabase JS client → should return empty +2. Admin API endpoints (which use `supabaseAdmin` / service role) should still return all data +3. Applicant flow (quiz, sigsheet, upload) should still work normally — own data is accessible + +--- + +## File Change Summary + +### New files +| File | Purpose | +|------|---------| +| `src/lib/server/auth.ts` | `requireAuth()`, `requireRole()`, `isAdmin()` helpers + `AppRole` type | +| `src/lib/server/supabaseAdmin.ts` | Service-role Supabase client (bypasses RLS) | +| `src/routes/api/admin/applicants/+server.ts` | List applicant profiles | +| `src/routes/api/admin/quiz-results/+server.ts` | All quiz submissions + scores | +| `src/routes/api/admin/quiz-results/[userId]/+server.ts` | Single applicant quiz detail | +| `src/routes/api/admin/sigsheet-progress/+server.ts` | All sigsheet progress | + +### Modified files +| File | Change | +|------|--------| +| `src/app.d.ts` | Add `userRole: AppRole \| null` to Locals and PageData | +| `src/hooks.server.ts` | Add `authGuard` handle that fetches role, add to `sequence()` | +| `src/routes/+layout.server.ts` | Pass `userRole` in returned data | +| `src/routes/api/get_gdrive_folder/+server.js` | Add auth check, use `locals.supabase` instead of browser client | +| `src/routes/api/upload/+server.js` | Add auth check, use `locals.supabase` instead of browser client | +| `.env` | Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` | +| `.github/workflows/deploy.yml` | Update env var name | +| `.github/workflows/ci.yml` | Update env var name | + +### Unchanged files +- `src/routes/login/callback/+server.js` — login flow stays the same +- `src/routes/+layout.ts` — frontend concern +- `src/lib/supabaseClient.js` — browser client stays (used by frontend) +- All Svelte components — frontend concern + +--- + +## Implementation Order + +Phases **must** be done in order due to dependencies: + +``` +Phase 1 (DB schema) + ↓ role column must exist +Phase 2 (auth infra) + ↓ helpers + admin client must exist +Phase 3 (hooks + types) + ↓ userRole must be on event.locals +Phase 4 (fix existing endpoints) ←── can be parallel with Phase 5 +Phase 5 (admin endpoints) ←── can be parallel with Phase 4 + ↓ +Phase 6 (RLS policies) +``` + +Phase 4 and 5 can be done in parallel by different team members since they touch different files. + +--- + +## Future: Adding "member" Role + +When the time comes: +1. `ALTER TYPE public.app_role ADD VALUE 'member';` +2. Add member-specific RLS policies +3. Add endpoints under `/api/member/` +4. The `requireRole()` and `isAdmin()` pattern extends naturally — add `isMember()` etc. diff --git a/docs/2-admin-roles-data-flow-diagrams.md b/docs/2-admin-roles-data-flow-diagrams.md new file mode 100644 index 0000000..0ea1b62 --- /dev/null +++ b/docs/2-admin-roles-data-flow-diagrams.md @@ -0,0 +1,209 @@ +# Admin Roles — System Overview Diagrams + +> Companion to `1-admin-roles-implementation-plan.md`. +> Diagrams use Mermaid syntax — rendered natively on GitHub. To use in Excalidraw, paste the code blocks into the "Mermaid to Excalidraw" feature (wand icon). + +--- + +## 1. How Sign-In Works + +Everyone signs in the same way. The system figures out your role after you log in. + +```mermaid +flowchart LR + A["User clicks Sign In"] --> B["Google login\n(@up.edu.ph only)"] + B --> C{"Email in\nwhitelist?"} + C -->|No| D["Rejected"] + C -->|Yes| E["Logged in"] + E --> F["System looks up\nrole from database"] + F --> G["Applicant"] + F --> H["Admin"] + + style D fill:#fce8e6,stroke:#d93025 + style G fill:#e8f4fd,stroke:#1a73e8 + style H fill:#fce8e6,stroke:#d93025 +``` + +--- + +## 2. What Each Role Can See and Do + +```mermaid +flowchart TB + subgraph NOT_LOGGED_IN["Not Logged In"] + N1["Login page only"] + end + + subgraph APPLICANT_ACCESS["Applicant"] + direction TB + A1["Take the constitution quiz"] + A2["View & save their own answers"] + A3["Collect signatures on sigsheet"] + A4["Upload files to Google Drive"] + end + + subgraph ADMIN_ACCESS["Admin"] + direction TB + B1["View all applicant profiles"] + B2["View all quiz submissions & scores"] + B3["View any applicant's detailed answers"] + B4["View sigsheet progress of all applicants"] + end + + style NOT_LOGGED_IN fill:#f5f5f5,stroke:#999 + style APPLICANT_ACCESS fill:#e8f4fd,stroke:#1a73e8 + style ADMIN_ACCESS fill:#fce8e6,stroke:#d93025 +``` + +Key difference: **applicants only see their own data**, **admins can see everyone's data**. + +--- + +## 3. How a Request Flows Through the System + +Every page visit or API call goes through the same pipeline. + +```mermaid +flowchart TD + A["User visits a page\nor calls an API"] --> B["Server checks:\nAre you logged in?"] + + B -->|Not logged in| C["Can only see\npublic pages"] + B -->|Logged in| D["Server looks up\nyour role"] + + D --> E{"What's your role?"} + + E -->|Applicant| F["Can access\napplicant features"] + E -->|Admin| G["Can access\nadmin features"] + + F --> H["Data is filtered:\nyou only see YOUR stuff"] + G --> I["Data is unfiltered:\nyou see ALL applicants' stuff"] + + style C fill:#f5f5f5,stroke:#999 + style F fill:#e8f4fd,stroke:#1a73e8 + style G fill:#fce8e6,stroke:#d93025 + style H fill:#e8f4fd,stroke:#1a73e8 + style I fill:#fce8e6,stroke:#d93025 +``` + +--- + +## 4. System Architecture Overview + +```mermaid +flowchart TB + subgraph USERS["Users"] + APPLICANT["Applicant\n(@up.edu.ph)"] + ADMIN["Admin\n(@up.edu.ph)"] + end + + subgraph APP["UP CSI App"] + LOGIN["Login\n(Google OAuth)"] + ROLE_CHECK["Role Check\n(on every request)"] + + subgraph APPLICANT_PAGES["Applicant Features"] + QUIZ["Constitution Quiz"] + SIG["Sigsheet"] + UPLOAD["File Upload"] + end + + subgraph ADMIN_PAGES["Admin Features"] + VIEW_PROFILES["View Applicant Profiles"] + VIEW_QUIZ["View Quiz Results & Scores"] + VIEW_SIG["View Sigsheet Progress"] + end + end + + subgraph SERVICES["External Services"] + GOOGLE["Google OAuth"] + GDRIVE["Google Drive\n(file storage)"] + SUPABASE["Supabase\n(database + auth)"] + end + + APPLICANT --> LOGIN + ADMIN --> LOGIN + LOGIN --> GOOGLE + GOOGLE --> ROLE_CHECK + + ROLE_CHECK -->|"role = applicant"| APPLICANT_PAGES + ROLE_CHECK -->|"role = admin"| ADMIN_PAGES + + QUIZ --> SUPABASE + SIG --> SUPABASE + UPLOAD --> GDRIVE + VIEW_PROFILES --> SUPABASE + VIEW_QUIZ --> SUPABASE + VIEW_SIG --> SUPABASE + + style APPLICANT fill:#e8f4fd,stroke:#1a73e8 + style ADMIN fill:#fce8e6,stroke:#d93025 + style APPLICANT_PAGES fill:#e8f4fd,stroke:#1a73e8 + style ADMIN_PAGES fill:#fce8e6,stroke:#d93025 +``` + +--- + +## 5. What Happens When Access is Denied + +```mermaid +flowchart TD + A["Someone tries to\naccess an admin page"] --> B{"Logged in?"} + + B -->|No| C["401: Please log in"] + B -->|Yes| D{"Role = admin?"} + + D -->|No, applicant| E["403: You don't have\npermission for this"] + D -->|Yes| F["200: Here's the data"] + + style C fill:#fce8e6,stroke:#d93025 + style E fill:#fef7e0,stroke:#f9ab00 + style F fill:#e6f4ea,stroke:#137333 +``` + +--- + +## 6. Two Layers of Protection + +The system protects data at two levels — even if one layer has a bug, the other catches it. + +```mermaid +flowchart TD + A["User makes a request"] --> B + + subgraph B["Layer 1: App Server"] + B1["Checks if you're logged in"] + B2["Checks if you have the right role"] + B1 --> B2 + end + + B --> C + + subgraph C["Layer 2: Database"] + C1["Row Level Security (RLS)"] + C2["Applicants can only\nread/write their own rows"] + C3["Admins can read all rows"] + C1 --> C2 + C1 --> C3 + end + + C --> D["Data returned"] + + style B fill:#e8f4fd,stroke:#1a73e8 + style C fill:#fef7e0,stroke:#f9ab00 + style D fill:#e6f4ea,stroke:#137333 +``` + +--- + +## 7. Data Ownership Summary + +| Data | Applicant can... | Admin can... | +|------|-----------------|--------------| +| Own profile | View, update | - | +| All profiles | - | View all | +| Own quiz answers | View, save, submit | - | +| All quiz answers | - | View all + scores | +| Own sigsheet entries | View, create | - | +| All sigsheet entries | - | View all + progress | +| Own GDrive folder | Create, upload to | - | +| Quiz questions/options | View (read-only) | View (read-only) | +| Members list | View (read-only) | View (read-only) | From 415a6a21bee50584a6c86ea49a7455e2e23bf5ee Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 21:47:48 +0800 Subject: [PATCH 02/28] feat: add server-side auth helpers and admin Supabase client - requireAuth(), requireRole(), isAdmin() utilities in lib/server/auth.ts - Service-role Supabase client in lib/server/supabaseAdmin.ts (bypasses RLS) - AppRole type includes applicant, admin, withdrawn, inactive Co-Authored-By: Claude Opus 4.6 --- src/lib/server/auth.ts | 33 +++++++++++++++++++++++++++++++++ src/lib/server/supabaseAdmin.ts | 9 +++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/lib/server/auth.ts create mode 100644 src/lib/server/supabaseAdmin.ts diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..dd54373 --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,33 @@ +import { error } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; + +export type AppRole = 'applicant' | 'admin' | 'withdrawn' | 'inactive'; + +/** + * Requires authenticated user. Throws 401 if not logged in. + */ +export function requireAuth(event: RequestEvent) { + const { user, session } = event.locals; + if (!session || !user) { + throw error(401, 'Authentication required'); + } + return { user, session }; +} + +/** + * Requires a specific role. Throws 401 if not authenticated, 403 if wrong role. + */ +export function requireRole(event: RequestEvent, role: AppRole) { + const { user, session } = requireAuth(event); + if (event.locals.userRole !== role) { + throw error(403, 'Insufficient permissions'); + } + return { user, session }; +} + +/** + * Check if current user is admin. Does not throw. + */ +export function isAdmin(event: RequestEvent): boolean { + return event.locals.userRole === 'admin'; +} diff --git a/src/lib/server/supabaseAdmin.ts b/src/lib/server/supabaseAdmin.ts new file mode 100644 index 0000000..e4b7a05 --- /dev/null +++ b/src/lib/server/supabaseAdmin.ts @@ -0,0 +1,9 @@ +import { SUPABASE_SERVICE_KEY } from '$env/static/private'; +import { PUBLIC_SUPABASE_URL } from '$env/static/public'; +import { createClient } from '@supabase/supabase-js'; + +/** + * Service-role Supabase client. Bypasses RLS. + * ONLY use in server-side code for admin operations. + */ +export const supabaseAdmin = createClient(PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_KEY); From 510be2aa4b1043113f6de04ed3d10d05c01cef88 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 21:47:55 +0800 Subject: [PATCH 03/28] fix: rename PUBLIC_SUPABASE_SERVICE_KEY to SUPABASE_SERVICE_KEY in CI/CD The PUBLIC_ prefix exposes the service key to the browser in SvelteKit. Renamed to SUPABASE_SERVICE_KEY and moved to secrets in deploy workflow. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 575d418..3dd31a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: env: PUBLIC_SUPABASE_URL: ${{ vars.PUBLIC_SUPABASE_URL || 'http://dummy' }} PUBLIC_SUPABASE_ANON_KEY: ${{ vars.PUBLIC_SUPABASE_ANON_KEY || 'anon-key' }} - PUBLIC_SUPABASE_SERVICE_KEY: ${{ vars.PUBLIC_SUPABASE_SERVICE_KEY }} + SUPABASE_SERVICE_KEY: ${{ vars.SUPABASE_SERVICE_KEY }} PUBLIC_GOOGLE_SERVICE_EMAIL: ${{ vars.PUBLIC_GOOGLE_SERVICE_EMAIL }} PUBLIC_GOOGLE_PRIVATE_KEY: ${{ vars.PUBLIC_GOOGLE_PRIVATE_KEY }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6a03196..a3292d9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,7 +12,7 @@ env: # SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} PUBLIC_SUPABASE_URL: ${{ vars.PUBLIC_SUPABASE_URL || 'http://dummy' }} PUBLIC_SUPABASE_ANON_KEY: ${{ vars.PUBLIC_SUPABASE_ANON_KEY || 'anon-key' }} - PUBLIC_SUPABASE_SERVICE_KEY: ${{ vars.PUBLIC_SUPABASE_SERVICE_KEY }} + SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} PUBLIC_GOOGLE_SERVICE_EMAIL: ${{ vars.PUBLIC_GOOGLE_SERVICE_EMAIL }} PUBLIC_GOOGLE_PRIVATE_KEY: ${{ vars.PUBLIC_GOOGLE_PRIVATE_KEY }} @@ -59,7 +59,7 @@ jobs: CONTAINER_PORT=${{ env.CONTAINER_PORT }} PUBLIC_SUPABASE_URL=${{ env.PUBLIC_SUPABASE_URL }} PUBLIC_SUPABASE_ANON_KEY=${{ env.PUBLIC_SUPABASE_ANON_KEY }} - PUBLIC_SUPABASE_SERVICE_KEY=${{ env.PUBLIC_SUPABASE_SERVICE_KEY }} + SUPABASE_SERVICE_KEY=${{ env.SUPABASE_SERVICE_KEY }} PUBLIC_GOOGLE_SERVICE_EMAIL=${{ env.PUBLIC_GOOGLE_SERVICE_EMAIL }} PUBLIC_GOOGLE_PRIVATE_KEY=${{ env.PUBLIC_GOOGLE_PRIVATE_KEY }} EOF From a07decee59e2bdfb8d5fc16ff4ee56301c9cf755 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 22:07:37 +0800 Subject: [PATCH 04/28] chore: fix lint and formatting issues Co-Authored-By: Claude Opus 4.6 --- docs/1-admin-roles-implementation-plan.md | 89 ++++++++++++----------- docs/2-admin-roles-data-flow-diagrams.md | 22 +++--- src/lib/server/auth.ts | 3 +- src/lib/server/supabaseAdmin.ts | 2 +- 4 files changed, 58 insertions(+), 58 deletions(-) diff --git a/docs/1-admin-roles-implementation-plan.md b/docs/1-admin-roles-implementation-plan.md index 5213df0..7476836 100644 --- a/docs/1-admin-roles-implementation-plan.md +++ b/docs/1-admin-roles-implementation-plan.md @@ -78,6 +78,7 @@ The service key must NOT have the `PUBLIC_` prefix — SvelteKit exposes `PUBLIC **`.env`:** Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` **`.github/workflows/deploy.yml`:** Update all references: + ```yaml # Change these lines: PUBLIC_SUPABASE_SERVICE_KEY: ${{ vars.PUBLIC_SUPABASE_SERVICE_KEY }} @@ -255,6 +256,7 @@ Two endpoints currently have no auth checks and use the browser Supabase client **File: `src/routes/api/get_gdrive_folder/+server.js`** Changes: + 1. Remove `import { supabase } from '$lib/supabaseClient';` 2. Add auth check using `locals.safeGetSession()` (or import `requireAuth` if converting to `.ts`) 3. Use `locals.supabase` instead of the browser singleton @@ -286,6 +288,7 @@ export async function POST({ request, locals }) { **File: `src/routes/api/upload/+server.js`** Same pattern: + 1. Remove `import { supabase } from '../../../lib/supabaseClient';` 2. Add auth check 3. Use `locals.supabase` for DB operations @@ -323,6 +326,7 @@ export async function POST({ request, locals }) { ## Phase 5: Admin API Endpoints All new endpoints go under `src/routes/api/admin/`. Every endpoint: + - Calls `requireRole(event, 'admin')` as the first line - Uses `supabaseAdmin` (service-role client) to bypass RLS for cross-user reads - Returns JSON @@ -365,9 +369,7 @@ export async function GET(event) { requireRole(event, 'admin'); // Get all submissions with profile info - const { data: submissions, error: subError } = await supabaseAdmin - .from('constiquiz-submissions') - .select(` + const { data: submissions, error: subError } = await supabaseAdmin.from('constiquiz-submissions').select(` submission_id, submitted_at, user_id, @@ -428,24 +430,18 @@ export async function GET(event) { const [answersRes, profileRes, submissionRes] = await Promise.all([ supabaseAdmin .from('constiquiz-answers') - .select(` + .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(), + 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) { @@ -473,13 +469,9 @@ export async function GET(event) { requireRole(event, 'admin'); // Get total member count for progress calculation - const { count: totalMembers } = await supabaseAdmin - .from('members') - .select('*', { count: 'exact', head: true }); + const { count: totalMembers } = await supabaseAdmin.from('members').select('*', { count: 'exact', head: true }); - const { data, error } = await supabaseAdmin - .from('sigsheet') - .select(` + 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 ) `); @@ -513,12 +505,12 @@ export async function GET(event) { ### Endpoint summary -| Method | Path | Purpose | -|--------|------|---------| -| GET | `/api/admin/applicants` | List all applicant profiles | -| GET | `/api/admin/quiz-results` | All submissions with total scores | -| GET | `/api/admin/quiz-results/[userId]` | Single applicant's detailed answers | -| GET | `/api/admin/sigsheet-progress` | All sigsheet progress grouped by applicant | +| Method | Path | Purpose | +| ------ | ---------------------------------- | ------------------------------------------ | +| GET | `/api/admin/applicants` | List all applicant profiles | +| GET | `/api/admin/quiz-results` | All submissions with total scores | +| GET | `/api/admin/quiz-results/[userId]` | Single applicant's detailed answers | +| GET | `/api/admin/sigsheet-progress` | All sigsheet progress grouped by applicant | ### Verify each endpoint @@ -566,6 +558,7 @@ ALTER TABLE public."pic-folders" ENABLE ROW LEVEL SECURITY; ### 6.3 Policies — User-scoped tables **profiles:** + ```sql CREATE POLICY "Users can view own profile" ON public.profiles FOR SELECT @@ -581,6 +574,7 @@ CREATE POLICY "Admins can view all profiles" ``` **constiquiz-answers:** + ```sql CREATE POLICY "Users can view own answers" ON public."constiquiz-answers" FOR SELECT @@ -600,6 +594,7 @@ CREATE POLICY "Admins can view all answers" ``` **constiquiz-submissions:** + ```sql CREATE POLICY "Users can view own submissions" ON public."constiquiz-submissions" FOR SELECT @@ -615,6 +610,7 @@ CREATE POLICY "Admins can view all submissions" ``` **sigsheet:** + ```sql CREATE POLICY "Users can view own sigsheet" ON public.sigsheet FOR SELECT @@ -630,6 +626,7 @@ CREATE POLICY "Admins can view all sigsheet" ``` **pic-folders:** + ```sql CREATE POLICY "Users can view own folder" ON public."pic-folders" FOR SELECT @@ -685,28 +682,31 @@ CREATE POLICY "Authenticated can check whitelist" ## File Change Summary ### New files -| File | Purpose | -|------|---------| -| `src/lib/server/auth.ts` | `requireAuth()`, `requireRole()`, `isAdmin()` helpers + `AppRole` type | -| `src/lib/server/supabaseAdmin.ts` | Service-role Supabase client (bypasses RLS) | -| `src/routes/api/admin/applicants/+server.ts` | List applicant profiles | -| `src/routes/api/admin/quiz-results/+server.ts` | All quiz submissions + scores | -| `src/routes/api/admin/quiz-results/[userId]/+server.ts` | Single applicant quiz detail | -| `src/routes/api/admin/sigsheet-progress/+server.ts` | All sigsheet progress | + +| File | Purpose | +| ------------------------------------------------------- | ---------------------------------------------------------------------- | +| `src/lib/server/auth.ts` | `requireAuth()`, `requireRole()`, `isAdmin()` helpers + `AppRole` type | +| `src/lib/server/supabaseAdmin.ts` | Service-role Supabase client (bypasses RLS) | +| `src/routes/api/admin/applicants/+server.ts` | List applicant profiles | +| `src/routes/api/admin/quiz-results/+server.ts` | All quiz submissions + scores | +| `src/routes/api/admin/quiz-results/[userId]/+server.ts` | Single applicant quiz detail | +| `src/routes/api/admin/sigsheet-progress/+server.ts` | All sigsheet progress | ### Modified files -| File | Change | -|------|--------| -| `src/app.d.ts` | Add `userRole: AppRole \| null` to Locals and PageData | -| `src/hooks.server.ts` | Add `authGuard` handle that fetches role, add to `sequence()` | -| `src/routes/+layout.server.ts` | Pass `userRole` in returned data | + +| File | Change | +| --------------------------------------------- | --------------------------------------------------------------- | +| `src/app.d.ts` | Add `userRole: AppRole \| null` to Locals and PageData | +| `src/hooks.server.ts` | Add `authGuard` handle that fetches role, add to `sequence()` | +| `src/routes/+layout.server.ts` | Pass `userRole` in returned data | | `src/routes/api/get_gdrive_folder/+server.js` | Add auth check, use `locals.supabase` instead of browser client | -| `src/routes/api/upload/+server.js` | Add auth check, use `locals.supabase` instead of browser client | -| `.env` | Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` | -| `.github/workflows/deploy.yml` | Update env var name | -| `.github/workflows/ci.yml` | Update env var name | +| `src/routes/api/upload/+server.js` | Add auth check, use `locals.supabase` instead of browser client | +| `.env` | Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` | +| `.github/workflows/deploy.yml` | Update env var name | +| `.github/workflows/ci.yml` | Update env var name | ### Unchanged files + - `src/routes/login/callback/+server.js` — login flow stays the same - `src/routes/+layout.ts` — frontend concern - `src/lib/supabaseClient.js` — browser client stays (used by frontend) @@ -738,6 +738,7 @@ Phase 4 and 5 can be done in parallel by different team members since they touch ## Future: Adding "member" Role When the time comes: + 1. `ALTER TYPE public.app_role ADD VALUE 'member';` 2. Add member-specific RLS policies 3. Add endpoints under `/api/member/` diff --git a/docs/2-admin-roles-data-flow-diagrams.md b/docs/2-admin-roles-data-flow-diagrams.md index 0ea1b62..e6903ea 100644 --- a/docs/2-admin-roles-data-flow-diagrams.md +++ b/docs/2-admin-roles-data-flow-diagrams.md @@ -196,14 +196,14 @@ flowchart TD ## 7. Data Ownership Summary -| Data | Applicant can... | Admin can... | -|------|-----------------|--------------| -| Own profile | View, update | - | -| All profiles | - | View all | -| Own quiz answers | View, save, submit | - | -| All quiz answers | - | View all + scores | -| Own sigsheet entries | View, create | - | -| All sigsheet entries | - | View all + progress | -| Own GDrive folder | Create, upload to | - | -| Quiz questions/options | View (read-only) | View (read-only) | -| Members list | View (read-only) | View (read-only) | +| Data | Applicant can... | Admin can... | +| ---------------------- | ------------------ | ------------------- | +| Own profile | View, update | - | +| All profiles | - | View all | +| Own quiz answers | View, save, submit | - | +| All quiz answers | - | View all + scores | +| Own sigsheet entries | View, create | - | +| All sigsheet entries | - | View all + progress | +| Own GDrive folder | Create, upload to | - | +| Quiz questions/options | View (read-only) | View (read-only) | +| Members list | View (read-only) | View (read-only) | diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index dd54373..070064b 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,5 +1,4 @@ -import { error } from '@sveltejs/kit'; -import type { RequestEvent } from '@sveltejs/kit'; +import { type RequestEvent, error } from '@sveltejs/kit'; export type AppRole = 'applicant' | 'admin' | 'withdrawn' | 'inactive'; diff --git a/src/lib/server/supabaseAdmin.ts b/src/lib/server/supabaseAdmin.ts index e4b7a05..08c38c7 100644 --- a/src/lib/server/supabaseAdmin.ts +++ b/src/lib/server/supabaseAdmin.ts @@ -1,5 +1,5 @@ -import { SUPABASE_SERVICE_KEY } from '$env/static/private'; import { PUBLIC_SUPABASE_URL } from '$env/static/public'; +import { SUPABASE_SERVICE_KEY } from '$env/static/private'; import { createClient } from '@supabase/supabase-js'; /** From eedcb0dd04911a22b5eb3246c43287662ac77aa1 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 22:13:56 +0800 Subject: [PATCH 05/28] feat: add userRole to App.Locals and App.PageData types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of Phase 3 — needed for auth helpers to type-check correctly. Co-Authored-By: Claude Opus 4.6 --- src/app.d.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app.d.ts b/src/app.d.ts index 20bce2d..20540b5 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,5 +1,6 @@ import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; import type { Database } from './database.types.ts'; // import generated types +import type { AppRole } from '$lib/server/auth'; declare global { namespace App { @@ -9,9 +10,11 @@ declare global { safeGetSession: () => Promise<{ session: Session | null; user: User | null }>; session: Session | null; user: User | null; + userRole: AppRole | null; } interface PageData { session: Session | null; + userRole: AppRole | null; } // interface PageState {} // interface Platform {} From a64f1f38005de79b3ea737308167a0eb610f6d87 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 22:19:20 +0800 Subject: [PATCH 06/28] chore: fix import sort order in app.d.ts Co-Authored-By: Claude Opus 4.6 --- src/app.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.d.ts b/src/app.d.ts index 20540b5..004f5e9 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,6 @@ import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; -import type { Database } from './database.types.ts'; // import generated types import type { AppRole } from '$lib/server/auth'; +import type { Database } from './database.types.ts'; // import generated types declare global { namespace App { From e9f272a024cc54ff5b2f0557c65590350b1f4aa9 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 22:23:46 +0800 Subject: [PATCH 07/28] chore: remove docs/ and CLAUDE.md from tracking Add both to .gitignore. Files kept locally but not in the repo. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + CLAUDE.md | 77 --- docs/1-admin-roles-implementation-plan.md | 745 ---------------------- docs/2-admin-roles-data-flow-diagrams.md | 209 ------ 4 files changed, 2 insertions(+), 1031 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 docs/1-admin-roles-implementation-plan.md delete mode 100644 docs/2-admin-roles-data-flow-diagrams.md diff --git a/.gitignore b/.gitignore index fb7f88c..a80deee 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /.svelte-kit node_modules .env +docs/ +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f9e39a0..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,77 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -UP CSI member application processing web app built with **SvelteKit 2** (Svelte 5), **TypeScript**, **Supabase** (auth + PostgreSQL), and **Google Drive API** (file storage). Uses **pnpm** as package manager. - -## Commands - -```bash -pnpm dev # Start dev server -pnpm build # Production build -pnpm preview # Preview production build - -pnpm fmt # Check formatting (Prettier) -pnpm fmt:fix # Auto-fix formatting -pnpm lint # Run all linters in parallel (html, css, js, svelte) -pnpm lint:js # ESLint only -pnpm lint:svelte # Svelte type checking only -``` - -## Architecture - -### Routing (SvelteKit file-based) - -- `/` — Dashboard/home -- `/login` — Google OAuth login (restricted to `@up.edu.ph` emails, checked against Supabase `whitelist` table) -- `/login/callback` — OAuth callback with domain + whitelist validation -- `/sigsheet` — Signature sheet feature (member grid with modals) -- `/consti-quiz` — Constitution quiz (multiple question types: radio, checkbox, short/long text) -- `/api/answers` — POST quiz answers (upsert pattern) -- `/api/get_gdrive_folder` — Create/get Google Drive folders -- `/api/upload` — Upload files to Google Drive - -### Auth Flow - -Supabase SSR OAuth with Google. Server hook (`hooks.server.ts`) creates a Supabase client per request with cookie management. Browser client uses `createBrowserClient()`. `safeGetSession()` helper for auth state. Layout's `onAuthStateChange` invalidates data on auth changes. - -### Data Layer - -- **Database**: Supabase PostgREST — tables include `sigsheet`, `constiquiz-sections`, `constiquiz-questions`, `constiquiz-options`, `constiquiz-answers`, `constiquiz-submissions`, `constiquiz-availability`, `profiles`, `whitelist`, `pic-folders` -- **File storage**: Google Drive API with JWT service account auth -- **Client state**: Svelte writable stores in `$lib/shared.ts` (`uuid`, `username`, `gdrive_folder_id`, `filledSigsheet`, `applicant_names_list`) -- **Data loading**: Parallel fetching with `Promise.all()` in `+layout.ts`, dependency tracking with `depends()` - -### Key Files - -- `src/hooks.server.ts` — Server hook creating Supabase client per request -- `src/lib/supabaseClient.js` — Browser-side Supabase client -- `src/lib/shared.ts` — Global Svelte stores -- `src/routes/+layout.ts` — Root data loader (auth, quiz data) -- `src/routes/consti-quiz/constiquiz-types.ts` — Quiz question type definitions (discriminated unions) - -### Environment Variables - -``` -PUBLIC_SUPABASE_URL -PUBLIC_SUPABASE_ANON_KEY -PUBLIC_SUPABASE_SERVICE_KEY -PUBLIC_GOOGLE_SERVICE_EMAIL -PUBLIC_GOOGLE_PRIVATE_KEY -``` - -## Code Conventions - -- **Svelte 5 runes**: Uses `$props()`, `$state()`, snippet types for component composition -- **TypeScript strict mode** with `noUncheckedIndexedAccess` and `noImplicitOverride` -- **Prettier**: 4-space indentation, 120 char width, single quotes, Tailwind class sorting plugin -- **Tailwind CSS**: Custom CSI brand colors defined in `tailwind.config.ts` (`csi-blue: #00C6D7`, `csi-black: #212121`, `csi-yellow: #F7CF2F`, plus committee colors) -- **ESLint**: No unused vars, no console (warning), prefer const - -## Deployment - -- Docker multi-stage build (Node.js Alpine), port 3000 -- CI runs on PRs/push to main: install → format check → lint → build -- Deploy triggers on push to `production` branch → builds Docker image → pushes to GitHub Container Registry diff --git a/docs/1-admin-roles-implementation-plan.md b/docs/1-admin-roles-implementation-plan.md deleted file mode 100644 index 7476836..0000000 --- a/docs/1-admin-roles-implementation-plan.md +++ /dev/null @@ -1,745 +0,0 @@ -# Admin Roles Implementation Plan - -> **Scope:** Database, auth, and backend changes only. No frontend. -> **Goal:** Add an "admin" role so org leaders can view/check applicant data (quiz responses, sigsheet progress, profiles). - ---- - -## Current State Summary - -- **Auth:** Google OAuth → `@up.edu.ph` domain check → `whitelist` table check → session created. All users treated identically as applicants. -- **Database:** `profiles` table has no role column. No RLS policies on any table. -- **Backend:** Two API endpoints (`/api/get_gdrive_folder`, `/api/upload`) have no auth checks and use a browser-side Supabase client on the server. Other endpoints check `safeGetSession()` but have no role awareness. -- **Hooks:** `hooks.server.ts` has a commented-out `authGuard`. The `sequence()` only runs the `supabase` handle. -- **Service key:** `PUBLIC_SUPABASE_SERVICE_KEY` exists in CI env vars but is never imported in code. The `PUBLIC_` prefix is wrong — it would expose the key to the browser. - ---- - -## Phase 1: Database Schema Changes - -### 1.1 Add role to profiles - -Run this migration in Supabase SQL Editor (or via Supabase CLI migration): - -```sql --- Create enum type for roles (extensible: add 'member' later with ALTER TYPE) -CREATE TYPE public.app_role AS ENUM ('applicant', 'admin'); - --- Add role column — all existing users default to 'applicant' -ALTER TABLE public.profiles - ADD COLUMN role public.app_role NOT NULL DEFAULT 'applicant'; - --- Index for fast role lookups (used on every request in hooks) -CREATE INDEX idx_profiles_role ON public.profiles (role); -``` - -**Design decision:** Role on `profiles` directly (not a separate table) because it's a 1:1 relationship with two values. If multi-role is needed later, migrate to a join table then. - -### 1.2 Seed admin users - -```sql -UPDATE public.profiles -SET role = 'admin' -WHERE id IN ( - SELECT id FROM auth.users - WHERE email IN ( - 'admin1@up.edu.ph', - 'admin2@up.edu.ph' - -- Replace with actual admin emails - ) -); -``` - -Ensure these emails are also in the `whitelist` table: - -```sql -INSERT INTO public.whitelist (email) -VALUES ('admin1@up.edu.ph'), ('admin2@up.edu.ph') -ON CONFLICT (email) DO NOTHING; -``` - -### 1.3 Verify - -```sql -SELECT p.id, u.email, p.role -FROM public.profiles p -JOIN auth.users u ON p.id = u.id -ORDER BY p.role, u.email; -``` - ---- - -## Phase 2: Server-Side Auth Infrastructure - -### 2.1 Rename service key env var - -The service key must NOT have the `PUBLIC_` prefix — SvelteKit exposes `PUBLIC_` vars to the browser. - -**`.env`:** Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` - -**`.github/workflows/deploy.yml`:** Update all references: - -```yaml -# Change these lines: -PUBLIC_SUPABASE_SERVICE_KEY: ${{ vars.PUBLIC_SUPABASE_SERVICE_KEY }} -# To: -SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} -``` - -**`.github/workflows/ci.yml`:** Same rename. Also update the GitHub Actions repo settings to use the new variable name (or move to a secret since it's a sensitive key). - -### 2.2 Create service-role Supabase client - -**New file: `src/lib/server/supabaseAdmin.ts`** - -```typescript -import { SUPABASE_SERVICE_KEY } from '$env/static/private'; -import { PUBLIC_SUPABASE_URL } from '$env/static/public'; -import { createClient } from '@supabase/supabase-js'; - -/** - * Service-role Supabase client. Bypasses RLS. - * ONLY use in server-side code for admin operations. - */ -export const supabaseAdmin = createClient(PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_KEY); -``` - -The `src/lib/server/` directory is a SvelteKit convention — files here can only be imported from server-side code (`+server.ts`, `+page.server.ts`, `hooks.server.ts`). This prevents accidental browser exposure. - -### 2.3 Create auth helper utilities - -**New file: `src/lib/server/auth.ts`** - -```typescript -import { error } from '@sveltejs/kit'; -import type { RequestEvent } from '@sveltejs/kit'; - -export type AppRole = 'applicant' | 'admin'; - -/** - * Requires authenticated user. Throws 401 if not logged in. - */ -export function requireAuth(event: RequestEvent) { - const { user, session } = event.locals; - if (!session || !user) { - throw error(401, 'Authentication required'); - } - return { user, session }; -} - -/** - * Requires a specific role. Throws 401 if not authenticated, 403 if wrong role. - */ -export function requireRole(event: RequestEvent, role: AppRole) { - const { user, session } = requireAuth(event); - if (event.locals.userRole !== role) { - throw error(403, 'Insufficient permissions'); - } - return { user, session }; -} - -/** - * Check if current user is admin. Does not throw. - */ -export function isAdmin(event: RequestEvent): boolean { - return event.locals.userRole === 'admin'; -} -``` - ---- - -## Phase 3: Auth Flow — Role Resolution in Hooks - -### 3.1 Update TypeScript types - -**File: `src/app.d.ts`** - -```typescript -import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; -import type { Database } from './database.types.ts'; -import type { AppRole } from '$lib/server/auth'; - -declare global { - namespace App { - interface Locals { - supabase: SupabaseClient; - safeGetSession: () => Promise<{ session: Session | null; user: User | null }>; - session: Session | null; - user: User | null; - userRole: AppRole | null; - } - interface PageData { - session: Session | null; - userRole: AppRole | null; - } - } -} - -export {}; -``` - -### 3.2 Add authGuard to hooks - -**File: `src/hooks.server.ts`** - -Replace the commented-out `authGuard` and update the `sequence()` export: - -```typescript -import { type Handle } from '@sveltejs/kit'; -import { createServerClient } from '@supabase/ssr'; -import { sequence } from '@sveltejs/kit/hooks'; -import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; -import type { AppRole } from '$lib/server/auth'; - -const supabase: Handle = ({ event, resolve }) => { - // ... KEEP EXISTING CODE (lines 8-48) EXACTLY AS-IS ... -}; - -const authGuard: Handle = async ({ event, resolve }) => { - const { session, user } = await event.locals.safeGetSession(); - event.locals.session = session; - event.locals.user = user; - event.locals.userRole = null; - - if (user) { - const { data: profile } = await event.locals.supabase - .from('profiles') - .select('role') - .eq('id', user.id) - .single(); - - event.locals.userRole = (profile?.role as AppRole) ?? 'applicant'; - } - - return resolve(event); -}; - -export const handle: Handle = sequence(supabase, authGuard); -``` - -**What this does:** On every request, after the Supabase client is set up, fetch the user's role from `profiles` and attach it to `event.locals.userRole`. All downstream handlers (layout loads, API endpoints) can read `event.locals.userRole` without an extra DB query. - -### 3.3 Pass role to page data - -**File: `src/routes/+layout.server.ts`** - -```typescript -export const load = async ({ locals: { safeGetSession, userRole }, cookies }) => { - const { session } = await safeGetSession(); - return { - session, - cookies: cookies.getAll(), - userRole: userRole ?? null, - }; -}; -``` - -### 3.4 Login callback — no changes needed - -Admins log in via the same Google OAuth flow. They must be in the `whitelist` table. Their role is determined from `profiles.role`, not the login flow. - -### 3.5 Verify - -1. Add `console.log('userRole:', event.locals.userRole)` temporarily in the `authGuard` -2. Log in as a seeded admin → should print `'admin'` -3. Log in as a regular applicant → should print `'applicant'` -4. Access any page without logging in → should print `null` - ---- - -## Phase 4: Fix Existing Unprotected Endpoints - -Two endpoints currently have no auth checks and use the browser Supabase client singleton on the server. - -### 4.1 Fix `/api/get_gdrive_folder` - -**File: `src/routes/api/get_gdrive_folder/+server.js`** - -Changes: - -1. Remove `import { supabase } from '$lib/supabaseClient';` -2. Add auth check using `locals.safeGetSession()` (or import `requireAuth` if converting to `.ts`) -3. Use `locals.supabase` instead of the browser singleton -4. Use `user.id` from auth instead of `uuid` from request body (prevents spoofing) - -```javascript -// Before: -import { supabase } from '$lib/supabaseClient'; -export async function POST({ request }) { - const { uuid, username } = await request.json(); - // ... uses uuid from body - -// After: -export async function POST({ request, locals }) { - const { user } = await locals.safeGetSession(); - if (!user) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }); - } - const uuid = user.id; // Use authenticated user's ID - const { username } = await request.json(); - // ... replace all `supabase.` calls with `locals.supabase.` -``` - -### 4.2 Fix `/api/upload` - -**File: `src/routes/api/upload/+server.js`** - -Same pattern: - -1. Remove `import { supabase } from '../../../lib/supabaseClient';` -2. Add auth check -3. Use `locals.supabase` for DB operations -4. Use `user.id` instead of `formData.get('uuid')` - -```javascript -// Before: -import { supabase } from '../../../lib/supabaseClient'; -export async function POST({ request }) { - const formData = await request.formData(); - const uuid = formData.get('uuid'); - -// After: -export async function POST({ request, locals }) { - const { user } = await locals.safeGetSession(); - if (!user) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }); - } - const uuid = user.id; - const formData = await request.formData(); - // ... replace all `supabase.` calls with `locals.supabase.` -``` - -### 4.3 Verify - -- Call `POST /api/get_gdrive_folder` without a session cookie → expect 401 -- Call `POST /api/upload` without a session cookie → expect 401 -- Call both while logged in → should work as before - ---- - -## Phase 5: Admin API Endpoints - -All new endpoints go under `src/routes/api/admin/`. Every endpoint: - -- Calls `requireRole(event, 'admin')` as the first line -- Uses `supabaseAdmin` (service-role client) to bypass RLS for cross-user reads -- Returns JSON - -### 5.1 List all applicant profiles - -**New file: `src/routes/api/admin/applicants/+server.ts`** - -```typescript -import { json } from '@sveltejs/kit'; -import { requireRole } from '$lib/server/auth'; -import { supabaseAdmin } from '$lib/server/supabaseAdmin'; - -export async function GET(event) { - requireRole(event, 'admin'); - - const { data, error } = await supabaseAdmin - .from('profiles') - .select('id, username, full_name, avatar_url, role') - .eq('role', 'applicant'); - - if (error) { - return json({ error: error.message }, { status: 500 }); - } - - return json({ applicants: data }); -} -``` - -### 5.2 All quiz submissions with scores - -**New file: `src/routes/api/admin/quiz-results/+server.ts`** - -```typescript -import { json } from '@sveltejs/kit'; -import { requireRole } from '$lib/server/auth'; -import { supabaseAdmin } from '$lib/server/supabaseAdmin'; - -export async function GET(event) { - requireRole(event, 'admin'); - - // 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); - } - - 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 }); -} -``` - -### 5.3 Single applicant's detailed quiz answers - -**New file: `src/routes/api/admin/quiz-results/[userId]/+server.ts`** - -```typescript -import { json } from '@sveltejs/kit'; -import { requireRole } from '$lib/server/auth'; -import { supabaseAdmin } from '$lib/server/supabaseAdmin'; - -export async function GET(event) { - requireRole(event, 'admin'); - const userId = event.params.userId; - - 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 }); - } - - return json({ - profile: profileRes.data, - submitted_at: submissionRes.data?.submitted_at ?? null, - answers: answersRes.data, - }); -} -``` - -### 5.4 All applicants' sigsheet progress - -**New file: `src/routes/api/admin/sigsheet-progress/+server.ts`** - -```typescript -import { json } from '@sveltejs/kit'; -import { requireRole } from '$lib/server/auth'; -import { supabaseAdmin } from '$lib/server/supabaseAdmin'; - -export async function GET(event) { - requireRole(event, 'admin'); - - // 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 }); - } - - // Group by applicant - const byApplicant: Record = {}; - for (const row of data ?? []) { - const key = row.applicant.id; - if (!byApplicant[key]) { - byApplicant[key] = { profile: row.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), - }); -} -``` - -### Endpoint summary - -| Method | Path | Purpose | -| ------ | ---------------------------------- | ------------------------------------------ | -| GET | `/api/admin/applicants` | List all applicant profiles | -| GET | `/api/admin/quiz-results` | All submissions with total scores | -| GET | `/api/admin/quiz-results/[userId]` | Single applicant's detailed answers | -| GET | `/api/admin/sigsheet-progress` | All sigsheet progress grouped by applicant | - -### Verify each endpoint - -For each: unauthenticated → 401, applicant → 403, admin → 200 with data. - ---- - -## Phase 6: Row Level Security (RLS) - -RLS is defense-in-depth. Even if application code has a bug, the database enforces access rules. The `supabaseAdmin` client (service role) bypasses RLS by design — admin endpoints still work. - -### 6.1 Create helper function - -```sql -CREATE OR REPLACE FUNCTION public.get_user_role() -RETURNS public.app_role -LANGUAGE sql -STABLE -SECURITY DEFINER -AS $$ - SELECT role FROM public.profiles WHERE id = auth.uid() -$$; -``` - -`SECURITY DEFINER` lets it read `profiles` even when RLS is enabled. `STABLE` allows caching within a transaction. - -### 6.2 Enable RLS on all tables - -```sql -ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.whitelist ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-answers" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-submissions" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-sections" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-questions" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-options" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."constiquiz-availability" ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.sigsheet ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.members ENABLE ROW LEVEL SECURITY; -ALTER TABLE public."pic-folders" ENABLE ROW LEVEL SECURITY; -``` - -> **Note:** Table names with hyphens must be double-quoted in SQL. - -### 6.3 Policies — User-scoped tables - -**profiles:** - -```sql -CREATE POLICY "Users can view own profile" - ON public.profiles FOR SELECT - USING (auth.uid() = id); - -CREATE POLICY "Users can update own profile" - ON public.profiles FOR UPDATE - USING (auth.uid() = id); - -CREATE POLICY "Admins can view all profiles" - ON public.profiles FOR SELECT - USING (public.get_user_role() = 'admin'); -``` - -**constiquiz-answers:** - -```sql -CREATE POLICY "Users can view own answers" - ON public."constiquiz-answers" FOR SELECT - USING (auth.uid() = user_id); - -CREATE POLICY "Users can insert own answers" - ON public."constiquiz-answers" FOR INSERT - WITH CHECK (auth.uid() = user_id); - -CREATE POLICY "Users can update own answers" - ON public."constiquiz-answers" FOR UPDATE - USING (auth.uid() = user_id); - -CREATE POLICY "Admins can view all answers" - ON public."constiquiz-answers" FOR SELECT - USING (public.get_user_role() = 'admin'); -``` - -**constiquiz-submissions:** - -```sql -CREATE POLICY "Users can view own submissions" - ON public."constiquiz-submissions" FOR SELECT - USING (auth.uid() = user_id); - -CREATE POLICY "Users can insert own submissions" - ON public."constiquiz-submissions" FOR INSERT - WITH CHECK (auth.uid() = user_id); - -CREATE POLICY "Admins can view all submissions" - ON public."constiquiz-submissions" FOR SELECT - USING (public.get_user_role() = 'admin'); -``` - -**sigsheet:** - -```sql -CREATE POLICY "Users can view own sigsheet" - ON public.sigsheet FOR SELECT - USING (auth.uid() = applicant_id); - -CREATE POLICY "Users can insert own sigsheet" - ON public.sigsheet FOR INSERT - WITH CHECK (auth.uid() = applicant_id); - -CREATE POLICY "Admins can view all sigsheet" - ON public.sigsheet FOR SELECT - USING (public.get_user_role() = 'admin'); -``` - -**pic-folders:** - -```sql -CREATE POLICY "Users can view own folder" - ON public."pic-folders" FOR SELECT - USING (auth.uid() = applicant_uuid); - -CREATE POLICY "Users can insert own folder" - ON public."pic-folders" FOR INSERT - WITH CHECK (auth.uid() = applicant_uuid); - -CREATE POLICY "Admins can view all folders" - ON public."pic-folders" FOR SELECT - USING (public.get_user_role() = 'admin'); -``` - -### 6.4 Policies — Read-only reference tables - -These are shared data all authenticated users can read: - -```sql -CREATE POLICY "Authenticated can view sections" - ON public."constiquiz-sections" FOR SELECT - USING (auth.uid() IS NOT NULL); - -CREATE POLICY "Authenticated can view questions" - ON public."constiquiz-questions" FOR SELECT - USING (auth.uid() IS NOT NULL); - -CREATE POLICY "Authenticated can view options" - ON public."constiquiz-options" FOR SELECT - USING (auth.uid() IS NOT NULL); - -CREATE POLICY "Authenticated can view availability" - ON public."constiquiz-availability" FOR SELECT - USING (auth.uid() IS NOT NULL); - -CREATE POLICY "Authenticated can view members" - ON public.members FOR SELECT - USING (auth.uid() IS NOT NULL); - -CREATE POLICY "Authenticated can check whitelist" - ON public.whitelist FOR SELECT - USING (auth.uid() IS NOT NULL); -``` - -### 6.5 Verify RLS - -1. Open browser DevTools as an applicant. Try querying another user's answers via the Supabase JS client → should return empty -2. Admin API endpoints (which use `supabaseAdmin` / service role) should still return all data -3. Applicant flow (quiz, sigsheet, upload) should still work normally — own data is accessible - ---- - -## File Change Summary - -### New files - -| File | Purpose | -| ------------------------------------------------------- | ---------------------------------------------------------------------- | -| `src/lib/server/auth.ts` | `requireAuth()`, `requireRole()`, `isAdmin()` helpers + `AppRole` type | -| `src/lib/server/supabaseAdmin.ts` | Service-role Supabase client (bypasses RLS) | -| `src/routes/api/admin/applicants/+server.ts` | List applicant profiles | -| `src/routes/api/admin/quiz-results/+server.ts` | All quiz submissions + scores | -| `src/routes/api/admin/quiz-results/[userId]/+server.ts` | Single applicant quiz detail | -| `src/routes/api/admin/sigsheet-progress/+server.ts` | All sigsheet progress | - -### Modified files - -| File | Change | -| --------------------------------------------- | --------------------------------------------------------------- | -| `src/app.d.ts` | Add `userRole: AppRole \| null` to Locals and PageData | -| `src/hooks.server.ts` | Add `authGuard` handle that fetches role, add to `sequence()` | -| `src/routes/+layout.server.ts` | Pass `userRole` in returned data | -| `src/routes/api/get_gdrive_folder/+server.js` | Add auth check, use `locals.supabase` instead of browser client | -| `src/routes/api/upload/+server.js` | Add auth check, use `locals.supabase` instead of browser client | -| `.env` | Rename `PUBLIC_SUPABASE_SERVICE_KEY` → `SUPABASE_SERVICE_KEY` | -| `.github/workflows/deploy.yml` | Update env var name | -| `.github/workflows/ci.yml` | Update env var name | - -### Unchanged files - -- `src/routes/login/callback/+server.js` — login flow stays the same -- `src/routes/+layout.ts` — frontend concern -- `src/lib/supabaseClient.js` — browser client stays (used by frontend) -- All Svelte components — frontend concern - ---- - -## Implementation Order - -Phases **must** be done in order due to dependencies: - -``` -Phase 1 (DB schema) - ↓ role column must exist -Phase 2 (auth infra) - ↓ helpers + admin client must exist -Phase 3 (hooks + types) - ↓ userRole must be on event.locals -Phase 4 (fix existing endpoints) ←── can be parallel with Phase 5 -Phase 5 (admin endpoints) ←── can be parallel with Phase 4 - ↓ -Phase 6 (RLS policies) -``` - -Phase 4 and 5 can be done in parallel by different team members since they touch different files. - ---- - -## Future: Adding "member" Role - -When the time comes: - -1. `ALTER TYPE public.app_role ADD VALUE 'member';` -2. Add member-specific RLS policies -3. Add endpoints under `/api/member/` -4. The `requireRole()` and `isAdmin()` pattern extends naturally — add `isMember()` etc. diff --git a/docs/2-admin-roles-data-flow-diagrams.md b/docs/2-admin-roles-data-flow-diagrams.md deleted file mode 100644 index e6903ea..0000000 --- a/docs/2-admin-roles-data-flow-diagrams.md +++ /dev/null @@ -1,209 +0,0 @@ -# Admin Roles — System Overview Diagrams - -> Companion to `1-admin-roles-implementation-plan.md`. -> Diagrams use Mermaid syntax — rendered natively on GitHub. To use in Excalidraw, paste the code blocks into the "Mermaid to Excalidraw" feature (wand icon). - ---- - -## 1. How Sign-In Works - -Everyone signs in the same way. The system figures out your role after you log in. - -```mermaid -flowchart LR - A["User clicks Sign In"] --> B["Google login\n(@up.edu.ph only)"] - B --> C{"Email in\nwhitelist?"} - C -->|No| D["Rejected"] - C -->|Yes| E["Logged in"] - E --> F["System looks up\nrole from database"] - F --> G["Applicant"] - F --> H["Admin"] - - style D fill:#fce8e6,stroke:#d93025 - style G fill:#e8f4fd,stroke:#1a73e8 - style H fill:#fce8e6,stroke:#d93025 -``` - ---- - -## 2. What Each Role Can See and Do - -```mermaid -flowchart TB - subgraph NOT_LOGGED_IN["Not Logged In"] - N1["Login page only"] - end - - subgraph APPLICANT_ACCESS["Applicant"] - direction TB - A1["Take the constitution quiz"] - A2["View & save their own answers"] - A3["Collect signatures on sigsheet"] - A4["Upload files to Google Drive"] - end - - subgraph ADMIN_ACCESS["Admin"] - direction TB - B1["View all applicant profiles"] - B2["View all quiz submissions & scores"] - B3["View any applicant's detailed answers"] - B4["View sigsheet progress of all applicants"] - end - - style NOT_LOGGED_IN fill:#f5f5f5,stroke:#999 - style APPLICANT_ACCESS fill:#e8f4fd,stroke:#1a73e8 - style ADMIN_ACCESS fill:#fce8e6,stroke:#d93025 -``` - -Key difference: **applicants only see their own data**, **admins can see everyone's data**. - ---- - -## 3. How a Request Flows Through the System - -Every page visit or API call goes through the same pipeline. - -```mermaid -flowchart TD - A["User visits a page\nor calls an API"] --> B["Server checks:\nAre you logged in?"] - - B -->|Not logged in| C["Can only see\npublic pages"] - B -->|Logged in| D["Server looks up\nyour role"] - - D --> E{"What's your role?"} - - E -->|Applicant| F["Can access\napplicant features"] - E -->|Admin| G["Can access\nadmin features"] - - F --> H["Data is filtered:\nyou only see YOUR stuff"] - G --> I["Data is unfiltered:\nyou see ALL applicants' stuff"] - - style C fill:#f5f5f5,stroke:#999 - style F fill:#e8f4fd,stroke:#1a73e8 - style G fill:#fce8e6,stroke:#d93025 - style H fill:#e8f4fd,stroke:#1a73e8 - style I fill:#fce8e6,stroke:#d93025 -``` - ---- - -## 4. System Architecture Overview - -```mermaid -flowchart TB - subgraph USERS["Users"] - APPLICANT["Applicant\n(@up.edu.ph)"] - ADMIN["Admin\n(@up.edu.ph)"] - end - - subgraph APP["UP CSI App"] - LOGIN["Login\n(Google OAuth)"] - ROLE_CHECK["Role Check\n(on every request)"] - - subgraph APPLICANT_PAGES["Applicant Features"] - QUIZ["Constitution Quiz"] - SIG["Sigsheet"] - UPLOAD["File Upload"] - end - - subgraph ADMIN_PAGES["Admin Features"] - VIEW_PROFILES["View Applicant Profiles"] - VIEW_QUIZ["View Quiz Results & Scores"] - VIEW_SIG["View Sigsheet Progress"] - end - end - - subgraph SERVICES["External Services"] - GOOGLE["Google OAuth"] - GDRIVE["Google Drive\n(file storage)"] - SUPABASE["Supabase\n(database + auth)"] - end - - APPLICANT --> LOGIN - ADMIN --> LOGIN - LOGIN --> GOOGLE - GOOGLE --> ROLE_CHECK - - ROLE_CHECK -->|"role = applicant"| APPLICANT_PAGES - ROLE_CHECK -->|"role = admin"| ADMIN_PAGES - - QUIZ --> SUPABASE - SIG --> SUPABASE - UPLOAD --> GDRIVE - VIEW_PROFILES --> SUPABASE - VIEW_QUIZ --> SUPABASE - VIEW_SIG --> SUPABASE - - style APPLICANT fill:#e8f4fd,stroke:#1a73e8 - style ADMIN fill:#fce8e6,stroke:#d93025 - style APPLICANT_PAGES fill:#e8f4fd,stroke:#1a73e8 - style ADMIN_PAGES fill:#fce8e6,stroke:#d93025 -``` - ---- - -## 5. What Happens When Access is Denied - -```mermaid -flowchart TD - A["Someone tries to\naccess an admin page"] --> B{"Logged in?"} - - B -->|No| C["401: Please log in"] - B -->|Yes| D{"Role = admin?"} - - D -->|No, applicant| E["403: You don't have\npermission for this"] - D -->|Yes| F["200: Here's the data"] - - style C fill:#fce8e6,stroke:#d93025 - style E fill:#fef7e0,stroke:#f9ab00 - style F fill:#e6f4ea,stroke:#137333 -``` - ---- - -## 6. Two Layers of Protection - -The system protects data at two levels — even if one layer has a bug, the other catches it. - -```mermaid -flowchart TD - A["User makes a request"] --> B - - subgraph B["Layer 1: App Server"] - B1["Checks if you're logged in"] - B2["Checks if you have the right role"] - B1 --> B2 - end - - B --> C - - subgraph C["Layer 2: Database"] - C1["Row Level Security (RLS)"] - C2["Applicants can only\nread/write their own rows"] - C3["Admins can read all rows"] - C1 --> C2 - C1 --> C3 - end - - C --> D["Data returned"] - - style B fill:#e8f4fd,stroke:#1a73e8 - style C fill:#fef7e0,stroke:#f9ab00 - style D fill:#e6f4ea,stroke:#137333 -``` - ---- - -## 7. Data Ownership Summary - -| Data | Applicant can... | Admin can... | -| ---------------------- | ------------------ | ------------------- | -| Own profile | View, update | - | -| All profiles | - | View all | -| Own quiz answers | View, save, submit | - | -| All quiz answers | - | View all + scores | -| Own sigsheet entries | View, create | - | -| All sigsheet entries | - | View all + progress | -| Own GDrive folder | Create, upload to | - | -| Quiz questions/options | View (read-only) | View (read-only) | -| Members list | View (read-only) | View (read-only) | From f1cf68e6b7a29c816647fb58241194f2a94d7c95 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Wed, 11 Mar 2026 22:25:28 +0800 Subject: [PATCH 08/28] chore: add tool config files to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index a80deee..1d41d45 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ node_modules .env docs/ CLAUDE.md +.agents/ +.claude/ +skills-lock.json From 35188171f687a85fd876d7cca18aad95a0f9a41c Mon Sep 17 00:00:00 2001 From: LigsQt Date: Sat, 14 Mar 2026 17:36:42 +0800 Subject: [PATCH 09/28] feat: add authGuard hook and secure API endpoints - Implement authGuard that resolves user role from profiles table and attaches it to event.locals on every request - Pass userRole through layout server load to page data - Secure /api/get_gdrive_folder and /api/upload with session-based auth checks, replacing browser Supabase singleton with locals.supabase Made-with: Cursor --- src/hooks.server.ts | 36 ++++++++++++--------- src/routes/+layout.server.ts | 7 ++-- src/routes/api/get_gdrive_folder/+server.js | 15 ++++++--- src/routes/api/upload/+server.js | 14 ++++++-- 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 4b317a1..0cea04a 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,7 @@ import { type Handle } from '@sveltejs/kit'; import { createServerClient } from '@supabase/ssr'; import { sequence } from '@sveltejs/kit/hooks'; +import type { AppRole } from '$lib/server/auth'; import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; @@ -48,20 +49,23 @@ const supabase: Handle = ({ event, resolve }) => { }); }; -// const authGuard: Handle = async ({ event, resolve }) => { -// const { session, user } = await event.locals.safeGetSession() -// event.locals.session = session -// event.locals.user = user -// -// if (!event.locals.session && event.url.pathname.startsWith('/private')) { -// redirect(303, '/login') -// } -// -// if (event.locals.session && event.url.pathname === '/login') { -// redirect(303, '/private') -// } -// -// return resolve(event) -// } +const authGuard: Handle = async ({ event, resolve }) => { + const { session, user } = await event.locals.safeGetSession() + event.locals.session = session + event.locals.user = user + event.locals.userRole = null; -export const handle: Handle = sequence(supabase); + if (user) { + const { data: profile } = await event.locals.supabase + .from('profiles') + .select('role') + .eq('id', user.id) + .single(); + event.locals.userRole = (profile?.role as AppRole) ?? 'applicant'; + console.log('userRole:', event.locals.userRole); +} + + return resolve(event) +} + +export const handle: Handle = sequence(supabase, authGuard); diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index f4f38ea..0da62f6 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,7 +1,10 @@ -export const load = async ({ locals: { safeGetSession }, cookies }) => { +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals: { safeGetSession, userRole }, cookies }) => { const { session } = await safeGetSession(); return { session, cookies: cookies.getAll(), + userRole: userRole ?? null, }; -}; +}; \ No newline at end of file diff --git a/src/routes/api/get_gdrive_folder/+server.js b/src/routes/api/get_gdrive_folder/+server.js index b9a5115..d02a90a 100644 --- a/src/routes/api/get_gdrive_folder/+server.js +++ b/src/routes/api/get_gdrive_folder/+server.js @@ -1,9 +1,8 @@ import { PUBLIC_GOOGLE_PRIVATE_KEY, PUBLIC_GOOGLE_SERVICE_EMAIL } from '$env/static/public'; import { gdrive_root_folder } from '$lib/shared'; import { google } from 'googleapis'; -import { supabase } from '$lib/supabaseClient'; -export async function POST({ request }) { +export async function POST({ request, locals }) { console.log('Received POST request at /api/get_gdrive_folder'); // Add debugging logs to verify environment variables @@ -13,8 +12,16 @@ export async function POST({ request }) { }); try { - const { uuid, username } = await request.json(); - + const { user } = await locals.safeGetSession(); + if (!user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + const uuid = user.id; + const { username } = await request.json(); + const supabase = locals.supabase; // Ensure uuid is not empty if (uuid === '' || uuid === null) { console.error('Validation Error: $uuid is empty: '); diff --git a/src/routes/api/upload/+server.js b/src/routes/api/upload/+server.js index 0812dec..6b95d85 100644 --- a/src/routes/api/upload/+server.js +++ b/src/routes/api/upload/+server.js @@ -1,9 +1,9 @@ import { PUBLIC_GOOGLE_PRIVATE_KEY, PUBLIC_GOOGLE_SERVICE_EMAIL } from '$env/static/public'; import { Readable } from 'stream'; import { google } from 'googleapis'; -import { supabase } from '../../../lib/supabaseClient'; -export async function POST({ request }) { +/** @type {import('./$types').RequestHandler} */ +export async function POST({ request, locals }) { console.log('Received POST request at /api/upload'); // Add debugging logs to verify environment variables @@ -13,8 +13,16 @@ export async function POST({ request }) { }); try { + const { user } = await locals.safeGetSession(); + if (!user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + const uuid = user.id; const formData = await request.formData(); - const uuid = formData.get('uuid'); + const supabase = locals.supabase; const username = formData.get('username'); const gdrive_folder_id = formData.get('gdrive_folder_id'); const member_id = formData.get('member_id'); From a0f4922cf8d1750b48f602d513ee408ffe1f51d6 Mon Sep 17 00:00:00 2001 From: LigsQt Date: Sat, 14 Mar 2026 17:59:13 +0800 Subject: [PATCH 10/28] fix: resolve eslint errors in hooks and API endpoints Sort imports alphabetically, use object destructuring for locals, and fix require-atomic-updates race conditions in authGuard hook. Made-with: Cursor --- src/hooks.server.ts | 20 +++++++++++--------- src/routes/api/get_gdrive_folder/+server.js | 3 ++- src/routes/api/upload/+server.js | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0cea04a..c5fa372 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,7 +1,7 @@ +import type { AppRole } from '$lib/server/auth'; import { type Handle } from '@sveltejs/kit'; import { createServerClient } from '@supabase/ssr'; import { sequence } from '@sveltejs/kit/hooks'; -import type { AppRole } from '$lib/server/auth'; import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; @@ -50,20 +50,22 @@ const supabase: Handle = ({ event, resolve }) => { }; const authGuard: Handle = async ({ event, resolve }) => { - const { session, user } = await event.locals.safeGetSession() - event.locals.session = session - event.locals.user = user - event.locals.userRole = null; + const { locals } = event; + const { session, user } = await locals.safeGetSession() + let userRole: AppRole | null = null; if (user) { - const { data: profile } = await event.locals.supabase + const { data: profile } = await locals.supabase .from('profiles') .select('role') .eq('id', user.id) .single(); - event.locals.userRole = (profile?.role as AppRole) ?? 'applicant'; - console.log('userRole:', event.locals.userRole); -} + userRole = (profile?.role as AppRole) ?? 'applicant'; + } + + locals.session = session + locals.user = user + locals.userRole = userRole; return resolve(event) } diff --git a/src/routes/api/get_gdrive_folder/+server.js b/src/routes/api/get_gdrive_folder/+server.js index d02a90a..b9a4111 100644 --- a/src/routes/api/get_gdrive_folder/+server.js +++ b/src/routes/api/get_gdrive_folder/+server.js @@ -2,6 +2,7 @@ import { PUBLIC_GOOGLE_PRIVATE_KEY, PUBLIC_GOOGLE_SERVICE_EMAIL } from '$env/sta import { gdrive_root_folder } from '$lib/shared'; import { google } from 'googleapis'; +/** @type {import('./$types').RequestHandler} */ export async function POST({ request, locals }) { console.log('Received POST request at /api/get_gdrive_folder'); @@ -21,7 +22,7 @@ export async function POST({ request, locals }) { } const uuid = user.id; const { username } = await request.json(); - const supabase = locals.supabase; + const { supabase } = locals; // Ensure uuid is not empty if (uuid === '' || uuid === null) { console.error('Validation Error: $uuid is empty: '); diff --git a/src/routes/api/upload/+server.js b/src/routes/api/upload/+server.js index 6b95d85..c66e14c 100644 --- a/src/routes/api/upload/+server.js +++ b/src/routes/api/upload/+server.js @@ -22,7 +22,7 @@ export async function POST({ request, locals }) { } const uuid = user.id; const formData = await request.formData(); - const supabase = locals.supabase; + const { supabase } = locals; const username = formData.get('username'); const gdrive_folder_id = formData.get('gdrive_folder_id'); const member_id = formData.get('member_id'); From 401abda64deddfd660264a116e8efbd177d75eea Mon Sep 17 00:00:00 2001 From: LigsQt Date: Thu, 19 Mar 2026 20:02:15 +0800 Subject: [PATCH 11/28] chore: fix code formatting --- src/hooks.server.ts | 28 ++++++++++++---------------- src/routes/+layout.server.ts | 2 +- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c5fa372..e4e5ede 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -50,24 +50,20 @@ const supabase: Handle = ({ event, resolve }) => { }; const authGuard: Handle = async ({ event, resolve }) => { - const { locals } = event; - const { session, user } = await locals.safeGetSession() - let userRole: AppRole | null = null; + const { locals } = event; + const { session, user } = await locals.safeGetSession(); + let userRole: AppRole | null = null; - if (user) { - const { data: profile } = await locals.supabase - .from('profiles') - .select('role') - .eq('id', user.id) - .single(); - userRole = (profile?.role as AppRole) ?? 'applicant'; - } + if (user) { + const { data: profile } = await locals.supabase.from('profiles').select('role').eq('id', user.id).single(); + userRole = (profile?.role as AppRole) ?? 'applicant'; + } - locals.session = session - locals.user = user - locals.userRole = userRole; + locals.session = session; + locals.user = user; + locals.userRole = userRole; - return resolve(event) -} + return resolve(event); +}; export const handle: Handle = sequence(supabase, authGuard); diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 0da62f6..ebca90d 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -7,4 +7,4 @@ export const load: LayoutServerLoad = async ({ locals: { safeGetSession, userRol cookies: cookies.getAll(), userRole: userRole ?? null, }; -}; \ No newline at end of file +}; From c50cb3c91873727ddacff6f55fc628976f26ed44 Mon Sep 17 00:00:00 2001 From: SHIROKAMIQQ Date: Fri, 20 Mar 2026 17:27:08 +0800 Subject: [PATCH 12/28] feat(auth): add admin-side API endpoints --- src/routes/api/admin/applicants/+server.ts | 25 +++++++++ src/routes/api/admin/quiz-results/+server.ts | 54 +++++++++++++++++++ .../admin/quiz-results/[userId]/+server.ts | 42 +++++++++++++++ .../api/admin/sigsheet-progress/+server.ts | 45 ++++++++++++++++ 4 files changed, 166 insertions(+) create mode 100644 src/routes/api/admin/applicants/+server.ts create mode 100644 src/routes/api/admin/quiz-results/+server.ts create mode 100644 src/routes/api/admin/quiz-results/[userId]/+server.ts create mode 100644 src/routes/api/admin/sigsheet-progress/+server.ts diff --git a/src/routes/api/admin/applicants/+server.ts b/src/routes/api/admin/applicants/+server.ts new file mode 100644 index 0000000..86b96cd --- /dev/null +++ b/src/routes/api/admin/applicants/+server.ts @@ -0,0 +1,25 @@ +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; +import { type RequestEvent } from '@sveltejs/kit'; + +/** + * List all applicant profiles + */ +export async function GET(event: RequestEvent) { + requireRole(event, 'admin'); + + const { data, error } = await supabaseAdmin + .from('profiles') + .select('id, username, full_name, avatar_url, role') + .eq('role', 'applicant'); + + if (error) { + return json( + {error: error.message}, + {status: 500} + ); + } + + return json({ applicants: data }); +} \ No newline at end of file diff --git a/src/routes/api/admin/quiz-results/+server.ts b/src/routes/api/admin/quiz-results/+server.ts new file mode 100644 index 0000000..74e9c57 --- /dev/null +++ b/src/routes/api/admin/quiz-results/+server.ts @@ -0,0 +1,54 @@ +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; +import { type RequestEvent } from '@sveltejs/kit'; + +export async function GET(event: RequestEvent) { + requireRole(event, 'admin'); + + // 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); + } + + 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 }); +} \ No newline at end of file diff --git a/src/routes/api/admin/quiz-results/[userId]/+server.ts b/src/routes/api/admin/quiz-results/[userId]/+server.ts new file mode 100644 index 0000000..ef42052 --- /dev/null +++ b/src/routes/api/admin/quiz-results/[userId]/+server.ts @@ -0,0 +1,42 @@ +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; +import { type RequestEvent } from '@sveltejs/kit'; + +export async function GET(event: RequestEvent) { + requireRole(event, 'admin'); + const userId = event.params.userId; + + 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 }); + } + + return json({ + profile: profileRes.data, + submitted_at: submissionRes.data?.submitted_at ?? null, + answers: answersRes.data, + }); +} \ No newline at end of file diff --git a/src/routes/api/admin/sigsheet-progress/+server.ts b/src/routes/api/admin/sigsheet-progress/+server.ts new file mode 100644 index 0000000..fc16fb7 --- /dev/null +++ b/src/routes/api/admin/sigsheet-progress/+server.ts @@ -0,0 +1,45 @@ +import { json } from '@sveltejs/kit'; +import { requireRole } from '$lib/server/auth'; +import { supabaseAdmin } from '$lib/server/supabaseAdmin'; +import { type RequestEvent } from '@sveltejs/kit'; + +export async function GET(event: RequestEvent) { + requireRole(event, 'admin'); + + // 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 }); + } + + // Group by applicant + const byApplicant: Record = {}; + for (const row of data ?? []) { + const key = row.applicant.id; + if (!byApplicant[key]) { + byApplicant[key] = { profile: row.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), + }); +} \ No newline at end of file From 0a815b40ec16cda16d083cd19d7c97d5389521d0 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 21:57:06 +0800 Subject: [PATCH 13/28] fix(security): move Google credentials to private env vars --- src/routes/api/get_gdrive_folder/+server.js | 10 +++++----- src/routes/api/upload/+server.js | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/routes/api/get_gdrive_folder/+server.js b/src/routes/api/get_gdrive_folder/+server.js index b9a4111..17bca52 100644 --- a/src/routes/api/get_gdrive_folder/+server.js +++ b/src/routes/api/get_gdrive_folder/+server.js @@ -1,4 +1,4 @@ -import { PUBLIC_GOOGLE_PRIVATE_KEY, PUBLIC_GOOGLE_SERVICE_EMAIL } from '$env/static/public'; +import { GOOGLE_PRIVATE_KEY, GOOGLE_SERVICE_EMAIL } from '$env/static/private'; import { gdrive_root_folder } from '$lib/shared'; import { google } from 'googleapis'; @@ -8,8 +8,8 @@ export async function POST({ request, locals }) { // Add debugging logs to verify environment variables console.log('Google API Credentials:', { - client_email: PUBLIC_GOOGLE_SERVICE_EMAIL, - private_key: PUBLIC_GOOGLE_PRIVATE_KEY ? 'Provided' : ' Not Provided', + client_email: GOOGLE_SERVICE_EMAIL, + private_key: GOOGLE_PRIVATE_KEY ? 'Provided' : ' Not Provided', }); try { @@ -46,8 +46,8 @@ export async function POST({ request, locals }) { // Authenticate with Google Drive API const auth = new google.auth.GoogleAuth({ credentials: { - client_email: PUBLIC_GOOGLE_SERVICE_EMAIL, - private_key: PUBLIC_GOOGLE_PRIVATE_KEY.replace(/\\n/g, '\n'), + client_email: GOOGLE_SERVICE_EMAIL, + private_key: GOOGLE_PRIVATE_KEY.replace(/\\n/g, '\n'), }, scopes: ['https://www.googleapis.com/auth/drive'], }); diff --git a/src/routes/api/upload/+server.js b/src/routes/api/upload/+server.js index c66e14c..cfa78ab 100644 --- a/src/routes/api/upload/+server.js +++ b/src/routes/api/upload/+server.js @@ -1,4 +1,4 @@ -import { PUBLIC_GOOGLE_PRIVATE_KEY, PUBLIC_GOOGLE_SERVICE_EMAIL } from '$env/static/public'; +import { GOOGLE_PRIVATE_KEY, GOOGLE_SERVICE_EMAIL } from '$env/static/private'; import { Readable } from 'stream'; import { google } from 'googleapis'; @@ -8,8 +8,8 @@ export async function POST({ request, locals }) { // Add debugging logs to verify environment variables console.log('Google API Credentials:', { - client_email: PUBLIC_GOOGLE_SERVICE_EMAIL, - private_key: PUBLIC_GOOGLE_PRIVATE_KEY ? 'Provided' : 'Not Provided', + client_email: GOOGLE_SERVICE_EMAIL, + private_key: GOOGLE_PRIVATE_KEY ? 'Provided' : 'Not Provided', }); try { @@ -49,8 +49,8 @@ export async function POST({ request, locals }) { // Authenticate with Google Drive API const auth = new google.auth.GoogleAuth({ credentials: { - client_email: PUBLIC_GOOGLE_SERVICE_EMAIL, - private_key: PUBLIC_GOOGLE_PRIVATE_KEY.replace(/\\n/g, '\n'), + client_email: GOOGLE_SERVICE_EMAIL, + private_key: GOOGLE_PRIVATE_KEY.replace(/\\n/g, '\n'), }, scopes: ['https://www.googleapis.com/auth/drive.file'], }); @@ -97,8 +97,8 @@ export async function POST({ request, locals }) { // Additional debugging for credentials and folder permissions console.error('Google Drive API credentials:', { - client_email: PUBLIC_GOOGLE_SERVICE_EMAIL, - private_key: PUBLIC_GOOGLE_PRIVATE_KEY ? 'Provided' : 'Not Provided', + client_email: GOOGLE_SERVICE_EMAIL, + private_key: GOOGLE_PRIVATE_KEY ? 'Provided' : 'Not Provided', }); console.error('Folder ID:', fileMetadata.parents); From 830851a3f46ca5eaa0808b7527902151db58b8ba Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 21:57:16 +0800 Subject: [PATCH 14/28] fix(ci): use secrets for sensitive env vars --- .github/workflows/ci.yml | 6 +++--- .github/workflows/deploy.yml | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dd31a0..9146506 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,9 @@ on: env: PUBLIC_SUPABASE_URL: ${{ vars.PUBLIC_SUPABASE_URL || 'http://dummy' }} PUBLIC_SUPABASE_ANON_KEY: ${{ vars.PUBLIC_SUPABASE_ANON_KEY || 'anon-key' }} - SUPABASE_SERVICE_KEY: ${{ vars.SUPABASE_SERVICE_KEY }} - PUBLIC_GOOGLE_SERVICE_EMAIL: ${{ vars.PUBLIC_GOOGLE_SERVICE_EMAIL }} - PUBLIC_GOOGLE_PRIVATE_KEY: ${{ vars.PUBLIC_GOOGLE_PRIVATE_KEY }} + SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} + GOOGLE_SERVICE_EMAIL: ${{ secrets.GOOGLE_SERVICE_EMAIL }} + GOOGLE_PRIVATE_KEY: ${{ secrets.GOOGLE_PRIVATE_KEY }} jobs: build: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a3292d9..c8bd1ef 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,8 +13,8 @@ env: PUBLIC_SUPABASE_URL: ${{ vars.PUBLIC_SUPABASE_URL || 'http://dummy' }} PUBLIC_SUPABASE_ANON_KEY: ${{ vars.PUBLIC_SUPABASE_ANON_KEY || 'anon-key' }} SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} - PUBLIC_GOOGLE_SERVICE_EMAIL: ${{ vars.PUBLIC_GOOGLE_SERVICE_EMAIL }} - PUBLIC_GOOGLE_PRIVATE_KEY: ${{ vars.PUBLIC_GOOGLE_PRIVATE_KEY }} + GOOGLE_SERVICE_EMAIL: ${{ secrets.GOOGLE_SERVICE_EMAIL }} + GOOGLE_PRIVATE_KEY: ${{ secrets.GOOGLE_PRIVATE_KEY }} # Custom environment used for the Container registry domain, and a name for the Docker image that this workflow builds. REGISTRY: ghcr.io @@ -60,8 +60,8 @@ jobs: PUBLIC_SUPABASE_URL=${{ env.PUBLIC_SUPABASE_URL }} PUBLIC_SUPABASE_ANON_KEY=${{ env.PUBLIC_SUPABASE_ANON_KEY }} SUPABASE_SERVICE_KEY=${{ env.SUPABASE_SERVICE_KEY }} - PUBLIC_GOOGLE_SERVICE_EMAIL=${{ env.PUBLIC_GOOGLE_SERVICE_EMAIL }} - PUBLIC_GOOGLE_PRIVATE_KEY=${{ env.PUBLIC_GOOGLE_PRIVATE_KEY }} + GOOGLE_SERVICE_EMAIL=${{ env.GOOGLE_SERVICE_EMAIL }} + GOOGLE_PRIVATE_KEY=${{ env.GOOGLE_PRIVATE_KEY }} EOF # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. From 770f1618241490fbccc1cf6c9b41a021b9e10cfc Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 22:08:55 +0800 Subject: [PATCH 15/28] style: add user role and sidebar animation --- src/routes/+layout.svelte | 21 ++++++++++++++------- src/routes/+layout.ts | 11 +++-------- src/routes/+page.svelte | 3 +++ 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 8f32f0e..b42ba4f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -65,13 +65,20 @@
{#if page.url.pathname !== '/login/'} - {#if isNavBarOpen} -
- -
- {/if} + + +
(isNavBarOpen = false)} + >
+ +
+ +
{/if}
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 17244c2..8bae374 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -22,13 +22,8 @@ export async function load({ data, depends, fetch }) { }, }); - const { - data: { session }, - } = await supabase.auth.getSession(); - - const { - data: { user }, - } = await supabase.auth.getUser(); + const session = data.session; + const user = session?.user ?? null; if (!user) { console.error('Failed to fetch user.'); @@ -157,5 +152,5 @@ export async function load({ data, depends, fetch }) { // fetch in parallel for faster results const [sections, questions, answers] = await Promise.all([fetchSections(), fetchQuestions(), fetchAnswers()]); - return { session, supabase, user, uuid, username, filledSigsheet, gdrive_folder_id, sections, questions, answers }; + return { session, supabase, user, uuid, username, filledSigsheet, gdrive_folder_id, sections, questions, answers, userRole: data.userRole }; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6e55d3d..0e0926c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -184,6 +184,9 @@

Hello, {$username}! + {#if data.userRole} + ({data.userRole}) + {/if}

Your Dashboard

From 20d43103813720049a64440786f53cfc92e00352 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 22:19:47 +0800 Subject: [PATCH 16/28] fix: wrap setInterval in $effect with cleanup to prevent memory leak --- src/routes/+page.svelte | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0e0926c..b5c22de 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -176,8 +176,11 @@ } } - updateTimeLeft(); - setInterval(updateTimeLeft, 1000); + $effect(() => { + updateTimeLeft(); + const interval = setInterval(updateTimeLeft, 1000); + return () => clearInterval(interval); + }); {#if data.session} @@ -194,7 +197,7 @@

Signature Sheet

- {#each signatureSheet as section} + {#each signatureSheet as section (section.name)}

{section.name}

From 157465460fecf22e29581aa448aeb4245e9e7eca Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 22:19:57 +0800 Subject: [PATCH 17/28] fix: properly track message timeout in SaveButton to prevent overlapping timers --- src/routes/consti-quiz/SaveButton.svelte | 27 +++++------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/src/routes/consti-quiz/SaveButton.svelte b/src/routes/consti-quiz/SaveButton.svelte index c26a074..b4bcc24 100644 --- a/src/routes/consti-quiz/SaveButton.svelte +++ b/src/routes/consti-quiz/SaveButton.svelte @@ -8,7 +8,7 @@ let isSaving = $state(false); let isSubmitting = $state(false); let saveMessage = $state(''); - const messageTimeout: ReturnType | null = null; + let messageTimeout = $state | null>(null); async function handleSave() { isSaving = true; @@ -16,20 +16,12 @@ try { const { message } = await saveAnswers(); saveMessage = message; - - if (messageTimeout) { - clearTimeout(messageTimeout); - } } catch (err) { console.error(err); saveMessage = 'Failed to save progress'; - - if (messageTimeout) { - clearTimeout(messageTimeout); - } } finally { - // hide message after 2 seconds - setTimeout(() => { + if (messageTimeout) clearTimeout(messageTimeout); + messageTimeout = setTimeout(() => { saveMessage = ''; }, 2000); isSaving = false; @@ -48,26 +40,17 @@ const { message } = data; saveMessage = message; - if (messageTimeout) { - clearTimeout(messageTimeout); - } - if (data.submitted) { window.location.reload(); } } catch (error) { console.error(error); saveMessage = 'Failed to submit answers'; - - if (messageTimeout) { - clearTimeout(messageTimeout); - } } finally { - // hide message after 2 seconds - setTimeout(() => { + if (messageTimeout) clearTimeout(messageTimeout); + messageTimeout = setTimeout(() => { saveMessage = ''; }, 2000); - isSubmitting = false; } } From 8d76ac0327ffbbd7718a33365f110f5c7c9c35c7 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 22:20:06 +0800 Subject: [PATCH 18/28] refactor: replace local writable store with $state rune in Modal --- src/routes/sigsheet/Modal.svelte | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/routes/sigsheet/Modal.svelte b/src/routes/sigsheet/Modal.svelte index 3c974ed..d74d2ce 100644 --- a/src/routes/sigsheet/Modal.svelte +++ b/src/routes/sigsheet/Modal.svelte @@ -1,7 +1,5 @@ diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 8bae374..2cd6dea 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -38,13 +38,9 @@ export async function load({ data, depends, fetch }) { } const uuid = user.id; - console.log('Fetched uuid:', uuid); - const username = user.email?.split('@')[0] ?? ''; - console.log('Fetched username:', username); // Fetch filledSigsheet - console.log('Fetching sigsheet from Supabase with ID:', uuid); let filledSigsheet: Set = new Set(); try { const { data: sigRows, error: sigError } = await supabase @@ -55,13 +51,11 @@ export async function load({ data, depends, fetch }) { if (sigError) throw sigError; filledSigsheet = new Set(sigRows?.map(row => row.member_id) ?? []); - console.log(`Fetched filledSigsheet: sigsheet size ${filledSigsheet.size}`); } catch (sigError) { console.error('Error fetching sigsheet: ', sigError); } // Fetch gdrive_folder_id - console.log('Fetching gdrive_folder_id for Applicant:', uuid); let gdrive_folder_id: string = ''; try { const response = await fetch('/api/get_gdrive_folder', { @@ -152,5 +146,17 @@ export async function load({ data, depends, fetch }) { // fetch in parallel for faster results const [sections, questions, answers] = await Promise.all([fetchSections(), fetchQuestions(), fetchAnswers()]); - return { session, supabase, user, uuid, username, filledSigsheet, gdrive_folder_id, sections, questions, answers, userRole: data.userRole }; + return { + session, + supabase, + user, + uuid, + username, + filledSigsheet, + gdrive_folder_id, + sections, + questions, + answers, + userRole: data.userRole, + }; } diff --git a/src/routes/consti-quiz/CheckboxQuestion.svelte b/src/routes/consti-quiz/CheckboxQuestion.svelte index 6160041..5caf013 100644 --- a/src/routes/consti-quiz/CheckboxQuestion.svelte +++ b/src/routes/consti-quiz/CheckboxQuestion.svelte @@ -19,7 +19,6 @@ valueSet.add(option); const valueList = Array.from(valueSet); value = valueList.join('-'); - console.log(value); } function removeOption(option: string) { @@ -28,7 +27,6 @@ .split('-') .filter(v => v !== option); value = valueList.join('-'); - console.log(valueList); } function isSelected(option: string) { From b5575320aad94c9c92365d8af59f251834807b68 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 22:22:24 +0800 Subject: [PATCH 21/28] chore: run formatter --- src/routes/api/admin/applicants/+server.ts | 21 ++++++++----------- src/routes/api/admin/quiz-results/+server.ts | 6 ++---- .../admin/quiz-results/[userId]/+server.ts | 20 +++++++----------- .../api/admin/sigsheet-progress/+server.ts | 10 +++------ 4 files changed, 21 insertions(+), 36 deletions(-) diff --git a/src/routes/api/admin/applicants/+server.ts b/src/routes/api/admin/applicants/+server.ts index 86b96cd..8a5af5b 100644 --- a/src/routes/api/admin/applicants/+server.ts +++ b/src/routes/api/admin/applicants/+server.ts @@ -4,22 +4,19 @@ import { supabaseAdmin } from '$lib/server/supabaseAdmin'; import { type RequestEvent } from '@sveltejs/kit'; /** - * List all applicant profiles + * List all applicant profiles */ export async function GET(event: RequestEvent) { - requireRole(event, 'admin'); + requireRole(event, 'admin'); + + const { data, error } = await supabaseAdmin + .from('profiles') + .select('id, username, full_name, avatar_url, role') + .eq('role', 'applicant'); - const { data, error } = await supabaseAdmin - .from('profiles') - .select('id, username, full_name, avatar_url, role') - .eq('role', 'applicant'); - if (error) { - return json( - {error: error.message}, - {status: 500} - ); + return json({ error: error.message }, { status: 500 }); } return json({ applicants: data }); -} \ No newline at end of file +} diff --git a/src/routes/api/admin/quiz-results/+server.ts b/src/routes/api/admin/quiz-results/+server.ts index 74e9c57..bb20caf 100644 --- a/src/routes/api/admin/quiz-results/+server.ts +++ b/src/routes/api/admin/quiz-results/+server.ts @@ -7,9 +7,7 @@ export async function GET(event: RequestEvent) { requireRole(event, 'admin'); // Get all submissions with profile info - const { data: submissions, error: subError } = await supabaseAdmin - .from('constiquiz-submissions') - .select(` + const { data: submissions, error: subError } = await supabaseAdmin.from('constiquiz-submissions').select(` submission_id, submitted_at, user_id, @@ -51,4 +49,4 @@ export async function GET(event: RequestEvent) { })); return json({ results }); -} \ No newline at end of file +} diff --git a/src/routes/api/admin/quiz-results/[userId]/+server.ts b/src/routes/api/admin/quiz-results/[userId]/+server.ts index ef42052..f511600 100644 --- a/src/routes/api/admin/quiz-results/[userId]/+server.ts +++ b/src/routes/api/admin/quiz-results/[userId]/+server.ts @@ -10,24 +10,18 @@ export async function GET(event: RequestEvent) { const [answersRes, profileRes, submissionRes] = await Promise.all([ supabaseAdmin .from('constiquiz-answers') - .select(` + .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(), + 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) { @@ -39,4 +33,4 @@ export async function GET(event: RequestEvent) { submitted_at: submissionRes.data?.submitted_at ?? null, answers: answersRes.data, }); -} \ No newline at end of file +} diff --git a/src/routes/api/admin/sigsheet-progress/+server.ts b/src/routes/api/admin/sigsheet-progress/+server.ts index fc16fb7..ed6516c 100644 --- a/src/routes/api/admin/sigsheet-progress/+server.ts +++ b/src/routes/api/admin/sigsheet-progress/+server.ts @@ -7,13 +7,9 @@ export async function GET(event: RequestEvent) { requireRole(event, 'admin'); // Get total member count for progress calculation - const { count: totalMembers } = await supabaseAdmin - .from('members') - .select('*', { count: 'exact', head: true }); + const { count: totalMembers } = await supabaseAdmin.from('members').select('*', { count: 'exact', head: true }); - const { data, error } = await supabaseAdmin - .from('sigsheet') - .select(` + 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 ) `); @@ -42,4 +38,4 @@ export async function GET(event: RequestEvent) { total_members: totalMembers, progress: Object.values(byApplicant), }); -} \ No newline at end of file +} From 18db89aefd50a1a412c14825b8d1cb6752a7e55b Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 22:31:08 +0800 Subject: [PATCH 22/28] refactor: synchronize $lib variables synchronously for child component access --- src/routes/+layout.svelte | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index edd9356..d6abf61 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -12,13 +12,11 @@ const { data, children } = $props(); const { session, supabase } = $derived(data); - // Sync $lib variables to data props reactively - $effect(() => { - if (data?.uuid) uuid.set(data.uuid); - if (data?.user?.user_metadata.full_name) username.set(data.user.user_metadata.full_name); - if (data?.filledSigsheet) filledSigsheet.set(data.filledSigsheet); - if (data?.gdrive_folder_id) gdrive_folder_id.set(data.gdrive_folder_id); - }); + // Sync $lib variables to data props (must be synchronous so children can read in onMount) + if (data?.uuid) uuid.set(data.uuid); + if (data?.user?.user_metadata.full_name) username.set(data.user.user_metadata.full_name); + if (data?.filledSigsheet) filledSigsheet.set(data.filledSigsheet); + if (data?.gdrive_folder_id) gdrive_folder_id.set(data.gdrive_folder_id); let isNavBarOpen = $state(false); onMount(() => { From a785ad6a91a64745323e0d6ed52aec4c107f42aa Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 22:33:55 +0800 Subject: [PATCH 23/28] refactor: destructure session and userId from data and event.params for cleaner code --- src/routes/+layout.ts | 2 +- src/routes/api/admin/quiz-results/[userId]/+server.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 2cd6dea..ea90ef7 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -22,7 +22,7 @@ export async function load({ data, depends, fetch }) { }, }); - const session = data.session; + const {session} = data; const user = session?.user ?? null; if (!user) { diff --git a/src/routes/api/admin/quiz-results/[userId]/+server.ts b/src/routes/api/admin/quiz-results/[userId]/+server.ts index f511600..8b122c5 100644 --- a/src/routes/api/admin/quiz-results/[userId]/+server.ts +++ b/src/routes/api/admin/quiz-results/[userId]/+server.ts @@ -5,7 +5,7 @@ import { type RequestEvent } from '@sveltejs/kit'; export async function GET(event: RequestEvent) { requireRole(event, 'admin'); - const userId = event.params.userId; + const {userId} = event.params; const [answersRes, profileRes, submissionRes] = await Promise.all([ supabaseAdmin From 6f723cc0efb079be072b4746a28adcfdbb892af8 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 22:58:36 +0800 Subject: [PATCH 24/28] fix: patch production security issues in auth flow --- src/routes/api/get_gdrive_folder/+server.js | 45 +++++++++------------ src/routes/login/+page.server.ts | 4 +- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/src/routes/api/get_gdrive_folder/+server.js b/src/routes/api/get_gdrive_folder/+server.js index 17bca52..5bd8a41 100644 --- a/src/routes/api/get_gdrive_folder/+server.js +++ b/src/routes/api/get_gdrive_folder/+server.js @@ -1,16 +1,11 @@ import { GOOGLE_PRIVATE_KEY, GOOGLE_SERVICE_EMAIL } from '$env/static/private'; import { gdrive_root_folder } from '$lib/shared'; +import { logger } from '$lib/logger'; import { google } from 'googleapis'; /** @type {import('./$types').RequestHandler} */ export async function POST({ request, locals }) { - console.log('Received POST request at /api/get_gdrive_folder'); - - // Add debugging logs to verify environment variables - console.log('Google API Credentials:', { - client_email: GOOGLE_SERVICE_EMAIL, - private_key: GOOGLE_PRIVATE_KEY ? 'Provided' : ' Not Provided', - }); + logger.debug('Received POST request at /api/get_gdrive_folder'); try { const { user } = await locals.safeGetSession(); @@ -25,24 +20,22 @@ export async function POST({ request, locals }) { const { supabase } = locals; // Ensure uuid is not empty if (uuid === '' || uuid === null) { - console.error('Validation Error: $uuid is empty: '); + logger.error('Validation Error: $uuid is empty'); return new Response(JSON.stringify({ error: 'UUID is empty.' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } - console.log('$uuid is filled.'); if (username === '' || username === null) { - console.error('Validation Error: $username is empty: '); + logger.error('Validation Error: $username is empty'); return new Response(JSON.stringify({ error: 'Username is empty.' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } - console.log('$username is filled.'); - console.log('Authenticating into Google Drive.'); + logger.debug('Authenticating into Google Drive'); // Authenticate with Google Drive API const auth = new google.auth.GoogleAuth({ credentials: { @@ -52,27 +45,26 @@ export async function POST({ request, locals }) { scopes: ['https://www.googleapis.com/auth/drive'], }); const drive = google.drive({ version: 'v3', auth }); - console.log('Authenticated into Google Drive.'); + logger.debug('Authenticated into Google Drive'); // Check if applicant already has a gdrive_folder try { - console.log('Searching for folder in Supabase.'); + logger.debug('Searching for folder in Supabase'); const { data, error } = await supabase .from('pic-folders') .select('gdrive_folder') .eq('applicant_uuid', uuid) .single(); - console.log('Done searching for folder in Supabase.'); // Error PGRST116: row not found; So throw unexpected error if (error && error.code !== 'PGRST116') { - console.error('Error reading from Supabase: ', error); + logger.error('Error reading from Supabase: ', error); throw new Error(error.message); } // If applicant has folder, return it if (data) { - console.log('Applicant does have folder:', data.gdrive_folder); + logger.debug('Applicant does have folder:', data.gdrive_folder); return new Response( JSON.stringify({ message: 'Applicant does have folder', @@ -85,7 +77,7 @@ export async function POST({ request, locals }) { ); } } catch (supabaseError) { - console.error('Error reading from Supabase:', supabaseError); + logger.error('Error reading from Supabase:', supabaseError); return new Response(JSON.stringify({ error: 'Error reading from Supabase' }), { status: 500, headers: { 'Content-Type': 'application/json' }, @@ -101,16 +93,15 @@ export async function POST({ request, locals }) { let folder_id = null; try { - console.log('No folder exists for user.'); - console.log('Creating new folder in Google Drive.'); + logger.debug('Creating new folder in Google Drive'); const gdrive_folder = await drive.files.create({ requestBody: fileMetadata, fields: 'id', }); folder_id = gdrive_folder.data.id; - console.log('New folder created:', folder_id); + logger.debug('New folder created:', folder_id); } catch (driveError) { - console.error('Error creating new folder in Google Drive:', { + logger.error('Error creating new folder in Google Drive:', { message: driveError instanceof Error ? driveError.message : 'Unknown error', errors: driveError instanceof Error && 'errors' in driveError @@ -121,7 +112,7 @@ export async function POST({ request, locals }) { } try { - console.log('Inserting new folder into Supabase.'); + logger.debug('Inserting new folder into Supabase'); const { data, error } = await supabase .from('pic-folders') .insert({ @@ -131,11 +122,11 @@ export async function POST({ request, locals }) { .select(); // makes Supabase return inserted rows if (error) { - console.error('Error inserting folder into Supabase:', error); + logger.error('Error inserting folder into Supabase:', error); throw new Error(error.message); } - console.log('Folder successfully inserted into Supabase'); + logger.debug('Folder successfully inserted into Supabase'); return new Response( JSON.stringify({ message: 'Folder successfully inserted into Supabase', @@ -147,14 +138,14 @@ export async function POST({ request, locals }) { }, ); } catch (supabaseError) { - console.error('Error inserting to Supabase:', supabaseError); + logger.error('Error inserting to Supabase:', supabaseError); return new Response(JSON.stringify({ error: 'Error inserting into Supabase' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } catch (err) { - console.error('Unexpected Error: ', err); + logger.error('Unexpected Error: ', err); return new Response(JSON.stringify({ error: err instanceof Error ? err.message : 'Unknown error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 338583a..c0a91a8 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -2,11 +2,11 @@ import type { Actions } from './$types'; import { redirect } from '@sveltejs/kit'; export const actions: Actions = { - login: async ({ locals: { supabase } }) => { + login: async ({ locals: { supabase }, url }) => { const { data, error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { - redirectTo: 'http://localhost:5173/login/callback', // Change this when prod + redirectTo: `${url.origin}/login/callback`, queryParams: { access_type: 'offline', prompt: 'consent', From aab833ca6c5bc78e9916e4b05c4d605575af5736 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 22:59:00 +0800 Subject: [PATCH 25/28] fix: use $derived for prop-derived values in Svelte components --- src/routes/consti-quiz/+page.svelte | 1 + src/routes/login/error/+page.svelte | 2 +- src/routes/sigsheet/MemberCard.svelte | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/routes/consti-quiz/+page.svelte b/src/routes/consti-quiz/+page.svelte index 128103f..64c4b02 100644 --- a/src/routes/consti-quiz/+page.svelte +++ b/src/routes/consti-quiz/+page.svelte @@ -15,6 +15,7 @@ import { onMount } from 'svelte'; const { data } = $props(); + // Data loaded once per page visit — intentionally not reactive const { user, sections, questions, answers, hasSubmitted, isOpen } = data; // NOTE: do we even this need this part diff --git a/src/routes/login/error/+page.svelte b/src/routes/login/error/+page.svelte index 3fd3ddf..55c09b0 100644 --- a/src/routes/login/error/+page.svelte +++ b/src/routes/login/error/+page.svelte @@ -1,7 +1,7 @@
Date: Fri, 20 Mar 2026 22:59:40 +0800 Subject: [PATCH 26/28] feat: add dev-only logger utility --- src/lib/logger.ts | 19 ++++++++ src/routes/api/upload/+server.js | 70 +++++----------------------- src/routes/login/callback/+server.js | 3 +- 3 files changed, 33 insertions(+), 59 deletions(-) create mode 100644 src/lib/logger.ts diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..9fbff23 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,19 @@ +import { dev } from '$app/environment'; + +function debug(...args: unknown[]) { + if (dev) { + console.log('[DEBUG]', ...args); + } +} + +function warn(...args: unknown[]) { + if (dev) { + console.warn('[WARN]', ...args); + } +} + +function error(...args: unknown[]) { + console.error(...args); +} + +export const logger = { debug, warn, error }; diff --git a/src/routes/api/upload/+server.js b/src/routes/api/upload/+server.js index cfa78ab..0dd4de7 100644 --- a/src/routes/api/upload/+server.js +++ b/src/routes/api/upload/+server.js @@ -1,16 +1,11 @@ import { GOOGLE_PRIVATE_KEY, GOOGLE_SERVICE_EMAIL } from '$env/static/private'; import { Readable } from 'stream'; +import { logger } from '$lib/logger'; import { google } from 'googleapis'; /** @type {import('./$types').RequestHandler} */ export async function POST({ request, locals }) { - console.log('Received POST request at /api/upload'); - - // Add debugging logs to verify environment variables - console.log('Google API Credentials:', { - client_email: GOOGLE_SERVICE_EMAIL, - private_key: GOOGLE_PRIVATE_KEY ? 'Provided' : 'Not Provided', - }); + logger.debug('Received POST request at /api/upload'); try { const { user } = await locals.safeGetSession(); @@ -31,11 +26,10 @@ export async function POST({ request, locals }) { const answer = formData.get('answer'); const imageFile = formData.get('image'); - // Add debugging logs to verify folder permissions - console.log('Google Drive Folder ID:', gdrive_folder_id); + logger.debug('Google Drive Folder ID:', gdrive_folder_id); if (!question || !answer || !imageFile || !(imageFile instanceof File)) { - console.error('Validation error: Missing or invalid required fields'); + logger.error('Validation error: Missing or invalid required fields'); return new Response(JSON.stringify({ error: 'Missing or invalid required fields' }), { status: 400, headers: { 'Content-Type': 'application/json' }, @@ -72,41 +66,17 @@ export async function POST({ request, locals }) { fileUrl = null; // Initialize variables try { - console.log('Uploading file to Google Drive with metadata:', fileMetadata); - console.log('File mimeType:', media.mimeType); - - console.log('driveResponse start'); + logger.debug('Uploading file to Google Drive with metadata:', fileMetadata); const driveResponse = await drive.files.create({ requestBody: fileMetadata, media: media, fields: 'id', }); - console.log('driveResponse success'); fileId = driveResponse.data.id; fileUrl = `https://drive.google.com/uc?id=${fileId}`; - console.log('File uploaded successfully. File ID:', fileId); + logger.debug('File uploaded successfully. File ID:', fileId); } catch (driveError) { - console.error('Error uploading to Google Drive:', { - message: driveError instanceof Error ? driveError.message : 'Unknown error', - errors: - driveError instanceof Error && 'errors' in driveError - ? driveError.errors - : 'No additional error details', - stack: driveError instanceof Error ? driveError.stack : 'No stack trace available', - }); - - // Additional debugging for credentials and folder permissions - console.error('Google Drive API credentials:', { - client_email: GOOGLE_SERVICE_EMAIL, - private_key: GOOGLE_PRIVATE_KEY ? 'Provided' : 'Not Provided', - }); - console.error('Folder ID:', fileMetadata.parents); - - // Log the media object details - console.error('Media object details:', { - mimeType: media.mimeType, - bodyType: typeof media.body, - }); + logger.error('Google Drive upload failed:', driveError instanceof Error ? driveError.message : driveError); return new Response( JSON.stringify({ error: 'Error uploading to Google Drive. Please check logs for details.' }), @@ -118,15 +88,7 @@ export async function POST({ request, locals }) { } // Save data to Supabase - console.log('Starting to save data to Supabase with the following details:', { - question, - answer, - image_url: fileUrl, // Use the correct file URL - applicant_id: uuid, - member_id, - member_name, - }); - + logger.debug('Saving data to Supabase for applicant:', uuid); try { const { data, error } = await supabase .from('sigsheet') // table name in supabase @@ -143,32 +105,24 @@ export async function POST({ request, locals }) { if (error.message.includes('unique_applicantid_signatoryname_pair')) { throw new Error("You have already have this co-applicant's signature. Try someone else"); } - console.error('Error inserting into Supabase:', error); + logger.error('Error inserting into Supabase:', error); throw new Error(error.message); } - console.log('Data successfully saved to Supabase:', { - question, - answer, - image_url: fileUrl, - applicant_id: uuid, - member_id, - member_name, - }); - + logger.debug('Data successfully saved to Supabase'); return new Response(JSON.stringify({ message: 'Data saved successfully', data }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } catch (supabaseError) { - console.error('Error saving to Supabase:', supabaseError); + logger.error('Error saving to Supabase:', supabaseError); return new Response(JSON.stringify({ error: `${supabaseError}` }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } catch (err) { - console.error('Unexpected error:', err); + logger.error('Unexpected error:', err); return new Response(JSON.stringify({ error: err instanceof Error ? err.message : 'Unknown error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, diff --git a/src/routes/login/callback/+server.js b/src/routes/login/callback/+server.js index 1970449..09c6070 100644 --- a/src/routes/login/callback/+server.js +++ b/src/routes/login/callback/+server.js @@ -44,7 +44,8 @@ export const GET = async ({ url, locals: { supabase } }) => { } // Login Successful: - throw redirect(303, `/${next.slice(1)}`); + const safePath = next.startsWith('/') && !next.startsWith('//') ? next : '/'; + throw redirect(303, safePath); } // return the user to an error page with instructions From 190358e17bea43e0aa8e15206d249e38f1321e89 Mon Sep 17 00:00:00 2001 From: carlsalces Date: Fri, 20 Mar 2026 23:15:29 +0800 Subject: [PATCH 27/28] chore: run linters and formatter --- src/lib/logger.ts | 3 +++ src/routes/+layout.svelte | 7 ++++-- src/routes/+layout.ts | 18 ++++++------- src/routes/+page.svelte | 3 ++- src/routes/api/admin/applicants/+server.ts | 3 +-- src/routes/api/admin/quiz-results/+server.ts | 3 +-- .../admin/quiz-results/[userId]/+server.ts | 5 ++-- .../api/admin/sigsheet-progress/+server.ts | 16 ++++++------ src/routes/api/answers/+server.js | 3 ++- src/routes/api/get_gdrive_folder/+server.js | 2 +- src/routes/api/upload/+server.js | 2 +- src/routes/consti-quiz/+page.server.js | 6 +++-- src/routes/consti-quiz/+page.svelte | 5 ++-- src/routes/consti-quiz/SaveButton.svelte | 5 ++-- src/routes/login/callback/+server.js | 11 ++++---- src/routes/sigsheet/+page.server.js | 4 ++- src/routes/sigsheet/Modal.svelte | 25 +++++++++++++------ 17 files changed, 73 insertions(+), 48 deletions(-) diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 9fbff23..6bea731 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -2,17 +2,20 @@ import { dev } from '$app/environment'; function debug(...args: unknown[]) { if (dev) { + // eslint-disable-next-line no-console console.log('[DEBUG]', ...args); } } function warn(...args: unknown[]) { if (dev) { + // eslint-disable-next-line no-console console.warn('[WARN]', ...args); } } function error(...args: unknown[]) { + // eslint-disable-next-line no-console console.error(...args); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d6abf61..78036f2 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import { page } from '$app/state'; import { invalidate } from '$app/navigation'; + import { logger } from '$lib/logger'; import { onMount } from 'svelte'; const { data, children } = $props(); @@ -32,7 +33,7 @@ onMount(async () => { const { data: app_data, error: app_error } = await supabase.from('profiles').select('full_name'); if (app_error) { - console.error('Error fetching profile names: ', app_error); + logger.error('Error fetching profile names: ', app_error); } else if (app_data) { applicant_names_list.set(app_data.map(row => row.full_name)); } @@ -63,12 +64,14 @@
{#if page.url.pathname !== '/login/'} -
(isNavBarOpen = false)} + onkeydown={(e) => { if (e.key === 'Escape') isNavBarOpen = false; }} >
row.member_id) ?? []); } catch (sigError) { - console.error('Error fetching sigsheet: ', sigError); + logger.error('Error fetching sigsheet: ', sigError); } // Fetch gdrive_folder_id @@ -68,13 +69,13 @@ export async function load({ data, depends, fetch }) { if (!response.ok) { const gDriveError = await response.json().catch(() => ({})); - console.error('Error fetching gdrive folder:', gDriveError); + logger.error('Error fetching gdrive folder:', gDriveError); } else { const folderData = await response.json(); gdrive_folder_id = folderData.folder_id ?? ''; } } catch (gDriveError) { - console.error('Unexpected error fetching gdrive folder:', gDriveError); + logger.error('Unexpected error fetching gdrive folder:', gDriveError); } // Functions for fetching constiquiz @@ -86,8 +87,7 @@ export async function load({ data, depends, fetch }) { `); if (error) { - // TODO: handle error - console.error(error); + logger.error(error); throw error; } @@ -112,7 +112,7 @@ export async function load({ data, depends, fetch }) { `); if (error || !data) { - console.error(error); + logger.error(error); throw error; } @@ -136,7 +136,7 @@ export async function load({ data, depends, fetch }) { .eq('user_id', uuid); if (error) { - console.error(error); + logger.error(error); throw error; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b5c22de..47096d7 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,6 @@