Modified by www.SourceFiles.app

This commit is contained in:
2026-08-07 04:35:39 +00:00
parent 8b6be4b20e
commit 2ea64031fd
@@ -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());