diff --git a/supabase/migrations/20260807000200_households.sql b/supabase/migrations/20260807000200_households.sql new file mode 100644 index 0000000..4dfff7a --- /dev/null +++ b/supabase/migrations/20260807000200_households.sql @@ -0,0 +1,368 @@ +-- Households, guardians, and parenting-plan restrictions — spec section 5. +-- +-- The existing parent_students table links a login to a student and nothing +-- more. The spec needs considerably more: two guardians who both view a student +-- but only one of whom is billed, a guardian barred from specific records, and +-- communications that must go out separately rather than to a shared thread. +-- Those are properties of the *relationship*, so they live on household_members. +-- +-- parent_students is left in place and keeps working. Households are the richer +-- path layered beside it, and is_parent_of() is taught to honour both. + +-- ============================================================================ +-- 1. HOUSEHOLDS +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.households ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + + street1 TEXT, + street2 TEXT, + city TEXT, + state TEXT, + postal_code TEXT, + country TEXT NOT NULL DEFAULT 'USA', + + -- When guardians live apart, each member may carry their own address; this + -- is the shared/default one. + billing_email TEXT, + phone TEXT, + + -- True when a parenting plan requires每 guardian to be contacted on their own + -- thread instead of a single household conversation. + communicate_separately BOOLEAN NOT NULL DEFAULT FALSE, + + notes TEXT, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.households TO authenticated; +GRANT ALL ON public.households TO service_role; +ALTER TABLE public.households ENABLE ROW LEVEL SECURITY; + +DROP TRIGGER IF EXISTS trg_households_upd ON public.households; +CREATE TRIGGER trg_households_upd BEFORE UPDATE ON public.households + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- ============================================================================ +-- 2. HOUSEHOLD MEMBERS (guardians) +-- ============================================================================ +-- user_id is nullable: a guardian may exist as a contact long before they ever +-- create a login, and emergency contacts often never get one. + +CREATE TABLE IF NOT EXISTS public.household_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE, + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + relationship TEXT, + + -- Per-guardian address, used when guardians do not share one. + street1 TEXT, + street2 TEXT, + city TEXT, + state TEXT, + postal_code TEXT, + + preferred_contact_method TEXT NOT NULL DEFAULT 'email', + + -- Billing: responsibility and invoice delivery are separate decisions. + -- A guardian can owe half the tuition yet not be the one invoiced. + is_billing_responsible BOOLEAN NOT NULL DEFAULT FALSE, + billing_share_percent NUMERIC(5,2) CHECK (billing_share_percent IS NULL + OR (billing_share_percent >= 0 AND billing_share_percent <= 100)), + receives_invoices BOOLEAN NOT NULL DEFAULT FALSE, + + -- Communication and record access, independently restrictable. + communication_allowed BOOLEAN NOT NULL DEFAULT TRUE, + can_view_records BOOLEAN NOT NULL DEFAULT TRUE, + -- Categories this guardian may NOT see even when can_view_records is true, + -- e.g. '{medical,billing}'. Enforced by can_guardian_view_category(). + restricted_record_categories TEXT[] NOT NULL DEFAULT '{}', + + is_emergency_contact BOOLEAN NOT NULL DEFAULT FALSE, + emergency_priority INTEGER, + + can_pick_up BOOLEAN NOT NULL DEFAULT TRUE, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT hm_contact_method_valid + CHECK (preferred_contact_method IN ('email','phone','sms','mail','portal')) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.household_members TO authenticated; +GRANT ALL ON public.household_members TO service_role; +ALTER TABLE public.household_members ENABLE ROW LEVEL SECURITY; + +CREATE INDEX IF NOT EXISTS hm_household_idx ON public.household_members (household_id); +CREATE INDEX IF NOT EXISTS hm_user_idx ON public.household_members (user_id); +-- Emergency call-down list ordering. +CREATE INDEX IF NOT EXISTS hm_emergency_idx ON public.household_members (household_id, emergency_priority) + WHERE is_emergency_contact; + +DROP TRIGGER IF EXISTS trg_hm_upd ON public.household_members; +CREATE TRIGGER trg_hm_upd BEFORE UPDATE ON public.household_members + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- Phone numbers and email addresses, many per guardian. +CREATE TABLE IF NOT EXISTS public.household_member_contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + member_id UUID NOT NULL REFERENCES public.household_members(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + value TEXT NOT NULL, + label TEXT, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT hmc_kind_valid CHECK (kind IN ('phone','mobile','email','fax')) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.household_member_contacts TO authenticated; +GRANT ALL ON public.household_member_contacts TO service_role; +ALTER TABLE public.household_member_contacts ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS hmc_member_idx ON public.household_member_contacts (member_id); + +-- ============================================================================ +-- 3. HOUSEHOLD ↔ STUDENT +-- ============================================================================ +-- A student can belong to more than one household (split families), so this is +-- a join table rather than a column on students. + +CREATE TABLE IF NOT EXISTS public.household_students ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + household_id UUID NOT NULL REFERENCES public.households(id) ON DELETE CASCADE, + student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, + is_primary_household BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (household_id, student_id) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.household_students TO authenticated; +GRANT ALL ON public.household_students TO service_role; +ALTER TABLE public.household_students ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS hs_household_idx ON public.household_students (household_id); +CREATE INDEX IF NOT EXISTS hs_student_idx ON public.household_students (student_id); + +-- ============================================================================ +-- 4. PARENTING PLANS +-- ============================================================================ +-- The legal instrument behind the restrictions. Section 6 renders the urgent +-- ones as red alerts; this table is the record of the plan itself. + +CREATE TABLE IF NOT EXISTS public.parenting_plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, + household_id UUID REFERENCES public.households(id) ON DELETE SET NULL, + title TEXT NOT NULL, + description TEXT, + + is_court_ordered BOOLEAN NOT NULL DEFAULT FALSE, + restricts_pickup BOOLEAN NOT NULL DEFAULT FALSE, + restricts_communication BOOLEAN NOT NULL DEFAULT FALSE, + restricts_billing BOOLEAN NOT NULL DEFAULT FALSE, + restricts_record_access BOOLEAN NOT NULL DEFAULT FALSE, + + effective_date DATE NOT NULL DEFAULT CURRENT_DATE, + expiration_date DATE, + document_path TEXT, + + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT pp_dates_ordered + CHECK (expiration_date IS NULL OR effective_date <= expiration_date) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.parenting_plans TO authenticated; +GRANT ALL ON public.parenting_plans TO service_role; +ALTER TABLE public.parenting_plans ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS pp_student_idx ON public.parenting_plans (student_id); +CREATE INDEX IF NOT EXISTS pp_household_idx ON public.parenting_plans (household_id); + +DROP TRIGGER IF EXISTS trg_pp_upd ON public.parenting_plans; +CREATE TRIGGER trg_pp_upd BEFORE UPDATE ON public.parenting_plans + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- ============================================================================ +-- 5. UNAUTHORIZED PICKUPS +-- ============================================================================ +-- The spec asks for "authorized and unauthorized pickup contacts". A flag on +-- the existing table is better than a parallel table: front-desk staff look up +-- one list and see both the yes and the emphatic no. + +ALTER TABLE public.authorized_pickups + ADD COLUMN IF NOT EXISTS is_authorized BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS restriction_reason TEXT, + ADD COLUMN IF NOT EXISTS parenting_plan_id UUID REFERENCES public.parenting_plans(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS ap_student_idx ON public.authorized_pickups (student_id); +-- Denials must be impossible to miss when the desk pulls up a student. +CREATE INDEX IF NOT EXISTS ap_unauthorized_idx ON public.authorized_pickups (student_id) + WHERE NOT is_authorized; + +-- ============================================================================ +-- 6. GUARDIAN ACCESS HELPERS +-- ============================================================================ + +-- Households the caller belongs to as a guardian. +CREATE OR REPLACE FUNCTION public.user_household_ids() +RETURNS SETOF UUID +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT household_id FROM public.household_members + WHERE user_id = (SELECT auth.uid()) +$$; + +-- Guardian of this student via a household, and not record-restricted. +CREATE OR REPLACE FUNCTION public.is_household_guardian_of(_student UUID) +RETURNS BOOLEAN +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.household_members m + JOIN public.household_students hs ON hs.household_id = m.household_id + WHERE m.user_id = (SELECT auth.uid()) + AND hs.student_id = _student + AND m.can_view_records + ) +$$; + +-- Category-level record restriction, for the "one guardian is restricted from +-- specific records" case. Callers pass a category such as 'medical'. +CREATE OR REPLACE FUNCTION public.can_guardian_view_category(_student UUID, _category TEXT) +RETURNS BOOLEAN +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.household_members m + JOIN public.household_students hs ON hs.household_id = m.household_id + WHERE m.user_id = (SELECT auth.uid()) + AND hs.student_id = _student + AND m.can_view_records + AND NOT (_category = ANY(m.restricted_record_categories)) + ) +$$; + +-- Teach the original parent check about households, so every policy written +-- against is_parent_of() — in this migration and all nine before it — honours +-- household guardianship and the can_view_records restriction automatically. +CREATE OR REPLACE FUNCTION public.is_parent_of(_student UUID) +RETURNS BOOLEAN +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.parent_students + WHERE parent_id = (SELECT auth.uid()) AND student_id = _student + ) + OR public.is_household_guardian_of(_student) +$$; + +-- Guardians who should actually receive an invoice for a student. The billing +-- engine in 20260807000600 uses this to decide addressing. +CREATE OR REPLACE FUNCTION public.invoice_recipients(_student UUID) +RETURNS TABLE (member_id UUID, user_id UUID, full_name TEXT, email TEXT) +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT m.id, m.user_id, m.first_name || ' ' || m.last_name, + COALESCE( + (SELECT c.value FROM public.household_member_contacts c + WHERE c.member_id = m.id AND c.kind = 'email' + ORDER BY c.is_primary DESC LIMIT 1), + h.billing_email + ) + FROM public.household_members m + JOIN public.households h ON h.id = m.household_id + JOIN public.household_students hs ON hs.household_id = m.household_id + WHERE hs.student_id = _student + AND m.receives_invoices +$$; + +-- ============================================================================ +-- 7. POLICIES +-- ============================================================================ + +DROP POLICY IF EXISTS "households read" ON public.households; +CREATE POLICY "households read" ON public.households FOR SELECT TO authenticated + USING ( + public.is_org_admin() OR public.is_billing_admin() OR public.is_auditor() + OR id IN (SELECT public.user_household_ids()) + OR EXISTS (SELECT 1 FROM public.household_students hs + WHERE hs.household_id = households.id AND public.can_access_student(hs.student_id)) + ); + +DROP POLICY IF EXISTS "households manage" ON public.households; +CREATE POLICY "households manage" ON public.households FOR ALL TO authenticated + USING (public.is_org_admin() OR public.is_billing_admin()) + WITH CHECK (public.is_org_admin() OR public.is_billing_admin()); + +DROP POLICY IF EXISTS "household_members read" ON public.household_members; +CREATE POLICY "household_members read" ON public.household_members FOR SELECT TO authenticated + USING ( + public.is_org_admin() OR public.is_billing_admin() OR public.is_auditor() + OR household_id IN (SELECT public.user_household_ids()) + OR EXISTS (SELECT 1 FROM public.household_students hs + WHERE hs.household_id = household_members.household_id + AND public.can_access_student(hs.student_id)) + ); + +DROP POLICY IF EXISTS "household_members manage" ON public.household_members; +CREATE POLICY "household_members manage" ON public.household_members FOR ALL TO authenticated + USING (public.is_org_admin() OR public.is_billing_admin()) + WITH CHECK (public.is_org_admin() OR public.is_billing_admin()); + +-- Guardians may maintain their own phone numbers and email addresses; the spec +-- allows parents to "update approved contact information". +DROP POLICY IF EXISTS "hmc read" ON public.household_member_contacts; +CREATE POLICY "hmc read" ON public.household_member_contacts FOR SELECT TO authenticated + USING ( + public.is_org_admin() OR public.is_billing_admin() OR public.is_auditor() + OR EXISTS (SELECT 1 FROM public.household_members m + WHERE m.id = member_id + AND (m.user_id = (SELECT auth.uid()) + OR m.household_id IN (SELECT public.user_household_ids()))) + ); + +DROP POLICY IF EXISTS "hmc self manage" ON public.household_member_contacts; +CREATE POLICY "hmc self manage" ON public.household_member_contacts FOR ALL TO authenticated + USING ( + public.is_org_admin() + OR EXISTS (SELECT 1 FROM public.household_members m + WHERE m.id = member_id AND m.user_id = (SELECT auth.uid())) + ) + WITH CHECK ( + public.is_org_admin() + OR EXISTS (SELECT 1 FROM public.household_members m + WHERE m.id = member_id AND m.user_id = (SELECT auth.uid())) + ); + +DROP POLICY IF EXISTS "household_students read" ON public.household_students; +CREATE POLICY "household_students read" ON public.household_students FOR SELECT TO authenticated + USING ( + public.is_org_admin() OR public.is_billing_admin() OR public.is_auditor() + OR household_id IN (SELECT public.user_household_ids()) + OR public.can_access_student(student_id) + ); + +DROP POLICY IF EXISTS "household_students manage" ON public.household_students; +CREATE POLICY "household_students manage" ON public.household_students FOR ALL TO authenticated + USING (public.is_org_admin() OR public.is_billing_admin()) + WITH CHECK (public.is_org_admin() OR public.is_billing_admin()); + +-- Parenting plans are legally sensitive: staff who work with the student may +-- read them, but only org admins may write. +DROP POLICY IF EXISTS "parenting_plans read" ON public.parenting_plans; +CREATE POLICY "parenting_plans read" ON public.parenting_plans FOR SELECT TO authenticated + USING (public.can_access_student(student_id)); + +DROP POLICY IF EXISTS "parenting_plans manage" ON public.parenting_plans; +CREATE POLICY "parenting_plans manage" ON public.parenting_plans FOR ALL TO authenticated + USING (public.is_org_admin()) WITH CHECK (public.is_org_admin());