diff --git a/supabase/migrations/20260807001100_reporting_views.sql b/supabase/migrations/20260807001100_reporting_views.sql new file mode 100644 index 0000000..7c46f94 --- /dev/null +++ b/supabase/migrations/20260807001100_reporting_views.sql @@ -0,0 +1,232 @@ +-- Reporting and dashboards — spec section 14. +-- +-- Every view is declared WITH (security_invoker = true). Without it a view runs +-- with the privileges of its owner and would hand any authenticated caller the +-- whole table, quietly defeating the row-level security the rest of this build +-- depends on. With it, a teacher querying v_student_directory sees exactly the +-- students their own policies allow. + +-- ============================================================================ +-- STUDENTS +-- ============================================================================ + +CREATE OR REPLACE VIEW public.v_student_directory +WITH (security_invoker = true) AS +SELECT + s.id AS student_id, + s.first_name, + s.middle_name, + s.last_name, + s.preferred_name, + s.dob, + s.grade_level, + s.enrollment_status, + s.attendance_basis, + s.calendar_basis, + s.is_support_student, + c.id AS primary_campus_id, + c.name AS primary_campus, + t.name AS tuition_tier, + hs.household_id, + h.name AS household_name, + -- Campuses beyond the primary one, for the multi-campus case. + (SELECT array_agg(DISTINCT cc.name) + FROM public.student_campus_schedules sc + JOIN public.campuses cc ON cc.id = sc.campus_id + WHERE sc.student_id = s.id + AND (sc.effective_end IS NULL OR sc.effective_end >= CURRENT_DATE) + ) AS scheduled_campuses, + (SELECT COALESCE(SUM(sc.scheduled_days_per_week), 0) + FROM public.student_campus_schedules sc + WHERE sc.student_id = s.id + AND sc.effective_start <= CURRENT_DATE + AND (sc.effective_end IS NULL OR sc.effective_end >= CURRENT_DATE) + ) AS scheduled_days_per_week, + (SELECT COUNT(*) FROM public.student_alerts a + WHERE a.student_id = s.id AND a.is_active + AND a.effective_date <= CURRENT_DATE + AND (a.expiration_date IS NULL OR a.expiration_date >= CURRENT_DATE) + ) AS active_alert_count +FROM public.students s +LEFT JOIN public.campuses c ON c.id = s.primary_campus_id +LEFT JOIN public.tuition_tiers t ON t.id = s.tuition_tier_id +LEFT JOIN public.household_students hs + ON hs.student_id = s.id AND hs.is_primary_household +LEFT JOIN public.households h ON h.id = hs.household_id; + +GRANT SELECT ON public.v_student_directory TO authenticated; + +-- ============================================================================ +-- BILLING +-- ============================================================================ + +CREATE OR REPLACE VIEW public.v_outstanding_invoices +WITH (security_invoker = true) AS +SELECT + i.id AS invoice_id, + i.invoice_number, + i.student_id, + s.first_name || ' ' || s.last_name AS student_name, + i.household_id, + i.campus_id, + c.name AS campus_name, + i.billing_period_start, + i.billing_period_end, + i.invoice_date, + i.due_date, + i.total_cents, + i.amount_paid_cents, + i.balance_due_cents, + i.status, + CURRENT_DATE - i.due_date AS days_overdue +FROM public.invoices i +JOIN public.students s ON s.id = i.student_id +LEFT JOIN public.campuses c ON c.id = i.campus_id +WHERE i.status IN ('issued','partially_paid') + AND i.balance_due_cents > 0; + +GRANT SELECT ON public.v_outstanding_invoices TO authenticated; + +-- Combined household position — the spec's "combined household account". +CREATE OR REPLACE VIEW public.v_household_balances +WITH (security_invoker = true) AS +SELECT + h.id AS household_id, + h.name AS household_name, + COUNT(DISTINCT hs.student_id) AS student_count, + COALESCE(SUM(i.total_cents), 0) AS invoiced_cents, + COALESCE(SUM(i.amount_paid_cents), 0) AS paid_cents, + COALESCE(SUM(i.balance_due_cents) FILTER ( + WHERE i.status IN ('issued','partially_paid')), 0) AS balance_due_cents +FROM public.households h +LEFT JOIN public.household_students hs ON hs.household_id = h.id +LEFT JOIN public.invoices i ON i.household_id = h.id AND i.status <> 'void' +GROUP BY h.id, h.name; + +GRANT SELECT ON public.v_household_balances TO authenticated; + +-- ============================================================================ +-- CAMPUS + ATTENDANCE +-- ============================================================================ + +CREATE OR REPLACE VIEW public.v_campus_enrollment +WITH (security_invoker = true) AS +SELECT + c.id AS campus_id, + c.name AS campus_name, + c.is_active, + c.student_capacity, + COUNT(DISTINCT s.id) FILTER (WHERE s.enrollment_status = 'enrolled') AS enrolled_count, + COUNT(DISTINCT s.id) FILTER ( + WHERE s.enrollment_status = 'enrolled' AND s.attendance_basis = 'full_time') AS full_time_count, + COUNT(DISTINCT s.id) FILTER ( + WHERE s.enrollment_status = 'enrolled' AND s.attendance_basis = 'part_time') AS part_time_count, + COUNT(DISTINCT s.id) FILTER ( + WHERE s.enrollment_status = 'enrolled' AND s.is_support_student) AS support_count, + CASE WHEN c.student_capacity IS NULL OR c.student_capacity = 0 THEN NULL + ELSE ROUND(COUNT(DISTINCT s.id) FILTER (WHERE s.enrollment_status = 'enrolled')::numeric + * 100.0 / c.student_capacity, 1) + END AS capacity_used_percent +FROM public.campuses c +LEFT JOIN public.students s ON s.primary_campus_id = c.id +GROUP BY c.id, c.name, c.is_active, c.student_capacity; + +GRANT SELECT ON public.v_campus_enrollment TO authenticated; + +CREATE OR REPLACE VIEW public.v_attendance_summary +WITH (security_invoker = true) AS +SELECT + a.student_id, + s.first_name || ' ' || s.last_name AS student_name, + s.primary_campus_id, + date_trunc('month', a.date)::date AS month, + COUNT(*) AS recorded_days, + COUNT(*) FILTER (WHERE a.status = 'present') AS present_days, + COUNT(*) FILTER (WHERE a.status = 'absent') AS absent_days, + COUNT(*) FILTER (WHERE a.status = 'late') AS late_days, + COUNT(*) FILTER (WHERE a.status = 'excused') AS excused_days, + ROUND(COUNT(*) FILTER (WHERE a.status IN ('present','late'))::numeric + * 100.0 / NULLIF(COUNT(*), 0), 1) AS attendance_rate_percent +FROM public.attendance a +JOIN public.students s ON s.id = a.student_id +GROUP BY a.student_id, s.first_name, s.last_name, s.primary_campus_id, date_trunc('month', a.date); + +GRANT SELECT ON public.v_attendance_summary TO authenticated; + +-- ============================================================================ +-- COMPLIANCE + MANAGEMENT +-- ============================================================================ + +CREATE OR REPLACE VIEW public.v_certification_expiry +WITH (security_invoker = true) AS +SELECT + sc.id AS certification_id, + sc.user_id, + p.full_name AS staff_name, + ct.name AS certification_name, + sc.issued_on, + sc.expires_on, + sc.status, + sc.expires_on - CURRENT_DATE AS days_until_expiry, + CASE + WHEN sc.expires_on IS NULL THEN 'no_expiry' + WHEN sc.expires_on < CURRENT_DATE THEN 'expired' + WHEN sc.expires_on < CURRENT_DATE + make_interval(days => ct.renewal_reminder_days) + THEN 'expiring_soon' + ELSE 'current' + END AS expiry_state +FROM public.staff_certifications sc +JOIN public.certification_types ct ON ct.id = sc.certification_type_id +LEFT JOIN public.profiles p ON p.id = sc.user_id +WHERE sc.status IN ('active','pending'); + +GRANT SELECT ON public.v_certification_expiry TO authenticated; + +-- The management dashboard's task queue. +CREATE OR REPLACE VIEW public.v_open_tasks +WITH (security_invoker = true) AS +SELECT + t.id AS task_id, + t.title, + t.priority, + t.status, + t.due_date, + t.campus_id, + c.name AS campus_name, + t.assigned_to, + p.full_name AS assigned_to_name, + t.related_student_id, + CASE + WHEN t.due_date IS NULL THEN 'no_due_date' + WHEN t.due_date < CURRENT_DATE THEN 'overdue' + WHEN t.due_date <= CURRENT_DATE + 7 THEN 'due_soon' + ELSE 'scheduled' + END AS due_state +FROM public.tasks t +LEFT JOIN public.campuses c ON c.id = t.campus_id +LEFT JOIN public.profiles p ON p.id = t.assigned_to +WHERE t.status IN ('open','in_progress','blocked'); + +GRANT SELECT ON public.v_open_tasks TO authenticated; + +-- Per-student follow-ups still owed, from the notes system. +CREATE OR REPLACE VIEW public.v_pending_followups +WITH (security_invoker = true) AS +SELECT + n.id AS note_id, + n.student_id, + s.first_name || ' ' || s.last_name AS student_name, + nc.name AS category, + n.title, + n.follow_up_date, + n.resolution_status, + n.author_id, + n.campus_id, + CURRENT_DATE - n.follow_up_date AS days_overdue +FROM public.student_notes n +JOIN public.students s ON s.id = n.student_id +JOIN public.note_categories nc ON nc.id = n.category_id +WHERE n.follow_up_date IS NOT NULL + AND n.resolution_status IN ('open','in_progress'); + +GRANT SELECT ON public.v_pending_followups TO authenticated;