From 80ebd8e60569993613c729a841442fbaf03e7496 Mon Sep 17 00:00:00 2001 From: Thierry Date: Thu, 4 Jun 2026 14:02:03 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(admin):=20THI-234=20Phase=209=20analyt?= =?UTF-8?q?ics=20widgets=20=E2=80=94=20Sant=C3=A9=20Supabase=20+=20activit?= =?UTF-8?q?y=20heatmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 2 of the 4 AdminPanel placeholder widgets with live data: - Santé Supabase: total users, active 7d, lessons completed 30d/total, top 5 lessons - Activité élèves: GitHub-style 52-week heatmap (THI-77) Data via 2 SECURITY DEFINER RPCs (migration 032) with an internal super_admin gate (raise PERMISSION_DENIED otherwise) + REVOKE public/anon — cross-user aggregates without exposing rows to non-admins. Isolation proven empirically via REST+JWT: super_admin 200, student 400 PERMISSION_DENIED, anon 401. Sentry + Application health stay placeholders (Phase 9 v2 — need API route + secret). Counts only, no PII. - migration 032 (applied to prod, additive) - Database.Functions types for the 2 RPCs (zero any) - useAdminAnalytics hook (parallel RPC fetch, read-only) - pure heatmap helpers (buildHeatmapGrid/heatmapLevel/heatmapMax) + tests - AdminPanel widgets data-driven (loading/error/empty states) - 24 new tests; adminPanel.test rewritten for the new widgets Co-Authored-By: Claude Opus 4.8 --- src/app/components/AdminPanel.tsx | 201 +++++++++++++----- src/app/types/database.ts | 25 +++ src/lib/admin/heatmap.ts | 59 +++++ src/lib/hooks/useAdminAnalytics.ts | 74 +++++++ src/test/admin/heatmap.test.ts | 70 ++++++ src/test/admin/useAdminAnalytics.test.ts | 82 +++++++ src/test/adminPanel.test.tsx | 99 +++++++-- .../migrations/032_admin_analytics_rpc.sql | 115 ++++++++++ 8 files changed, 654 insertions(+), 71 deletions(-) create mode 100644 src/lib/admin/heatmap.ts create mode 100644 src/lib/hooks/useAdminAnalytics.ts create mode 100644 src/test/admin/heatmap.test.ts create mode 100644 src/test/admin/useAdminAnalytics.test.ts create mode 100644 supabase/migrations/032_admin_analytics_rpc.sql diff --git a/src/app/components/AdminPanel.tsx b/src/app/components/AdminPanel.tsx index 2b242979..f54278e1 100644 --- a/src/app/components/AdminPanel.tsx +++ b/src/app/components/AdminPanel.tsx @@ -1,24 +1,19 @@ /** - * AdminPanel — THI-225 Phase 9 Admin Panel v1 (skeleton). + * AdminPanel — THI-225 Phase 9 Admin Panel + THI-234 widgets data-driven. * * Route `/app/admin` gated par ``. * - * Scope v1 (deadline 10 juin 2026) — read-only super_admin only : - * - 4 widgets placeholder skeleton (data réelle en S3 Sprint 2.5) - * - Supabase health (users actifs, lessons completed, agrégats progress) - * - Sentry events (last 24h errors/warnings) - * - Application health (RLS policies status, RPC calls) - * - Student activity heatmap (THI-77, GitHub-style) + * Widgets LIVE (THI-234, migration 032 RPC SECURITY DEFINER + gate super_admin) : + * - Santé Supabase → `admin_platform_stats()` (users, actifs 7j, leçons 30j/total, top 5) + * - Activité élèves → `admin_activity_heatmap()` (progress/jour sur 52 semaines, THI-77) * - * Hors scope v1 (différé v2 post-deadline) : - * - `institution_admin` role (RLS scope creep P=70% identifié agent challenge) - * - Drains Vercel Analytics (Pro plan blocker confirmé spike 18/05) - * - Maintenance mode, screenshots upload moderation - * (in-app support tickets triage shipped Étape 4 — THI-320, see SupportTicketsSection) - * - Teacher adoption heatmap (THI-78) + * Widgets placeholder (différés v2 post-démo) : + * - Sentry events → nécessite route API + secret Sentry (plus lourd/risqué) + * - Application health → agrégats techniques (peu parlants pour la cible écoles) * - * Le lien vers le dashboard Vercel externe est placé en footer pour les - * admins TL eux-mêmes (les autres rôles n'arrivent jamais ici). + * Les agrégats sont cross-user mais gated `super_admin` côté RPC (raise + * PERMISSION_DENIED sinon) + REVOKE public/anon — defense in depth avec le + * RequireRole UI. Voir migration 032 + `useAdminAnalytics`. */ import { Helmet } from 'react-helmet-async'; import { Activity, AlertCircle, BarChart3, ExternalLink, ShieldCheck, Users } from 'lucide-react'; @@ -27,6 +22,8 @@ import { RequireRole } from './auth/RequireRole'; import { SupportTicketsSection } from './SupportTicketsSection'; import { Button } from './ui/button'; import { Card } from './ui/card'; +import { useAdminAnalytics, type PlatformStats, type HeatmapDay } from '@/lib/hooks/useAdminAnalytics'; +import { buildHeatmapGrid, heatmapLevel, heatmapMax, HEATMAP_WEEKS } from '@/lib/admin/heatmap'; export function AdminPanel() { return ( @@ -37,6 +34,8 @@ export function AdminPanel() { } function AdminPanelContent() { + const { stats, heatmap, loading, error } = useAdminAnalytics(); + return (
@@ -53,18 +52,15 @@ function AdminPanelContent() { Panneau administrateur

- Supervision et monitoring de la plateforme.{' '} - - Édition v1 — données live disponibles bientôt. - + Supervision et monitoring de la plateforme.

- + - +
@@ -92,7 +88,7 @@ interface WidgetCardProps { icon: React.ReactNode; title: string; description: string; - comingIn: string; + comingIn?: string; children?: React.ReactNode; } @@ -125,14 +121,96 @@ function WidgetCard({ icon, title, description, comingIn, children }: WidgetCard ); } -function WidgetSupabaseHealth() { +/** + * Shared body wrapper for the data-driven widgets — renders loading / error / + * empty states consistently, otherwise the children (the actual data view). + */ +function WidgetBody({ + loading, + error, + empty, + emptyLabel = 'Aucune donnée pour le moment.', + children, +}: { + loading: boolean; + error: Error | null; + empty: boolean; + emptyLabel?: string; + children: React.ReactNode; +}) { + if (loading) { + return

Chargement…

; + } + if (error) { + return ( +

+ Chargement impossible. Réessaie dans un instant. +

+ ); + } + if (empty) { + return

{emptyLabel}

; + } + return <>{children}; +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
+ {value.toLocaleString('fr-FR')} +
+
{label}
+
+ ); +} + +function WidgetSupabaseHealth({ + stats, + loading, + error, +}: { + stats: PlatformStats | null; + loading: boolean; + error: Error | null; +}) { return ( } title="Santé Supabase" - description="Utilisateurs actifs (7j), leçons complétées (30j), modules populaires, sessions auth." - comingIn="Sprint S3 — requêtes Supabase REST sur progress + auth.users" - /> + description="Utilisateurs, activité récente et leçons complétées." + > + + {stats && ( +
+
+ + + + +
+ {stats.top_lessons.length > 0 && ( +
+

+ Top leçons +

+
    + {stats.top_lessons.map((l) => ( +
  • + {l.lesson_id} + {l.completions} +
  • + ))} +
+
+ )} +
+ )} +
+
); } @@ -142,7 +220,7 @@ function WidgetSentryEvents() { icon={} title="Événements Sentry" description="Erreurs et warnings des 24 dernières heures (free tier 5K events/mois)." - comingIn="Sprint S3 — Sentry REST API integration" + comingIn="Phase 9 v2 — Sentry REST API (route serveur + secret)" /> ); } @@ -153,37 +231,62 @@ function WidgetApplicationHealth() { icon={} title="Santé application" description="Status RLS policies, RPC calls réussis vs erreur, événements auth Supabase." - comingIn="Sprint S3 — Supabase Admin queries via supabase-js" + comingIn="Phase 9 v2 — Supabase Admin queries" /> ); } -function WidgetStudentHeatmap() { +// GitHub-style intensity palette (0 = aucune activité → 4 = jour le plus actif). +// emerald = couleur accent du projet (cohérent Sidebar / progress bar). +const HEATMAP_LEVEL_CLASS = [ + 'bg-[var(--github-bg)] border border-[var(--github-border-primary)]/40', + 'bg-emerald-900/70', + 'bg-emerald-700', + 'bg-emerald-500', + 'bg-emerald-400', +] as const; + +function WidgetStudentHeatmap({ + heatmap, + loading, + error, +}: { + heatmap: HeatmapDay[]; + loading: boolean; + error: Error | null; +}) { + const cells = buildHeatmapGrid(heatmap); + const max = heatmapMax(cells); + const totalCompletions = cells.reduce((sum, c) => sum + c.completions, 0); + return ( } title="Activité élèves" - description="Heatmap GitHub-style des leçons complétées par jour sur 52 semaines (THI-77)." - comingIn="Sprint S3 — agrégat progress par date_trunc('day', completed_at)" + description={`Leçons complétées par jour sur ${HEATMAP_WEEKS} semaines (THI-77).`} > - {/* Skeleton heatmap placeholder — emerald grid pattern */} -
- {Array.from({ length: 91 }, (_, i) => ( -
-

-

+ +
+
+ {cells.map((c) => ( +
+
+

+

+
); } diff --git a/src/app/types/database.ts b/src/app/types/database.ts index 144000a0..b69ef6ad 100644 --- a/src/app/types/database.ts +++ b/src/app/types/database.ts @@ -373,6 +373,31 @@ export interface Database { Args: { target_user_id: string }; Returns: void; }; + /** + * THI-234 Phase 9 — cross-user platform aggregates for the Admin Panel + * (total users, active 7d, lessons completed 30d/total, top 5 lessons). + * SECURITY DEFINER + internal super_admin gate (raises PERMISSION_DENIED + * otherwise) + REVOKE public/anon (migration 032). Counts only, no PII. + */ + admin_platform_stats: { + Args: Record; + Returns: { + total_users: number; + active_users_7d: number; + lessons_completed_30d: number; + lessons_completed_total: number; + top_lessons: { lesson_id: string; completions: number }[]; + }; + }; + /** + * THI-234 Phase 9 (THI-77) — daily completed-lesson counts over the last + * 365 days for the student activity heatmap. SECURITY DEFINER + super_admin + * gate + REVOKE public/anon (migration 032). Daily counts only, no PII. + */ + admin_activity_heatmap: { + Args: Record; + Returns: { day: string; completions: number }[]; + }; }; Enums: { [_ in never]: never }; CompositeTypes: { [_ in never]: never }; diff --git a/src/lib/admin/heatmap.ts b/src/lib/admin/heatmap.ts new file mode 100644 index 00000000..fdd47b91 --- /dev/null +++ b/src/lib/admin/heatmap.ts @@ -0,0 +1,59 @@ +/** + * Heatmap helpers — THI-234 Phase 9 (THI-77 student activity heatmap). + * + * Pure + deterministic transforms turning the `admin_activity_heatmap` RPC rows + * (sparse daily counts) into a fixed 52-week grid for a GitHub-style rendering. + * Extracted from the widget so the date-walking + bucketing logic is unit-tested + * without rendering React. + */ +import type { HeatmapDay } from '@/lib/hooks/useAdminAnalytics'; + +export interface HeatmapCell { + /** ISO date (YYYY-MM-DD, UTC). */ + date: string; + completions: number; +} + +export const HEATMAP_WEEKS = 52; +const HEATMAP_DAYS = HEATMAP_WEEKS * 7; // 364 + +/** + * Build a fixed 52-week (364-day) grid ending on `today`, oldest day first. + * RPC rows are sparse (only days with activity) — every other day is filled with + * `completions: 0`. UTC throughout to avoid TZ drift between the DB (date_trunc) + * and the client. + */ +export function buildHeatmapGrid(days: HeatmapDay[], today: Date = new Date()): HeatmapCell[] { + const byDate = new Map(); + for (const d of days) byDate.set(d.day, d.completions); + + const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); + const cells: HeatmapCell[] = []; + for (let i = HEATMAP_DAYS - 1; i >= 0; i--) { + const d = new Date(end); + d.setUTCDate(end.getUTCDate() - i); + const key = d.toISOString().slice(0, 10); + cells.push({ date: key, completions: byDate.get(key) ?? 0 }); + } + return cells; +} + +/** + * GitHub-style 0–4 intensity level, relative to the busiest day in the window. + * Level 0 = no activity; 1–4 scale by ratio to `max` so the palette adapts to + * the project's real volume (no hardcoded thresholds that would wash out on a + * low-traffic platform). + */ +export function heatmapLevel(completions: number, max: number): 0 | 1 | 2 | 3 | 4 { + if (completions <= 0 || max <= 0) return 0; + const ratio = completions / max; + if (ratio > 0.75) return 4; + if (ratio > 0.5) return 3; + if (ratio > 0.25) return 2; + return 1; +} + +/** Largest single-day completions in the set (0 when empty). */ +export function heatmapMax(cells: HeatmapCell[]): number { + return cells.reduce((m, c) => (c.completions > m ? c.completions : m), 0); +} diff --git a/src/lib/hooks/useAdminAnalytics.ts b/src/lib/hooks/useAdminAnalytics.ts new file mode 100644 index 00000000..6b7ab7be --- /dev/null +++ b/src/lib/hooks/useAdminAnalytics.ts @@ -0,0 +1,74 @@ +/** + * useAdminAnalytics — THI-234 Phase 9 Admin Panel (data-driven widgets). + * + * Read-only hook feeding the « Santé Supabase » + « Activité élèves » heatmap + * widgets in AdminPanel. Both data sources are cross-user aggregates exposed via + * the `admin_platform_stats` / `admin_activity_heatmap` RPCs (migration 032), + * which are SECURITY DEFINER + gated `super_admin` internally and REVOKE'd from + * public/anon. The UI is ALSO gated by + * (defense in depth) — so in practice only a super_admin ever calls these. + * + * A non-super_admin who somehow reached the call gets a PostgREST error + * (PERMISSION_DENIED / 42501) which surfaces as `error`; no aggregate ever leaks. + */ +import { useCallback, useEffect, useState } from 'react'; + +import { useAuth } from '@/app/context/AuthContext'; +import { supabase } from '@/lib/supabase'; +import type { Database } from '@/app/types/database'; + +export type PlatformStats = Database['public']['Functions']['admin_platform_stats']['Returns']; +export type HeatmapDay = Database['public']['Functions']['admin_activity_heatmap']['Returns'][number]; + +export interface UseAdminAnalyticsResult { + stats: PlatformStats | null; + heatmap: HeatmapDay[]; + loading: boolean; + error: Error | null; + refresh: () => Promise; +} + +export function useAdminAnalytics(): UseAdminAnalyticsResult { + const { user, initialized } = useAuth(); + const [stats, setStats] = useState(null); + const [heatmap, setHeatmap] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (!user || !supabase) { + setStats(null); + setHeatmap([]); + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + // Parallel — the two RPCs are independent reads, no ordering constraint. + const [statsRes, heatmapRes] = await Promise.all([ + supabase.rpc('admin_platform_stats'), + supabase.rpc('admin_activity_heatmap'), + ]); + if (statsRes.error) throw new Error(statsRes.error.message); + if (heatmapRes.error) throw new Error(heatmapRes.error.message); + setStats(statsRes.data ?? null); + setHeatmap(heatmapRes.data ?? []); + } catch (err) { + setError(err instanceof Error ? err : new Error('Chargement des analytics impossible')); + setStats(null); + setHeatmap([]); + } finally { + setLoading(false); + } + }, [user]); + + useEffect(() => { + if (!initialized) return; + /* eslint-disable react-hooks/set-state-in-effect -- async fetch on mount, setState after await (same pattern as useSupportTickets) */ + void refresh(); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [initialized, refresh]); + + return { stats, heatmap, loading, error, refresh }; +} diff --git a/src/test/admin/heatmap.test.ts b/src/test/admin/heatmap.test.ts new file mode 100644 index 00000000..c452cd31 --- /dev/null +++ b/src/test/admin/heatmap.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for heatmap helpers — THI-234 Phase 9 (THI-77). + */ +import { describe, it, expect } from 'vitest'; + +import { buildHeatmapGrid, heatmapLevel, heatmapMax, HEATMAP_WEEKS } from '@/lib/admin/heatmap'; + +const TODAY = new Date('2026-06-04T12:00:00Z'); + +describe('buildHeatmapGrid', () => { + it('returns exactly 52*7 cells', () => { + expect(buildHeatmapGrid([], TODAY)).toHaveLength(HEATMAP_WEEKS * 7); + }); + + it('ends on today (UTC) and is ordered oldest-first', () => { + const cells = buildHeatmapGrid([], TODAY); + expect(cells[cells.length - 1].date).toBe('2026-06-04'); + const ascending = cells.every((c, i) => i === 0 || c.date > cells[i - 1].date); + expect(ascending).toBe(true); + }); + + it('fills sparse RPC rows and defaults the rest to 0', () => { + const cells = buildHeatmapGrid( + [ + { day: '2026-06-04', completions: 9 }, + { day: '2026-06-01', completions: 3 }, + ], + TODAY, + ); + const byDate = new Map(cells.map((c) => [c.date, c.completions])); + expect(byDate.get('2026-06-04')).toBe(9); + expect(byDate.get('2026-06-01')).toBe(3); + expect(byDate.get('2026-06-03')).toBe(0); + }); + + it('ignores days outside the 52-week window', () => { + const cells = buildHeatmapGrid([{ day: '2020-01-01', completions: 99 }], TODAY); + expect(cells.every((c) => c.completions === 0)).toBe(true); + }); +}); + +describe('heatmapLevel', () => { + it('returns 0 for no activity or zero max', () => { + expect(heatmapLevel(0, 10)).toBe(0); + expect(heatmapLevel(5, 0)).toBe(0); + expect(heatmapLevel(-2, 10)).toBe(0); + }); + + it('buckets by ratio to the busiest day', () => { + expect(heatmapLevel(10, 10)).toBe(4); // 1.0 > 0.75 + expect(heatmapLevel(8, 10)).toBe(4); // 0.8 > 0.75 + expect(heatmapLevel(6, 10)).toBe(3); // 0.6 > 0.5 + expect(heatmapLevel(4, 10)).toBe(2); // 0.4 > 0.25 + expect(heatmapLevel(2, 10)).toBe(1); // 0.2 + expect(heatmapLevel(1, 10)).toBe(1); + }); +}); + +describe('heatmapMax', () => { + it('returns the busiest day, 0 when empty', () => { + expect(heatmapMax([])).toBe(0); + expect( + heatmapMax([ + { date: 'a', completions: 3 }, + { date: 'b', completions: 7 }, + { date: 'c', completions: 1 }, + ]), + ).toBe(7); + }); +}); diff --git a/src/test/admin/useAdminAnalytics.test.ts b/src/test/admin/useAdminAnalytics.test.ts new file mode 100644 index 00000000..50ebe910 --- /dev/null +++ b/src/test/admin/useAdminAnalytics.test.ts @@ -0,0 +1,82 @@ +/** + * Tests for useAdminAnalytics — THI-234 Phase 9. + * + * The two RPCs (admin_platform_stats / admin_activity_heatmap, migration 032) do + * the authorization server-side (super_admin gate + REVOKE public/anon). Here we + * verify the client contract of the read-only hook: parallel fetch on mount, + * empty when unauthenticated, error surfacing, read-only surface. + */ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + rpcMock: vi.fn(), + auth: { user: { id: 'sa' } as { id: string } | null, initialized: true }, +})); + +vi.mock('@/lib/supabase', () => ({ supabase: { rpc: h.rpcMock } })); +vi.mock('@/app/context/AuthContext', () => ({ + useAuth: () => ({ user: h.auth.user, initialized: h.auth.initialized }), +})); + +const { useAdminAnalytics } = await import('@/lib/hooks/useAdminAnalytics'); + +const STATS = { + total_users: 12, + active_users_7d: 1, + lessons_completed_30d: 2, + lessons_completed_total: 53, + top_lessons: [], +}; +const HEATMAP = [{ day: '2026-06-01', completions: 4 }]; + +beforeEach(() => { + vi.clearAllMocks(); + h.auth.user = { id: 'sa' }; + h.auth.initialized = true; + h.rpcMock.mockImplementation((fn: string) => { + if (fn === 'admin_platform_stats') return Promise.resolve({ data: STATS, error: null }); + if (fn === 'admin_activity_heatmap') return Promise.resolve({ data: HEATMAP, error: null }); + return Promise.resolve({ data: null, error: null }); + }); +}); + +describe('useAdminAnalytics', () => { + it('loads stats + heatmap on mount (both RPCs)', async () => { + const { result } = renderHook(() => useAdminAnalytics()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.stats).toEqual(STATS); + expect(result.current.heatmap).toEqual(HEATMAP); + expect(h.rpcMock).toHaveBeenCalledWith('admin_platform_stats'); + expect(h.rpcMock).toHaveBeenCalledWith('admin_activity_heatmap'); + }); + + it('stays empty (no RPC) when there is no authenticated user', async () => { + h.auth.user = null; + const { result } = renderHook(() => useAdminAnalytics()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.stats).toBeNull(); + expect(result.current.heatmap).toEqual([]); + expect(h.rpcMock).not.toHaveBeenCalled(); + }); + + it('surfaces an error and clears data when an RPC fails', async () => { + h.rpcMock.mockImplementation((fn: string) => { + if (fn === 'admin_platform_stats') { + return Promise.resolve({ data: null, error: { message: 'PERMISSION_DENIED' } }); + } + return Promise.resolve({ data: HEATMAP, error: null }); + }); + const { result } = renderHook(() => useAdminAnalytics()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.stats).toBeNull(); + expect(result.current.heatmap).toEqual([]); + }); + + it('exposes a refresh function (read-only surface)', async () => { + const { result } = renderHook(() => useAdminAnalytics()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(typeof result.current.refresh).toBe('function'); + }); +}); diff --git a/src/test/adminPanel.test.tsx b/src/test/adminPanel.test.tsx index 93e0698a..b9d7709b 100644 --- a/src/test/adminPanel.test.tsx +++ b/src/test/adminPanel.test.tsx @@ -1,30 +1,35 @@ /** - * Tests for AdminPanel — THI-225 Phase 9 v1 skeleton. + * Tests for AdminPanel — THI-225 Phase 9 + THI-234 data-driven widgets. * * Couvre : - * - render skeleton (heading + 4 widgets + footer lien Vercel) when super_admin - * - fallback "Accès réservé" pour les 4 autres rôles (institution_admin / - * teacher / pending_teacher / student) - * - fallback "Vous devez être connecté" anonymous - * - meta robots noindex,nofollow (admin panel n'est pas pour Google) + * - auth/role guard (anonymous → "connecté", non-super_admin → "Accès réservé") + * - super_admin render : header + 4 widgets + footer lien Vercel + meta noindex + * - widgets data-driven (THI-234) : Santé Supabase (stats), Activité élèves + * (heatmap 364 cells), loading + error states + * - widgets placeholder restants (Sentry / Application health → Phase 9 v2) */ import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import { HelmetProvider } from 'react-helmet-async'; import { beforeEach, describe, it, expect, vi } from 'vitest'; -type AuthState = { - user: { id: string } | null; - initialized: boolean; -}; +import type { PlatformStats, HeatmapDay } from '@/lib/hooks/useAdminAnalytics'; +type AuthState = { user: { id: string } | null; initialized: boolean }; type RoleState = { role: 'super_admin' | 'institution_admin' | 'teacher' | 'pending_teacher' | 'student' | null; loading: boolean; }; +type AnalyticsState = { + stats: PlatformStats | null; + heatmap: HeatmapDay[]; + loading: boolean; + error: Error | null; +}; const authState: AuthState = { user: null, initialized: true }; const roleState: RoleState = { role: null, loading: false }; +const analyticsState: AnalyticsState = { stats: null, heatmap: [], loading: false, error: null }; vi.mock('../app/context/AuthContext', () => ({ useAuth: () => ({ user: authState.user, initialized: authState.initialized }), @@ -34,6 +39,15 @@ vi.mock('@/lib/hooks/useUserRole', () => ({ useUserRole: () => ({ role: roleState.role, loading: roleState.loading }), })); +vi.mock('@/lib/hooks/useAdminAnalytics', () => ({ + useAdminAnalytics: () => ({ ...analyticsState, refresh: vi.fn() }), +})); + +// SupportTicketsSection fetches tickets on mount — stub it out (out of scope here). +vi.mock('../app/components/SupportTicketsSection', () => ({ + SupportTicketsSection: () => null, +})); + import { AdminPanel } from '../app/components/AdminPanel'; function renderAdmin() { @@ -46,11 +60,29 @@ function renderAdmin() { ); } +function sampleStats(overrides: Partial = {}): PlatformStats { + return { + total_users: 12, + active_users_7d: 1, + lessons_completed_30d: 2, + lessons_completed_total: 53, + top_lessons: [ + { lesson_id: 'fichiers/cp', completions: 2 }, + { lesson_id: 'fichiers/mkdir', completions: 2 }, + ], + ...overrides, + }; +} + beforeEach(() => { authState.user = null; authState.initialized = true; roleState.role = null; roleState.loading = false; + analyticsState.stats = null; + analyticsState.heatmap = []; + analyticsState.loading = false; + analyticsState.error = null; }); describe('AdminPanel — auth & role guard', () => { @@ -86,7 +118,7 @@ describe('AdminPanel — super_admin render', () => { expect(screen.getByRole('heading', { name: 'Panneau administrateur', level: 1 })).toBeInTheDocument(); }); - it('renders 4 widget sections', () => { + it('renders the 4 widget sections', () => { renderAdmin(); expect(screen.getByText('Santé Supabase')).toBeInTheDocument(); expect(screen.getByText('Événements Sentry')).toBeInTheDocument(); @@ -102,23 +134,46 @@ describe('AdminPanel — super_admin render', () => { expect(link).toHaveAttribute('rel', 'noopener noreferrer'); }); - it('mentions widget data coming in Sprint S3 (placeholder transparency)', () => { + it('keeps Sentry + Application health as Phase 9 v2 placeholders', () => { + renderAdmin(); + const v2Mentions = screen.getAllByText(/Phase 9 v2/i); + // 2 widget placeholders + footer Drains mention. + expect(v2Mentions.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe('AdminPanel — THI-234 data-driven widgets', () => { + beforeEach(() => { + authState.user = { id: 'super-123' }; + roleState.role = 'super_admin'; + }); + + it('renders platform stats numbers when loaded', () => { + analyticsState.stats = sampleStats(); renderAdmin(); - // At least one widget mentions Sprint S3 timing (we don't want to over-spec the exact text) - const sprintMentions = screen.getAllByText(/Sprint S3/i); - expect(sprintMentions.length).toBeGreaterThanOrEqual(3); + expect(screen.getByText('53')).toBeInTheDocument(); // total complétées + expect(screen.getByText('12')).toBeInTheDocument(); // utilisateurs + expect(screen.getByText('fichiers/cp')).toBeInTheDocument(); // top leçon }); - it('mentions Phase 9 v2 for Drains in footer (transparency)', () => { + it('renders the activity heatmap with a fixed 364-cell grid', () => { + analyticsState.heatmap = [{ day: '2026-06-01', completions: 4 }]; renderAdmin(); - expect(screen.getByText(/Phase 9 v2/i)).toBeInTheDocument(); + const heatmap = screen.getByRole('img', { name: /heatmap d'activité/i }); + expect(heatmap.querySelectorAll('span').length).toBe(364); }); - it('student heatmap renders 91 placeholder cells', () => { + it('shows a loading state while analytics are fetching', () => { + analyticsState.loading = true; renderAdmin(); - const heatmap = screen.getByRole('img', { name: /heatmap placeholder/i }); - expect(heatmap).toBeInTheDocument(); - const cells = heatmap.querySelectorAll('span'); - expect(cells.length).toBe(91); + expect(screen.getAllByText('Chargement…').length).toBeGreaterThanOrEqual(1); + }); + + it('surfaces an error state without crashing', () => { + analyticsState.error = new Error('boom'); + renderAdmin(); + expect(screen.getAllByRole('alert').length).toBeGreaterThanOrEqual(1); + // header still renders (error is widget-scoped, not page-fatal) + expect(screen.getByRole('heading', { name: 'Panneau administrateur', level: 1 })).toBeInTheDocument(); }); }); diff --git a/supabase/migrations/032_admin_analytics_rpc.sql b/supabase/migrations/032_admin_analytics_rpc.sql new file mode 100644 index 00000000..4d393505 --- /dev/null +++ b/supabase/migrations/032_admin_analytics_rpc.sql @@ -0,0 +1,115 @@ +-- Migration 032 — THI-234 Phase 9 Admin Panel : RPC analytics (widgets data-driven) +-- +-- Contexte : l'AdminPanel (/app/admin, super_admin only) affichait 4 widgets +-- placeholder skeleton (THI-225). Cette migration livre les agrégats RÉELS pour +-- 2 des widgets (Santé Supabase + Heatmap activité élèves), budget 0€, sans +-- table dédiée (agrégats live depuis `progress` + `profiles`). +-- +-- Pourquoi des RPC SECURITY DEFINER : +-- Les agrégats sont CROSS-USER (compter tous les users / toutes les leçons). +-- La RLS `progress: select own` / `profiles: select own` (migration 001) limite +-- chaque caller à SES propres lignes → un SELECT côté client ne verrait que +-- l'utilisateur courant. Une RPC SECURITY DEFINER contourne la RLS pour agréger, +-- MAIS pose un gate `super_admin` interne (mirror du pattern approve_teacher, +-- migration 027) pour que seul un super_admin obtienne les chiffres. +-- +-- Sécurité (pattern 027 + 015) : +-- - SECURITY DEFINER + `set search_path = public` (anti search_path hijack) +-- - Gate interne `get_my_role() = 'super_admin'` → raise PERMISSION_DENIED sinon +-- (un student/teacher authentifié appelle mais ne reçoit JAMAIS de données) +-- - REVOKE EXECUTE FROM public, anon (PostgreSQL grant PUBLIC par défaut, cf. 015) +-- - GRANT EXECUTE TO authenticated only (le gate role fait le tri à l'intérieur) +-- - `stable` (lecture seule, pas d'écriture) → planification optimisable +-- - Aucune PII individuelle exposée : seulement des agrégats (counts) + lesson_id +-- (identifiant de contenu, pas de donnée personnelle) + +-- ─── 1. Statistiques plateforme (widget « Santé Supabase ») ─────────────────── +create or replace function public.admin_platform_stats() +returns json +language plpgsql +stable +security definer +set search_path = public +as $$ +declare + caller_role text; + result json; +begin + caller_role := public.get_my_role(); + if caller_role is null or caller_role <> 'super_admin' then + raise exception 'PERMISSION_DENIED: super_admin role required'; + end if; + + select json_build_object( + 'total_users', (select count(*) from public.profiles), + 'active_users_7d', ( + select count(distinct user_id) + from public.progress + where completed_at >= now() - interval '7 days' + ), + 'lessons_completed_30d', ( + select count(*) + from public.progress + where completed = true and completed_at >= now() - interval '30 days' + ), + 'lessons_completed_total', ( + select count(*) from public.progress where completed = true + ), + 'top_lessons', ( + select coalesce(json_agg(t), '[]'::json) + from ( + select lesson_id, count(*) as completions + from public.progress + where completed = true + group by lesson_id + order by count(*) desc, lesson_id asc + limit 5 + ) t + ) + ) into result; + + return result; +end; +$$; + +revoke execute on function public.admin_platform_stats() from public; +revoke execute on function public.admin_platform_stats() from anon; +grant execute on function public.admin_platform_stats() to authenticated; + +comment on function public.admin_platform_stats() is + 'THI-234 Phase 9. Agrégats plateforme cross-user (total users, actifs 7j, leçons complétées 30j/total, top 5 leçons) pour le widget Santé Supabase. SECURITY DEFINER + gate super_admin interne (raise PERMISSION_DENIED sinon) + REVOKE public/anon. Aucune PII individuelle (counts only).'; + +-- ─── 2. Heatmap activité élèves (widget THI-77, GitHub-style 365j) ──────────── +create or replace function public.admin_activity_heatmap() +returns table(day date, completions bigint) +language plpgsql +stable +security definer +set search_path = public +as $$ +declare + caller_role text; +begin + caller_role := public.get_my_role(); + if caller_role is null or caller_role <> 'super_admin' then + raise exception 'PERMISSION_DENIED: super_admin role required'; + end if; + + -- group/order par position (1) pour éviter toute ambiguïté avec les colonnes + -- OUT `day`/`completions` (piège 42702 RETURNS TABLE, cf. feedback_happy_path_testing). + return query + select (date_trunc('day', p.completed_at))::date as day, count(*)::bigint as completions + from public.progress p + where p.completed = true + and p.completed_at >= now() - interval '365 days' + group by 1 + order by 1; +end; +$$; + +revoke execute on function public.admin_activity_heatmap() from public; +revoke execute on function public.admin_activity_heatmap() from anon; +grant execute on function public.admin_activity_heatmap() to authenticated; + +comment on function public.admin_activity_heatmap() is + 'THI-234 Phase 9. Agrégat progress par jour sur 365j (leçons complétées) pour le widget Heatmap activité élèves (THI-77). SECURITY DEFINER + gate super_admin interne + REVOKE public/anon. Counts journaliers only, aucune PII.'; From 5d89e32fbca46d13f5ed22ed92d752f7eaf67c1a Mon Sep 17 00:00:00 2001 From: Thierry Date: Thu, 4 Jun 2026 14:11:06 +0200 Subject: [PATCH 2/4] fix(admin): conditional heatmap aria-label when no activity (ui-auditor W2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the platform has zero completions the aria-label said "… jour le plus actif 0" which is confusing for screen readers. Use a clear "Aucune activité sur les 52 dernières semaines." in that case. THI-234. Co-Authored-By: Claude Opus 4.8 --- src/app/components/AdminPanel.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/components/AdminPanel.tsx b/src/app/components/AdminPanel.tsx index f54278e1..b39162c8 100644 --- a/src/app/components/AdminPanel.tsx +++ b/src/app/components/AdminPanel.tsx @@ -270,7 +270,11 @@ function WidgetStudentHeatmap({
{cells.map((c) => ( Date: Thu, 4 Jun 2026 14:11:06 +0200 Subject: [PATCH 3/4] fix(mobile): (THI-320) used text-sm (14px) → Safari iOS auto-zooms the page on focus when a form control is < 16px. Bump to text-base (16px). Out-of-scope drive-by surfaced by the THI-234 mobile gate (the select renders on the admin page this PR touches). Co-Authored-By: Claude Opus 4.8 --- src/app/components/SupportTicketsSection.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/components/SupportTicketsSection.tsx b/src/app/components/SupportTicketsSection.tsx index db528afb..248551fd 100644 --- a/src/app/components/SupportTicketsSection.tsx +++ b/src/app/components/SupportTicketsSection.tsx @@ -192,13 +192,14 @@ function TicketCard({ ticket, updating, onStatusChange }: TicketCardProps) { {/* Native onStatusChange(e.target.value as SupportTicketStatus)} - className="min-h-11 rounded-md border border-[var(--github-border-primary)] bg-[var(--github-bg)] px-2 py-1 text-sm text-[var(--github-text-primary)] font-mono [color-scheme:dark] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 disabled:opacity-50" + className="min-h-11 rounded-md border border-[var(--github-border-primary)] bg-[var(--github-bg)] px-2 py-1 text-base text-[var(--github-text-primary)] font-mono [color-scheme:dark] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 disabled:opacity-50" > {STATUS_ORDER.map((s) => (