diff --git a/supabase/migrations/20260807000200_households.sql b/supabase/migrations/20260807000200_households.sql deleted file mode 100644 index 5a67b27..0000000 --- a/supabase/migrations/20260807000200_households.sql +++ /dev/null @@ -1,368 +0,0 @@ --- 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 each 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());