/* * ------------------------------------------------------- * 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;