From edd8657c92ea73f31f2f3e7c326cad3a33eeb2fb Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 3 Mar 2026 15:32:51 +0100 Subject: [PATCH 01/18] Split seeds into bootstrap and dev-only --- CODE_GUIDELINES.md | 10 + docs/feature-roadmap.md | 3 +- priv/repo/seeds.exs | 812 +--------------------------------- priv/repo/seeds_bootstrap.exs | 313 +++++++++++++ priv/repo/seeds_dev.exs | 491 ++++++++++++++++++++ 5 files changed, 824 insertions(+), 805 deletions(-) create mode 100644 priv/repo/seeds_bootstrap.exs create mode 100644 priv/repo/seeds_dev.exs diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index b3f1c3f..bb127f1 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -269,6 +269,16 @@ defmodule Mv.Membership.Member do end ``` +### 1.2.1 Database Seeds + +Seeds are split into **bootstrap** and **dev**: + +- **`priv/repo/seeds.exs`** – Entrypoint. Runs `seeds_bootstrap.exs` always; runs `seeds_dev.exs` only when `Mix.env()` is `:dev` or `:test`. +- **`priv/repo/seeds_bootstrap.exs`** – Creates only data required for system startup: membership fee types, custom fields, roles, admin user, system user, global settings (including default membership fee type). No members, no groups. Used in all environments (dev, test, prod). +- **`priv/repo/seeds_dev.exs`** – Creates 20 sample members, groups, and optional custom field values. Run only in dev and test. + +In production, running `mix run priv/repo/seeds.exs` executes only the bootstrap part (no dev seeds). + ### 1.3 Domain-Driven Design **Use Ash Domains for Context Boundaries:** diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 66b46eb..23f19b7 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -1,7 +1,7 @@ # Feature Roadmap & Implementation Plan **Project:** Mila - Membership Management System -**Last Updated:** 2026-01-27 +**Last Updated:** 2026-03-03 **Status:** Active Development --- @@ -371,6 +371,7 @@ - ✅ Production Dockerfile - ✅ Drone CI/CD pipeline - ✅ Renovate for dependency updates +- ✅ Database seeds split into bootstrap (all envs) and dev-only seeds (20 members, groups; 2026-03-03) - ⚠️ No staging environment **Open Issues:** diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 676ae35..1b5d0a0 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -2,811 +2,15 @@ # # mix run priv/repo/seeds.exs # +# Bootstrap runs in all environments. Dev seeds (members, groups, sample data) +# run only in dev and test. -alias Mv.Accounts -alias Mv.Membership -alias Mv.MembershipFees.CycleGenerator -alias Mv.MembershipFees.MembershipFeeType +# Always run bootstrap (fee types, custom fields, roles, admin, system user, settings) +Code.eval_file("priv/repo/seeds_bootstrap.exs") -require Ash.Query - -# Create example membership fee types (no admin user yet; skip authorization for bootstrap) -for fee_type_attrs <- [ - %{ - name: "Standard (Jährlich)", - amount: Decimal.new("120.00"), - interval: :yearly, - description: "Standard jährlicher Mitgliedsbeitrag" - }, - %{ - name: "Standard (Halbjährlich)", - amount: Decimal.new("65.00"), - interval: :half_yearly, - description: "Standard halbjährlicher Mitgliedsbeitrag" - }, - %{ - name: "Standard (Vierteljährlich)", - amount: Decimal.new("35.00"), - interval: :quarterly, - description: "Standard vierteljährlicher Mitgliedsbeitrag" - }, - %{ - name: "Standard (Monatlich)", - amount: Decimal.new("12.00"), - interval: :monthly, - description: "Standard monatlicher Mitgliedsbeitrag" - } - ] do - MembershipFeeType - |> Ash.Changeset.for_create(:create, fee_type_attrs) - |> Ash.create!( - upsert?: true, - upsert_identity: :unique_name, - authorize?: false, - domain: Mv.MembershipFees - ) +# In dev and test only: run dev seeds (20 members, groups, custom field values) +if Mix.env() in [:dev, :test] do + Code.eval_file("priv/repo/seeds_dev.exs") end -for attrs <- [ - # Basic example fields (for testing) - %{ - name: "String Field", - value_type: :string, - description: "Example for a field of type string", - required: false - }, - %{ - name: "Date Field", - value_type: :date, - description: "Example for a field of type date", - required: false - }, - %{ - name: "Boolean Field", - value_type: :boolean, - description: "Example for a field of type boolean", - required: false - }, - %{ - name: "Email Field", - value_type: :email, - description: "Example for a field of type email", - required: false - }, - # Realistic custom fields - %{ - name: "Membership Number", - value_type: :string, - description: "Unique membership identification number", - required: false - }, - %{ - name: "Emergency Contact", - value_type: :string, - description: "Emergency contact person name and phone", - required: false - }, - %{ - name: "T-Shirt Size", - value_type: :string, - description: "T-Shirt size for events (XS, S, M, L, XL, XXL)", - required: false - }, - %{ - name: "Newsletter Subscription", - value_type: :boolean, - description: "Whether member wants to receive newsletter", - required: false - }, - %{ - name: "Date of Last Medical Check", - value_type: :date, - description: "Date of last medical examination", - required: false - }, - %{ - name: "Secondary Email", - value_type: :email, - description: "Alternative email address", - required: false - }, - %{ - name: "Membership Type", - value_type: :string, - description: "Type of membership (e.g., Regular, Student, Senior)", - required: false - }, - %{ - name: "Parking Permit", - value_type: :boolean, - description: "Whether member has parking permit", - required: false - } - ] do - # Bootstrap: no admin user yet; CustomField create requires admin, so skip authorization - Membership.create_custom_field!( - attrs, - upsert?: true, - upsert_identity: :unique_name, - authorize?: false - ) -end - -# Admin email: default for dev/test so seed_admin has a target -admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" -System.put_env("ADMIN_EMAIL", admin_email) - -# In dev/test, set fallback password so seed_admin creates the admin user when none is set -if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and - is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do - System.put_env("ADMIN_PASSWORD", "testpassword") -end - -# Create all authorization roles (idempotent - creates only if they don't exist) -# Roles are created using create_role_with_system_flag to allow setting is_system_role -role_configs = [ - %{ - name: "Mitglied", - description: "Default member role with access to own data only", - permission_set_name: "own_data", - is_system_role: true - }, - %{ - name: "Vorstand", - description: "Board member with read access to all member data", - permission_set_name: "read_only", - is_system_role: false - }, - %{ - name: "Kassenwart", - description: "Treasurer with full member and payment management", - permission_set_name: "normal_user", - is_system_role: false - }, - %{ - name: "Buchhaltung", - description: "Accounting with read-only access for auditing", - permission_set_name: "read_only", - is_system_role: false - }, - %{ - name: "Admin", - description: "Administrator with unrestricted access", - permission_set_name: "admin", - is_system_role: false - } -] - -# Create or update each role -Enum.each(role_configs, fn role_data -> - # Bind role name to variable to avoid issues with ^ pinning in macros - role_name = role_data.name - - case Mv.Authorization.Role - |> Ash.Query.filter(name == ^role_name) - |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do - {:ok, existing_role} when not is_nil(existing_role) -> - # Role exists - update if needed (preserve is_system_role) - if existing_role.permission_set_name != role_data.permission_set_name or - existing_role.description != role_data.description do - existing_role - |> Ash.Changeset.for_update(:update_role, %{ - description: role_data.description, - permission_set_name: role_data.permission_set_name - }) - |> Ash.update!(authorize?: false, domain: Mv.Authorization) - end - - {:ok, nil} -> - # Role doesn't exist - create it - Mv.Authorization.Role - |> Ash.Changeset.for_create(:create_role_with_system_flag, role_data) - |> Ash.create!(authorize?: false, domain: Mv.Authorization) - - {:error, error} -> - IO.puts("Warning: Failed to check for role #{role_data.name}: #{inspect(error)}") - end -end) - -# Get admin role for assignment to admin user -admin_role = - case Mv.Authorization.Role - |> Ash.Query.filter(name == "Admin") - |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do - {:ok, role} when not is_nil(role) -> role - _ -> nil - end - -if is_nil(admin_role) do - raise "Failed to create or find admin role. Cannot proceed with member seeding." -end - -# Create/update admin user via Release.seed_admin (uses ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE). -# Reduces duplication and exercises the same path as production entrypoint. -Mv.Release.seed_admin() - -# Load admin user with role for use as actor in member operations -# This ensures all member operations have proper authorization -admin_user_with_role = - case Accounts.User - |> Ash.Query.filter(email == ^admin_email) - |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do - {:ok, user} when not is_nil(user) -> - user - |> Ash.load!(:role, authorize?: false) - - {:ok, nil} -> - raise "Admin user not found after creation/assignment" - - {:error, error} -> - raise "Failed to load admin user: #{inspect(error)}" - end - -# Create system user for systemic operations (email sync, validations, cycle generation) -# This user is used by Mv.Helpers.SystemActor for operations that must always run -# Email is configurable via SYSTEM_ACTOR_EMAIL environment variable -system_user_email = Mv.Helpers.SystemActor.system_user_email() - -case Accounts.User - |> Ash.Query.filter(email == ^system_user_email) - |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do - {:ok, existing_system_user} when not is_nil(existing_system_user) -> - # System user already exists - ensure it has admin role - # Use authorize?: false for bootstrap; :update_internal bypasses system-user modification block - existing_system_user - |> Ash.Changeset.for_update(:update_internal, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) - |> Ash.update!(authorize?: false) - - {:ok, nil} -> - # System user doesn't exist - create it with admin role - # SECURITY: System user must NOT be able to log in: - # - No password (hashed_password = nil) - prevents password login - # - No OIDC ID (oidc_id = nil) - prevents OIDC login - # - This user is ONLY for internal system operations via SystemActor - # If either hashed_password or oidc_id is set, the user could potentially log in - # Use authorize?: false for bootstrap - system user creation happens before system actor exists - Accounts.create_user!(%{email: system_user_email}, - upsert?: true, - upsert_identity: :unique_email, - authorize?: false - ) - |> Ash.Changeset.for_update(:update_internal, %{}) - |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) - |> Ash.update!(authorize?: false) - - {:error, error} -> - # Log error but don't fail seeds - SystemActor will fall back to admin user - IO.puts("Warning: Failed to create system user: #{inspect(error)}") - IO.puts("SystemActor will fall back to admin user (#{admin_email})") -end - -# Load all membership fee types for assignment (admin actor for authorization) -# Sort by name to ensure deterministic order -all_fee_types = - MembershipFeeType - |> Ash.Query.sort(name: :asc) - |> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees) - |> Enum.to_list() - -# Create sample members for testing - use upsert to prevent duplicates -# Member 1: Hans - All cycles paid -# Member 2: Greta - All cycles unpaid -# Member 3: Friedrich - Mixed cycles (paid, unpaid, suspended) -# Member 4: Marianne - No membership fee type -member_attrs_list = [ - %{ - first_name: "Hans", - last_name: "Müller", - email: "hans.mueller@example.de", - join_date: ~D[2023-01-15], - city: "München", - street: "Hauptstraße", - house_number: "42", - postal_code: "80331", - membership_fee_type_id: Enum.at(all_fee_types, 0).id, - cycle_status: :all_paid - }, - %{ - first_name: "Greta", - last_name: "Schmidt", - email: "greta.schmidt@example.de", - join_date: ~D[2023-02-01], - city: "Hamburg", - street: "Lindenstraße", - house_number: "17", - postal_code: "20095", - notes: "Interessiert an Fortgeschrittenen-Kursen", - membership_fee_type_id: Enum.at(all_fee_types, 1).id, - cycle_status: :all_unpaid - }, - %{ - first_name: "Friedrich", - last_name: "Wagner", - email: "friedrich.wagner@example.de", - join_date: ~D[2022-11-10], - city: "Berlin", - street: "Kastanienallee", - house_number: "8", - postal_code: "10435", - membership_fee_type_id: Enum.at(all_fee_types, 2).id, - cycle_status: :mixed - }, - %{ - first_name: "Marianne", - last_name: "Wagner", - email: "marianne.wagner@example.de", - join_date: ~D[2022-11-10], - city: "Berlin", - street: "Kastanienallee", - house_number: "8", - postal_code: "10435" - # No membership_fee_type_id - member without fee type - } -] - -# Create members and generate cycles -Enum.each(member_attrs_list, fn member_attrs -> - cycle_status = Map.get(member_attrs, :cycle_status) - member_attrs_without_status = Map.delete(member_attrs, :cycle_status) - - # Use upsert to prevent duplicates based on email - # First create/update member without membership_fee_type_id to avoid overwriting existing assignments - member_attrs_without_fee_type = Map.delete(member_attrs_without_status, :membership_fee_type_id) - - member = - Membership.create_member!(member_attrs_without_fee_type, - upsert?: true, - upsert_identity: :unique_email, - actor: admin_user_with_role - ) - - # Only set membership_fee_type_id if member doesn't have one yet (idempotent) - final_member = - if is_nil(member.membership_fee_type_id) and - Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do - {:ok, updated} = - Membership.update_member( - member, - %{ - membership_fee_type_id: member_attrs_without_status.membership_fee_type_id - }, - actor: admin_user_with_role - ) - - updated - else - member - end - - # Generate cycles if member has a fee type - if final_member.membership_fee_type_id do - # Load member with cycles to check if they already exist (actor required for auth) - member_with_cycles = - Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role) - - # Only generate if no cycles exist yet (to avoid duplicates on re-run) - cycles = - if Enum.empty?(member_with_cycles.membership_fee_cycles) do - # Generate cycles - {:ok, new_cycles, _notifications} = - CycleGenerator.generate_cycles_for_member(final_member.id, - skip_lock?: true, - actor: admin_user_with_role - ) - - new_cycles - else - # Use existing cycles - member_with_cycles.membership_fee_cycles - end - - # Set cycle statuses based on member type - if cycle_status do - cycles - |> Enum.sort_by(& &1.cycle_start, Date) - |> Enum.with_index() - |> Enum.each(fn {cycle, index} -> - status = - case cycle_status do - :all_paid -> - :paid - - :all_unpaid -> - :unpaid - - :mixed -> - # Mix: first paid, second unpaid, third suspended, then repeat - case rem(index, 3) do - 0 -> :paid - 1 -> :unpaid - 2 -> :suspended - end - end - - # Only update if status is different - if cycle.status != status do - cycle - |> Ash.Changeset.for_update(:update, %{status: status}) - |> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees) - end - end) - end - end -end) - -# Create additional users for user-member linking examples (no password by default) -# Only admin gets a password (admin_set_password when created); all other users have no password. -additional_users = [ - %{email: "hans.mueller@example.de"}, - %{email: "greta.schmidt@example.de"}, - %{email: "maria.weber@example.de"}, - %{email: "thomas.klein@example.de"} -] - -created_users = - Enum.map(additional_users, fn user_attrs -> - user = - Accounts.create_user!(user_attrs, - upsert?: true, - upsert_identity: :unique_email, - actor: admin_user_with_role - ) - - # Reload user to ensure all fields (including member_id) are loaded - Accounts.User - |> Ash.Query.filter(id == ^user.id) - |> Ash.read_one!(domain: Mv.Accounts, actor: admin_user_with_role) - end) - -# Create members with linked users to demonstrate the 1:1 relationship -# Only create if users don't already have members -linked_members = [ - %{ - first_name: "Maria", - last_name: "Weber", - email: "maria.weber@example.de", - join_date: ~D[2023-03-15], - city: "Frankfurt", - street: "Goetheplatz", - house_number: "5", - postal_code: "60313", - notes: "Linked to user account", - # Link to the third user (maria.weber@example.de) - user: Enum.at(created_users, 2) - }, - %{ - first_name: "Thomas", - last_name: "Klein", - email: "thomas.klein@example.de", - join_date: ~D[2023-04-01], - city: "Köln", - street: "Rheinstraße", - house_number: "23", - postal_code: "50667", - notes: "Linked to user account - needs payment follow-up", - # Link to the fourth user (thomas.klein@example.de) - user: Enum.at(created_users, 3) - } -] - -# Create the linked members - use upsert to prevent duplicates -# Assign fee types to linked members using round-robin -# Continue from where we left off with the previous members -Enum.with_index(linked_members) -|> Enum.each(fn {member_attrs, index} -> - user = member_attrs.user - member_attrs_without_user = Map.delete(member_attrs, :user) - - # Use upsert to prevent duplicates based on email - # First create/update member without membership_fee_type_id to avoid overwriting existing assignments - member_attrs_without_fee_type = Map.delete(member_attrs_without_user, :membership_fee_type_id) - - # Check if user already has a member - member = - if user.member_id == nil do - # User is free, create member and link - use upsert to prevent duplicates - # Use authorize?: false for User lookup during relationship management (bootstrap phase) - Membership.create_member!( - Map.put(member_attrs_without_fee_type, :user, %{id: user.id}), - upsert?: true, - upsert_identity: :unique_email, - actor: admin_user_with_role, - authorize?: false - ) - else - # User already has a member, just create the member without linking - use upsert to prevent duplicates - Membership.create_member!(member_attrs_without_fee_type, - upsert?: true, - upsert_identity: :unique_email, - actor: admin_user_with_role - ) - end - - # Only set membership_fee_type_id if member doesn't have one yet (idempotent) - final_member = - if is_nil(member.membership_fee_type_id) do - # Assign deterministically using round-robin - # Start from where previous members ended (3 members before this) - fee_type_index = rem(3 + index, length(all_fee_types)) - fee_type = Enum.at(all_fee_types, fee_type_index) - - {:ok, updated} = - Membership.update_member(member, %{membership_fee_type_id: fee_type.id}, - actor: admin_user_with_role - ) - - updated - else - member - end - - # Generate cycles for linked members - if final_member.membership_fee_type_id do - # Load member with cycles to check if they already exist (actor required for auth) - member_with_cycles = - Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role) - - # Only generate if no cycles exist yet (to avoid duplicates on re-run) - cycles = - if Enum.empty?(member_with_cycles.membership_fee_cycles) do - # Generate cycles - {:ok, new_cycles, _notifications} = - CycleGenerator.generate_cycles_for_member(final_member.id, - skip_lock?: true, - actor: admin_user_with_role - ) - - new_cycles - else - # Use existing cycles - member_with_cycles.membership_fee_cycles - end - - # Set some cycles to paid for linked members (mixed status) - cycles - |> Enum.sort_by(& &1.cycle_start, Date) - |> Enum.with_index() - |> Enum.each(fn {cycle, index} -> - # Every other cycle is paid, rest unpaid - status = if rem(index, 2) == 0, do: :paid, else: :unpaid - - # Only update if status is different - if cycle.status != status do - cycle - |> Ash.Changeset.for_update(:update, %{status: status}) - |> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees) - end - end) - end -end) - -# Create example groups (idempotent: create only if name does not exist) -group_configs = [ - %{name: "Vorstand", description: "Gremium Vorstand"}, - %{name: "Trainer*innen", description: "Alle lizenzierten Trainer*innen"}, - %{name: "Jugend", description: "Jugendbereich"}, - %{name: "Newsletter", description: "Empfänger*innen Newsletter"} -] - -existing_groups = - case Membership.list_groups(actor: admin_user_with_role) do - {:ok, list} -> list - {:error, _} -> [] - end - -existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name)) - -seed_groups = - Enum.reduce(group_configs, %{}, fn config, acc -> - name = config.name - - if MapSet.member?(existing_names_lower, String.downcase(name)) do - group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name))) - Map.put(acc, name, group) - else - group = - Membership.create_group!(%{name: name, description: config.description}, - actor: admin_user_with_role - ) - - Map.put(acc, name, group) - end - end) - -# Create sample custom field values for some members -all_members = Ash.read!(Membership.Member, actor: admin_user_with_role) -all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role) - -# Helper function to find custom field by name -find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end -find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end - -# Assign seed members to groups (idempotent: duplicate create_member_group is skipped) -member_group_assignments = [ - {"hans.mueller@example.de", ["Vorstand", "Newsletter"]}, - {"greta.schmidt@example.de", ["Jugend", "Newsletter"]}, - {"friedrich.wagner@example.de", ["Trainer*innen"]}, - {"maria.weber@example.de", ["Newsletter"]}, - {"thomas.klein@example.de", ["Newsletter"]} -] - -Enum.each(member_group_assignments, fn {email, group_names} -> - member = find_member.(email) - - if member do - Enum.each(group_names, fn group_name -> - group = seed_groups[group_name] - - if group do - case Membership.create_member_group( - %{member_id: member.id, group_id: group.id}, - actor: admin_user_with_role - ) do - {:ok, _} -> :ok - {:error, _} -> :ok - end - end - end) - end -end) - -# Add custom field values for Hans Müller -if hans = find_member.("hans.mueller@example.de") do - [ - {find_field.("Membership Number"), - %{"_union_type" => "string", "_union_value" => "M-2023-001"}}, - {find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "L"}}, - {find_field.("Newsletter Subscription"), - %{"_union_type" => "boolean", "_union_value" => true}}, - {find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Regular"}}, - {find_field.("Parking Permit"), %{"_union_type" => "boolean", "_union_value" => true}}, - {find_field.("Secondary Email"), - %{"_union_type" => "email", "_union_value" => "hans.m@private.de"}} - ] - |> Enum.each(fn {field, value} -> - if field do - Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: hans.id, - custom_field_id: field.id, - value: value - }) - |> Ash.create!( - upsert?: true, - upsert_identity: :unique_custom_field_per_member, - actor: admin_user_with_role - ) - end - end) -end - -# Add custom field values for Greta Schmidt -if greta = find_member.("greta.schmidt@example.de") do - [ - {find_field.("Membership Number"), - %{"_union_type" => "string", "_union_value" => "M-2023-015"}}, - {find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "M"}}, - {find_field.("Newsletter Subscription"), - %{"_union_type" => "boolean", "_union_value" => true}}, - {find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Student"}}, - {find_field.("Emergency Contact"), - %{"_union_type" => "string", "_union_value" => "Anna Schmidt, +49301234567"}} - ] - |> Enum.each(fn {field, value} -> - if field do - Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: greta.id, - custom_field_id: field.id, - value: value - }) - |> Ash.create!( - upsert?: true, - upsert_identity: :unique_custom_field_per_member, - actor: admin_user_with_role - ) - end - end) -end - -# Add custom field values for Friedrich Wagner -if friedrich = find_member.("friedrich.wagner@example.de") do - [ - {find_field.("Membership Number"), - %{"_union_type" => "string", "_union_value" => "M-2022-042"}}, - {find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "XL"}}, - {find_field.("Newsletter Subscription"), - %{"_union_type" => "boolean", "_union_value" => false}}, - {find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Senior"}}, - {find_field.("Parking Permit"), %{"_union_type" => "boolean", "_union_value" => false}}, - {find_field.("Date of Last Medical Check"), - %{"_union_type" => "date", "_union_value" => ~D[2024-03-15]}} - ] - |> Enum.each(fn {field, value} -> - if field do - Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: friedrich.id, - custom_field_id: field.id, - value: value - }) - |> Ash.create!( - upsert?: true, - upsert_identity: :unique_custom_field_per_member, - actor: admin_user_with_role - ) - end - end) -end - -# Create or update global settings (singleton) -default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" - -case Membership.get_settings() do - {:ok, existing_settings} -> - # Settings exist, update if club_name is different from env var - # Also ensure exit_date is set to false by default if not already configured - updates = - %{} - |> then(fn acc -> - if existing_settings.club_name != default_club_name, - do: Map.put(acc, :club_name, default_club_name), - else: acc - end) - |> then(fn acc -> - visibility_config = existing_settings.member_field_visibility || %{} - # Ensure exit_date is set to false if not already configured - if not Map.has_key?(visibility_config, "exit_date") and - not Map.has_key?(visibility_config, :exit_date) do - updated_visibility = Map.put(visibility_config, "exit_date", false) - Map.put(acc, :member_field_visibility, updated_visibility) - else - acc - end - end) - - if map_size(updates) > 0 do - {:ok, _updated} = Membership.update_settings(existing_settings, updates) - end - - {:ok, nil} -> - # Settings don't exist yet, create with exit_date defaulting to false - {:ok, _settings} = - Membership.Setting - |> Ash.Changeset.for_create(:create, %{ - club_name: default_club_name, - member_field_visibility: %{"exit_date" => false} - }) - |> Ash.create!() -end - -IO.puts("✅ Seeds completed successfully!") -IO.puts("📝 Created sample data:") -IO.puts(" - Global settings: club_name = #{default_club_name}") -IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)") -IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)") - -password_configured = - System.get_env("ADMIN_PASSWORD") != nil or System.get_env("ADMIN_PASSWORD_FILE") != nil - -IO.puts( - " - Admin user: #{admin_email} (password: #{if password_configured, do: "set", else: "not set"})" -) - -IO.puts(" - Sample members: Hans, Greta, Friedrich") -IO.puts(" - Groups: Vorstand, Trainer*innen, Jugend, Newsletter (with members assigned)") - -IO.puts( - " - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de" -) - -IO.puts( - " - Linked members: Maria Weber ↔ maria.weber@example.de, Thomas Klein ↔ thomas.klein@example.de" -) - -IO.puts( - " - Custom field values: Sample data for Hans (6 fields), Greta (5 fields), Friedrich (6 fields)" -) - -IO.puts("🔗 Visit the application to see user-member relationships and custom fields in action!") +IO.puts("✅ All seeds completed.") diff --git a/priv/repo/seeds_bootstrap.exs b/priv/repo/seeds_bootstrap.exs new file mode 100644 index 0000000..15d74cd --- /dev/null +++ b/priv/repo/seeds_bootstrap.exs @@ -0,0 +1,313 @@ +# Bootstrap seeds: run in all environments (dev, test, prod). +# Creates only data required for system startup: fee types, custom fields, +# roles, admin user, system user, global settings. No members, no groups. + +alias Mv.Accounts +alias Mv.Membership +alias Mv.MembershipFees.MembershipFeeType + +require Ash.Query + +# 1. Membership fee types (authorize?: false for bootstrap) +# Names without interval to avoid duplication in UI; interval is shown separately. +fee_type_configs = [ + %{ + name: "Standard", + amount: Decimal.new("120.00"), + interval: :yearly, + description: "Standard jährlicher Mitgliedsbeitrag" + }, + %{ + name: "Ermäßigt", + amount: Decimal.new("80.00"), + interval: :yearly, + description: "Ermäßigter jährlicher Mitgliedsbeitrag" + }, + %{ + name: "Unterstützer", + amount: Decimal.new("60.00"), + interval: :half_yearly, + description: "Unterstützerbeitrag halbjährlich" + }, + %{ + name: "Fördermitglied", + amount: Decimal.new("30.00"), + interval: :quarterly, + description: "Fördermitgliedschaft quartalsweise" + }, + %{ + name: "Probemitgliedschaft", + amount: Decimal.new("10.00"), + interval: :monthly, + description: "Probemitgliedschaft monatlich" + } +] + +for attrs <- fee_type_configs do + MembershipFeeType + |> Ash.Changeset.for_create(:create, attrs) + |> Ash.create!( + upsert?: true, + upsert_identity: :unique_name, + authorize?: false, + domain: Mv.MembershipFees + ) +end + +# Resolve default fee type (Standard, 120€ yearly) for settings +default_fee_type = + Mv.MembershipFees.MembershipFeeType + |> Ash.Query.filter(name == "Standard") + |> Ash.read_one!(authorize?: false, domain: Mv.MembershipFees) + +# 2. Custom fields (authorize?: false for bootstrap) +# Only Geburtsdatum is shown in overview by default; others hidden to avoid clutter. +custom_field_configs = [ + %{ + name: "Geburtsdatum", + value_type: :date, + description: "Geburtsdatum der/des Mitglieds", + required: false, + show_in_overview: true + }, + %{ + name: "Datenschutzerklärung akzeptiert", + value_type: :boolean, + description: "Angabe, ob Datenschutzerklärung akzeptiert wurde", + required: false, + show_in_overview: false + }, + %{ + name: "SEPA-Mandat", + value_type: :boolean, + description: "SEPA-Lastschriftmandat erteilt", + required: false, + show_in_overview: false + }, + %{ + name: "Rechnungs-E-Mail", + value_type: :email, + description: "E-Mail-Adresse für Rechnungen", + required: false, + show_in_overview: false + }, + %{ + name: "IBAN", + value_type: :string, + description: "IBAN für Lastschrift", + required: false, + show_in_overview: false + }, + %{ + name: "Stunden ehrenamtlich", + value_type: :integer, + description: "Geleistete ehrenamtliche Stunden", + required: false, + show_in_overview: false + } +] + +for attrs <- custom_field_configs do + Membership.create_custom_field!( + attrs, + upsert?: true, + upsert_identity: :unique_name, + authorize?: false + ) +end + +# 3. Admin email and password fallback for dev/test +admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" +System.put_env("ADMIN_EMAIL", admin_email) + +if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and + is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do + System.put_env("ADMIN_PASSWORD", "testpassword") +end + +# 4. Authorization roles (German descriptions) +role_configs = [ + %{ + name: "Mitglied", + description: "Standardrolle für Mitglieder mit Zugriff nur auf die eigenen Daten", + permission_set_name: "own_data", + is_system_role: true + }, + %{ + name: "Vorstand", + description: "Vorstandsmitglied mit Lesezugriff auf alle Mitgliederdaten", + permission_set_name: "read_only", + is_system_role: false + }, + %{ + name: "Kassenwart", + description: "Kassenwart mit voller Mitglieder- und Zahlungsverwaltung", + permission_set_name: "normal_user", + is_system_role: false + }, + %{ + name: "Buchhaltung", + description: "Buchhaltung mit Lesezugriff für Prüfungen", + permission_set_name: "read_only", + is_system_role: false + }, + %{ + name: "Admin", + description: "Administrator mit uneingeschränktem Zugriff", + permission_set_name: "admin", + is_system_role: false + } +] + +Enum.each(role_configs, fn role_data -> + role_name = role_data.name + + case Mv.Authorization.Role + |> Ash.Query.filter(name == ^role_name) + |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do + {:ok, existing_role} when not is_nil(existing_role) -> + if existing_role.permission_set_name != role_data.permission_set_name or + existing_role.description != role_data.description do + existing_role + |> Ash.Changeset.for_update(:update_role, %{ + description: role_data.description, + permission_set_name: role_data.permission_set_name + }) + |> Ash.update!(authorize?: false, domain: Mv.Authorization) + end + + {:ok, nil} -> + Mv.Authorization.Role + |> Ash.Changeset.for_create(:create_role_with_system_flag, role_data) + |> Ash.create!(authorize?: false, domain: Mv.Authorization) + + {:error, error} -> + IO.puts("Warning: Failed to check for role #{role_data.name}: #{inspect(error)}") + end +end) + +admin_role = + case Mv.Authorization.Role + |> Ash.Query.filter(name == "Admin") + |> Ash.read_one(authorize?: false, domain: Mv.Authorization) do + {:ok, role} when not is_nil(role) -> role + _ -> nil + end + +if is_nil(admin_role) do + raise "Failed to create or find admin role. Cannot proceed with bootstrap." +end + +# 5. Admin user +Mv.Release.seed_admin() + +admin_user_with_role = + case Accounts.User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do + {:ok, user} when not is_nil(user) -> + user |> Ash.load!(:role, authorize?: false) + + {:ok, nil} -> + raise "Admin user not found after creation/assignment" + + {:error, error} -> + raise "Failed to load admin user: #{inspect(error)}" + end + +# 6. System user +system_user_email = Mv.Helpers.SystemActor.system_user_email() + +case Accounts.User + |> Ash.Query.filter(email == ^system_user_email) + |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do + {:ok, existing_system_user} when not is_nil(existing_system_user) -> + existing_system_user + |> Ash.Changeset.for_update(:update_internal, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!(authorize?: false) + + {:ok, nil} -> + Accounts.create_user!(%{email: system_user_email}, + upsert?: true, + upsert_identity: :unique_email, + authorize?: false + ) + |> Ash.Changeset.for_update(:update_internal, %{}) + |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) + |> Ash.update!(authorize?: false) + + {:error, error} -> + IO.puts("Warning: Failed to create system user: #{inspect(error)}") + IO.puts("SystemActor will fall back to admin user (#{admin_email})") +end + +# 7. Global settings (with default membership fee type and default field visibility) +# By default hide exit_date, notes, country, membership_fee_start_date in overview (like exit_date). +default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" + +default_hidden_in_overview = %{ + "exit_date" => false, + "notes" => false, + "country" => false, + "membership_fee_start_date" => false +} + +case Membership.get_settings() do + {:ok, existing_settings} -> + updates = + %{} + |> then(fn acc -> + if existing_settings.club_name != default_club_name, + do: Map.put(acc, :club_name, default_club_name), + else: acc + end) + |> then(fn acc -> + if existing_settings.default_membership_fee_type_id != default_fee_type.id, + do: Map.put(acc, :default_membership_fee_type_id, default_fee_type.id), + else: acc + end) + |> then(fn acc -> + visibility_config = existing_settings.member_field_visibility || %{} + # Ensure default-hidden fields are set if not already present (string or atom keys) + has_key = fn vis, k -> + try do + Map.has_key?(vis, k) or Map.has_key?(vis, String.to_existing_atom(k)) + rescue + ArgumentError -> Map.has_key?(vis, k) + end + end + merged = + Enum.reduce(default_hidden_in_overview, visibility_config, fn {key, val}, vis -> + if has_key.(vis, key), do: vis, else: Map.put(vis, key, val) + end) + if merged != visibility_config, do: Map.put(acc, :member_field_visibility, merged), else: acc + end) + + if map_size(updates) > 0 do + {:ok, _} = Membership.update_settings(existing_settings, updates) + end + + {:ok, nil} -> + {:ok, _} = + Membership.Setting + |> Ash.Changeset.for_create(:create, %{ + club_name: default_club_name, + member_field_visibility: default_hidden_in_overview, + default_membership_fee_type_id: default_fee_type.id + }) + |> Ash.create!() +end + +IO.puts("✅ Bootstrap seeds completed.") + +IO.puts( + " - Fee types: 5 (Standard, Ermäßigt, Unterstützer, Fördermitglied, Probemitgliedschaft)" +) + +IO.puts( + " - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)" +) + +IO.puts(" - Roles: 5 (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)") +IO.puts(" - Default fee type: Standard (120€ yearly)") diff --git a/priv/repo/seeds_dev.exs b/priv/repo/seeds_dev.exs new file mode 100644 index 0000000..edf7638 --- /dev/null +++ b/priv/repo/seeds_dev.exs @@ -0,0 +1,491 @@ +# Dev/local seeds: run only in dev and test (Mix.env in [:dev, :test]). +# Creates 20 sample members, groups, and optional custom field values. +# Requires bootstrap seeds to have run first. + +alias Mv.Accounts +alias Mv.Membership +alias Mv.MembershipFees.CycleGenerator +alias Mv.MembershipFees.MembershipFeeType + +require Ash.Query + +admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost" + +admin_user_with_role = + case Accounts.User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do + {:ok, user} when not is_nil(user) -> + user |> Ash.load!(:role, authorize?: false) + + _ -> + raise "Dev seeds require bootstrap: admin user not found (#{admin_email})" + end + +all_fee_types = + MembershipFeeType + |> Ash.Query.sort(name: :asc) + |> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees) + |> Enum.to_list() + +# Countries: mostly Germany, 1–2 exceptions (index 7 = Österreich, index 14 = Schweiz) +countries_list = + List.duplicate("Deutschland", 20) + |> List.replace_at(7, "Österreich") + |> List.replace_at(14, "Schweiz") + +# 20 members: varied names, cities, join dates; fee types by index (last 2 without fee type) +member_configs = [ + %{ + first_name: "Anna", + last_name: "Schmidt", + city: "München", + street: "Hauptstraße", + house_number: "1", + postal_code: "80331", + join_date: ~D[2022-01-10] + }, + %{ + first_name: "Bruno", + last_name: "Müller", + city: "Hamburg", + street: "Lindenstraße", + house_number: "5", + postal_code: "20095", + join_date: ~D[2022-03-15] + }, + %{ + first_name: "Clara", + last_name: "Fischer", + city: "Berlin", + street: "Kastanienallee", + house_number: "12", + postal_code: "10435", + join_date: ~D[2022-05-20] + }, + %{ + first_name: "David", + last_name: "Weber", + city: "Köln", + street: "Rheinstraße", + house_number: "8", + postal_code: "50667", + join_date: ~D[2022-07-01] + }, + %{ + first_name: "Elena", + last_name: "Wagner", + city: "Frankfurt", + street: "Goetheplatz", + house_number: "3", + postal_code: "60313", + join_date: ~D[2022-09-12] + }, + %{ + first_name: "Felix", + last_name: "Becker", + city: "Stuttgart", + street: "Königstraße", + house_number: "22", + postal_code: "70173", + join_date: ~D[2023-01-05] + }, + %{ + first_name: "Greta", + last_name: "Schulz", + city: "Düsseldorf", + street: "Schadowstraße", + house_number: "14", + postal_code: "40212", + join_date: ~D[2023-02-14] + }, + %{ + first_name: "Henrik", + last_name: "Hoffmann", + city: "Leipzig", + street: "Nikolaistraße", + house_number: "7", + postal_code: "04109", + join_date: ~D[2023-04-20] + }, + %{ + first_name: "Ines", + last_name: "Koch", + city: "Dortmund", + street: "Westenhellweg", + house_number: "45", + postal_code: "44137", + join_date: ~D[2023-06-08] + }, + %{ + first_name: "Jakob", + last_name: "Richter", + city: "Essen", + street: "Kettwiger Straße", + house_number: "2", + postal_code: "45127", + join_date: ~D[2023-08-11] + }, + %{ + first_name: "Laura", + last_name: "Klein", + city: "Dresden", + street: "Prager Straße", + house_number: "9", + postal_code: "01069", + join_date: ~D[2023-10-01] + }, + %{ + first_name: "Max", + last_name: "Wolf", + city: "Hannover", + street: "Georgstraße", + house_number: "50", + postal_code: "30159", + join_date: ~D[2023-11-15] + }, + %{ + first_name: "Nina", + last_name: "Schröder", + city: "Nürnberg", + street: "Königstraße", + house_number: "73", + postal_code: "90402", + join_date: ~D[2024-01-20] + }, + %{ + first_name: "Oliver", + last_name: "Neumann", + city: "Bremen", + street: "Obernstraße", + house_number: "31", + postal_code: "28195", + join_date: ~D[2024-03-10] + }, + %{ + first_name: "Paula", + last_name: "Schwarz", + city: "Mannheim", + street: "Planken", + house_number: "11", + postal_code: "68161", + join_date: ~D[2024-05-22] + }, + %{ + first_name: "Quirin", + last_name: "Zimmermann", + city: "Karlsruhe", + street: "Kaiserstraße", + house_number: "145", + postal_code: "76133", + join_date: ~D[2024-07-07] + }, + %{ + first_name: "Rosa", + last_name: "Braun", + city: "Wiesbaden", + street: "Wilhelmstraße", + house_number: "6", + postal_code: "65183", + join_date: ~D[2024-09-01] + }, + %{ + first_name: "Stefan", + last_name: "Krüger", + city: "Münster", + street: "Ludgeristraße", + house_number: "18", + postal_code: "48143", + join_date: ~D[2024-10-15] + }, + %{ + first_name: "Thea", + last_name: "Hartmann", + city: "Augsburg", + street: "Maximilianstraße", + house_number: "4", + postal_code: "86150", + join_date: ~D[2024-11-20] + }, + %{ + first_name: "Uwe", + last_name: "Lange", + city: "Bonn", + street: "Remigiusstraße", + house_number: "27", + postal_code: "53111", + join_date: ~D[2024-12-01] + } +] + +# Fee type index per member: 0..4 round-robin for first 18, nil for last 2 +# Cycle status: all_paid, all_unpaid, mixed (varied) +cycle_statuses = [ + :all_paid, + :all_unpaid, + :mixed, + :all_paid, + :mixed, + :all_unpaid, + :all_paid, + :mixed, + :all_unpaid, + :all_paid, + :mixed, + :all_paid, + :all_unpaid, + :mixed, + :all_paid, + :mixed, + :all_unpaid, + :all_paid, + :mixed, + nil +] + +Enum.with_index(member_configs) +|> Enum.each(fn {config, index} -> + email = "mitglied#{index + 1}@example.de" + fee_type_index = if index >= 18, do: nil, else: rem(index, length(all_fee_types)) + fee_type_id = if fee_type_index, do: Enum.at(all_fee_types, fee_type_index).id, else: nil + cycle_status = Enum.at(cycle_statuses, index) + + base_attrs = %{ + first_name: config.first_name, + last_name: config.last_name, + email: email, + join_date: config.join_date, + city: config.city, + street: config.street, + house_number: config.house_number, + postal_code: config.postal_code, + country: Enum.at(countries_list, index) + } + + member_attrs = + if fee_type_id, + do: Map.put(base_attrs, :membership_fee_type_id, fee_type_id), + else: base_attrs + + member = + Membership.create_member!(member_attrs, + upsert?: true, + upsert_identity: :unique_email, + actor: admin_user_with_role + ) + + final_member = + if is_nil(member.membership_fee_type_id) and fee_type_id do + {:ok, updated} = + Membership.update_member(member, %{membership_fee_type_id: fee_type_id}, + actor: admin_user_with_role + ) + + updated + else + member + end + + if not is_nil(final_member.membership_fee_type_id) and not is_nil(cycle_status) do + member_with_cycles = + Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role) + + cycles = + if Enum.empty?(member_with_cycles.membership_fee_cycles) do + {:ok, new_cycles, _} = + CycleGenerator.generate_cycles_for_member(final_member.id, + skip_lock?: true, + actor: admin_user_with_role + ) + + new_cycles + else + member_with_cycles.membership_fee_cycles + end + + cycles + |> Enum.sort_by(& &1.cycle_start, Date) + |> Enum.with_index() + |> Enum.each(fn {cycle, idx} -> + status = + case cycle_status do + :all_paid -> + :paid + + :all_unpaid -> + :unpaid + + :mixed -> + case rem(idx, 3) do + 0 -> :paid + 1 -> :unpaid + 2 -> :suspended + end + + _ -> + cycle.status + end + + if cycle.status != status do + cycle + |> Ash.Changeset.for_update(:update, %{status: status}) + |> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees) + end + end) + end +end) + +# Groups (idempotent) +group_configs = [ + %{name: "Vorstand", description: "Gremium Vorstand"}, + %{name: "Jugend", description: "Jugendbereich"}, + %{name: "Newsletter", description: "Empfänger*innen Newsletter"} +] + +existing_groups = + case Membership.list_groups(actor: admin_user_with_role) do + {:ok, list} -> list + {:error, _} -> [] + end + +existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name)) + +seed_groups = + Enum.reduce(group_configs, %{}, fn config, acc -> + name = config.name + + if MapSet.member?(existing_names_lower, String.downcase(name)) do + group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name))) + Map.put(acc, name, group) + else + {:ok, group} = + Membership.create_group(%{name: name, description: config.description}, + actor: admin_user_with_role + ) + + Map.put(acc, name, group) + end + end) + +# Test users: create users linked to members (same email as member so sync is no-op), +# each with a different role for testing authorization. +all_members = Ash.read!(Membership.Member, actor: admin_user_with_role) + +test_users_config = [ + {"mitglied1@example.de", "Mitglied"}, + {"mitglied2@example.de", "Vorstand"}, + {"mitglied3@example.de", "Kassenwart"}, + {"mitglied4@example.de", "Buchhaltung"} +] + +roles_by_name = + Mv.Authorization.Role + |> Ash.read!(authorize?: false, domain: Mv.Authorization) + |> Map.new(&{&1.name, &1}) + +Enum.each(test_users_config, fn {email, role_name} -> + member = Enum.find(all_members, &(&1.email == email)) + role = roles_by_name[role_name] + + if not is_nil(member) and not is_nil(role) do + user = + Accounts.create_user!( + %{email: email, member: %{id: member.id}}, + upsert?: true, + upsert_identity: :unique_email, + actor: admin_user_with_role + ) + + user + |> Ash.Changeset.for_update(:update_user, %{role_id: role.id}, domain: Mv.Accounts) + |> Ash.update!(actor: admin_user_with_role) + end +end) + +# Assign some members to groups (mitglied1–5 to Vorstand/Newsletter etc.) +member_group_assignments = [ + {"mitglied1@example.de", ["Vorstand", "Newsletter"]}, + {"mitglied2@example.de", ["Jugend", "Newsletter"]}, + {"mitglied3@example.de", ["Vorstand"]}, + {"mitglied4@example.de", ["Newsletter"]}, + {"mitglied5@example.de", ["Newsletter"]} +] + +find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end + +Enum.each(member_group_assignments, fn {email, group_names} -> + member = find_member.(email) + + if member do + Enum.each(group_names, fn group_name -> + group = seed_groups[group_name] + + if group do + Membership.create_member_group( + %{member_id: member.id, group_id: group.id}, + actor: admin_user_with_role + ) + end + end) + end +end) + +# Custom field values for ~80% of members (16 of 20): most of the 6 fields filled per member +all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role) +find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end + +# 16 members with 4–6 custom field values each (Geburtsdatum, Datenschutz, SEPA, Rechnungs-E-Mail, IBAN, Stunden) +custom_value_assignments = + Enum.map(1..16, fn n -> + email = "mitglied#{n}@example.de" + # Vary birth dates and values per index + base_date = Date.add(~D[1970-01-01], 365 * (n + 10) + rem(n * 31, 365)) + values = [ + {"Geburtsdatum", %{"_union_type" => "date", "_union_value" => base_date}}, + {"Datenschutzerklärung akzeptiert", + %{"_union_type" => "boolean", "_union_value" => n in [1, 2, 3, 5, 7, 9, 11, 13, 15]}}, + {"SEPA-Mandat", %{"_union_type" => "boolean", "_union_value" => rem(n, 3) != 0}}, + {"Rechnungs-E-Mail", + %{"_union_type" => "email", "_union_value" => "rechnung#{n}@example.de"}}, + {"IBAN", + %{ + "_union_type" => "string", + "_union_value" => + "DE8937040044#{String.pad_leading(to_string(rem(532013000 + n, 1_000_000_000)), 10, "0")}" + }}, + {"Stunden ehrenamtlich", %{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}} + ] + # Drop 0–2 fields per member so not all have 6 (still ~80% overall filled) + drop_count = rem(n, 3) + {email, Enum.take(values, 6 - drop_count)} + end) + +for {email, values} <- custom_value_assignments do + member = find_member.(email) + + if member do + Enum.each(values, fn {field_name, value} -> + field = find_field.(field_name) + + if field do + Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: value + }) + |> Ash.create!( + upsert?: true, + upsert_identity: :unique_custom_field_per_member, + actor: admin_user_with_role + ) + end + end) + end +end + +IO.puts("✅ Dev seeds completed.") +IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz)") +IO.puts(" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung") +IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)") +IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)") -- 2.47.2 From f0a8dfcc217c1ccffb99dec4662a36d15bc1e14b Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 3 Mar 2026 16:48:02 +0100 Subject: [PATCH 02/18] Suppress redefining module warnings via compiler_options --- mix.exs | 2 ++ priv/repo/seeds.exs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/mix.exs b/mix.exs index d200150..364396a 100644 --- a/mix.exs +++ b/mix.exs @@ -2,6 +2,8 @@ defmodule Mv.MixProject do use Mix.Project def project do + Code.compiler_options(ignore_module_conflict: true) + [ app: :mv, version: "0.1.0", diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 1b5d0a0..fda4586 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -5,6 +5,8 @@ # Bootstrap runs in all environments. Dev seeds (members, groups, sample data) # run only in dev and test. +Code.compiler_options(ignore_module_conflict: true) + # Always run bootstrap (fee types, custom fields, roles, admin, system user, settings) Code.eval_file("priv/repo/seeds_bootstrap.exs") @@ -14,3 +16,4 @@ if Mix.env() in [:dev, :test] do end IO.puts("✅ All seeds completed.") +Code.compiler_options(ignore_module_conflict: false) -- 2.47.2 From 81ce2045021aadff0eb4844794bd776540a23539 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 3 Mar 2026 17:46:38 +0100 Subject: [PATCH 03/18] Fix Credo Readability (strict) - Max line length, implicit try, alias order, zero-arity defs - String sigils, long comments split; redundant blank lines fixed --- .../user/validations/oidc_email_collision.ex | 2 +- lib/mv/membership/member_export/build.ex | 8 +-- lib/mv/membership/members_csv.ex | 8 +-- lib/mv/membership/members_pdf.ex | 8 +-- lib/mv/oidc_role_sync.ex | 8 +-- .../sync_linked_member_after_user_change.ex | 6 +- lib/mv/vereinfacht/vereinfacht.ex | 6 +- lib/mv_web.ex | 2 +- .../controllers/member_export_controller.ex | 12 ++-- lib/mv_web/live/member_live/form.ex | 34 ++++------ lib/mv_web/live/member_live/index.ex | 8 +-- .../show/membership_fees_component.ex | 6 +- lib/mv_web/live/role_live/show.ex | 68 +++++++++---------- lib/mv_web/live/statistics_live.ex | 2 +- lib/mv_web/plugs/check_page_permission.ex | 3 +- .../member_cycle_calculations_test.exs | 4 +- .../member_type_change_integration_test.exs | 4 +- .../changes/validate_same_interval_test.exs | 2 +- test/membership_fees/foreign_key_test.exs | 2 +- .../membership_fee_type_integration_test.exs | 4 +- .../membership_fee_type_test.exs | 2 +- test/mv/helpers/system_actor_test.exs | 4 +- .../custom_field_value_policies_test.exs | 2 +- test/mv/membership/member_policies_test.exs | 2 +- .../membership_fee_cycle_policies_test.exs | 2 +- test/mv/oidc_role_sync_test.exs | 2 +- test/mv/statistics_test.exs | 2 +- test/mv_web/authorization_test.exs | 4 +- .../controllers/auth_controller_test.exs | 3 +- .../member_export_controller_test.exs | 4 +- .../helpers/membership_fee_helpers_test.exs | 2 +- test/mv_web/live/group_live/index_test.exs | 2 +- .../live/group_live/integration_test.exs | 2 +- .../group_live/show_accessibility_test.exs | 2 +- .../live/group_live/show_add_member_test.exs | 2 +- .../show_add_remove_members_test.exs | 2 +- .../group_live/show_authorization_test.exs | 2 +- .../live/group_live/show_integration_test.exs | 2 +- .../group_live/show_member_search_test.exs | 2 +- .../group_live/show_remove_member_test.exs | 2 +- test/mv_web/live/group_live/show_test.exs | 2 +- .../index/membership_fee_status_test.exs | 4 +- .../index_membership_fee_status_test.exs | 2 +- test/mv_web/member_live/index_test.exs | 2 +- .../membership_fee_integration_test.exs | 2 +- .../member_live/show_membership_fees_test.exs | 2 +- .../plugs/check_page_permission_test.exs | 14 ++-- test/mv_web/user_live/index_test.exs | 3 +- 48 files changed, 131 insertions(+), 144 deletions(-) diff --git a/lib/accounts/user/validations/oidc_email_collision.ex b/lib/accounts/user/validations/oidc_email_collision.ex index 08a8911..f92c7e2 100644 --- a/lib/accounts/user/validations/oidc_email_collision.ex +++ b/lib/accounts/user/validations/oidc_email_collision.ex @@ -164,7 +164,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do end @impl true - def atomic?(), do: false + def atomic?, do: false @impl true def describe(_opts) do diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index 9a1c03a..e08a6f4 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -519,11 +519,9 @@ defmodule Mv.Membership.MemberExport.Build do defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_binary(k) do - try do - String.to_existing_atom(k) - rescue - ArgumentError -> k - end + String.to_existing_atom(k) + rescue + ArgumentError -> k end defp get_cfv_by_id(member, id) do diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex index 3d1fdd8..6331893 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -74,11 +74,9 @@ defmodule Mv.Membership.MembersCSV do defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_binary(k) do - try do - String.to_existing_atom(k) - rescue - ArgumentError -> k - end + String.to_existing_atom(k) + rescue + ArgumentError -> k end defp get_cfv_by_id(member, id) do diff --git a/lib/mv/membership/members_pdf.ex b/lib/mv/membership/members_pdf.ex index 0d6e469..b2989ca 100644 --- a/lib/mv/membership/members_pdf.ex +++ b/lib/mv/membership/members_pdf.ex @@ -299,11 +299,9 @@ defmodule Mv.Membership.MembersPDF do defp date_column?(_), do: false defp key_to_atom_safe(key) when is_binary(key) do - try do - String.to_existing_atom(key) - rescue - ArgumentError -> key - end + String.to_existing_atom(key) + rescue + ArgumentError -> key end defp key_to_atom_safe(key), do: key diff --git a/lib/mv/oidc_role_sync.ex b/lib/mv/oidc_role_sync.ex index fbec9de..a13748a 100644 --- a/lib/mv/oidc_role_sync.ex +++ b/lib/mv/oidc_role_sync.ex @@ -82,11 +82,9 @@ defmodule Mv.OidcRoleSync do end defp safe_get_atom(map, key) when is_binary(key) do - try do - Map.get(map, String.to_existing_atom(key)) - rescue - ArgumentError -> nil - end + Map.get(map, String.to_existing_atom(key)) + rescue + ArgumentError -> nil end defp safe_get_atom(_map, _key), do: nil diff --git a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex index cffb079..4465690 100644 --- a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex +++ b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex @@ -10,10 +10,10 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do use Ash.Resource.Change require Logger - alias Mv.Membership.Member - alias Mv.Membership - alias Mv.Helpers.SystemActor alias Mv.Helpers + alias Mv.Helpers.SystemActor + alias Mv.Membership + alias Mv.Membership.Member @impl true def change(changeset, _opts, _context) do diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex index 6520b64..83492b7 100644 --- a/lib/mv/vereinfacht/vereinfacht.ex +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -9,10 +9,10 @@ defmodule Mv.Vereinfacht do """ require Ash.Query import Ash.Expr - alias Mv.Vereinfacht.Client - alias Mv.Membership.Member - alias Mv.Helpers.SystemActor alias Mv.Helpers + alias Mv.Helpers.SystemActor + alias Mv.Membership.Member + alias Mv.Vereinfacht.Client @doc """ Tests the connection to the Vereinfacht API using the current configuration. diff --git a/lib/mv_web.ex b/lib/mv_web.ex index 2b1ade6..f827e2f 100644 --- a/lib/mv_web.ex +++ b/lib/mv_web.ex @@ -94,8 +94,8 @@ defmodule MvWeb do import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2] # Common modules used in templates - alias Phoenix.LiveView.JS alias MvWeb.Layouts + alias Phoenix.LiveView.JS # Routes generation with the ~p sigil unquote(verified_routes()) diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 715f86a..a1730ee 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -14,8 +14,8 @@ defmodule MvWeb.MemberExportController do alias Mv.Membership.CustomField alias Mv.Membership.Member alias Mv.Membership.MembersCSV - alias MvWeb.Translations.MemberFields alias MvWeb.MemberLive.Index.MembershipFeeStatus + alias MvWeb.Translations.MemberFields use Gettext, backend: MvWeb.Gettext @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ @@ -105,12 +105,10 @@ defmodule MvWeb.MemberExportController do end defp atom_exists?(name) do - try do - _ = String.to_existing_atom(name) - true - rescue - ArgumentError -> false - end + _ = String.to_existing_atom(name) + true + rescue + ArgumentError -> false end defp extract_list(params, key) do diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index c83837f..88d0444 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -446,23 +446,21 @@ defmodule MvWeb.MemberLive.Form do end def handle_event("save", %{"member" => member_params}, socket) do - try do - actor = current_actor(socket) + actor = current_actor(socket) - case submit_form(socket.assigns.form, member_params, actor) do - {:ok, member} -> - handle_save_success(socket, member) + case submit_form(socket.assigns.form, member_params, actor) do + {:ok, member} -> + handle_save_success(socket, member) - {:error, form} -> - handle_save_error(socket, form) - end - rescue - _e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] -> - handle_save_forbidden(socket) - - e -> - handle_save_exception(socket, e) + {:error, form} -> + handle_save_error(socket, form) end + rescue + _e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] -> + handle_save_forbidden(socket) + + e -> + handle_save_exception(socket, e) end @impl true @@ -690,11 +688,9 @@ defmodule MvWeb.MemberLive.Form do # Extracts message from struct error using Ash.ErrorKind protocol defp extract_struct_error_message(error) do - try do - Ash.ErrorKind.message(error) - rescue - Protocol.UndefinedError -> gettext("Failed to save member. Please try again.") - end + Ash.ErrorKind.message(error) + rescue + Protocol.UndefinedError -> gettext("Failed to save member. Please try again.") end # Checks if form has any errors diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index da1b6cf..c83a204 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -708,11 +708,9 @@ defmodule MvWeb.MemberLive.Index do end defp to_sort_id(field) when is_binary(field) do - try do - String.to_existing_atom("sort_#{field}") - rescue - ArgumentError -> :"sort_#{field}" - end + String.to_existing_atom("sort_#{field}") + rescue + ArgumentError -> :"sort_#{field}" end defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}" diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index be72392..185de88 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -18,10 +18,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do alias Mv.Membership alias Mv.MembershipFees - alias Mv.MembershipFees.MembershipFeeType - alias Mv.MembershipFees.MembershipFeeCycle - alias Mv.MembershipFees.CycleGenerator alias Mv.MembershipFees.CalendarCycles + alias Mv.MembershipFees.CycleGenerator + alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.MembershipFeeHelpers @impl true diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index ae390c3..a5402c4 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -21,49 +21,47 @@ defmodule MvWeb.RoleLive.Show do @impl true def mount(%{"id" => id}, _session, socket) do - try do - case Ash.get( - Mv.Authorization.Role, - id, - domain: Mv.Authorization, - actor: socket.assigns[:current_user] - ) do - {:ok, role} -> - user_count = load_user_count(role, socket.assigns[:current_user]) + case Ash.get( + Mv.Authorization.Role, + id, + domain: Mv.Authorization, + actor: socket.assigns[:current_user] + ) do + {:ok, role} -> + user_count = load_user_count(role, socket.assigns[:current_user]) - {:ok, - socket - |> assign(:page_title, gettext("Show Role")) - |> assign(:role, role) - |> assign(:user_count, user_count) - |> assign(:show_delete_modal, false)} + {:ok, + socket + |> assign(:page_title, gettext("Show Role")) + |> assign(:role, role) + |> assign(:user_count, user_count) + |> assign(:show_delete_modal, false)} - {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} -> + {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} -> + {:ok, + socket + |> put_flash(:error, gettext("Role not found.")) + |> redirect(to: ~p"/admin/roles")} + + {:error, error} -> + {:ok, + socket + |> put_flash(:error, format_error(error)) + |> redirect(to: ~p"/admin/roles")} + end + rescue + e in [Ash.Error.Invalid] -> + # Handle exceptions that Ash.get might throw (e.g., policy violations) + case e do + %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]} -> {:ok, socket |> put_flash(:error, gettext("Role not found.")) |> redirect(to: ~p"/admin/roles")} - {:error, error} -> - {:ok, - socket - |> put_flash(:error, format_error(error)) - |> redirect(to: ~p"/admin/roles")} + _ -> + reraise e, __STACKTRACE__ end - rescue - e in [Ash.Error.Invalid] -> - # Handle exceptions that Ash.get might throw (e.g., policy violations) - case e do - %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]} -> - {:ok, - socket - |> put_flash(:error, gettext("Role not found.")) - |> redirect(to: ~p"/admin/roles")} - - _ -> - reraise e, __STACKTRACE__ - end - end end @impl true diff --git a/lib/mv_web/live/statistics_live.ex b/lib/mv_web/live/statistics_live.ex index 63ed0c2..1ac613e 100644 --- a/lib/mv_web/live/statistics_live.ex +++ b/lib/mv_web/live/statistics_live.ex @@ -9,8 +9,8 @@ defmodule MvWeb.StatisticsLive do require Logger import MvWeb.LiveHelpers, only: [current_actor: 1] - alias Mv.Statistics alias Mv.MembershipFees.MembershipFeeType + alias Mv.Statistics alias MvWeb.Helpers.MembershipFeeHelpers @impl true diff --git a/lib/mv_web/plugs/check_page_permission.ex b/lib/mv_web/plugs/check_page_permission.ex index 616d7fc..cbbb76d 100644 --- a/lib/mv_web/plugs/check_page_permission.ex +++ b/lib/mv_web/plugs/check_page_permission.ex @@ -221,7 +221,8 @@ defmodule MvWeb.Plugs.CheckPagePermission do defp path_param_equals(_, _, _, _), do: false - # For own_data: only allow show/edit when :id is the user's linked member. For other permission sets: allow when not reserved. + # For own_data: only allow show/edit when :id is the user's linked member. + # For other permission sets: allow when not reserved. defp members_show_allowed?(pattern, request_path, user) do if permission_set_name_from_user(user) == "own_data" do path_param_equals(pattern, request_path, "id", user_member_id(user)) diff --git a/test/membership/member_cycle_calculations_test.exs b/test/membership/member_cycle_calculations_test.exs index 98cdb7c..9be1272 100644 --- a/test/membership/member_cycle_calculations_test.exs +++ b/test/membership/member_cycle_calculations_test.exs @@ -4,9 +4,9 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do """ use Mv.DataCase, async: true - alias Mv.MembershipFees.MembershipFeeType - alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.CalendarCycles + alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.MembershipFeeType setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() diff --git a/test/membership/member_type_change_integration_test.exs b/test/membership/member_type_change_integration_test.exs index 6c252d6..35b3137 100644 --- a/test/membership/member_type_change_integration_test.exs +++ b/test/membership/member_type_change_integration_test.exs @@ -4,9 +4,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do """ use Mv.DataCase, async: true - alias Mv.MembershipFees.MembershipFeeType - alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.CalendarCycles + alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.MembershipFeeType require Ash.Query diff --git a/test/membership_fees/changes/validate_same_interval_test.exs b/test/membership_fees/changes/validate_same_interval_test.exs index 2310537..31c2847 100644 --- a/test/membership_fees/changes/validate_same_interval_test.exs +++ b/test/membership_fees/changes/validate_same_interval_test.exs @@ -4,8 +4,8 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do """ use Mv.DataCase, async: true - alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.Changes.ValidateSameInterval + alias Mv.MembershipFees.MembershipFeeType setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() diff --git a/test/membership_fees/foreign_key_test.exs b/test/membership_fees/foreign_key_test.exs index 54a7cc5..6118d18 100644 --- a/test/membership_fees/foreign_key_test.exs +++ b/test/membership_fees/foreign_key_test.exs @@ -4,9 +4,9 @@ defmodule Mv.MembershipFees.ForeignKeyTest do """ use Mv.DataCase, async: true + alias Mv.Membership.Member alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeType - alias Mv.Membership.Member setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() diff --git a/test/membership_fees/membership_fee_type_integration_test.exs b/test/membership_fees/membership_fee_type_integration_test.exs index 88f620d..f834094 100644 --- a/test/membership_fees/membership_fee_type_integration_test.exs +++ b/test/membership_fees/membership_fee_type_integration_test.exs @@ -4,9 +4,9 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do """ use Mv.DataCase, async: false - alias Mv.MembershipFees.MembershipFeeType - alias Mv.MembershipFees.MembershipFeeCycle alias Mv.Membership.Member + alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.MembershipFeeType require Ash.Query diff --git a/test/membership_fees/membership_fee_type_test.exs b/test/membership_fees/membership_fee_type_test.exs index 21f3100..e80b196 100644 --- a/test/membership_fees/membership_fee_type_test.exs +++ b/test/membership_fees/membership_fee_type_test.exs @@ -169,8 +169,8 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do end test "cannot delete when cycles exist", %{actor: actor, fee_type: fee_type} do - alias Mv.MembershipFees.MembershipFeeCycle alias Mv.Membership.Member + alias Mv.MembershipFees.MembershipFeeCycle # Create a member with this fee type {:ok, member} = diff --git a/test/mv/helpers/system_actor_test.exs b/test/mv/helpers/system_actor_test.exs index add2ad5..96d38d8 100644 --- a/test/mv/helpers/system_actor_test.exs +++ b/test/mv/helpers/system_actor_test.exs @@ -4,9 +4,9 @@ defmodule Mv.Helpers.SystemActorTest do """ use Mv.DataCase, async: false - alias Mv.Helpers.SystemActor - alias Mv.Authorization alias Mv.Accounts + alias Mv.Authorization + alias Mv.Helpers.SystemActor require Ash.Query diff --git a/test/mv/membership/custom_field_value_policies_test.exs b/test/mv/membership/custom_field_value_policies_test.exs index 64d6ff2..a7b5f2b 100644 --- a/test/mv/membership/custom_field_value_policies_test.exs +++ b/test/mv/membership/custom_field_value_policies_test.exs @@ -9,8 +9,8 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do # async: false because we need database commits to be visible across queries use Mv.DataCase, async: false - alias Mv.Membership.{CustomField, CustomFieldValue} alias Mv.Accounts + alias Mv.Membership.{CustomField, CustomFieldValue} require Ash.Query diff --git a/test/mv/membership/member_policies_test.exs b/test/mv/membership/member_policies_test.exs index f2d3084..d45bdd0 100644 --- a/test/mv/membership/member_policies_test.exs +++ b/test/mv/membership/member_policies_test.exs @@ -10,8 +10,8 @@ defmodule Mv.Membership.MemberPoliciesTest do # in the same test (especially for unlinked members) use Mv.DataCase, async: false - alias Mv.Membership alias Mv.Accounts + alias Mv.Membership require Ash.Query diff --git a/test/mv/membership_fees/membership_fee_cycle_policies_test.exs b/test/mv/membership_fees/membership_fee_cycle_policies_test.exs index 4d0badb..cf7e889 100644 --- a/test/mv/membership_fees/membership_fee_cycle_policies_test.exs +++ b/test/mv/membership_fees/membership_fee_cycle_policies_test.exs @@ -8,8 +8,8 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do """ use Mv.DataCase, async: false - alias Mv.MembershipFees alias Mv.Membership + alias Mv.MembershipFees setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() diff --git a/test/mv/oidc_role_sync_test.exs b/test/mv/oidc_role_sync_test.exs index a4729ec..1f6a129 100644 --- a/test/mv/oidc_role_sync_test.exs +++ b/test/mv/oidc_role_sync_test.exs @@ -92,7 +92,7 @@ defmodule Mv.OidcRoleSyncTest do # Minimal JWT: header.payload.signature with "groups" in payload (Rauthy puts groups in access_token) payload = Jason.encode!(%{"groups" => ["mila-admin"], "sub" => "oidc-123"}) payload_b64 = Base.url_encode64(payload, padding: false) - header_b64 = Base.url_encode64("{\"alg\":\"HS256\",\"typ\":\"JWT\"}", padding: false) + header_b64 = Base.url_encode64(~s({"alg":"HS256","typ":"JWT"}), padding: false) sig_b64 = Base.url_encode64("sig", padding: false) access_token = "#{header_b64}.#{payload_b64}.#{sig_b64}" oauth_tokens = %{"access_token" => access_token} diff --git a/test/mv/statistics_test.exs b/test/mv/statistics_test.exs index d4b4e05..6b72ffb 100644 --- a/test/mv/statistics_test.exs +++ b/test/mv/statistics_test.exs @@ -8,10 +8,10 @@ defmodule Mv.StatisticsTest do import Ash.Expr alias Mv.Membership.Member - alias Mv.Statistics alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeType + alias Mv.Statistics setup do actor = Mv.Helpers.SystemActor.get_system_actor() diff --git a/test/mv_web/authorization_test.exs b/test/mv_web/authorization_test.exs index 7bb0b2a..d15b523 100644 --- a/test/mv_web/authorization_test.exs +++ b/test/mv_web/authorization_test.exs @@ -4,9 +4,9 @@ defmodule MvWeb.AuthorizationTest do """ use ExUnit.Case, async: true - alias MvWeb.Authorization - alias Mv.Membership.Member alias Mv.Accounts.User + alias Mv.Membership.Member + alias MvWeb.Authorization describe "can?/3 with resource atom" do test "returns true when user has permission for resource+action" do diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index f31327c..0841e68 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -199,7 +199,8 @@ defmodule MvWeb.AuthControllerTest do assert to =~ "/auth/user/password/sign_in_with_token" - # After login, user is redirected to /auth/user/password/sign_in_with_token. Session handling for protected routes should be tested in integration or E2E tests. + # After login, user is redirected to /auth/user/password/sign_in_with_token. + # Session handling for protected routes should be tested in integration or E2E tests. end # Edge cases diff --git a/test/mv_web/controllers/member_export_controller_test.exs b/test/mv_web/controllers/member_export_controller_test.exs index cfc89ec..4192a27 100644 --- a/test/mv_web/controllers/member_export_controller_test.exs +++ b/test/mv_web/controllers/member_export_controller_test.exs @@ -177,8 +177,8 @@ defmodule MvWeb.MemberExportControllerTest do assert body =~ "Alice" end - # Regression: when membership_fee_start_date is not in member_fields, Fee Type must still be exported (append fallback) - test "export includes Fee Type when only first_name and membership_fee_type are requested (no start_date)", + # Regression: when membership_fee_start_date is not in member_fields, Fee Type must still be exported. + test "export includes Fee Type when first_name and membership_fee_type only (no start_date)", %{ conn: conn, member1: m1 diff --git a/test/mv_web/helpers/membership_fee_helpers_test.exs b/test/mv_web/helpers/membership_fee_helpers_test.exs index 9cba825..773bd26 100644 --- a/test/mv_web/helpers/membership_fee_helpers_test.exs +++ b/test/mv_web/helpers/membership_fee_helpers_test.exs @@ -6,8 +6,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do require Ash.Query - alias MvWeb.Helpers.MembershipFeeHelpers alias Mv.MembershipFees.CalendarCycles + alias MvWeb.Helpers.MembershipFeeHelpers setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() diff --git a/test/mv_web/live/group_live/index_test.exs b/test/mv_web/live/group_live/index_test.exs index 751b5c6..59e32a4 100644 --- a/test/mv_web/live/group_live/index_test.exs +++ b/test/mv_web/live/group_live/index_test.exs @@ -13,8 +13,8 @@ defmodule MvWeb.GroupLive.IndexTest do import Phoenix.LiveViewTest use Gettext, backend: MvWeb.Gettext - alias Mv.Membership alias Mv.Fixtures + alias Mv.Membership describe "mount and display" do test "page renders successfully for admin user", %{conn: conn} do diff --git a/test/mv_web/live/group_live/integration_test.exs b/test/mv_web/live/group_live/integration_test.exs index 96e9031..937ed1e 100644 --- a/test/mv_web/live/group_live/integration_test.exs +++ b/test/mv_web/live/group_live/integration_test.exs @@ -14,8 +14,8 @@ defmodule MvWeb.GroupLive.IntegrationTest do import Ash.Expr use Gettext, backend: MvWeb.Gettext - alias Mv.Membership alias Mv.Fixtures + alias Mv.Membership describe "complete workflow" do test "create → view via slug → edit → view via slug (slug unchanged)", %{ diff --git a/test/mv_web/live/group_live/show_accessibility_test.exs b/test/mv_web/live/group_live/show_accessibility_test.exs index fc63551..14866ef 100644 --- a/test/mv_web/live/group_live/show_accessibility_test.exs +++ b/test/mv_web/live/group_live/show_accessibility_test.exs @@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do import Phoenix.LiveViewTest use Gettext, backend: MvWeb.Gettext - alias Mv.Membership alias Mv.Fixtures + alias Mv.Membership describe "ARIA labels and roles" do test "search input has proper ARIA attributes", %{conn: conn} do diff --git a/test/mv_web/live/group_live/show_add_member_test.exs b/test/mv_web/live/group_live/show_add_member_test.exs index 0e1af32..761dc83 100644 --- a/test/mv_web/live/group_live/show_add_member_test.exs +++ b/test/mv_web/live/group_live/show_add_member_test.exs @@ -9,8 +9,8 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do import MvWeb.GroupLiveHelpers use Gettext, backend: MvWeb.Gettext - alias Mv.Membership alias Mv.Fixtures + alias Mv.Membership describe "successful add member" do test "member is added to group after selection and clicking Add", %{conn: conn} do diff --git a/test/mv_web/live/group_live/show_add_remove_members_test.exs b/test/mv_web/live/group_live/show_add_remove_members_test.exs index 047205d..ede25fd 100644 --- a/test/mv_web/live/group_live/show_add_remove_members_test.exs +++ b/test/mv_web/live/group_live/show_add_remove_members_test.exs @@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do import Phoenix.LiveViewTest use Gettext, backend: MvWeb.Gettext - alias Mv.Membership alias Mv.Fixtures + alias Mv.Membership describe "Add Member button visibility" do @tag role: :read_only diff --git a/test/mv_web/live/group_live/show_authorization_test.exs b/test/mv_web/live/group_live/show_authorization_test.exs index 4bc2a49..31f90a9 100644 --- a/test/mv_web/live/group_live/show_authorization_test.exs +++ b/test/mv_web/live/group_live/show_authorization_test.exs @@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do import Phoenix.LiveViewTest use Gettext, backend: MvWeb.Gettext - alias Mv.Membership alias Mv.Fixtures + alias Mv.Membership describe "server-side authorization" do test "add member event handler checks :update permission", %{conn: conn} do diff --git a/test/mv_web/live/group_live/show_integration_test.exs b/test/mv_web/live/group_live/show_integration_test.exs index 13f8e5d..407ed1e 100644 --- a/test/mv_web/live/group_live/show_integration_test.exs +++ b/test/mv_web/live/group_live/show_integration_test.exs @@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do import Phoenix.LiveViewTest use Gettext, backend: MvWeb.Gettext - alias Mv.Membership alias Mv.Fixtures + alias Mv.Membership describe "data consistency" do test "member appears in group after add (verified in database)", %{conn: conn} do diff --git a/test/mv_web/live/group_live/show_member_search_test.exs b/test/mv_web/live/group_live/show_member_search_test.exs index ed8a55d..8a3d191 100644 --- a/test/mv_web/live/group_live/show_member_search_test.exs +++ b/test/mv_web/live/group_live/show_member_search_test.exs @@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do import Phoenix.LiveViewTest use Gettext, backend: MvWeb.Gettext - alias Mv.Membership alias Mv.Fixtures + alias Mv.Membership # Helper to setup authenticated connection for admin defp setup_admin_conn(conn) do diff --git a/test/mv_web/live/group_live/show_remove_member_test.exs b/test/mv_web/live/group_live/show_remove_member_test.exs index 2b47941..1dfd290 100644 --- a/test/mv_web/live/group_live/show_remove_member_test.exs +++ b/test/mv_web/live/group_live/show_remove_member_test.exs @@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do import Phoenix.LiveViewTest use Gettext, backend: MvWeb.Gettext - alias Mv.Membership alias Mv.Fixtures + alias Mv.Membership describe "successful remove member" do test "member is removed from group after clicking Remove", %{conn: conn} do diff --git a/test/mv_web/live/group_live/show_test.exs b/test/mv_web/live/group_live/show_test.exs index 07a0c98..1f0f1c2 100644 --- a/test/mv_web/live/group_live/show_test.exs +++ b/test/mv_web/live/group_live/show_test.exs @@ -15,8 +15,8 @@ defmodule MvWeb.GroupLive.ShowTest do require Ash.Query use Gettext, backend: MvWeb.Gettext - alias Mv.Membership alias Mv.Fixtures + alias Mv.Membership describe "mount and display" do test "page renders successfully", %{conn: conn} do diff --git a/test/mv_web/member_live/index/membership_fee_status_test.exs b/test/mv_web/member_live/index/membership_fee_status_test.exs index aa729ef..5d96e68 100644 --- a/test/mv_web/member_live/index/membership_fee_status_test.exs +++ b/test/mv_web/member_live/index/membership_fee_status_test.exs @@ -4,10 +4,10 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do """ use Mv.DataCase, async: false - alias MvWeb.MemberLive.Index.MembershipFeeStatus alias Mv.Membership.Member - alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.MembershipFeeType + alias MvWeb.MemberLive.Index.MembershipFeeStatus require Ash.Query diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs index add2fba..ce6bee8 100644 --- a/test/mv_web/member_live/index_membership_fee_status_test.exs +++ b/test/mv_web/member_live/index_membership_fee_status_test.exs @@ -6,8 +6,8 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do import Phoenix.LiveViewTest - alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.MembershipFeeType require Ash.Query diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index ec35f4d..7642067 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -3,8 +3,8 @@ defmodule MvWeb.MemberLive.IndexTest do import Phoenix.LiveViewTest require Ash.Query - alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.MembershipFeeType # Helper to create a membership fee type (shared across all tests) defp create_fee_type(attrs, actor) do diff --git a/test/mv_web/member_live/membership_fee_integration_test.exs b/test/mv_web/member_live/membership_fee_integration_test.exs index ca9d658..ac60220 100644 --- a/test/mv_web/member_live/membership_fee_integration_test.exs +++ b/test/mv_web/member_live/membership_fee_integration_test.exs @@ -6,8 +6,8 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do import Phoenix.LiveViewTest - alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.MembershipFeeType require Ash.Query diff --git a/test/mv_web/member_live/show_membership_fees_test.exs b/test/mv_web/member_live/show_membership_fees_test.exs index 57abfd1..2abb0cb 100644 --- a/test/mv_web/member_live/show_membership_fees_test.exs +++ b/test/mv_web/member_live/show_membership_fees_test.exs @@ -6,8 +6,8 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do import Phoenix.LiveViewTest - alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.MembershipFees.MembershipFeeType require Ash.Query diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index 6dd8022..1b3f827 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -4,8 +4,8 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do """ use MvWeb.ConnCase, async: true - alias MvWeb.Plugs.CheckPagePermission alias Mv.Fixtures + alias MvWeb.Plugs.CheckPagePermission defp conn_with_user(path, user) do build_conn(:get, path) @@ -46,21 +46,21 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do end describe "dynamic routes" do - test "user with \"/members/:id\" permission can access \"/members/123\"" do + test ~s(user with "/members/:id" permission can access "/members/123") do user = Fixtures.user_with_role_fixture("read_only") conn = conn_with_user("/members/123", user) |> CheckPagePermission.call([]) refute conn.halted end - test "user with \"/members/:id/edit\" permission can access \"/members/456/edit\"" do + test ~s(user with "/members/:id/edit" permission can access "/members/456/edit") do user = Fixtures.user_with_role_fixture("normal_user") conn = conn_with_user("/members/456/edit", user) |> CheckPagePermission.call([]) refute conn.halted end - test "user with only \"/members/:id\" cannot access \"/members/123/edit\"" do + test ~s(user with only "/members/:id" cannot access "/members/123/edit") do user = Fixtures.user_with_role_fixture("read_only") conn = conn_with_user("/members/123/edit", user) |> CheckPagePermission.call([]) @@ -456,7 +456,8 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do assert conn.status == 200 end - # Full-router test: session may not preserve member_id; plug logic covered by unit test "own_data user with linked member can access /members/:id/edit (plug direct call)" + # Full-router test: session may not preserve member_id; plug logic covered by unit test + # "own_data user with linked member can access /members/:id/edit (plug direct call)". @tag role: :member @tag :skip test "GET /members/:id/edit (linked member edit) returns 200 when user has linked member", %{ @@ -512,7 +513,8 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do assert conn.status == 200 end - # Skipped: MemberLive.Show requires membership fee cycle data; plug allows access (page loads then LiveView may error). + # Skipped: MemberLive.Show requires membership fee cycle data; plug allows access + # (page loads then LiveView may error). @tag role: :member @tag :skip test "GET /members/:id for linked member returns 200", %{conn: conn, current_user: user} do diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index c0be795..c75196e 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -116,7 +116,8 @@ defmodule MvWeb.UserLive.IndexTest do end describe "delete functionality" do - # Delete is only on user show page (Danger zone), not on index (per CODE_GUIDELINES: at most one UI smoke test for delete) + # Delete is only on user show page (Danger zone), not on index + # (per CODE_GUIDELINES: at most one UI smoke test for delete). test "can delete a user from show page", %{conn: conn} do user = create_test_user(%{email: "delete-me@example.com"}) conn = conn_with_oidc_user(conn) -- 2.47.2 From cfc8900c5ca734ab83c2ea938c30d9c57a343e98 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 3 Mar 2026 19:03:41 +0100 Subject: [PATCH 04/18] CI: run Credo in strict mode Exclude test files from AliasUsage check in .credo.exs. Use mix credo --strict in Justfile and .drone.yml. --- .credo.exs | 8 +++++++- .drone.yml | 4 ++-- Justfile | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.credo.exs b/.credo.exs index 4eddee8..3a4f8dc 100644 --- a/.credo.exs +++ b/.credo.exs @@ -82,8 +82,14 @@ # You can customize the priority of any check # Priority values are: `low, normal, high, higher` # + # AliasUsage only for lib and support; test files excluded (many nested module refs by design) {Credo.Check.Design.AliasUsage, - [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + [ + priority: :low, + if_nested_deeper_than: 2, + if_called_more_often_than: 0, + files: %{excluded: ["test/"]} + ]}, {Credo.Check.Design.TagFIXME, []}, # You can also customize the exit_status of each check. # If you don't want TODO comments to cause `mix credo` to fail, just diff --git a/.drone.yml b/.drone.yml index 5eefd61..70ea161 100644 --- a/.drone.yml +++ b/.drone.yml @@ -52,7 +52,7 @@ steps: # Check for dependencies that are not maintained anymore - mix hex.audit # Provide hints for improving code quality - - mix credo + - mix credo --strict # Check that translations are up to date - mix gettext.extract --check-up-to-date @@ -159,7 +159,7 @@ steps: # Check for dependencies that are not maintained anymore - mix hex.audit # Provide hints for improving code quality - - mix credo + - mix credo --strict # Check that translations are up to date - mix gettext.extract --check-up-to-date diff --git a/Justfile b/Justfile index bce8bf6..f3ad5a3 100644 --- a/Justfile +++ b/Justfile @@ -31,7 +31,7 @@ gettext: lint: mix format --check-formatted mix compile --warnings-as-errors - mix credo + mix credo --strict # Check that all German translations are filled (UI must be in German) @bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done' mix gettext.extract --check-up-to-date -- 2.47.2 From 7a8b069834d6ab9c734c553c7a3805abec480aae Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 3 Mar 2026 19:03:47 +0100 Subject: [PATCH 05/18] Fix Credo Design (AliasUsage): add aliases in lib Add module aliases at top and use short names instead of fully qualified nested modules across lib/. --- lib/accounts/user.ex | 21 +++++---- .../user/validations/oidc_email_collision.ex | 8 ++-- lib/membership/member.ex | 44 +++++++++---------- lib/mv/application.ex | 16 ++++--- .../checks/actor_is_system_user.ex | 4 +- .../checks/custom_field_value_create_scope.ex | 3 +- lib/mv/authorization/checks/has_permission.ex | 3 +- lib/mv/authorization/role.ex | 6 ++- lib/mv/membership/member_export.ex | 3 +- lib/mv/membership/member_export/build.ex | 3 +- lib/mv/membership_fees/cycle_generator.ex | 4 +- lib/mv/vereinfacht/changes/sync_contact.ex | 13 +++--- lib/mv_web/components/core_components.ex | 9 ++-- .../live/auth/link_oidc_account_live.ex | 20 +++++---- .../live/custom_field_live/index_component.ex | 4 +- lib/mv_web/live/global_settings_live.ex | 12 +++-- lib/mv_web/live/member_live/form.ex | 15 ++++--- lib/mv_web/live/member_live/index.ex | 3 +- lib/mv_web/live/member_live/show.ex | 22 ++++++---- lib/mv_web/live/user_live/form.ex | 37 +++++++++------- lib/mv_web/live/user_live/index.ex | 10 +++-- lib/mv_web/live/user_live/show.ex | 10 +++-- lib/mv_web/live_helpers.ex | 3 +- lib/mv_web/live_user_auth.ex | 9 ++-- lib/mv_web/plugs/check_page_permission.ex | 3 +- 25 files changed, 176 insertions(+), 109 deletions(-) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 5e24445..6b9cd1e 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -11,6 +11,11 @@ defmodule Mv.Accounts.User do require Ash.Query import Ash.Expr + alias Ash.Resource.Preparation.Builtins + alias Mv.Authorization.Role, as: RoleResource + alias Mv.Helpers.SystemActor + alias Mv.OidcRoleSync + postgres do table "users" repo Mv.Repo @@ -282,20 +287,20 @@ defmodule Mv.Accounts.User do # Sync role from OIDC groups after sign-in (e.g. admin group → Admin role) # get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each - prepare Ash.Resource.Preparation.Builtins.after_action(fn query, result, _context -> + prepare Builtins.after_action(fn query, result, _context -> user_info = Ash.Query.get_argument(query, :user_info) || %{} oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{} users = case result do nil -> [] - u when is_struct(u, User) -> [u] + u when is_struct(u, __MODULE__) -> [u] list when is_list(list) -> list _ -> [] end Enum.each(users, fn user -> - Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens) + OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens) end) {:ok, result} @@ -483,10 +488,10 @@ defmodule Mv.Accounts.User do |> Enum.map(& &1.id) # Count only non-system users with admin role (system user is for internal ops) - system_email = Mv.Helpers.SystemActor.system_user_email() + system_email = SystemActor.system_user_email() count = - Mv.Accounts.User + __MODULE__ |> Ash.Query.for_read(:read) |> Ash.Query.filter(expr(role_id in ^admin_role_ids)) |> Ash.Query.filter(expr(email != ^system_email)) @@ -512,7 +517,7 @@ defmodule Mv.Accounts.User do # Prevent modification of the system actor user (required for internal operations). # Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests. validate fn changeset, _context -> - if Mv.Helpers.SystemActor.system_user?(changeset.data) do + if SystemActor.system_user?(changeset.data) do {:error, field: :email, message: @@ -641,8 +646,8 @@ defmodule Mv.Accounts.User do case Process.get({__MODULE__, :default_role_id}) do nil -> role_id = - case Mv.Authorization.Role.get_mitglied_role() do - {:ok, %Mv.Authorization.Role{id: id}} -> id + case RoleResource.get_mitglied_role() do + {:ok, %RoleResource{id: id}} -> id _ -> nil end diff --git a/lib/accounts/user/validations/oidc_email_collision.ex b/lib/accounts/user/validations/oidc_email_collision.ex index f92c7e2..7ae8510 100644 --- a/lib/accounts/user/validations/oidc_email_collision.ex +++ b/lib/accounts/user/validations/oidc_email_collision.ex @@ -26,7 +26,9 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do use Ash.Resource.Validation require Logger + alias Mv.Accounts.User alias Mv.Accounts.User.Errors.PasswordVerificationRequired + alias Mv.Helpers.SystemActor @impl true def init(opts), do: {:ok, opts} @@ -43,10 +45,10 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do # Check if a user with this oidc_id already exists # If yes, this will be an upsert (email update), not a new registration # Use SystemActor for authorization during OIDC registration (no logged-in actor) - system_actor = Mv.Helpers.SystemActor.get_system_actor() + system_actor = SystemActor.get_system_actor() existing_oidc_user = - case Mv.Accounts.User + case User |> Ash.Query.filter(oidc_id == ^to_string(oidc_id)) |> Ash.read_one(actor: system_actor) do {:ok, user} -> user @@ -62,7 +64,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do # Find existing user with this email # Use SystemActor for authorization during OIDC registration (no logged-in actor) - case Mv.Accounts.User + case User |> Ash.Query.filter(email == ^to_string(email)) |> Ash.read_one(actor: system_actor) do {:ok, nil} -> diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 8f24595..0519a72 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -39,10 +39,16 @@ defmodule Mv.Membership.Member do require Ash.Query import Ash.Expr + alias Ecto.Adapters.SQL, as: EctoSQL alias Mv.Helpers - require Logger - + alias Mv.Helpers.SystemActor alias Mv.Membership.Helpers.VisibilityConfig + alias Mv.MembershipFees.CalendarCycles + alias Mv.MembershipFees.CycleGenerator + alias Mv.MembershipFees.MembershipFeeCycle + alias Mv.Repo + + require Logger # Module constants @member_search_limit 10 @@ -813,7 +819,7 @@ defmodule Mv.Membership.Member do case Map.get(cycle, :membership_fee_type) do %{interval: interval} -> cycle_end = - Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) + CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) Date.compare(cycle.cycle_start, today) in [:lt, :eq] and Date.compare(today, cycle_end) in [:lt, :eq] @@ -847,7 +853,7 @@ defmodule Mv.Membership.Member do case Map.get(cycle, :membership_fee_type) do %{interval: interval} -> cycle_end = - Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) + CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) Date.compare(today, cycle_end) == :gt @@ -863,7 +869,7 @@ defmodule Mv.Membership.Member do cycles, fn cycle -> interval = Map.get(cycle, :membership_fee_type).interval - Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) + CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) end, {:desc, Date} ) @@ -890,7 +896,7 @@ defmodule Mv.Membership.Member do case Map.get(cycle, :membership_fee_type) do %{interval: interval} -> cycle_end = - Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) + CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt @@ -908,15 +914,12 @@ defmodule Mv.Membership.Member do @doc false # Uses system actor for cycle regeneration (mandatory side effect) def regenerate_cycles_on_type_change(member, _opts \\ []) do - alias Mv.Helpers - alias Mv.Helpers.SystemActor - today = Date.utc_today() lock_key = :erlang.phash2(member.id) # Use advisory lock to prevent concurrent deletion and regeneration # This ensures atomicity when multiple updates happen simultaneously - if Mv.Repo.in_transaction?() do + if Repo.in_transaction?() do regenerate_cycles_in_transaction(member, today, lock_key) else regenerate_cycles_new_transaction(member, today, lock_key) @@ -926,15 +929,15 @@ defmodule Mv.Membership.Member do # Already in transaction: use advisory lock directly # Returns {:ok, notifications} - notifications should be returned to after_action hook defp regenerate_cycles_in_transaction(member, today, lock_key) do - Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) + EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) end # Not in transaction: start new transaction with advisory lock # Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action) defp regenerate_cycles_new_transaction(member, today, lock_key) do - Mv.Repo.transaction(fn -> - Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) + Repo.transaction(fn -> + EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do {:ok, notifications} -> @@ -942,7 +945,7 @@ defmodule Mv.Membership.Member do notifications {:error, reason} -> - Mv.Repo.rollback(reason) + Repo.rollback(reason) end end) |> case do @@ -956,9 +959,6 @@ defmodule Mv.Membership.Member do # notifications are collected to be sent after transaction commits # Uses system actor for all operations defp do_regenerate_cycles_on_type_change(member, today, opts) do - alias Mv.Helpers - alias Mv.Helpers.SystemActor - require Ash.Query skip_lock? = Keyword.get(opts, :skip_lock?, false) @@ -968,7 +968,7 @@ defmodule Mv.Membership.Member do # Find all unpaid cycles for this member # We need to check cycle_end for each cycle using its own interval all_unpaid_cycles_query = - Mv.MembershipFees.MembershipFeeCycle + MembershipFeeCycle |> Ash.Query.filter(member_id == ^member.id) |> Ash.Query.filter(status == :unpaid) |> Ash.Query.load([:membership_fee_type]) @@ -997,7 +997,7 @@ defmodule Mv.Membership.Member do case cycle.membership_fee_type do %{interval: interval} -> cycle_end = - Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) + CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) Date.compare(today, cycle_end) in [:lt, :eq] @@ -1047,7 +1047,7 @@ defmodule Mv.Membership.Member do defp regenerate_cycles(member_id, today, opts) do skip_lock? = Keyword.get(opts, :skip_lock?, false) - case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( + case CycleGenerator.generate_cycles_for_member( member_id, today: today, skip_lock?: skip_lock? @@ -1078,7 +1078,7 @@ defmodule Mv.Membership.Member do # Runs cycle generation synchronously (for test environment) defp handle_cycle_generation_sync(member, initiator) do - case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( + case CycleGenerator.generate_cycles_for_member( member.id, today: Date.utc_today(), initiator: initiator @@ -1099,7 +1099,7 @@ defmodule Mv.Membership.Member do # Runs cycle generation asynchronously (for production environment) defp handle_cycle_generation_async(member, initiator) do Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn -> - case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, + case CycleGenerator.generate_cycles_for_member(member.id, initiator: initiator ) do {:ok, cycles, notifications} -> diff --git a/lib/mv/application.ex b/lib/mv/application.ex index 1967ddd..835652f 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -5,22 +5,28 @@ defmodule Mv.Application do use Application + alias Mv.Helpers.SystemActor + alias Mv.Repo + alias Mv.Vereinfacht.SyncFlash + alias MvWeb.Endpoint + alias MvWeb.Telemetry + @impl true def start(_type, _args) do - Mv.Vereinfacht.SyncFlash.create_table!() + SyncFlash.create_table!() children = [ - MvWeb.Telemetry, - Mv.Repo, + Telemetry, + Repo, {Task.Supervisor, name: Mv.TaskSupervisor}, {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: Mv.PubSub}, {AshAuthentication.Supervisor, otp_app: :my}, - Mv.Helpers.SystemActor, + SystemActor, # Start a worker by calling: Mv.Worker.start_link(arg) # {Mv.Worker, arg}, # Start to serve requests, typically the last entry - MvWeb.Endpoint + Endpoint ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/mv/authorization/checks/actor_is_system_user.ex b/lib/mv/authorization/checks/actor_is_system_user.ex index a614a83..804a24d 100644 --- a/lib/mv/authorization/checks/actor_is_system_user.ex +++ b/lib/mv/authorization/checks/actor_is_system_user.ex @@ -7,9 +7,11 @@ defmodule Mv.Authorization.Checks.ActorIsSystemUser do """ use Ash.Policy.SimpleCheck + alias Mv.Helpers.SystemActor + @impl true def describe(_opts), do: "actor is the system user" @impl true - def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor) + def match?(actor, _context, _opts), do: SystemActor.system_user?(actor) end diff --git a/lib/mv/authorization/checks/custom_field_value_create_scope.ex b/lib/mv/authorization/checks/custom_field_value_create_scope.ex index 0b24e74..bbbdacc 100644 --- a/lib/mv/authorization/checks/custom_field_value_create_scope.ex +++ b/lib/mv/authorization/checks/custom_field_value_create_scope.ex @@ -22,6 +22,7 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do end """ use Ash.Policy.Check + alias Mv.Authorization.Actor alias Mv.Authorization.PermissionSets @impl true @@ -67,5 +68,5 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do end end - defp ensure_role_loaded(actor), do: Mv.Authorization.Actor.ensure_loaded(actor) + defp ensure_role_loaded(actor), do: Actor.ensure_loaded(actor) end diff --git a/lib/mv/authorization/checks/has_permission.ex b/lib/mv/authorization/checks/has_permission.ex index 721cee7..a11bf2e 100644 --- a/lib/mv/authorization/checks/has_permission.ex +++ b/lib/mv/authorization/checks/has_permission.ex @@ -81,6 +81,7 @@ defmodule Mv.Authorization.Checks.HasPermission do use Ash.Policy.Check require Ash.Query import Ash.Expr + alias Mv.Authorization.Actor alias Mv.Authorization.PermissionSets require Logger @@ -397,6 +398,6 @@ defmodule Mv.Authorization.Checks.HasPermission do # Fallback: Load role if not loaded (in case on_mount didn't run) # Delegates to centralized Actor helper defp ensure_role_loaded(actor) do - Mv.Authorization.Actor.ensure_loaded(actor) + Actor.ensure_loaded(actor) end end diff --git a/lib/mv/authorization/role.ex b/lib/mv/authorization/role.ex index 8700a33..77e0507 100644 --- a/lib/mv/authorization/role.ex +++ b/lib/mv/authorization/role.ex @@ -94,14 +94,16 @@ defmodule Mv.Authorization.Role do end end + alias Mv.Authorization.PermissionSets + validations do validate one_of( :permission_set_name, - Mv.Authorization.PermissionSets.all_permission_sets() + PermissionSets.all_permission_sets() |> Enum.map(&Atom.to_string/1) ), message: - "must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}" + "must be one of: #{PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}" validate fn changeset, _context -> if changeset.data.is_system_role do diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index bbfbb6e..635c832 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -13,6 +13,7 @@ defmodule Mv.Membership.MemberExport do alias Mv.Membership.CustomField alias Mv.Membership.Member alias Mv.Membership.MemberExportSort + alias MvWeb.MemberLive.Index alias MvWeb.MemberLive.Index.MembershipFeeStatus @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ @@ -169,7 +170,7 @@ defmodule Mv.Membership.MemberExport do if parsed.selected_ids == [] do members |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle) - |> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( + |> Index.apply_boolean_custom_field_filters( parsed.boolean_filters || %{}, Map.values(custom_fields_by_id) ) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index e08a6f4..7159679 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -21,6 +21,7 @@ defmodule Mv.Membership.MemberExport.Build do import Ash.Expr alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort} + alias MvWeb.MemberLive.Index alias MvWeb.MemberLive.Index.MembershipFeeStatus @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -169,7 +170,7 @@ defmodule Mv.Membership.MemberExport.Build do if parsed.selected_ids == [] do members |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle) - |> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( + |> Index.apply_boolean_custom_field_filters( parsed.boolean_filters || %{}, Map.values(custom_fields_by_id) ) diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index 1a33ca8..52776b6 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -54,6 +54,8 @@ defmodule Mv.MembershipFees.CycleGenerator do alias Mv.MembershipFees.MembershipFeeCycle alias Mv.Repo + alias Ecto.Adapters.SQL, as: EctoSQL + require Ash.Query require Logger @@ -113,7 +115,7 @@ defmodule Mv.MembershipFees.CycleGenerator do lock_key = :erlang.phash2(member.id) Repo.transaction(fn -> - Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) + EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) case do_generate_cycles(member, today, opts) do {:ok, cycles, notifications} -> diff --git a/lib/mv/vereinfacht/changes/sync_contact.ex b/lib/mv/vereinfacht/changes/sync_contact.ex index 99875e0..f3679d4 100644 --- a/lib/mv/vereinfacht/changes/sync_contact.ex +++ b/lib/mv/vereinfacht/changes/sync_contact.ex @@ -14,6 +14,9 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do """ use Ash.Resource.Change + alias Mv.Vereinfacht + alias Mv.Vereinfacht.SyncFlash + require Logger @synced_attributes [ @@ -60,13 +63,13 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do # Ash calls after_transaction with (changeset, result) only - 2 args. defp sync_after_transaction(_changeset, {:ok, member}) do - case Mv.Vereinfacht.sync_member(member) do + case Vereinfacht.sync_member(member) do :ok -> - Mv.Vereinfacht.SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.") + SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.") {:ok, member} {:ok, member_updated} -> - Mv.Vereinfacht.SyncFlash.store( + SyncFlash.store( to_string(member_updated.id), :ok, "Synced to Vereinfacht." @@ -77,10 +80,10 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do {:error, reason} -> Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}") - Mv.Vereinfacht.SyncFlash.store( + SyncFlash.store( to_string(member.id), :warning, - Mv.Vereinfacht.format_error(reason) + Vereinfacht.format_error(reason) ) {:ok, member} diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 78b8bfb..3606b34 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -29,6 +29,7 @@ defmodule MvWeb.CoreComponents do use Phoenix.Component use Gettext, backend: MvWeb.Gettext + alias Phoenix.HTML.Form, as: HTMLForm alias Phoenix.LiveView.JS # WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items) @@ -669,7 +670,7 @@ defmodule MvWeb.CoreComponents do def input(%{type: "checkbox"} = assigns) do assigns = assign_new(assigns, :checked, fn -> - Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) + HTMLForm.normalize_value("checkbox", assigns[:value]) end) # For checkboxes, we don't use HTML required attribute (means "must be checked") @@ -736,7 +737,7 @@ defmodule MvWeb.CoreComponents do {@rest} > - {Phoenix.HTML.Form.options_for_select(@options, @value)} + {HTMLForm.options_for_select(@options, @value)} <.error :for={msg <- @errors}>{msg} @@ -765,7 +766,7 @@ defmodule MvWeb.CoreComponents do @errors != [] && (@error_class || "textarea-error") ]} {@rest} - >{Phoenix.HTML.Form.normalize_value("textarea", @value)} + >{HTMLForm.normalize_value("textarea", @value)} <.error :for={msg <- @errors}>{msg} @@ -790,7 +791,7 @@ defmodule MvWeb.CoreComponents do type={@type} name={@name} id={@id} - value={Phoenix.HTML.Form.normalize_value(@type, @value)} + value={HTMLForm.normalize_value(@type, @value)} class={[ @class || "w-full input", @errors != [] && (@error_class || "input-error") diff --git a/lib/mv_web/live/auth/link_oidc_account_live.ex b/lib/mv_web/live/auth/link_oidc_account_live.ex index 01bd57b..c4b3ab0 100644 --- a/lib/mv_web/live/auth/link_oidc_account_live.ex +++ b/lib/mv_web/live/auth/link_oidc_account_live.ex @@ -18,15 +18,19 @@ defmodule MvWeb.LinkOidcAccountLive do require Ash.Query require Logger + alias AshAuthentication.Strategy.Password.Actions, as: PasswordActions + alias Mv.Accounts.User, as: UserResource + alias Mv.Helpers.SystemActor + @impl true def mount(_params, session, socket) do # Use SystemActor for authorization during OIDC linking (user is not yet logged in) - system_actor = Mv.Helpers.SystemActor.get_system_actor() + system_actor = SystemActor.get_system_actor() with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"), oidc_user_info when not is_nil(oidc_user_info) <- Map.get(session, "oidc_linking_user_info"), - {:ok, user} <- Ash.get(Mv.Accounts.User, user_id, actor: system_actor) do + {:ok, user} <- Ash.get(UserResource, user_id, actor: system_actor) do # Check if user is passwordless if passwordless?(user) do # Auto-link passwordless user immediately @@ -50,9 +54,9 @@ defmodule MvWeb.LinkOidcAccountLive do defp reload_user!(user_id) do # Use SystemActor for authorization during OIDC linking (user is not yet logged in) - system_actor = Mv.Helpers.SystemActor.get_system_actor() + system_actor = SystemActor.get_system_actor() - Mv.Accounts.User + UserResource |> Ash.Query.filter(id == ^user_id) |> Ash.read_one!(actor: system_actor) end @@ -65,7 +69,7 @@ defmodule MvWeb.LinkOidcAccountLive do oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id") # Use SystemActor for authorization (passwordless user auto-linking) - system_actor = Mv.Helpers.SystemActor.get_system_actor() + system_actor = SystemActor.get_system_actor() case user.id |> reload_user!() @@ -176,11 +180,11 @@ defmodule MvWeb.LinkOidcAccountLive do defp verify_password(email, password) do # Use AshAuthentication password strategy to verify - strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User) + strategies = AshAuthentication.Info.authentication_strategies(UserResource) password_strategy = Enum.find(strategies, fn s -> s.name == :password end) if password_strategy do - AshAuthentication.Strategy.Password.Actions.sign_in( + PasswordActions.sign_in( password_strategy, %{ "email" => email, @@ -197,7 +201,7 @@ defmodule MvWeb.LinkOidcAccountLive do oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id") # Use SystemActor for authorization (user just verified password but is not yet logged in) - system_actor = Mv.Helpers.SystemActor.get_system_actor() + system_actor = SystemActor.get_system_actor() # Update the user with the OIDC ID case user.id diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index 6d1fc2f..4d654e2 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -12,11 +12,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do """ use MvWeb, :live_component + alias MvWeb.Translations.FieldTypes + require Logger @impl true def render(assigns) do - assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1) + assigns = assign(assigns, :field_type_label, &FieldTypes.label/1) ~H"""
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 289d721..8c4ea2c 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -26,7 +26,11 @@ defmodule MvWeb.GlobalSettingsLive do require Ash.Query import Ash.Expr + alias Mv.Helpers + alias Mv.Helpers.SystemActor alias Mv.Membership + alias Mv.Membership.Member, as: MemberResource + alias MvWeb.Helpers.MemberHelpers on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} @@ -551,13 +555,13 @@ defmodule MvWeb.GlobalSettingsLive do end defp fetch_member_names_by_ids(ids) do - actor = Mv.Helpers.SystemActor.get_system_actor() - opts = Mv.Helpers.ash_actor_opts(actor) - query = Ash.Query.filter(Mv.Membership.Member, expr(id in ^ids)) + actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(actor) + query = Ash.Query.filter(MemberResource, expr(id in ^ids)) case Ash.read(query, opts) do {:ok, members} -> - Map.new(members, fn m -> {m.id, MvWeb.Helpers.MemberHelpers.display_name(m)} end) + Map.new(members, fn m -> {m.id, MemberHelpers.display_name(m)} end) _ -> %{} diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 88d0444..4d76f33 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -25,8 +25,11 @@ defmodule MvWeb.MemberLive.Form do alias Mv.Membership alias Mv.Membership.Helpers.VisibilityConfig + alias Mv.Membership.Member, as: MemberResource alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType + alias Mv.Vereinfacht.SyncFlash + alias MvWeb.Helpers.MemberHelpers alias MvWeb.Helpers.MembershipFeeHelpers @impl true @@ -51,7 +54,7 @@ defmodule MvWeb.MemberLive.Form do <%= if @member do %> - {MvWeb.Helpers.MemberHelpers.display_name(@member)} + {MemberHelpers.display_name(@member)} <% else %> {gettext("New Member")} <% end %> @@ -289,7 +292,7 @@ defmodule MvWeb.MemberLive.Form do data-testid="member-delete" aria-label={ gettext("Delete member %{name}", - name: MvWeb.Helpers.MemberHelpers.display_name(@member) + name: MemberHelpers.display_name(@member) ) } > @@ -316,7 +319,7 @@ defmodule MvWeb.MemberLive.Form do

{gettext( "Are you sure you want to delete %{name}? This action cannot be undone.", - name: MvWeb.Helpers.MemberHelpers.display_name(@member) + name: MemberHelpers.display_name(@member) )}