/* * ------------------------------------------------------- * Verbandsverwaltung (Association Management) Schema * Association types, member clubs, contacts, fees, * billing, notes, history * ------------------------------------------------------- */ -- ===================================================== -- 1. Extend app_permissions -- (Moved to 20260411900001_fischerei_enum_values.sql) -- ===================================================== -- ===================================================== -- 2. association_types (Verbandsarten) -- ===================================================== CREATE TABLE IF NOT EXISTS public.association_types ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, name text NOT NULL, description text, sort_order integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX ix_association_types_account ON public.association_types(account_id); ALTER TABLE public.association_types ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.association_types FROM authenticated, service_role; GRANT SELECT, INSERT, UPDATE, DELETE ON public.association_types TO authenticated; GRANT ALL ON public.association_types TO service_role; CREATE POLICY association_types_select ON public.association_types FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); CREATE POLICY association_types_mutate ON public.association_types FOR ALL TO authenticated USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); -- ===================================================== -- 3. member_clubs (Mitgliedsvereine) -- ===================================================== CREATE TABLE IF NOT EXISTS public.member_clubs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, -- Identity name text NOT NULL, short_name text, club_number text, association_type_id uuid REFERENCES public.association_types(id) ON DELETE SET NULL, -- Contact address_street text, address_zip text, address_city text, phone text, email text, website text, -- Bank details iban text, bic text, account_holder text, -- Stats member_count integer NOT NULL DEFAULT 0, youth_count integer NOT NULL DEFAULT 0, founding_year integer, -- Flags is_active boolean NOT NULL DEFAULT true, is_archived boolean NOT NULL DEFAULT false, -- Meta notes text, custom_data jsonb NOT NULL DEFAULT '{}'::jsonb, created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX ix_member_clubs_account ON public.member_clubs(account_id); CREATE INDEX ix_member_clubs_name ON public.member_clubs(account_id, name); CREATE INDEX ix_member_clubs_type ON public.member_clubs(account_id, association_type_id); CREATE INDEX ix_member_clubs_active ON public.member_clubs(account_id, is_active); CREATE INDEX ix_member_clubs_archived ON public.member_clubs(account_id, is_archived); ALTER TABLE public.member_clubs ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.member_clubs FROM authenticated, service_role; GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_clubs TO authenticated; GRANT ALL ON public.member_clubs TO service_role; CREATE POLICY member_clubs_select ON public.member_clubs FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); CREATE POLICY member_clubs_insert ON public.member_clubs FOR INSERT TO authenticated WITH CHECK (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); CREATE POLICY member_clubs_update ON public.member_clubs FOR UPDATE TO authenticated USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); CREATE POLICY member_clubs_delete ON public.member_clubs FOR DELETE TO authenticated USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); CREATE TRIGGER trg_member_clubs_updated_at BEFORE UPDATE ON public.member_clubs FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); -- ===================================================== -- 4. club_roles (Vereinsfunktionen) -- ===================================================== CREATE TABLE IF NOT EXISTS public.club_roles ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, name text NOT NULL, description text, sort_order integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX ix_club_roles_account ON public.club_roles(account_id); ALTER TABLE public.club_roles ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.club_roles FROM authenticated, service_role; GRANT SELECT, INSERT, UPDATE, DELETE ON public.club_roles TO authenticated; GRANT ALL ON public.club_roles TO service_role; CREATE POLICY club_roles_select ON public.club_roles FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); CREATE POLICY club_roles_mutate ON public.club_roles FOR ALL TO authenticated USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); -- ===================================================== -- 5. club_contacts (Ansprechpartner) -- ===================================================== CREATE TABLE IF NOT EXISTS public.club_contacts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), club_id uuid NOT NULL REFERENCES public.member_clubs(id) ON DELETE CASCADE, role_id uuid REFERENCES public.club_roles(id) ON DELETE SET NULL, -- Person info first_name text NOT NULL, last_name text NOT NULL, email text, phone text, mobile text, -- Period valid_from date, valid_until date, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX ix_club_contacts_club ON public.club_contacts(club_id); CREATE INDEX ix_club_contacts_role ON public.club_contacts(role_id); ALTER TABLE public.club_contacts ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.club_contacts FROM authenticated, service_role; GRANT SELECT, INSERT, UPDATE, DELETE ON public.club_contacts TO authenticated; GRANT ALL ON public.club_contacts TO service_role; CREATE POLICY club_contacts_select ON public.club_contacts FOR SELECT TO authenticated USING (EXISTS ( SELECT 1 FROM public.member_clubs mc WHERE mc.id = club_contacts.club_id AND public.has_role_on_account(mc.account_id) )); CREATE POLICY club_contacts_mutate ON public.club_contacts FOR ALL TO authenticated USING (EXISTS ( SELECT 1 FROM public.member_clubs mc WHERE mc.id = club_contacts.club_id AND public.has_permission(auth.uid(), mc.account_id, 'verband.write'::public.app_permissions) )); CREATE TRIGGER trg_club_contacts_updated_at BEFORE UPDATE ON public.club_contacts FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); -- ===================================================== -- 6. club_fee_types (Beitragsarten) -- ===================================================== CREATE TABLE IF NOT EXISTS public.club_fee_types ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, name text NOT NULL, description text, default_amount numeric(10,2) NOT NULL DEFAULT 0, is_per_member boolean NOT NULL DEFAULT false, is_active boolean NOT NULL DEFAULT true, sort_order integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX ix_club_fee_types_account ON public.club_fee_types(account_id); ALTER TABLE public.club_fee_types ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.club_fee_types FROM authenticated, service_role; GRANT SELECT, INSERT, UPDATE, DELETE ON public.club_fee_types TO authenticated; GRANT ALL ON public.club_fee_types TO service_role; CREATE POLICY club_fee_types_select ON public.club_fee_types FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); CREATE POLICY club_fee_types_mutate ON public.club_fee_types FOR ALL TO authenticated USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); -- ===================================================== -- 7. club_fee_billings (Beitragsabrechnungen) -- ===================================================== CREATE TABLE IF NOT EXISTS public.club_fee_billings ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), club_id uuid NOT NULL REFERENCES public.member_clubs(id) ON DELETE CASCADE, fee_type_id uuid NOT NULL REFERENCES public.club_fee_types(id) ON DELETE RESTRICT, -- Billing period billing_year integer NOT NULL, billing_period text DEFAULT 'annual' CHECK (billing_period IN ('annual', 'semi_annual', 'quarterly')), -- Amounts amount numeric(10,2) NOT NULL DEFAULT 0, member_count_at_billing integer, -- Payment tracking status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'invoiced', 'paid', 'overdue', 'cancelled')), invoice_number text, invoice_date date, paid_date date, paid_amount numeric(10,2), remarks text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX ix_club_fee_billings_club ON public.club_fee_billings(club_id); CREATE INDEX ix_club_fee_billings_fee_type ON public.club_fee_billings(fee_type_id); CREATE INDEX ix_club_fee_billings_year ON public.club_fee_billings(billing_year); CREATE INDEX ix_club_fee_billings_status ON public.club_fee_billings(status); ALTER TABLE public.club_fee_billings ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.club_fee_billings FROM authenticated, service_role; GRANT SELECT, INSERT, UPDATE, DELETE ON public.club_fee_billings TO authenticated; GRANT ALL ON public.club_fee_billings TO service_role; CREATE POLICY club_fee_billings_select ON public.club_fee_billings FOR SELECT TO authenticated USING (EXISTS ( SELECT 1 FROM public.member_clubs mc WHERE mc.id = club_fee_billings.club_id AND public.has_role_on_account(mc.account_id) )); CREATE POLICY club_fee_billings_mutate ON public.club_fee_billings FOR ALL TO authenticated USING (EXISTS ( SELECT 1 FROM public.member_clubs mc WHERE mc.id = club_fee_billings.club_id AND public.has_permission(auth.uid(), mc.account_id, 'verband.write'::public.app_permissions) )); CREATE TRIGGER trg_club_fee_billings_updated_at BEFORE UPDATE ON public.club_fee_billings FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp(); -- ===================================================== -- 8. club_notes (Vereinsnotizen) -- ===================================================== CREATE TABLE IF NOT EXISTS public.club_notes ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), club_id uuid NOT NULL REFERENCES public.member_clubs(id) ON DELETE CASCADE, title text, content text NOT NULL, note_date date NOT NULL DEFAULT current_date, created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX ix_club_notes_club ON public.club_notes(club_id); CREATE INDEX ix_club_notes_date ON public.club_notes(note_date DESC); ALTER TABLE public.club_notes ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.club_notes FROM authenticated, service_role; GRANT SELECT, INSERT, UPDATE, DELETE ON public.club_notes TO authenticated; GRANT ALL ON public.club_notes TO service_role; CREATE POLICY club_notes_select ON public.club_notes FOR SELECT TO authenticated USING (EXISTS ( SELECT 1 FROM public.member_clubs mc WHERE mc.id = club_notes.club_id AND public.has_role_on_account(mc.account_id) )); CREATE POLICY club_notes_mutate ON public.club_notes FOR ALL TO authenticated USING (EXISTS ( SELECT 1 FROM public.member_clubs mc WHERE mc.id = club_notes.club_id AND public.has_permission(auth.uid(), mc.account_id, 'verband.write'::public.app_permissions) )); -- ===================================================== -- 9. association_history (Verbandschronik) -- ===================================================== CREATE TABLE IF NOT EXISTS public.association_history ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, club_id uuid REFERENCES public.member_clubs(id) ON DELETE SET NULL, -- Event event_type text NOT NULL DEFAULT 'note' CHECK (event_type IN ('joined', 'left', 'name_change', 'merge', 'split', 'status_change', 'note')), event_date date NOT NULL DEFAULT current_date, description text NOT NULL, -- Metadata old_value text, new_value text, created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX ix_association_history_account ON public.association_history(account_id); CREATE INDEX ix_association_history_club ON public.association_history(club_id); CREATE INDEX ix_association_history_date ON public.association_history(event_date DESC); CREATE INDEX ix_association_history_type ON public.association_history(event_type); ALTER TABLE public.association_history ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.association_history FROM authenticated, service_role; GRANT SELECT, INSERT, UPDATE, DELETE ON public.association_history TO authenticated; GRANT ALL ON public.association_history TO service_role; CREATE POLICY association_history_select ON public.association_history FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); CREATE POLICY association_history_insert ON public.association_history FOR INSERT TO authenticated WITH CHECK (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); CREATE POLICY association_history_update ON public.association_history FOR UPDATE TO authenticated USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); CREATE POLICY association_history_delete ON public.association_history FOR DELETE TO authenticated USING (public.has_permission(auth.uid(), account_id, 'verband.write'::public.app_permissions)); -- ===================================================== -- 10. Dashboard stats RPC -- ===================================================== CREATE OR REPLACE FUNCTION public.get_verband_dashboard_stats(p_account_id uuid) RETURNS TABLE( total_clubs bigint, active_clubs bigint, total_members bigint, total_youth bigint, open_fees bigint, open_fees_amount numeric ) LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN RETURN QUERY SELECT (SELECT COUNT(*) FROM public.member_clubs WHERE account_id = p_account_id AND is_archived = false)::bigint AS total_clubs, (SELECT COUNT(*) FROM public.member_clubs WHERE account_id = p_account_id AND is_active = true AND is_archived = false)::bigint AS active_clubs, (SELECT COALESCE(SUM(member_count), 0) FROM public.member_clubs WHERE account_id = p_account_id AND is_archived = false)::bigint AS total_members, (SELECT COALESCE(SUM(youth_count), 0) FROM public.member_clubs WHERE account_id = p_account_id AND is_archived = false)::bigint AS total_youth, (SELECT COUNT(*) FROM public.club_fee_billings cfb JOIN public.member_clubs mc ON mc.id = cfb.club_id WHERE mc.account_id = p_account_id AND cfb.status IN ('open', 'overdue'))::bigint AS open_fees, (SELECT COALESCE(SUM(cfb.amount), 0) FROM public.club_fee_billings cfb JOIN public.member_clubs mc ON mc.id = cfb.club_id WHERE mc.account_id = p_account_id AND cfb.status IN ('open', 'overdue'))::numeric AS open_fees_amount; END; $$; GRANT EXECUTE ON FUNCTION public.get_verband_dashboard_stats(uuid) TO authenticated, service_role;