Seeds split, Credo strict, and member/settings UI polish #458
5 changed files with 824 additions and 805 deletions
|
|
@ -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:**
|
||||||
|
|
|
||||||
|
|
@ -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:**
|
||||||
|
|
|
||||||
|
|
@ -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!")
|
|
||||||
|
|
|
||||||
313
priv/repo/seeds_bootstrap.exs
Normal file
313
priv/repo/seeds_bootstrap.exs
Normal 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
491
priv/repo/seeds_dev.exs
Normal 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, 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)")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue