From edd8657c92ea73f31f2f3e7c326cad3a33eeb2fb Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 3 Mar 2026 15:32:51 +0100 Subject: [PATCH] Split seeds into bootstrap and dev-only --- CODE_GUIDELINES.md | 10 + docs/feature-roadmap.md | 3 +- priv/repo/seeds.exs | 812 +--------------------------------- priv/repo/seeds_bootstrap.exs | 313 +++++++++++++ priv/repo/seeds_dev.exs | 491 ++++++++++++++++++++ 5 files changed, 824 insertions(+), 805 deletions(-) create mode 100644 priv/repo/seeds_bootstrap.exs create mode 100644 priv/repo/seeds_dev.exs diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index b3f1c3f..bb127f1 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -269,6 +269,16 @@ defmodule Mv.Membership.Member do end ``` +### 1.2.1 Database Seeds + +Seeds are split into **bootstrap** and **dev**: + +- **`priv/repo/seeds.exs`** – Entrypoint. Runs `seeds_bootstrap.exs` always; runs `seeds_dev.exs` only when `Mix.env()` is `:dev` or `:test`. +- **`priv/repo/seeds_bootstrap.exs`** – Creates only data required for system startup: membership fee types, custom fields, roles, admin user, system user, global settings (including default membership fee type). No members, no groups. Used in all environments (dev, test, prod). +- **`priv/repo/seeds_dev.exs`** – Creates 20 sample members, groups, and optional custom field values. Run only in dev and test. + +In production, running `mix run priv/repo/seeds.exs` executes only the bootstrap part (no dev seeds). + ### 1.3 Domain-Driven Design **Use Ash Domains for Context Boundaries:** diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 66b46eb..23f19b7 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -1,7 +1,7 @@ # Feature Roadmap & Implementation Plan **Project:** Mila - Membership Management System -**Last Updated:** 2026-01-27 +**Last Updated:** 2026-03-03 **Status:** Active Development --- @@ -371,6 +371,7 @@ - ✅ Production Dockerfile - ✅ Drone CI/CD pipeline - ✅ Renovate for dependency updates +- ✅ Database seeds split into bootstrap (all envs) and dev-only seeds (20 members, groups; 2026-03-03) - ⚠️ No staging environment **Open Issues:** diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 676ae35..1b5d0a0 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -2,811 +2,15 @@ # # mix run priv/repo/seeds.exs # +# Bootstrap runs in all environments. Dev seeds (members, groups, sample data) +# run only in dev and test. -alias Mv.Accounts -alias Mv.Membership -alias Mv.MembershipFees.CycleGenerator -alias Mv.MembershipFees.MembershipFeeType +# Always run bootstrap (fee types, custom fields, roles, admin, system user, settings) +Code.eval_file("priv/repo/seeds_bootstrap.exs") -require Ash.Query - -# Create example membership fee types (no admin user yet; skip authorization for bootstrap) -for fee_type_attrs <- [ - %{ - name: "Standard (Jährlich)", - amount: Decimal.new("120.00"), - interval: :yearly, - description: "Standard jährlicher Mitgliedsbeitrag" - }, - %{ - name: "Standard (Halbjährlich)", - amount: Decimal.new("65.00"), - interval: :half_yearly, - description: "Standard halbjährlicher Mitgliedsbeitrag" - }, - %{ - name: "Standard (Vierteljährlich)", - amount: Decimal.new("35.00"), - interval: :quarterly, - description: "Standard vierteljährlicher Mitgliedsbeitrag" - }, - %{ - name: "Standard (Monatlich)", - amount: Decimal.new("12.00"), - interval: :monthly, - description: "Standard monatlicher Mitgliedsbeitrag" - } - ] do - MembershipFeeType - |> Ash.Changeset.for_create(:create, fee_type_attrs) - |> Ash.create!( - upsert?: true, - upsert_identity: :unique_name, - authorize?: false, - domain: Mv.MembershipFees - ) +# In dev and test only: run dev seeds (20 members, groups, custom field values) +if Mix.env() in [:dev, :test] do + Code.eval_file("priv/repo/seeds_dev.exs") end -for attrs <- [ - # Basic example fields (for testing) - %{ - name: "String Field", - value_type: :string, - description: "Example for a field of type string", - required: false - }, - %{ - name: "Date Field", - value_type: :date, - description: "Example for a field of type date", - required: false - }, - %{ - name: "Boolean Field", - value_type: :boolean, - description: "Example for a field of type boolean", - required: false - }, - %{ - name: "Email Field", - value_type: :email, - description: "Example for a field of type email", - required: false - }, - # Realistic custom fields - %{ - name: "Membership Number", - value_type: :string, - description: "Unique membership identification number", - required: false - }, - %{ - name: "Emergency Contact", - value_type: :string, - description: "Emergency contact person name and phone", - required: false - }, - %{ - name: "T-Shirt Size", - value_type: :string, - description: "T-Shirt size for events (XS, S, M, L, XL, XXL)", - required: false - }, - %{ - name: "Newsletter Subscription", - value_type: :boolean, - description: "Whether member wants to receive newsletter", - required: false - }, - %{ - name: "Date of Last Medical Check", - value_type: :date, - description: "Date of last medical examination", - required: false - }, - %{ - name: "Secondary Email", - value_type: :email, - description: "Alternative email address", - required: false - }, - %{ - name: "Membership Type", - value_type: :string, - description: "Type of membership (e.g., Regular, Student, Senior)", - required: false - }, - %{ - name: "Parking Permit", - value_type: :boolean, - description: "Whether member has parking permit", - required: false - } - ] do - # Bootstrap: no admin user yet; CustomField create requires admin, so skip authorization - Membership.create_custom_field!( - attrs, - upsert?: true, - upsert_identity: :unique_name, - authorize?: false - ) -end - -# Admin email: default for dev/test so seed_admin has a target -admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" -System.put_env("ADMIN_EMAIL", admin_email) - -# In dev/test, set fallback password so seed_admin creates the admin user when none is set -if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and - is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do - System.put_env("ADMIN_PASSWORD", "testpassword") -end - -# Create all authorization roles (idempotent - creates only if they don't exist) -# Roles are created using create_role_with_system_flag to allow setting is_system_role -role_configs = [ - %{ - name: "Mitglied", - description: "Default member role with access to own data only", - permission_set_name: "own_data", - is_system_role: true - }, - %{ - name: "Vorstand", - description: "Board member with read access to all member data", - permission_set_name: "read_only", - is_system_role: false - }, - %{ - name: "Kassenwart", - description: "Treasurer with full member and payment management", - permission_set_name: "normal_user", - is_system_role: false - }, - %{ - name: "Buchhaltung", - description: "Accounting with read-only access for auditing", - permission_set_name: "read_only", - is_system_role: false - }, - %{ - name: "Admin", - description: "Administrator with unrestricted access", - permission_set_name: "admin", - is_system_role: false - } -] - -# Create or update each role -Enum.each(role_configs, fn role_data -> - # Bind role name to variable to avoid issues with ^ pinning in macros - role_name = role_data.name - - case Mv.Authorization.Role - |> Ash.Query.filter(name == ^role_name) - |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do - {:ok, existing_role} when not is_nil(existing_role) -> - # Role exists - update if needed (preserve is_system_role) - if existing_role.permission_set_name != role_data.permission_set_name or - existing_role.description != role_data.description do - existing_role - |> Ash.Changeset.for_update(:update_role, %{ - description: role_data.description, - permission_set_name: role_data.permission_set_name - }) - |> Ash.update!(authorize?: false, domain: Mv.Authorization) - end - - {:ok, nil} -> - # Role doesn't exist - create it - Mv.Authorization.Role - |> Ash.Changeset.for_create(:create_role_with_system_flag, role_data) - |> Ash.create!(authorize?: false, domain: Mv.Authorization) - - {:error, error} -> - IO.puts("Warning: Failed to check for role #{role_data.name}: #{inspect(error)}") - end -end) - -# Get admin role for assignment to admin user -admin_role = - case Mv.Authorization.Role - |> Ash.Query.filter(name == "Admin") - |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do - {:ok, role} when not is_nil(role) -> role - _ -> nil - end - -if is_nil(admin_role) do - raise "Failed to create or find admin role. Cannot proceed with member seeding." -end - -# Create/update admin user via Release.seed_admin (uses ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE). -# Reduces duplication and exercises the same path as production entrypoint. -Mv.Release.seed_admin() - -# Load admin user with role for use as actor in member operations -# This ensures all member operations have proper authorization -admin_user_with_role = - case Accounts.User - |> Ash.Query.filter(email == ^admin_email) - |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do - {:ok, user} when not is_nil(user) -> - user - |> Ash.load!(:role, authorize?: false) - - {:ok, nil} -> - raise "Admin user not found after creation/assignment" - - {:error, error} -> - raise "Failed to load admin user: #{inspect(error)}" - end - -# Create system user for systemic operations (email sync, validations, cycle generation) -# This user is used by Mv.Helpers.SystemActor for operations that must always run -# Email is configurable via SYSTEM_ACTOR_EMAIL environment variable -system_user_email = Mv.Helpers.SystemActor.system_user_email() - -case Accounts.User - |> Ash.Query.filter(email == ^system_user_email) - |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do - {:ok, existing_system_user} when not is_nil(existing_system_user) -> - # System user already exists - ensure it has admin role - # Use authorize?: false for bootstrap; :update_internal bypasses system-user modification block - existing_system_user - |> Ash.Changeset.for_update(:update_internal, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) - |> Ash.update!(authorize?: false) - - {:ok, nil} -> - # System user doesn't exist - create it with admin role - # SECURITY: System user must NOT be able to log in: - # - No password (hashed_password = nil) - prevents password login - # - No OIDC ID (oidc_id = nil) - prevents OIDC login - # - This user is ONLY for internal system operations via SystemActor - # If either hashed_password or oidc_id is set, the user could potentially log in - # Use authorize?: false for bootstrap - system user creation happens before system actor exists - Accounts.create_user!(%{email: system_user_email}, - upsert?: true, - upsert_identity: :unique_email, - authorize?: false - ) - |> Ash.Changeset.for_update(:update_internal, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) - |> Ash.update!(authorize?: false) - - {:error, error} -> - # Log error but don't fail seeds - SystemActor will fall back to admin user - IO.puts("Warning: Failed to create system user: #{inspect(error)}") - IO.puts("SystemActor will fall back to admin user (#{admin_email})") -end - -# Load all membership fee types for assignment (admin actor for authorization) -# Sort by name to ensure deterministic order -all_fee_types = - MembershipFeeType - |> Ash.Query.sort(name: :asc) - |> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees) - |> Enum.to_list() - -# Create sample members for testing - use upsert to prevent duplicates -# Member 1: Hans - All cycles paid -# Member 2: Greta - All cycles unpaid -# Member 3: Friedrich - Mixed cycles (paid, unpaid, suspended) -# Member 4: Marianne - No membership fee type -member_attrs_list = [ - %{ - first_name: "Hans", - last_name: "Müller", - email: "hans.mueller@example.de", - join_date: ~D[2023-01-15], - city: "München", - street: "Hauptstraße", - house_number: "42", - postal_code: "80331", - membership_fee_type_id: Enum.at(all_fee_types, 0).id, - cycle_status: :all_paid - }, - %{ - first_name: "Greta", - last_name: "Schmidt", - email: "greta.schmidt@example.de", - join_date: ~D[2023-02-01], - city: "Hamburg", - street: "Lindenstraße", - house_number: "17", - postal_code: "20095", - notes: "Interessiert an Fortgeschrittenen-Kursen", - membership_fee_type_id: Enum.at(all_fee_types, 1).id, - cycle_status: :all_unpaid - }, - %{ - first_name: "Friedrich", - last_name: "Wagner", - email: "friedrich.wagner@example.de", - join_date: ~D[2022-11-10], - city: "Berlin", - street: "Kastanienallee", - house_number: "8", - postal_code: "10435", - membership_fee_type_id: Enum.at(all_fee_types, 2).id, - cycle_status: :mixed - }, - %{ - first_name: "Marianne", - last_name: "Wagner", - email: "marianne.wagner@example.de", - join_date: ~D[2022-11-10], - city: "Berlin", - street: "Kastanienallee", - house_number: "8", - postal_code: "10435" - # No membership_fee_type_id - member without fee type - } -] - -# Create members and generate cycles -Enum.each(member_attrs_list, fn member_attrs -> - cycle_status = Map.get(member_attrs, :cycle_status) - member_attrs_without_status = Map.delete(member_attrs, :cycle_status) - - # Use upsert to prevent duplicates based on email - # First create/update member without membership_fee_type_id to avoid overwriting existing assignments - member_attrs_without_fee_type = Map.delete(member_attrs_without_status, :membership_fee_type_id) - - member = - Membership.create_member!(member_attrs_without_fee_type, - upsert?: true, - upsert_identity: :unique_email, - actor: admin_user_with_role - ) - - # Only set membership_fee_type_id if member doesn't have one yet (idempotent) - final_member = - if is_nil(member.membership_fee_type_id) and - Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do - {:ok, updated} = - Membership.update_member( - member, - %{ - membership_fee_type_id: member_attrs_without_status.membership_fee_type_id - }, - actor: admin_user_with_role - ) - - updated - else - member - end - - # Generate cycles if member has a fee type - if final_member.membership_fee_type_id do - # Load member with cycles to check if they already exist (actor required for auth) - member_with_cycles = - Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role) - - # Only generate if no cycles exist yet (to avoid duplicates on re-run) - cycles = - if Enum.empty?(member_with_cycles.membership_fee_cycles) do - # Generate cycles - {:ok, new_cycles, _notifications} = - CycleGenerator.generate_cycles_for_member(final_member.id, - skip_lock?: true, - actor: admin_user_with_role - ) - - new_cycles - else - # Use existing cycles - member_with_cycles.membership_fee_cycles - end - - # Set cycle statuses based on member type - if cycle_status do - cycles - |> Enum.sort_by(& &1.cycle_start, Date) - |> Enum.with_index() - |> Enum.each(fn {cycle, index} -> - status = - case cycle_status do - :all_paid -> - :paid - - :all_unpaid -> - :unpaid - - :mixed -> - # Mix: first paid, second unpaid, third suspended, then repeat - case rem(index, 3) do - 0 -> :paid - 1 -> :unpaid - 2 -> :suspended - end - end - - # Only update if status is different - if cycle.status != status do - cycle - |> Ash.Changeset.for_update(:update, %{status: status}) - |> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees) - end - end) - end - end -end) - -# Create additional users for user-member linking examples (no password by default) -# Only admin gets a password (admin_set_password when created); all other users have no password. -additional_users = [ - %{email: "hans.mueller@example.de"}, - %{email: "greta.schmidt@example.de"}, - %{email: "maria.weber@example.de"}, - %{email: "thomas.klein@example.de"} -] - -created_users = - Enum.map(additional_users, fn user_attrs -> - user = - Accounts.create_user!(user_attrs, - upsert?: true, - upsert_identity: :unique_email, - actor: admin_user_with_role - ) - - # Reload user to ensure all fields (including member_id) are loaded - Accounts.User - |> Ash.Query.filter(id == ^user.id) - |> Ash.read_one!(domain: Mv.Accounts, actor: admin_user_with_role) - end) - -# Create members with linked users to demonstrate the 1:1 relationship -# Only create if users don't already have members -linked_members = [ - %{ - first_name: "Maria", - last_name: "Weber", - email: "maria.weber@example.de", - join_date: ~D[2023-03-15], - city: "Frankfurt", - street: "Goetheplatz", - house_number: "5", - postal_code: "60313", - notes: "Linked to user account", - # Link to the third user (maria.weber@example.de) - user: Enum.at(created_users, 2) - }, - %{ - first_name: "Thomas", - last_name: "Klein", - email: "thomas.klein@example.de", - join_date: ~D[2023-04-01], - city: "Köln", - street: "Rheinstraße", - house_number: "23", - postal_code: "50667", - notes: "Linked to user account - needs payment follow-up", - # Link to the fourth user (thomas.klein@example.de) - user: Enum.at(created_users, 3) - } -] - -# Create the linked members - use upsert to prevent duplicates -# Assign fee types to linked members using round-robin -# Continue from where we left off with the previous members -Enum.with_index(linked_members) -|> Enum.each(fn {member_attrs, index} -> - user = member_attrs.user - member_attrs_without_user = Map.delete(member_attrs, :user) - - # Use upsert to prevent duplicates based on email - # First create/update member without membership_fee_type_id to avoid overwriting existing assignments - member_attrs_without_fee_type = Map.delete(member_attrs_without_user, :membership_fee_type_id) - - # Check if user already has a member - member = - if user.member_id == nil do - # User is free, create member and link - use upsert to prevent duplicates - # Use authorize?: false for User lookup during relationship management (bootstrap phase) - Membership.create_member!( - Map.put(member_attrs_without_fee_type, :user, %{id: user.id}), - upsert?: true, - upsert_identity: :unique_email, - actor: admin_user_with_role, - authorize?: false - ) - else - # User already has a member, just create the member without linking - use upsert to prevent duplicates - Membership.create_member!(member_attrs_without_fee_type, - upsert?: true, - upsert_identity: :unique_email, - actor: admin_user_with_role - ) - end - - # Only set membership_fee_type_id if member doesn't have one yet (idempotent) - final_member = - if is_nil(member.membership_fee_type_id) do - # Assign deterministically using round-robin - # Start from where previous members ended (3 members before this) - fee_type_index = rem(3 + index, length(all_fee_types)) - fee_type = Enum.at(all_fee_types, fee_type_index) - - {:ok, updated} = - Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, - actor: admin_user_with_role - ) - - updated - else - member - end - - # Generate cycles for linked members - if final_member.membership_fee_type_id do - # Load member with cycles to check if they already exist (actor required for auth) - member_with_cycles = - Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role) - - # Only generate if no cycles exist yet (to avoid duplicates on re-run) - cycles = - if Enum.empty?(member_with_cycles.membership_fee_cycles) do - # Generate cycles - {:ok, new_cycles, _notifications} = - CycleGenerator.generate_cycles_for_member(final_member.id, - skip_lock?: true, - actor: admin_user_with_role - ) - - new_cycles - else - # Use existing cycles - member_with_cycles.membership_fee_cycles - end - - # Set some cycles to paid for linked members (mixed status) - cycles - |> Enum.sort_by(& &1.cycle_start, Date) - |> Enum.with_index() - |> Enum.each(fn {cycle, index} -> - # Every other cycle is paid, rest unpaid - status = if rem(index, 2) == 0, do: :paid, else: :unpaid - - # Only update if status is different - if cycle.status != status do - cycle - |> Ash.Changeset.for_update(:update, %{status: status}) - |> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees) - end - end) - end -end) - -# Create example groups (idempotent: create only if name does not exist) -group_configs = [ - %{name: "Vorstand", description: "Gremium Vorstand"}, - %{name: "Trainer*innen", description: "Alle lizenzierten Trainer*innen"}, - %{name: "Jugend", description: "Jugendbereich"}, - %{name: "Newsletter", description: "Empfänger*innen Newsletter"} -] - -existing_groups = - case Membership.list_groups(actor: admin_user_with_role) do - {:ok, list} -> list - {:error, _} -> [] - end - -existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name)) - -seed_groups = - Enum.reduce(group_configs, %{}, fn config, acc -> - name = config.name - - if MapSet.member?(existing_names_lower, String.downcase(name)) do - group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name))) - Map.put(acc, name, group) - else - group = - Membership.create_group!(%{name: name, description: config.description}, - actor: admin_user_with_role - ) - - Map.put(acc, name, group) - end - end) - -# Create sample custom field values for some members -all_members = Ash.read!(Membership.Member, actor: admin_user_with_role) -all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role) - -# Helper function to find custom field by name -find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end -find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end - -# Assign seed members to groups (idempotent: duplicate create_member_group is skipped) -member_group_assignments = [ - {"hans.mueller@example.de", ["Vorstand", "Newsletter"]}, - {"greta.schmidt@example.de", ["Jugend", "Newsletter"]}, - {"friedrich.wagner@example.de", ["Trainer*innen"]}, - {"maria.weber@example.de", ["Newsletter"]}, - {"thomas.klein@example.de", ["Newsletter"]} -] - -Enum.each(member_group_assignments, fn {email, group_names} -> - member = find_member.(email) - - if member do - Enum.each(group_names, fn group_name -> - group = seed_groups[group_name] - - if group do - case Membership.create_member_group( - %{member_id: member.id, group_id: group.id}, - actor: admin_user_with_role - ) do - {:ok, _} -> :ok - {:error, _} -> :ok - end - end - end) - end -end) - -# Add custom field values for Hans Müller -if hans = find_member.("hans.mueller@example.de") do - [ - {find_field.("Membership Number"), - %{"_union_type" => "string", "_union_value" => "M-2023-001"}}, - {find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "L"}}, - {find_field.("Newsletter Subscription"), - %{"_union_type" => "boolean", "_union_value" => true}}, - {find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Regular"}}, - {find_field.("Parking Permit"), %{"_union_type" => "boolean", "_union_value" => true}}, - {find_field.("Secondary Email"), - %{"_union_type" => "email", "_union_value" => "hans.m@private.de"}} - ] - |> Enum.each(fn {field, value} -> - if field do - Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: hans.id, - custom_field_id: field.id, - value: value - }) - |> Ash.create!( - upsert?: true, - upsert_identity: :unique_custom_field_per_member, - actor: admin_user_with_role - ) - end - end) -end - -# Add custom field values for Greta Schmidt -if greta = find_member.("greta.schmidt@example.de") do - [ - {find_field.("Membership Number"), - %{"_union_type" => "string", "_union_value" => "M-2023-015"}}, - {find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "M"}}, - {find_field.("Newsletter Subscription"), - %{"_union_type" => "boolean", "_union_value" => true}}, - {find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Student"}}, - {find_field.("Emergency Contact"), - %{"_union_type" => "string", "_union_value" => "Anna Schmidt, +49301234567"}} - ] - |> Enum.each(fn {field, value} -> - if field do - Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: greta.id, - custom_field_id: field.id, - value: value - }) - |> Ash.create!( - upsert?: true, - upsert_identity: :unique_custom_field_per_member, - actor: admin_user_with_role - ) - end - end) -end - -# Add custom field values for Friedrich Wagner -if friedrich = find_member.("friedrich.wagner@example.de") do - [ - {find_field.("Membership Number"), - %{"_union_type" => "string", "_union_value" => "M-2022-042"}}, - {find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "XL"}}, - {find_field.("Newsletter Subscription"), - %{"_union_type" => "boolean", "_union_value" => false}}, - {find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Senior"}}, - {find_field.("Parking Permit"), %{"_union_type" => "boolean", "_union_value" => false}}, - {find_field.("Date of Last Medical Check"), - %{"_union_type" => "date", "_union_value" => ~D[2024-03-15]}} - ] - |> Enum.each(fn {field, value} -> - if field do - Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: friedrich.id, - custom_field_id: field.id, - value: value - }) - |> Ash.create!( - upsert?: true, - upsert_identity: :unique_custom_field_per_member, - actor: admin_user_with_role - ) - end - end) -end - -# Create or update global settings (singleton) -default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" - -case Membership.get_settings() do - {:ok, existing_settings} -> - # Settings exist, update if club_name is different from env var - # Also ensure exit_date is set to false by default if not already configured - updates = - %{} - |> then(fn acc -> - if existing_settings.club_name != default_club_name, - do: Map.put(acc, :club_name, default_club_name), - else: acc - end) - |> then(fn acc -> - visibility_config = existing_settings.member_field_visibility || %{} - # Ensure exit_date is set to false if not already configured - if not Map.has_key?(visibility_config, "exit_date") and - not Map.has_key?(visibility_config, :exit_date) do - updated_visibility = Map.put(visibility_config, "exit_date", false) - Map.put(acc, :member_field_visibility, updated_visibility) - else - acc - end - end) - - if map_size(updates) > 0 do - {:ok, _updated} = Membership.update_settings(existing_settings, updates) - end - - {:ok, nil} -> - # Settings don't exist yet, create with exit_date defaulting to false - {:ok, _settings} = - Membership.Setting - |> Ash.Changeset.for_create(:create, %{ - club_name: default_club_name, - member_field_visibility: %{"exit_date" => false} - }) - |> Ash.create!() -end - -IO.puts("✅ Seeds completed successfully!") -IO.puts("📝 Created sample data:") -IO.puts(" - Global settings: club_name = #{default_club_name}") -IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)") -IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)") - -password_configured = - System.get_env("ADMIN_PASSWORD") != nil or System.get_env("ADMIN_PASSWORD_FILE") != nil - -IO.puts( - " - Admin user: #{admin_email} (password: #{if password_configured, do: "set", else: "not set"})" -) - -IO.puts(" - Sample members: Hans, Greta, Friedrich") -IO.puts(" - Groups: Vorstand, Trainer*innen, Jugend, Newsletter (with members assigned)") - -IO.puts( - " - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de" -) - -IO.puts( - " - Linked members: Maria Weber ↔ maria.weber@example.de, Thomas Klein ↔ thomas.klein@example.de" -) - -IO.puts( - " - Custom field values: Sample data for Hans (6 fields), Greta (5 fields), Friedrich (6 fields)" -) - -IO.puts("🔗 Visit the application to see user-member relationships and custom fields in action!") +IO.puts("✅ All seeds completed.") diff --git a/priv/repo/seeds_bootstrap.exs b/priv/repo/seeds_bootstrap.exs new file mode 100644 index 0000000..15d74cd --- /dev/null +++ b/priv/repo/seeds_bootstrap.exs @@ -0,0 +1,313 @@ +# Bootstrap seeds: run in all environments (dev, test, prod). +# Creates only data required for system startup: fee types, custom fields, +# roles, admin user, system user, global settings. No members, no groups. + +alias Mv.Accounts +alias Mv.Membership +alias Mv.MembershipFees.MembershipFeeType + +require Ash.Query + +# 1. Membership fee types (authorize?: false for bootstrap) +# Names without interval to avoid duplication in UI; interval is shown separately. +fee_type_configs = [ + %{ + name: "Standard", + amount: Decimal.new("120.00"), + interval: :yearly, + description: "Standard jährlicher Mitgliedsbeitrag" + }, + %{ + name: "Ermäßigt", + amount: Decimal.new("80.00"), + interval: :yearly, + description: "Ermäßigter jährlicher Mitgliedsbeitrag" + }, + %{ + name: "Unterstützer", + amount: Decimal.new("60.00"), + interval: :half_yearly, + description: "Unterstützerbeitrag halbjährlich" + }, + %{ + name: "Fördermitglied", + amount: Decimal.new("30.00"), + interval: :quarterly, + description: "Fördermitgliedschaft quartalsweise" + }, + %{ + name: "Probemitgliedschaft", + amount: Decimal.new("10.00"), + interval: :monthly, + description: "Probemitgliedschaft monatlich" + } +] + +for attrs <- fee_type_configs do + MembershipFeeType + |> Ash.Changeset.for_create(:create, attrs) + |> Ash.create!( + upsert?: true, + upsert_identity: :unique_name, + authorize?: false, + domain: Mv.MembershipFees + ) +end + +# Resolve default fee type (Standard, 120€ yearly) for settings +default_fee_type = + Mv.MembershipFees.MembershipFeeType + |> Ash.Query.filter(name == "Standard") + |> Ash.read_one!(authorize?: false, domain: Mv.MembershipFees) + +# 2. Custom fields (authorize?: false for bootstrap) +# Only Geburtsdatum is shown in overview by default; others hidden to avoid clutter. +custom_field_configs = [ + %{ + name: "Geburtsdatum", + value_type: :date, + description: "Geburtsdatum der/des Mitglieds", + required: false, + show_in_overview: true + }, + %{ + name: "Datenschutzerklärung akzeptiert", + value_type: :boolean, + description: "Angabe, ob Datenschutzerklärung akzeptiert wurde", + required: false, + show_in_overview: false + }, + %{ + name: "SEPA-Mandat", + value_type: :boolean, + description: "SEPA-Lastschriftmandat erteilt", + required: false, + show_in_overview: false + }, + %{ + name: "Rechnungs-E-Mail", + value_type: :email, + description: "E-Mail-Adresse für Rechnungen", + required: false, + show_in_overview: false + }, + %{ + name: "IBAN", + value_type: :string, + description: "IBAN für Lastschrift", + required: false, + show_in_overview: false + }, + %{ + name: "Stunden ehrenamtlich", + value_type: :integer, + description: "Geleistete ehrenamtliche Stunden", + required: false, + show_in_overview: false + } +] + +for attrs <- custom_field_configs do + Membership.create_custom_field!( + attrs, + upsert?: true, + upsert_identity: :unique_name, + authorize?: false + ) +end + +# 3. Admin email and password fallback for dev/test +admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" +System.put_env("ADMIN_EMAIL", admin_email) + +if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and + is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do + System.put_env("ADMIN_PASSWORD", "testpassword") +end + +# 4. Authorization roles (German descriptions) +role_configs = [ + %{ + name: "Mitglied", + description: "Standardrolle für Mitglieder mit Zugriff nur auf die eigenen Daten", + permission_set_name: "own_data", + is_system_role: true + }, + %{ + name: "Vorstand", + description: "Vorstandsmitglied mit Lesezugriff auf alle Mitgliederdaten", + permission_set_name: "read_only", + is_system_role: false + }, + %{ + name: "Kassenwart", + description: "Kassenwart mit voller Mitglieder- und Zahlungsverwaltung", + permission_set_name: "normal_user", + is_system_role: false + }, + %{ + name: "Buchhaltung", + description: "Buchhaltung mit Lesezugriff für Prüfungen", + permission_set_name: "read_only", + is_system_role: false + }, + %{ + name: "Admin", + description: "Administrator mit uneingeschränktem Zugriff", + permission_set_name: "admin", + is_system_role: false + } +] + +Enum.each(role_configs, fn role_data -> + role_name = role_data.name + + case Mv.Authorization.Role + |> Ash.Query.filter(name == ^role_name) + |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do + {:ok, existing_role} when not is_nil(existing_role) -> + if existing_role.permission_set_name != role_data.permission_set_name or + existing_role.description != role_data.description do + existing_role + |> Ash.Changeset.for_update(:update_role, %{ + description: role_data.description, + permission_set_name: role_data.permission_set_name + }) + |> Ash.update!(authorize?: false, domain: Mv.Authorization) + end + + {:ok, nil} -> + Mv.Authorization.Role + |> Ash.Changeset.for_create(:create_role_with_system_flag, role_data) + |> Ash.create!(authorize?: false, domain: Mv.Authorization) + + {:error, error} -> + IO.puts("Warning: Failed to check for role #{role_data.name}: #{inspect(error)}") + end +end) + +admin_role = + case Mv.Authorization.Role + |> Ash.Query.filter(name == "Admin") + |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do + {:ok, role} when not is_nil(role) -> role + _ -> nil + end + +if is_nil(admin_role) do + raise "Failed to create or find admin role. Cannot proceed with bootstrap." +end + +# 5. Admin user +Mv.Release.seed_admin() + +admin_user_with_role = + case Accounts.User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do + {:ok, user} when not is_nil(user) -> + user |> Ash.load!(:role, authorize?: false) + + {:ok, nil} -> + raise "Admin user not found after creation/assignment" + + {:error, error} -> + raise "Failed to load admin user: #{inspect(error)}" + end + +# 6. System user +system_user_email = Mv.Helpers.SystemActor.system_user_email() + +case Accounts.User + |> Ash.Query.filter(email == ^system_user_email) + |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do + {:ok, existing_system_user} when not is_nil(existing_system_user) -> + existing_system_user + |> Ash.Changeset.for_update(:update_internal, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!(authorize?: false) + + {:ok, nil} -> + Accounts.create_user!(%{email: system_user_email}, + upsert?: true, + upsert_identity: :unique_email, + authorize?: false + ) + |> Ash.Changeset.for_update(:update_internal, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!(authorize?: false) + + {:error, error} -> + IO.puts("Warning: Failed to create system user: #{inspect(error)}") + IO.puts("SystemActor will fall back to admin user (#{admin_email})") +end + +# 7. Global settings (with default membership fee type and default field visibility) +# By default hide exit_date, notes, country, membership_fee_start_date in overview (like exit_date). +default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" + +default_hidden_in_overview = %{ + "exit_date" => false, + "notes" => false, + "country" => false, + "membership_fee_start_date" => false +} + +case Membership.get_settings() do + {:ok, existing_settings} -> + updates = + %{} + |> then(fn acc -> + if existing_settings.club_name != default_club_name, + do: Map.put(acc, :club_name, default_club_name), + else: acc + end) + |> then(fn acc -> + if existing_settings.default_membership_fee_type_id != default_fee_type.id, + do: Map.put(acc, :default_membership_fee_type_id, default_fee_type.id), + else: acc + end) + |> then(fn acc -> + visibility_config = existing_settings.member_field_visibility || %{} + # Ensure default-hidden fields are set if not already present (string or atom keys) + has_key = fn vis, k -> + try do + Map.has_key?(vis, k) or Map.has_key?(vis, String.to_existing_atom(k)) + rescue + ArgumentError -> Map.has_key?(vis, k) + end + end + merged = + Enum.reduce(default_hidden_in_overview, visibility_config, fn {key, val}, vis -> + if has_key.(vis, key), do: vis, else: Map.put(vis, key, val) + end) + if merged != visibility_config, do: Map.put(acc, :member_field_visibility, merged), else: acc + end) + + if map_size(updates) > 0 do + {:ok, _} = Membership.update_settings(existing_settings, updates) + end + + {:ok, nil} -> + {:ok, _} = + Membership.Setting + |> Ash.Changeset.for_create(:create, %{ + club_name: default_club_name, + member_field_visibility: default_hidden_in_overview, + default_membership_fee_type_id: default_fee_type.id + }) + |> Ash.create!() +end + +IO.puts("✅ Bootstrap seeds completed.") + +IO.puts( + " - Fee types: 5 (Standard, Ermäßigt, Unterstützer, Fördermitglied, Probemitgliedschaft)" +) + +IO.puts( + " - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)" +) + +IO.puts(" - Roles: 5 (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)") +IO.puts(" - Default fee type: Standard (120€ yearly)") diff --git a/priv/repo/seeds_dev.exs b/priv/repo/seeds_dev.exs new file mode 100644 index 0000000..edf7638 --- /dev/null +++ b/priv/repo/seeds_dev.exs @@ -0,0 +1,491 @@ +# Dev/local seeds: run only in dev and test (Mix.env in [:dev, :test]). +# Creates 20 sample members, groups, and optional custom field values. +# Requires bootstrap seeds to have run first. + +alias Mv.Accounts +alias Mv.Membership +alias Mv.MembershipFees.CycleGenerator +alias Mv.MembershipFees.MembershipFeeType + +require Ash.Query + +admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" + +admin_user_with_role = + case Accounts.User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do + {:ok, user} when not is_nil(user) -> + user |> Ash.load!(:role, authorize?: false) + + _ -> + raise "Dev seeds require bootstrap: admin user not found (#{admin_email})" + end + +all_fee_types = + MembershipFeeType + |> Ash.Query.sort(name: :asc) + |> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees) + |> Enum.to_list() + +# Countries: mostly Germany, 1–2 exceptions (index 7 = Österreich, index 14 = Schweiz) +countries_list = + List.duplicate("Deutschland", 20) + |> List.replace_at(7, "Österreich") + |> List.replace_at(14, "Schweiz") + +# 20 members: varied names, cities, join dates; fee types by index (last 2 without fee type) +member_configs = [ + %{ + first_name: "Anna", + last_name: "Schmidt", + city: "München", + street: "Hauptstraße", + house_number: "1", + postal_code: "80331", + join_date: ~D[2022-01-10] + }, + %{ + first_name: "Bruno", + last_name: "Müller", + city: "Hamburg", + street: "Lindenstraße", + house_number: "5", + postal_code: "20095", + join_date: ~D[2022-03-15] + }, + %{ + first_name: "Clara", + last_name: "Fischer", + city: "Berlin", + street: "Kastanienallee", + house_number: "12", + postal_code: "10435", + join_date: ~D[2022-05-20] + }, + %{ + first_name: "David", + last_name: "Weber", + city: "Köln", + street: "Rheinstraße", + house_number: "8", + postal_code: "50667", + join_date: ~D[2022-07-01] + }, + %{ + first_name: "Elena", + last_name: "Wagner", + city: "Frankfurt", + street: "Goetheplatz", + house_number: "3", + postal_code: "60313", + join_date: ~D[2022-09-12] + }, + %{ + first_name: "Felix", + last_name: "Becker", + city: "Stuttgart", + street: "Königstraße", + house_number: "22", + postal_code: "70173", + join_date: ~D[2023-01-05] + }, + %{ + first_name: "Greta", + last_name: "Schulz", + city: "Düsseldorf", + street: "Schadowstraße", + house_number: "14", + postal_code: "40212", + join_date: ~D[2023-02-14] + }, + %{ + first_name: "Henrik", + last_name: "Hoffmann", + city: "Leipzig", + street: "Nikolaistraße", + house_number: "7", + postal_code: "04109", + join_date: ~D[2023-04-20] + }, + %{ + first_name: "Ines", + last_name: "Koch", + city: "Dortmund", + street: "Westenhellweg", + house_number: "45", + postal_code: "44137", + join_date: ~D[2023-06-08] + }, + %{ + first_name: "Jakob", + last_name: "Richter", + city: "Essen", + street: "Kettwiger Straße", + house_number: "2", + postal_code: "45127", + join_date: ~D[2023-08-11] + }, + %{ + first_name: "Laura", + last_name: "Klein", + city: "Dresden", + street: "Prager Straße", + house_number: "9", + postal_code: "01069", + join_date: ~D[2023-10-01] + }, + %{ + first_name: "Max", + last_name: "Wolf", + city: "Hannover", + street: "Georgstraße", + house_number: "50", + postal_code: "30159", + join_date: ~D[2023-11-15] + }, + %{ + first_name: "Nina", + last_name: "Schröder", + city: "Nürnberg", + street: "Königstraße", + house_number: "73", + postal_code: "90402", + join_date: ~D[2024-01-20] + }, + %{ + first_name: "Oliver", + last_name: "Neumann", + city: "Bremen", + street: "Obernstraße", + house_number: "31", + postal_code: "28195", + join_date: ~D[2024-03-10] + }, + %{ + first_name: "Paula", + last_name: "Schwarz", + city: "Mannheim", + street: "Planken", + house_number: "11", + postal_code: "68161", + join_date: ~D[2024-05-22] + }, + %{ + first_name: "Quirin", + last_name: "Zimmermann", + city: "Karlsruhe", + street: "Kaiserstraße", + house_number: "145", + postal_code: "76133", + join_date: ~D[2024-07-07] + }, + %{ + first_name: "Rosa", + last_name: "Braun", + city: "Wiesbaden", + street: "Wilhelmstraße", + house_number: "6", + postal_code: "65183", + join_date: ~D[2024-09-01] + }, + %{ + first_name: "Stefan", + last_name: "Krüger", + city: "Münster", + street: "Ludgeristraße", + house_number: "18", + postal_code: "48143", + join_date: ~D[2024-10-15] + }, + %{ + first_name: "Thea", + last_name: "Hartmann", + city: "Augsburg", + street: "Maximilianstraße", + house_number: "4", + postal_code: "86150", + join_date: ~D[2024-11-20] + }, + %{ + first_name: "Uwe", + last_name: "Lange", + city: "Bonn", + street: "Remigiusstraße", + house_number: "27", + postal_code: "53111", + join_date: ~D[2024-12-01] + } +] + +# Fee type index per member: 0..4 round-robin for first 18, nil for last 2 +# Cycle status: all_paid, all_unpaid, mixed (varied) +cycle_statuses = [ + :all_paid, + :all_unpaid, + :mixed, + :all_paid, + :mixed, + :all_unpaid, + :all_paid, + :mixed, + :all_unpaid, + :all_paid, + :mixed, + :all_paid, + :all_unpaid, + :mixed, + :all_paid, + :mixed, + :all_unpaid, + :all_paid, + :mixed, + nil +] + +Enum.with_index(member_configs) +|> Enum.each(fn {config, index} -> + email = "mitglied#{index + 1}@example.de" + fee_type_index = if index >= 18, do: nil, else: rem(index, length(all_fee_types)) + fee_type_id = if fee_type_index, do: Enum.at(all_fee_types, fee_type_index).id, else: nil + cycle_status = Enum.at(cycle_statuses, index) + + base_attrs = %{ + first_name: config.first_name, + last_name: config.last_name, + email: email, + join_date: config.join_date, + city: config.city, + street: config.street, + house_number: config.house_number, + postal_code: config.postal_code, + country: Enum.at(countries_list, index) + } + + member_attrs = + if fee_type_id, + do: Map.put(base_attrs, :membership_fee_type_id, fee_type_id), + else: base_attrs + + member = + Membership.create_member!(member_attrs, + upsert?: true, + upsert_identity: :unique_email, + actor: admin_user_with_role + ) + + final_member = + if is_nil(member.membership_fee_type_id) and fee_type_id do + {:ok, updated} = + Membership.update_member(member, %{membership_fee_type_id: fee_type_id}, + actor: admin_user_with_role + ) + + updated + else + member + end + + if not is_nil(final_member.membership_fee_type_id) and not is_nil(cycle_status) do + member_with_cycles = + Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role) + + cycles = + if Enum.empty?(member_with_cycles.membership_fee_cycles) do + {:ok, new_cycles, _} = + CycleGenerator.generate_cycles_for_member(final_member.id, + skip_lock?: true, + actor: admin_user_with_role + ) + + new_cycles + else + member_with_cycles.membership_fee_cycles + end + + cycles + |> Enum.sort_by(& &1.cycle_start, Date) + |> Enum.with_index() + |> Enum.each(fn {cycle, idx} -> + status = + case cycle_status do + :all_paid -> + :paid + + :all_unpaid -> + :unpaid + + :mixed -> + case rem(idx, 3) do + 0 -> :paid + 1 -> :unpaid + 2 -> :suspended + end + + _ -> + cycle.status + end + + if cycle.status != status do + cycle + |> Ash.Changeset.for_update(:update, %{status: status}) + |> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees) + end + end) + end +end) + +# Groups (idempotent) +group_configs = [ + %{name: "Vorstand", description: "Gremium Vorstand"}, + %{name: "Jugend", description: "Jugendbereich"}, + %{name: "Newsletter", description: "Empfänger*innen Newsletter"} +] + +existing_groups = + case Membership.list_groups(actor: admin_user_with_role) do + {:ok, list} -> list + {:error, _} -> [] + end + +existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name)) + +seed_groups = + Enum.reduce(group_configs, %{}, fn config, acc -> + name = config.name + + if MapSet.member?(existing_names_lower, String.downcase(name)) do + group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name))) + Map.put(acc, name, group) + else + {:ok, group} = + Membership.create_group(%{name: name, description: config.description}, + actor: admin_user_with_role + ) + + Map.put(acc, name, group) + end + end) + +# Test users: create users linked to members (same email as member so sync is no-op), +# each with a different role for testing authorization. +all_members = Ash.read!(Membership.Member, actor: admin_user_with_role) + +test_users_config = [ + {"mitglied1@example.de", "Mitglied"}, + {"mitglied2@example.de", "Vorstand"}, + {"mitglied3@example.de", "Kassenwart"}, + {"mitglied4@example.de", "Buchhaltung"} +] + +roles_by_name = + Mv.Authorization.Role + |> Ash.read!(authorize?: false, domain: Mv.Authorization) + |> Map.new(&{&1.name, &1}) + +Enum.each(test_users_config, fn {email, role_name} -> + member = Enum.find(all_members, &(&1.email == email)) + role = roles_by_name[role_name] + + if not is_nil(member) and not is_nil(role) do + user = + Accounts.create_user!( + %{email: email, member: %{id: member.id}}, + upsert?: true, + upsert_identity: :unique_email, + actor: admin_user_with_role + ) + + user + |> Ash.Changeset.for_update(:update_user, %{role_id: role.id}, domain: Mv.Accounts) + |> Ash.update!(actor: admin_user_with_role) + end +end) + +# Assign some members to groups (mitglied1–5 to Vorstand/Newsletter etc.) +member_group_assignments = [ + {"mitglied1@example.de", ["Vorstand", "Newsletter"]}, + {"mitglied2@example.de", ["Jugend", "Newsletter"]}, + {"mitglied3@example.de", ["Vorstand"]}, + {"mitglied4@example.de", ["Newsletter"]}, + {"mitglied5@example.de", ["Newsletter"]} +] + +find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end + +Enum.each(member_group_assignments, fn {email, group_names} -> + member = find_member.(email) + + if member do + Enum.each(group_names, fn group_name -> + group = seed_groups[group_name] + + if group do + Membership.create_member_group( + %{member_id: member.id, group_id: group.id}, + actor: admin_user_with_role + ) + end + end) + end +end) + +# Custom field values for ~80% of members (16 of 20): most of the 6 fields filled per member +all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role) +find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end + +# 16 members with 4–6 custom field values each (Geburtsdatum, Datenschutz, SEPA, Rechnungs-E-Mail, IBAN, Stunden) +custom_value_assignments = + Enum.map(1..16, fn n -> + email = "mitglied#{n}@example.de" + # Vary birth dates and values per index + base_date = Date.add(~D[1970-01-01], 365 * (n + 10) + rem(n * 31, 365)) + values = [ + {"Geburtsdatum", %{"_union_type" => "date", "_union_value" => base_date}}, + {"Datenschutzerklärung akzeptiert", + %{"_union_type" => "boolean", "_union_value" => n in [1, 2, 3, 5, 7, 9, 11, 13, 15]}}, + {"SEPA-Mandat", %{"_union_type" => "boolean", "_union_value" => rem(n, 3) != 0}}, + {"Rechnungs-E-Mail", + %{"_union_type" => "email", "_union_value" => "rechnung#{n}@example.de"}}, + {"IBAN", + %{ + "_union_type" => "string", + "_union_value" => + "DE8937040044#{String.pad_leading(to_string(rem(532013000 + n, 1_000_000_000)), 10, "0")}" + }}, + {"Stunden ehrenamtlich", %{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}} + ] + # Drop 0–2 fields per member so not all have 6 (still ~80% overall filled) + drop_count = rem(n, 3) + {email, Enum.take(values, 6 - drop_count)} + end) + +for {email, values} <- custom_value_assignments do + member = find_member.(email) + + if member do + Enum.each(values, fn {field_name, value} -> + field = find_field.(field_name) + + if field do + Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: value + }) + |> Ash.create!( + upsert?: true, + upsert_identity: :unique_custom_field_per_member, + actor: admin_user_with_role + ) + end + end) + end +end + +IO.puts("✅ Dev seeds completed.") +IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz)") +IO.puts(" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung") +IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)") +IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)")