-- 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());