Seeds split, Credo strict, and member/settings UI polish #458

Merged
moritz merged 18 commits from feat/seeds into main 2026-03-04 20:19:51 +01:00
5 changed files with 824 additions and 805 deletions
Showing only changes of commit edd8657c92 - Show all commits

View file

@ -269,6 +269,16 @@ defmodule Mv.Membership.Member do
end 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 ### 1.3 Domain-Driven Design
**Use Ash Domains for Context Boundaries:** **Use Ash Domains for Context Boundaries:**

View file

@ -1,7 +1,7 @@
# Feature Roadmap & Implementation Plan # Feature Roadmap & Implementation Plan
**Project:** Mila - Membership Management System **Project:** Mila - Membership Management System
**Last Updated:** 2026-01-27 **Last Updated:** 2026-03-03
**Status:** Active Development **Status:** Active Development
--- ---
@ -371,6 +371,7 @@
- ✅ Production Dockerfile - ✅ Production Dockerfile
- ✅ Drone CI/CD pipeline - ✅ Drone CI/CD pipeline
- ✅ Renovate for dependency updates - ✅ Renovate for dependency updates
- ✅ Database seeds split into bootstrap (all envs) and dev-only seeds (20 members, groups; 2026-03-03)
- ⚠️ No staging environment - ⚠️ No staging environment
**Open Issues:** **Open Issues:**

View file

@ -2,811 +2,15 @@
# #
# mix run priv/repo/seeds.exs # 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 # Always run bootstrap (fee types, custom fields, roles, admin, system user, settings)
alias Mv.Membership Code.eval_file("priv/repo/seeds_bootstrap.exs")
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query # In dev and test only: run dev seeds (20 members, groups, custom field values)
if Mix.env() in [:dev, :test] do
# Create example membership fee types (no admin user yet; skip authorization for bootstrap) Code.eval_file("priv/repo/seeds_dev.exs")
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
)
end end
for attrs <- [ IO.puts("✅ All seeds completed.")
# 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!")

View file

@ -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)")

491
priv/repo/seeds_dev.exs Normal file
View file

@ -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, 12 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 (mitglied15 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 46 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 02 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 mitglied14 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung")
IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)")
IO.puts(" - Custom field values: ~80% filled (16 members, 46 fields each)")