Files
myeasycms-v2/apps/web/supabase/migrations/20260414000001_account_hierarchy.sql

158 lines
5.0 KiB
PL/PgSQL

/*
* -------------------------------------------------------
* Account Hierarchy Support
*
* Adds parent_account_id to accounts for Verein/Subverein/Verband
* hierarchy. This migration is additive — no behavior change until
* hierarchy-aware RLS policies are added in a future migration.
* -------------------------------------------------------
*/
-- Add nullable parent reference (team accounts only)
ALTER TABLE public.accounts
ADD COLUMN IF NOT EXISTS parent_account_id uuid
REFERENCES public.accounts(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS ix_accounts_parent_account_id
ON public.accounts(parent_account_id);
-- Prevent personal accounts from having a parent (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'accounts_personal_no_parent'
) THEN
ALTER TABLE public.accounts
ADD CONSTRAINT accounts_personal_no_parent
CHECK (is_personal_account = false OR parent_account_id IS NULL);
END IF;
END $$;
-- Prevent an account from being its own parent (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'accounts_no_self_parent'
) THEN
ALTER TABLE public.accounts
ADD CONSTRAINT accounts_no_self_parent
CHECK (id <> parent_account_id);
END IF;
END $$;
-- -------------------------------------------------------
-- Trigger: prevent hierarchy cycles on insert/update
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION kit.prevent_account_hierarchy_cycle()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NEW.parent_account_id IS NOT NULL THEN
-- Walk up from the proposed parent; if we find NEW.id, it's a cycle
IF EXISTS (
WITH RECURSIVE ancestors AS (
SELECT acc.id, acc.parent_account_id, ARRAY[acc.id] AS path
FROM public.accounts acc
WHERE acc.id = NEW.parent_account_id
UNION ALL
SELECT a.id, a.parent_account_id, anc.path || a.id
FROM public.accounts a
JOIN ancestors anc ON a.id = anc.parent_account_id
WHERE NOT a.id = ANY(anc.path) -- cycle guard
)
SELECT 1 FROM ancestors WHERE id = NEW.id
) THEN
RAISE EXCEPTION 'Setting parent_account_id would create a hierarchy cycle';
END IF;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS prevent_account_hierarchy_cycle ON public.accounts;
CREATE TRIGGER prevent_account_hierarchy_cycle
BEFORE INSERT OR UPDATE OF parent_account_id
ON public.accounts
FOR EACH ROW
WHEN (NEW.parent_account_id IS NOT NULL)
EXECUTE FUNCTION kit.prevent_account_hierarchy_cycle();
-- -------------------------------------------------------
-- Helper: get all descendant account IDs (recursive, cycle-safe)
-- Uses path array for cycle detection (works on Postgres 14+)
-- Restricted to service_role — called via RLS SECURITY DEFINER functions
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_account_descendants(root_id uuid)
RETURNS SETOF uuid
LANGUAGE sql STABLE
SET search_path = ''
AS $$
WITH RECURSIVE tree AS (
SELECT acc.id, ARRAY[acc.id] AS path
FROM public.accounts acc WHERE acc.id = root_id
UNION ALL
SELECT a.id, t.path || a.id
FROM public.accounts a
JOIN tree t ON a.parent_account_id = t.id
WHERE NOT a.id = ANY(t.path)
)
SELECT tree.id FROM tree;
$$;
GRANT EXECUTE ON FUNCTION public.get_account_descendants(uuid)
TO service_role;
-- -------------------------------------------------------
-- Helper: get all ancestor account IDs (walk up, cycle-safe)
-- Restricted to service_role
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_account_ancestors(child_id uuid)
RETURNS SETOF uuid
LANGUAGE sql STABLE
SET search_path = ''
AS $$
WITH RECURSIVE tree AS (
SELECT acc.id, acc.parent_account_id, ARRAY[acc.id] AS path
FROM public.accounts acc WHERE acc.id = child_id
UNION ALL
SELECT a.id, a.parent_account_id, t.path || a.id
FROM public.accounts a
JOIN tree t ON a.id = t.parent_account_id
WHERE NOT a.id = ANY(t.path)
)
SELECT tree.id FROM tree;
$$;
GRANT EXECUTE ON FUNCTION public.get_account_ancestors(uuid)
TO service_role;
-- -------------------------------------------------------
-- Helper: get the depth of the hierarchy (0 = root, cycle-safe)
-- Restricted to service_role
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_account_depth(account_id uuid)
RETURNS integer
LANGUAGE sql STABLE
SET search_path = ''
AS $$
WITH RECURSIVE tree AS (
SELECT acc.id, acc.parent_account_id, 0 AS depth, ARRAY[acc.id] AS path
FROM public.accounts acc
WHERE acc.id = get_account_depth.account_id
UNION ALL
SELECT a.id, a.parent_account_id, t.depth + 1, t.path || a.id
FROM public.accounts a
JOIN tree t ON a.id = t.parent_account_id
WHERE NOT a.id = ANY(t.path)
)
SELECT MAX(depth) FROM tree;
$$;
GRANT EXECUTE ON FUNCTION public.get_account_depth(uuid)
TO service_role;