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