195 lines
8.9 KiB
PL/PgSQL
195 lines
8.9 KiB
PL/PgSQL
-- 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());
|