diff --git a/supabase/migrations/20260807000700_compliance.sql b/supabase/migrations/20260807000700_compliance.sql deleted file mode 100644 index 892b04e..0000000 --- a/supabase/migrations/20260807000700_compliance.sql +++ /dev/null @@ -1,194 +0,0 @@ --- Staff compliance and certification tracking — spec section 11. --- --- Certification *types* are org-level reference data; a requirement binds a --- type to a role and optionally to a campus, so a campus with an infant room --- can demand credentials the others do not. --- --- Employee records are explicitly named in section 3 as something teachers must --- not have access to, so everything here is readable only by the person it --- concerns plus management-level roles. - -CREATE TABLE IF NOT EXISTS public.certification_types ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - slug TEXT NOT NULL UNIQUE, - issuing_body TEXT, - description TEXT, - -- NULL means the credential does not expire. - validity_months INTEGER CHECK (validity_months IS NULL OR validity_months > 0), - renewal_reminder_days INTEGER NOT NULL DEFAULT 60, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -GRANT SELECT, INSERT, UPDATE, DELETE ON public.certification_types TO authenticated; -GRANT ALL ON public.certification_types TO service_role; -ALTER TABLE public.certification_types ENABLE ROW LEVEL SECURITY; - -CREATE TABLE IF NOT EXISTS public.compliance_requirements ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - certification_type_id UUID NOT NULL REFERENCES public.certification_types(id) ON DELETE CASCADE, - campus_id UUID REFERENCES public.campuses(id) ON DELETE CASCADE, - applies_to_roles app_role[] NOT NULL DEFAULT ARRAY['teacher','staff']::app_role[], - is_mandatory BOOLEAN NOT NULL DEFAULT TRUE, - grace_period_days INTEGER NOT NULL DEFAULT 0, - notes TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (certification_type_id, campus_id) -); - -GRANT SELECT, INSERT, UPDATE, DELETE ON public.compliance_requirements TO authenticated; -GRANT ALL ON public.compliance_requirements TO service_role; -ALTER TABLE public.compliance_requirements ENABLE ROW LEVEL SECURITY; -CREATE INDEX IF NOT EXISTS creq_type_idx ON public.compliance_requirements (certification_type_id); -CREATE INDEX IF NOT EXISTS creq_campus_idx ON public.compliance_requirements (campus_id); - -CREATE TABLE IF NOT EXISTS public.staff_certifications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - certification_type_id UUID NOT NULL REFERENCES public.certification_types(id) ON DELETE RESTRICT, - - certificate_number TEXT, - issued_on DATE, - expires_on DATE, - document_path TEXT, - - status TEXT NOT NULL DEFAULT 'active', - verified_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, - verified_at TIMESTAMPTZ, - notes TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - CONSTRAINT stc_status_valid CHECK (status IN ('pending','active','expired','revoked')), - CONSTRAINT stc_dates_ordered CHECK (issued_on IS NULL OR expires_on IS NULL OR issued_on <= expires_on) -); - -GRANT SELECT, INSERT, UPDATE, DELETE ON public.staff_certifications TO authenticated; -GRANT ALL ON public.staff_certifications TO service_role; -ALTER TABLE public.staff_certifications ENABLE ROW LEVEL SECURITY; -CREATE INDEX IF NOT EXISTS stc_user_idx ON public.staff_certifications (user_id); -CREATE INDEX IF NOT EXISTS stc_type_idx ON public.staff_certifications (certification_type_id); --- The compliance dashboard's central query: what lapses soon. -CREATE INDEX IF NOT EXISTS stc_expiring_idx ON public.staff_certifications (expires_on) - WHERE status = 'active' AND expires_on IS NOT NULL; - -DROP TRIGGER IF EXISTS trg_stc_upd ON public.staff_certifications; -CREATE TRIGGER trg_stc_upd BEFORE UPDATE ON public.staff_certifications - FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); - --- Derive expiry from the certification type when the user did not supply one. -CREATE OR REPLACE FUNCTION public.derive_certification_expiry() -RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -DECLARE - months INTEGER; -BEGIN - IF NEW.expires_on IS NULL AND NEW.issued_on IS NOT NULL THEN - SELECT validity_months INTO months - FROM public.certification_types WHERE id = NEW.certification_type_id; - IF months IS NOT NULL THEN - NEW.expires_on := NEW.issued_on + (months || ' months')::interval; - END IF; - END IF; - - -- Keep status honest without waiting for a nightly job. - IF NEW.expires_on IS NOT NULL AND NEW.expires_on < CURRENT_DATE - AND NEW.status = 'active' THEN - NEW.status := 'expired'; - END IF; - - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS trg_stc_expiry ON public.staff_certifications; -CREATE TRIGGER trg_stc_expiry BEFORE INSERT OR UPDATE ON public.staff_certifications - FOR EACH ROW EXECUTE FUNCTION public.derive_certification_expiry(); - --- Requirements a user has not satisfied: missing entirely, expired, or lapsing --- inside the reminder window. -CREATE OR REPLACE FUNCTION public.staff_compliance_gaps(_user UUID) -RETURNS TABLE ( - certification_type_id UUID, certification_name TEXT, campus_id UUID, - gap_kind TEXT, expires_on DATE -) -LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ - SELECT - t.id, t.name, req.campus_id, - CASE - WHEN c.id IS NULL THEN 'missing' - WHEN c.status = 'revoked' THEN 'revoked' - WHEN c.expires_on IS NOT NULL AND c.expires_on < CURRENT_DATE THEN 'expired' - ELSE 'expiring_soon' - END, - c.expires_on - FROM public.compliance_requirements req - JOIN public.certification_types t ON t.id = req.certification_type_id - -- The requirement applies only if the user holds one of its roles, and (when - -- campus-specific) is assigned to that campus. - JOIN public.user_roles ur ON ur.user_id = _user AND ur.role = ANY(req.applies_to_roles) - LEFT JOIN public.staff_campus_assignments sca - ON sca.user_id = _user AND sca.campus_id = req.campus_id - LEFT JOIN LATERAL ( - SELECT sc.* FROM public.staff_certifications sc - WHERE sc.user_id = _user AND sc.certification_type_id = t.id - AND sc.status IN ('active','pending') - ORDER BY sc.expires_on DESC NULLS FIRST - LIMIT 1 - ) c ON TRUE - WHERE req.is_mandatory - AND (req.campus_id IS NULL OR sca.id IS NOT NULL) - AND ( - c.id IS NULL - OR c.status = 'revoked' - OR (c.expires_on IS NOT NULL - AND c.expires_on < CURRENT_DATE + make_interval(days => t.renewal_reminder_days)) - ) -$$; - --- ============================================================================ --- POLICIES --- ============================================================================ - -DROP POLICY IF EXISTS "cert types read" ON public.certification_types; -CREATE POLICY "cert types read" ON public.certification_types FOR SELECT TO authenticated - USING (public.is_management() OR public.is_auditor() - OR public.current_user_has_any_role(ARRAY['teacher','staff','campus_admin']::app_role[])); -DROP POLICY IF EXISTS "cert types manage" ON public.certification_types; -CREATE POLICY "cert types manage" ON public.certification_types FOR ALL TO authenticated - USING (public.is_org_admin()) WITH CHECK (public.is_org_admin()); - -DROP POLICY IF EXISTS "compliance req read" ON public.compliance_requirements; -CREATE POLICY "compliance req read" ON public.compliance_requirements FOR SELECT TO authenticated - USING (public.is_management() OR public.is_auditor() - OR public.current_user_has_any_role(ARRAY['teacher','staff','campus_admin']::app_role[])); -DROP POLICY IF EXISTS "compliance req manage" ON public.compliance_requirements; -CREATE POLICY "compliance req manage" ON public.compliance_requirements FOR ALL TO authenticated - USING (public.is_org_admin()) WITH CHECK (public.is_org_admin()); - --- Own record, management, or the campus admin of a campus this person works at. -DROP POLICY IF EXISTS "staff certs read" ON public.staff_certifications; -CREATE POLICY "staff certs read" ON public.staff_certifications FOR SELECT TO authenticated - USING ( - user_id = (SELECT auth.uid()) - OR public.is_management() OR public.is_auditor() - OR (public.current_user_has_any_role(ARRAY['campus_admin']::app_role[]) - AND EXISTS (SELECT 1 FROM public.staff_campus_assignments a - WHERE a.user_id = staff_certifications.user_id - AND a.campus_id IN (SELECT public.user_campus_ids()))) - ); - --- Staff may upload their own credentials; only management may verify them, --- which is why verified_by/verified_at are management-writable in practice. -DROP POLICY IF EXISTS "staff certs self insert" ON public.staff_certifications; -CREATE POLICY "staff certs self insert" ON public.staff_certifications FOR INSERT TO authenticated - WITH CHECK (user_id = (SELECT auth.uid()) OR public.is_management()); - -DROP POLICY IF EXISTS "staff certs manage" ON public.staff_certifications; -CREATE POLICY "staff certs manage" ON public.staff_certifications FOR UPDATE TO authenticated - USING (public.is_management()) WITH CHECK (public.is_management()); - -DROP POLICY IF EXISTS "staff certs delete" ON public.staff_certifications; -CREATE POLICY "staff certs delete" ON public.staff_certifications FOR DELETE TO authenticated - USING (public.is_org_admin());