Modified by www.SourceFiles.app
This commit is contained in:
@@ -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());
|
||||
Reference in New Issue
Block a user