Merge pull request 'Seeds split, Credo strict, and member/settings UI polish' (#458) from feat/seeds into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #458
This commit is contained in:
moritz 2026-03-04 20:19:49 +01:00
commit 23e1afa994
96 changed files with 2425 additions and 1553 deletions

View file

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

View file

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

View file

@ -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:**

View file

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

View file

@ -108,8 +108,7 @@
word-spacing: inherit;
}
/* WCAG 2 AA: success/error text on light backgrounds (e.g. base-200). Use instead of
text-success/text-error when contrast ratio of theme colors is insufficient. */
/* WCAG 2 AA: success/error/warning text. Light theme: dark tones on light bg; dark theme: light tones on dark bg. */
.text-success-aa {
color: oklch(0.35 0.12 165);
}
@ -118,6 +117,22 @@
color: oklch(0.45 0.2 25);
}
.text-warning-aa {
color: oklch(0.45 0.14 75);
}
[data-theme="dark"] .text-success-aa {
color: oklch(0.72 0.12 165);
}
[data-theme="dark"] .text-error-aa {
color: oklch(0.75 0.18 25);
}
[data-theme="dark"] .text-warning-aa {
color: oklch(0.78 0.14 75);
}
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
outline badges always have a visible background in both themes. */
@ -548,4 +563,96 @@
--color-secondary-content: oklch(98% 0 0);
}
/* ============================================
WCAG 2.2 AA: Tab list inactive tab text contrast (4.5:1)
============================================ */
#member-tablist .tab:not(.tab-active) {
color: oklch(0.35 0.02 285);
}
[data-theme="dark"] #member-tablist .tab:not(.tab-active) {
color: oklch(0.72 0.02 257);
}
/* ============================================
WCAG 2.2 AA: Link contrast - primary and accent
============================================ */
[data-theme="light"] .link.link-primary {
color: oklch(0.45 0.15 35);
}
[data-theme="light"] .link.link-primary:hover {
color: oklch(0.38 0.14 35);
}
[data-theme="dark"] .link.link-primary {
color: oklch(0.82 0.14 45);
}
[data-theme="dark"] .link.link-primary:hover {
color: oklch(0.88 0.12 45);
}
[data-theme="dark"] .link.link-accent {
color: oklch(0.82 0.18 292);
}
[data-theme="dark"] .link.link-accent:hover {
color: oklch(0.88 0.16 292);
}
/* ============================================
WCAG 2.2 AA: Danger zone heading contrast (dark theme)
============================================ */
[data-theme="dark"] #danger-zone-heading.text-error {
color: oklch(0.78 0.18 25);
}
/* ============================================
WCAG 2.2 AA: Blue link contrast in dark theme
============================================ */
[data-theme="dark"] a.text-blue-700,
[data-theme="dark"] a.text-blue-600,
[data-theme="dark"] a.hover\:text-blue-800 {
color: oklch(0.72 0.16 255);
}
[data-theme="dark"] a.text-blue-700:hover,
[data-theme="dark"] a.text-blue-600:hover {
color: oklch(0.82 0.14 255);
}
/* ============================================
WCAG 2.2 AA: Password / form label on light box in dark theme
============================================ */
[data-theme="dark"] .bg-gray-50 {
background-color: var(--color-base-200);
color: var(--color-base-content);
}
[data-theme="dark"] .bg-gray-50 .label,
[data-theme="dark"] .bg-gray-50 .mb-1.label,
[data-theme="dark"] .bg-gray-50 .text-gray-600,
[data-theme="dark"] .bg-gray-50 .text-gray-700,
[data-theme="dark"] .bg-gray-50 strong,
[data-theme="dark"] .bg-gray-50 p,
[data-theme="dark"] .bg-gray-50 li {
color: var(--color-base-content);
}
/* Dark mode: orange/red info boxes (admin note, OIDC warning) dark bg, light text */
[data-theme="dark"] .bg-orange-50 {
background-color: oklch(0.32 0.06 55);
border-color: oklch(0.42 0.08 55);
color: var(--color-base-content);
}
[data-theme="dark"] .bg-orange-50 .text-orange-800,
[data-theme="dark"] .bg-orange-50 p,
[data-theme="dark"] .bg-orange-50 strong {
color: var(--color-base-content);
}
[data-theme="dark"] .bg-red-50 {
background-color: oklch(0.32 0.08 25);
border-color: oklch(0.42 0.12 25);
color: var(--color-base-content);
}
[data-theme="dark"] .bg-red-50 .text-red-800,
[data-theme="dark"] .bg-red-50 .text-red-700,
[data-theme="dark"] .bg-red-50 p,
[data-theme="dark"] .bg-red-50 strong {
color: var(--color-base-content);
}
/* This file is for your main application CSS */

View file

@ -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:**

View file

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

View file

@ -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} ->
@ -164,7 +166,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

View file

@ -37,12 +37,19 @@ defmodule Mv.Membership.Member do
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
import Bitwise
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 +820,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 +854,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 +870,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 +897,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
@ -900,6 +907,25 @@ defmodule Mv.Membership.Member do
end)
end
# Returns a deterministic 64-bit key for pg_advisory_xact_lock from a member id (UUID string).
# Reduces collision risk vs phash2 when multiple members are locked.
@doc false
def advisory_lock_key_for_member_id(member_id) when is_binary(member_id) do
hex = String.replace(member_id, "-", "")
if String.length(hex) >= 16 do
first_8_hex = String.slice(hex, 0, 16)
bin = Base.decode16!(first_8_hex, case: :lower)
decoded = :binary.decode_unsigned(bin, :big)
# Postgres bigint is signed 64-bit; keep in non-negative range
rem(decoded, 1 <<< 63)
else
:erlang.phash2(member_id)
end
rescue
ArgumentError -> :erlang.phash2(member_id)
end
# Regenerates cycles when membership fee type changes
# Deletes future unpaid cycles and regenerates them with the new type/amount
# Uses advisory lock to prevent concurrent modifications
@ -908,15 +934,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)
lock_key = advisory_lock_key_for_member_id(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 +949,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 +965,7 @@ defmodule Mv.Membership.Member do
notifications
{:error, reason} ->
Mv.Repo.rollback(reason)
Repo.rollback(reason)
end
end)
|> case do
@ -956,9 +979,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 +988,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 +1017,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]
@ -1025,18 +1045,17 @@ defmodule Mv.Membership.Member do
end
end
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
# Uses system actor for authorization to ensure deletion always works
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise.
# Returns the first error for debugging; uses system actor for authorization.
defp delete_cycles(cycles_to_delete, actor_opts) do
delete_results =
Enum.map(cycles_to_delete, fn cycle ->
Ash.destroy(cycle, actor_opts)
end)
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
{:error, :deletion_failed}
else
:ok
case Enum.find(delete_results, &match?({:error, _}, &1)) do
{:error, reason} -> {:error, reason}
nil -> :ok
end
end
@ -1047,7 +1066,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 +1097,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 +1118,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} ->

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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)
)
@ -519,12 +520,10 @@ 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
end
defp get_cfv_by_id(member, id) do
values =

View file

@ -74,12 +74,10 @@ 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
end
defp get_cfv_by_id(member, id) do
values =

View file

@ -299,12 +299,10 @@ 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
end
defp key_to_atom_safe(key), do: key

View file

@ -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
@ -110,10 +112,10 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
defp do_generate_cycles_with_lock(member, today, false, opts) do
lock_key = :erlang.phash2(member.id)
lock_key = Member.advisory_lock_key_for_member_id(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} ->

View file

@ -82,12 +82,10 @@ 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
end
defp safe_get_atom(_map, _key), do: nil

View file

@ -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}

View file

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

View file

@ -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.

View file

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

View file

@ -32,10 +32,11 @@ defmodule MvWeb.AuthOverrides do
set :root_class, "md:min-w-md"
end
# Replace banner logo with text
# Replace banner logo with text (no image in light or dark so link has discernible text)
override AshAuthentication.Phoenix.Components.Banner do
set :text, "Mitgliederverwaltung"
set :image_url, nil
set :dark_image_url, nil
end
# Translate the "or" in the horizontal rule (between password form and SSO).

View file

@ -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)
@ -561,13 +562,17 @@ defmodule MvWeb.CoreComponents do
phx-target={@phx_target}
>
<%= if @checkboxes do %>
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
tabindex="-1"
<%!-- Visual-only indicator: do not nest an interactive control (checkbox) inside the button for screen reader and focus correctness (WCAG 2.1.2). --%>
<span
class={
if Map.get(@selected, item.value, true),
do: "text-primary",
else: "text-base-300"
}
aria-hidden="true"
/>
>
<.icon name="hero-check" class="size-4 shrink-0" />
</span>
<% end %>
<span>{item.label}</span>
</button>
@ -669,7 +674,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 +741,7 @@ defmodule MvWeb.CoreComponents do
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
{HTMLForm.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
@ -765,7 +770,7 @@ defmodule MvWeb.CoreComponents do
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
>{HTMLForm.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</fieldset>
@ -790,7 +795,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")

View file

@ -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,13 +105,11 @@ defmodule MvWeb.MemberExportController do
end
defp atom_exists?(name) do
try do
_ = String.to_existing_atom(name)
true
rescue
ArgumentError -> false
end
end
defp extract_list(params, key) do
case Map.get(params, key) do

View file

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

View file

@ -54,13 +54,15 @@ defmodule MvWeb.SignInLive do
@impl true
def render(assigns) do
~H"""
<div
<main
id="sign-in-page"
role="main"
class={@root_class}
data-oidc-configured={to_string(@oidc_configured)}
data-oidc-only={to_string(@oidc_only)}
data-locale={@locale}
>
<h1 class="sr-only">{dgettext("auth", "Sign in")}</h1>
<%!-- Language selector --%>
<nav
aria-label={dgettext("auth", "Language selection")}
@ -95,7 +97,7 @@ defmodule MvWeb.SignInLive do
context={@context}
gettext_fn={@gettext_fn}
/>
</div>
</main>
"""
end
end

View file

@ -32,9 +32,9 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h3 class="card-title">
<h2 class="card-title text-xl">
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
</h3>
</h2>
</div>
<.form

View file

@ -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"""
<div id={@id}>

View file

@ -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}
@ -300,22 +304,20 @@ defmodule MvWeb.GlobalSettingsLive do
}
/>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<.input
field={@form[:oidc_only]}
type="checkbox"
class="checkbox checkbox-sm"
disabled={@oidc_only_env_set or not @oidc_configured}
label={
if @oidc_only_env_set do
gettext("Only OIDC sign-in (hide password login)") <>
" (" <> gettext("From OIDC_ONLY") <> ")"
else
gettext("Only OIDC sign-in (hide password login)")
end
}
/>
<span class="label-text">
{gettext("Only OIDC sign-in (hide password login)")}
<%= if @oidc_only_env_set do %>
<span class="label-text-alt text-base-content/70 ml-1">
({gettext("From OIDC_ONLY")})
</span>
<% end %>
</span>
</label>
<p class="label-text-alt text-base-content/70 mt-1">
{gettext(
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
@ -551,13 +553,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)
_ ->
%{}

View file

@ -88,16 +88,17 @@ defmodule MvWeb.ImportLive.Components do
phx-submit="start_import"
data-testid="csv-upload-form"
>
<fieldset class="mb-2 fieldset w-md">
<label for="csv_file">
<span class="mb-1 label">{gettext("CSV File")}</span>
</label>
<fieldset class="mb-2 fieldset w-md" aria-labelledby="csv_file_label">
<label id="csv_file_label" for="csv_file" class="label block">
<span class="mb-1 label text-base-content">{gettext("CSV File")}</span>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered"
class="file-input file-input-bordered block"
aria-describedby="csv_file_help"
aria-label={gettext("CSV File")}
/>
</label>
<p class="text-sm text-base-content/60 mt-2" id="csv_file_help">
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
</p>

View file

@ -50,9 +50,9 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h3 class="card-title">
<h2 class="card-title text-xl">
{gettext("Edit Field: %{field}", field: @field_label)}
</h3>
</h2>
</div>
<.form

View file

@ -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,15 +54,10 @@ defmodule MvWeb.MemberLive.Form do
</.button>
</:leading>
<%= if @member do %>
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
{MemberHelpers.display_name(@member)}
<% else %>
{gettext("New Member")}
<% end %>
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<div class="mt-6 space-y-6">
@ -217,14 +215,16 @@ defmodule MvWeb.MemberLive.Form do
<.form_section title={gettext("Membership Fee")}>
<div class="space-y-4">
<div>
<label class="label">
<label for={@form[:membership_fee_type_id].id} class="label">
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
</label>
<select
id={@form[:membership_fee_type_id].id}
class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name}
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
aria-label={gettext("Membership Fee Type")}
>
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
<option value="">{gettext("Select a membership fee type")}</option>
@ -289,7 +289,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 +316,7 @@ defmodule MvWeb.MemberLive.Form do
<p class="py-4">
{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)
)}
</p>
<div class="modal-action">
@ -371,7 +371,7 @@ defmodule MvWeb.MemberLive.Form do
member =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type], actor: actor)
id -> Ash.get!(MemberResource, id, load: [:membership_fee_type], actor: actor)
end
page_title =
@ -446,7 +446,6 @@ defmodule MvWeb.MemberLive.Form do
end
def handle_event("save", %{"member" => member_params}, socket) do
try do
actor = current_actor(socket)
case submit_form(socket.assigns.form, member_params, actor) do
@ -463,7 +462,6 @@ defmodule MvWeb.MemberLive.Form do
e ->
handle_save_exception(socket, e)
end
end
@impl true
def handle_event("open_delete_modal", _params, socket) do
@ -564,7 +562,7 @@ defmodule MvWeb.MemberLive.Form do
end
defp maybe_put_vereinfacht_sync_flash(socket, member_id) do
case Mv.Vereinfacht.SyncFlash.take(to_string(member_id)) do
case SyncFlash.take(to_string(member_id)) do
{:warning, message} ->
put_flash(socket, :warning, translate_vereinfacht_flash(message))
@ -690,12 +688,10 @@ 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
end
# Checks if form has any errors
defp has_form_errors?(form) do
@ -771,7 +767,7 @@ defmodule MvWeb.MemberLive.Form do
)
else
AshPhoenix.Form.for_create(
Mv.Membership.Member,
MemberResource,
:create_member,
api: Mv.Membership,
as: "member",

View file

@ -32,6 +32,7 @@ defmodule MvWeb.MemberLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership
alias Mv.Membership.Member, as: MemberResource
alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
@ -708,12 +709,10 @@ 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
end
defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}"
@ -1014,7 +1013,7 @@ defmodule MvWeb.MemberLive.Index do
defp apply_search_filter(query, search_query) do
if search_query && String.trim(search_query) != "" do
query
|> Mv.Membership.Member.fuzzy_search(%{query: search_query})
|> MemberResource.fuzzy_search(%{query: search_query})
else
query
end

View file

@ -54,6 +54,14 @@
boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)}
/>
<.tooltip
content={
gettext(
"Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
)
}
position="top"
>
<.button
type="button"
variant="secondary"
@ -62,25 +70,20 @@
data-testid="toggle-cycle-view"
aria-label={
if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"),
else: gettext("Last Cycle Payment Status")
)
}
title={
if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"),
else: gettext("Last Cycle Payment Status")
do: gettext("Current payment cycle"),
else: gettext("Last payment cycle")
)
}
>
<.icon name="hero-arrow-path" class="h-5 w-5" />
<span class="hidden sm:inline">
{if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"),
else: gettext("Last Cycle Payment Status")
do: gettext("Current payment cycle"),
else: gettext("Last payment cycle")
)}
</span>
</.button>
</.tooltip>
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"

View file

@ -24,7 +24,13 @@ defmodule MvWeb.MemberLive.Show do
import Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership.CustomField
alias Mv.Membership.CustomFieldValue
alias Mv.Membership.Member, as: MemberResource
alias Mv.Vereinfacht.Client, as: VereinfachtClient
alias MvWeb.Helpers.MemberHelpers
alias MvWeb.Helpers.MembershipFeeHelpers
alias Phoenix.HTML.Engine, as: HTMLEngine
@impl true
def render(assigns) do
@ -41,7 +47,7 @@ defmodule MvWeb.MemberLive.Show do
{gettext("Back")}
</.button>
</:leading>
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
{MemberHelpers.display_name(@member)}
<:actions>
<%= if can?(@current_user, :update, @member) do %>
<.button
@ -329,7 +335,7 @@ defmodule MvWeb.MemberLive.Show do
data-testid="member-delete"
aria-label={
gettext("Delete member %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
name: MemberHelpers.display_name(@member)
)
}
>
@ -355,7 +361,7 @@ defmodule MvWeb.MemberLive.Show do
</h3>
<p class="py-4">
{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)
)}
</p>
<div class="modal-action">
@ -402,13 +408,13 @@ defmodule MvWeb.MemberLive.Show do
# Load custom fields once using assign_new to avoid repeated queries
socket =
assign_new(socket, :custom_fields, fn ->
Mv.Membership.CustomField
CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
end)
query =
Mv.Membership.Member
MemberResource
|> filter(id == ^id)
|> load([
:user,
@ -527,7 +533,7 @@ defmodule MvWeb.MemberLive.Show do
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
response =
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
case VereinfachtClient.get_contact_with_receipts(contact_id) do
{:ok, receipts} -> {:ok, receipts}
{:error, reason} -> {:error, reason}
end
@ -717,7 +723,7 @@ defmodule MvWeb.MemberLive.Show do
# Handles both CustomFieldValue structs and direct values
defp format_custom_field_value(nil, _type), do: render_empty_value()
defp format_custom_field_value(%Mv.Membership.CustomFieldValue{} = cfv, value_type) do
defp format_custom_field_value(%CustomFieldValue{} = cfv, value_type) do
format_custom_field_value(cfv.value, value_type)
end
@ -759,6 +765,6 @@ defmodule MvWeb.MemberLive.Show do
# Returns safe HTML so it can be used from helpers without LiveView assigns.
defp render_empty_value do
text = gettext("Not set")
{:safe, ["<span class=\"sr-only\">", Phoenix.HTML.Engine.html_escape(text), "</span>"]}
{:safe, ["<span class=\"sr-only\">", HTMLEngine.html_escape(text), "</span>"]}
end
end

View file

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

View file

@ -151,24 +151,22 @@ defmodule MvWeb.MembershipFeeSettingsLive do
</:actions>
</.header>
<div class="grid gap-6 lg:grid-cols-2">
<%!-- Settings Form --%>
<%!-- One card: default setting + fee types table --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-cog-6-tooth" class="size-5" />
{gettext("Global Settings")}
</h2>
<div class="card-body space-y-6">
<%!-- Default setting: one row, clear section title and split hints --%>
<.form
for={@form}
phx-change="validate"
phx-submit="save"
class="space-y-6"
class="space-y-2"
>
<%!-- Default Membership Fee Type --%>
<fieldset class="fieldset">
<label for="default_membership_fee_type_id" class="label">
<h2 class="text-base font-semibold text-base-content">
{gettext("Default settings")}
</h2>
<div class="flex flex-wrap items-end gap-6">
<fieldset class="fieldset flex-1 min-w-[200px] max-w-md">
<label for="default_membership_fee_type_id" class="label py-0">
<span class="label-text font-semibold">
{gettext("Default Membership Fee Type")}
</span>
@ -177,7 +175,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
id="default_membership_fee_type_id"
name="settings[default_membership_fee_type_id]"
class={[
"select select-bordered",
"select select-bordered w-full",
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
]}
phx-debounce="blur"
@ -200,16 +198,10 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"This membership fee type is automatically assigned to all new members. Can be changed individually per member."
)}
</p>
</fieldset>
<%!-- Include Joining Cycle --%>
<fieldset class="fieldset">
<label class="label cursor-pointer justify-start gap-3">
<fieldset class="fieldset flex-shrink-0">
<label class="label cursor-pointer justify-start gap-3 py-0 min-h-0">
<input
type="checkbox"
name="settings[include_joining_cycle]"
@ -224,31 +216,131 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<%= if @form.errors[:include_joining_cycle] do %>
<%= for error <- List.wrap(@form.errors[:include_joining_cycle]) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm ml-9 mt-1">{msg}</p>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<% end %>
<div class="ml-9 space-y-2">
<p class="text-sm text-base-content/60">
{gettext("When active: Members pay from the cycle of their joining.")}
</p>
<p class="text-sm text-base-content/60">
{gettext("When inactive: Members pay from the next full cycle after joining.")}
</p>
</div>
</fieldset>
<div class="divider"></div>
<.button type="submit" variant="primary" class="w-full">
<div class="flex-shrink-0 ml-auto border-l border-base-300 pl-6">
<.button type="submit" variant="primary">
<.icon name="hero-check" class="size-5" />
{gettext("Save Settings")}
</.button>
</.form>
</div>
</div>
<%!-- Examples Card (collapsible) --%>
<div class="card bg-base-200">
<ul class="text-sm text-base-content/60 list-disc list-inside space-y-0.5">
<li>{gettext("Default type: Assigned to new members; can be changed per member.")}</li>
<li>
{gettext(
"Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
)}
</li>
</ul>
</.form>
<div class="divider"></div>
<%!-- Fee types table: row click opens edit --%>
<h2 class="text-lg font-semibold">{gettext("Membership Fee Types")}</h2>
<.table
id="membership_fee_types"
rows={@membership_fee_types}
row_id={fn mft -> "mft-#{mft.id}" end}
row_click={
fn mft ->
Phoenix.LiveView.JS.navigate(~p"/membership_fee_settings/#{mft.id}/edit_fee_type")
end
}
row_tooltip={gettext("Click to edit membership fee type")}
>
<:col :let={mft} label={gettext("Name")}>
<span class="font-medium">{mft.name}</span>
<p :if={mft.description} class="text-sm text-base-content/70">{mft.description}</p>
</:col>
<:col :let={mft} label={gettext("Amount")}>
<span class="font-mono">{MembershipFeeHelpers.format_currency(mft.amount)}</span>
</:col>
<:col :let={mft} label={gettext("Interval")}>
<.badge variant="neutral" style="outline">
{MembershipFeeHelpers.format_interval(mft.interval)}
</.badge>
</:col>
<:col :let={mft} label={gettext("Members")}>
<span class="text-sm">{get_member_count(mft, @member_counts)}</span>
</:col>
<:action :let={mft}>
<.tooltip
:if={get_member_count(mft, @member_counts) > 0}
content={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
position="left"
>
<button
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error opacity-50 cursor-not-allowed"
aria-label={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
disabled={true}
>
<.icon name="hero-trash" class="size-4" />
</button>
</.tooltip>
<.button
:if={get_member_count(mft, @member_counts) == 0}
variant="danger"
size="sm"
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
aria-label={gettext("Delete Membership Fee Type")}
>
<.icon name="hero-trash" class="size-4" />
</.button>
</:action>
</.table>
</div>
</div>
<%!-- About membership fee types (info above examples) --%>
<div class="mt-6 rounded-lg bg-base-200 p-4 prose prose-sm max-w-none">
<p>
{gettext(
"Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
)}
</p>
<ul>
<li>
<strong>{gettext("Name & Amount")}</strong>
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
</li>
<li>
<strong>{gettext("Interval")}</strong>
- {gettext(
"Fixed after creation. Members can only switch between types with the same interval."
)}
</li>
<li>
<strong>{gettext("Deletion")}</strong>
- {gettext("Only possible if no members are assigned to this type.")}
</li>
</ul>
</div>
<%!-- Examples (collapsible) --%>
<div class="mt-6 card bg-base-200">
<div class="card-body">
<details class="group">
<summary class="card-title cursor-pointer list-none flex items-center gap-2">
@ -303,117 +395,6 @@ defmodule MvWeb.MembershipFeeSettingsLive do
</details>
</div>
</div>
</div>
<%!-- Fee Types Table --%>
<div class="mt-8">
<h2 class="text-lg font-semibold mb-4">{gettext("Membership Fee Types")}</h2>
<.table
id="membership_fee_types"
rows={@membership_fee_types}
row_id={fn mft -> "mft-#{mft.id}" end}
>
<:col :let={mft} label={gettext("Name")}>
<span class="font-medium">{mft.name}</span>
<p :if={mft.description} class="text-sm text-base-content/70">{mft.description}</p>
</:col>
<:col :let={mft} label={gettext("Amount")}>
<span class="font-mono">{MembershipFeeHelpers.format_currency(mft.amount)}</span>
</:col>
<:col :let={mft} label={gettext("Interval")}>
<.badge variant="neutral" style="outline">
{MembershipFeeHelpers.format_interval(mft.interval)}
</.badge>
</:col>
<:col :let={mft} label={gettext("Members")}>
<span class="text-sm">{get_member_count(mft, @member_counts)}</span>
</:col>
<:action :let={mft}>
<.tooltip content={gettext("Edit membership fee type")} position="left">
<.button
variant="ghost"
size="sm"
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
aria-label={gettext("Edit membership fee type")}
>
<.icon name="hero-pencil" class="size-4" />
</.button>
</.tooltip>
</:action>
<:action :let={mft}>
<.tooltip
:if={get_member_count(mft, @member_counts) > 0}
content={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
position="left"
>
<button
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error opacity-50 cursor-not-allowed"
aria-label={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
disabled={true}
>
<.icon name="hero-trash" class="size-4" />
</button>
</.tooltip>
<.button
:if={get_member_count(mft, @member_counts) == 0}
variant="danger"
size="sm"
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
aria-label={gettext("Delete Membership Fee Type")}
>
<.icon name="hero-trash" class="size-4" />
</.button>
</:action>
</.table>
<details class="mt-6 card bg-base-200">
<summary class="card-body cursor-pointer list-none card-title">
<.icon name="hero-information-circle" class="size-5" />
{gettext("About Membership Fee Types")}
</summary>
<div class="card-body pt-0 prose prose-sm max-w-none">
<p>
{gettext(
"Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
)}
</p>
<ul>
<li>
<strong>{gettext("Name & Amount")}</strong>
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
</li>
<li>
<strong>{gettext("Interval")}</strong>
- {gettext(
"Fixed after creation. Members can only switch between types with the same interval."
)}
</li>
<li>
<strong>{gettext("Deletion")}</strong>
- {gettext("Only possible if no members are assigned to this type.")}
</li>
</ul>
</div>
</details>
</div>
</Layouts.app>
"""
end

View file

@ -21,7 +21,6 @@ defmodule MvWeb.RoleLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
try do
case Ash.get(
Mv.Authorization.Role,
id,
@ -64,7 +63,6 @@ defmodule MvWeb.RoleLive.Show do
reraise e, __STACKTRACE__
end
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do

View file

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

View file

@ -35,7 +35,14 @@ defmodule MvWeb.UserLive.Form do
require Jason
alias Mv.Accounts
alias Mv.Accounts.User, as: UserResource
alias Mv.Authorization
alias Mv.Authorization.Role, as: RoleResource
alias Mv.Helpers.SystemActor
alias Mv.Membership
alias Mv.Membership.Member, as: MemberResource
alias MvWeb.Helpers.MemberHelpers
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
import MvWeb.Authorization, only: [can?: 3]
@ -303,7 +310,7 @@ defmodule MvWeb.UserLive.Form do
<% end %>
<%!-- Danger zone: canonical pattern (same as member form) --%>
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
<%= if @user && can?(@current_user, :destroy, @user) && !SystemActor.system_user?(@user) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
@ -402,9 +409,9 @@ defmodule MvWeb.UserLive.Form do
defp load_user_or_redirect(nil, _actor, _socket), do: {:ok, nil}
defp load_user_or_redirect(id, actor, socket) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
user = Ash.get!(UserResource, id, domain: Accounts, load: [:member], actor: actor)
if Mv.Helpers.SystemActor.system_user?(user) do
if SystemActor.system_user?(user) do
{:redirect,
socket
|> put_flash(:error, gettext("This user cannot be edited."))
@ -420,9 +427,9 @@ defmodule MvWeb.UserLive.Form do
page_title = action <> " " <> gettext("User")
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
can_manage_member_linking = can?(actor, :destroy, UserResource)
# Only admins can assign user roles (Role update permission).
can_assign_role = can?(actor, :update, Mv.Authorization.Role)
can_assign_role = can?(actor, :update, RoleResource)
roles = if can_assign_role, do: load_roles(actor), else: []
{:ok,
@ -541,7 +548,7 @@ defmodule MvWeb.UserLive.Form do
|> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)}
Mv.Helpers.SystemActor.system_user?(user) ->
SystemActor.system_user?(user) ->
{:noreply,
socket
|> put_flash(:error, gettext("System user cannot be deleted."))
@ -634,7 +641,7 @@ defmodule MvWeb.UserLive.Form do
member_name =
if selected_member,
do: MvWeb.Helpers.MemberHelpers.display_name(selected_member),
do: MemberHelpers.display_name(selected_member),
else: ""
# Store the selected member ID and name in socket state and clear unlink flag
@ -704,17 +711,17 @@ defmodule MvWeb.UserLive.Form do
defp perform_member_link_action(socket, user, actor) do
# Only admins may link/unlink (backend policy also restricts update_user; UI must not call it).
if can?(actor, :destroy, Mv.Accounts.User) do
if can?(actor, :destroy, UserResource) do
cond do
# Selected member ID takes precedence (new link)
socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
actor: actor
)
# Unlink flag is set
socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
Accounts.update_user(user, %{member: nil}, actor: actor)
# No changes to member relationship
true ->
@ -831,8 +838,8 @@ defmodule MvWeb.UserLive.Form do
# For new users, use password registration if password fields are shown
action = if show_password_fields, do: :register_with_password, else: :create_user
AshPhoenix.Form.for_create(Mv.Accounts.User, action,
domain: Mv.Accounts,
AshPhoenix.Form.for_create(UserResource, action,
domain: Accounts,
as: "user",
actor: actor
)
@ -878,7 +885,7 @@ defmodule MvWeb.UserLive.Form do
search_query_str = if search_query && search_query != "", do: search_query, else: nil
query =
Mv.Membership.Member
MemberResource
|> Ash.Query.for_read(:available_for_linking, %{
user_email: user_email_str,
search_query: search_query_str
@ -890,7 +897,7 @@ defmodule MvWeb.UserLive.Form do
if is_nil(actor) do
[]
else
case Ash.read(query, domain: Mv.Membership, actor: actor) do
case Ash.read(query, domain: Membership, actor: actor) do
{:ok, members} -> apply_email_filter(members, user_email_str)
{:error, _} -> []
end
@ -902,7 +909,7 @@ defmodule MvWeb.UserLive.Form do
defp apply_email_filter(members, nil), do: members
defp apply_email_filter(members, user_email_str) when is_binary(user_email_str) do
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
MemberResource.filter_by_email_match(members, user_email_str)
end
@spec load_roles(any()) :: [Mv.Authorization.Role.t()]

View file

@ -19,6 +19,10 @@ defmodule MvWeb.UserLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Accounts
alias Mv.Accounts.User, as: UserResource
alias Mv.Helpers.SystemActor
require Ash.Query
@impl true
@ -26,9 +30,9 @@ defmodule MvWeb.UserLive.Index do
actor = current_actor(socket)
users =
Mv.Accounts.User
|> Ash.Query.filter(email != ^Mv.Helpers.SystemActor.system_user_email())
|> Ash.read!(domain: Mv.Accounts, load: [:member, :role], actor: actor)
UserResource
|> Ash.Query.filter(email != ^SystemActor.system_user_email())
|> Ash.read!(domain: Accounts, load: [:member, :role], actor: actor)
sorted = Enum.sort_by(users, & &1.email)

View file

@ -29,6 +29,10 @@ defmodule MvWeb.UserLive.Show do
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
alias Mv.Accounts
alias Mv.Accounts.User, as: UserResource
alias Mv.Helpers.SystemActor
@impl true
def render(assigns) do
~H"""
@ -86,7 +90,7 @@ defmodule MvWeb.UserLive.Show do
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</.link>
<% else %>
<span class="italic text-gray-500">{gettext("No member linked")}</span>
<span class="italic text-base-content/70">{gettext("No member linked")}</span>
<% end %>
</:item>
</.list>
@ -167,9 +171,9 @@ defmodule MvWeb.UserLive.Show do
actor = current_actor(socket)
user =
Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member, :role], actor: actor)
Ash.get!(UserResource, id, domain: Accounts, load: [:member, :role], actor: actor)
if Mv.Helpers.SystemActor.system_user?(user) do
if SystemActor.system_user?(user) do
{:ok,
socket
|> put_flash(:error, gettext("This user cannot be viewed."))
@ -221,7 +225,7 @@ defmodule MvWeb.UserLive.Show do
|> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)}
Mv.Helpers.SystemActor.system_user?(user) ->
SystemActor.system_user?(user) ->
{:noreply,
socket
|> put_flash(:error, gettext("System user cannot be deleted."))

View file

@ -16,6 +16,7 @@ defmodule MvWeb.LiveHelpers do
```
"""
import Phoenix.Component
alias Mv.Authorization.Actor
alias MvWeb.Plugs.CheckPagePermission
def on_mount(:default, _params, session, socket) do
@ -68,7 +69,7 @@ defmodule MvWeb.LiveHelpers do
if user do
# Use centralized Actor helper to ensure role is loaded
user_with_role = Mv.Authorization.Actor.ensure_loaded(user)
user_with_role = Actor.ensure_loaded(user)
assign(socket, :current_user, user_with_role)
else
socket

View file

@ -6,6 +6,9 @@ defmodule MvWeb.LiveUserAuth do
import Phoenix.Component
use MvWeb, :verified_routes
alias AshAuthentication.Phoenix.LiveSession
alias Phoenix.LiveView
# This is used for nested liveviews to fetch the current user.
# To use, place the following at the top of that liveview:
# on_mount {MvWeb.LiveUserAuth, :current_user}
@ -15,7 +18,7 @@ defmodule MvWeb.LiveUserAuth do
socket =
socket
|> assign(:return_to, return_to)
|> AshAuthentication.Phoenix.LiveSession.assign_new_resources(session)
|> LiveSession.assign_new_resources(session)
{:cont, session, socket}
end
@ -29,14 +32,14 @@ defmodule MvWeb.LiveUserAuth do
end
def on_mount(:live_user_required, _params, session, socket) do
socket = AshAuthentication.Phoenix.LiveSession.assign_new_resources(socket, session)
socket = LiveSession.assign_new_resources(socket, session)
case socket.assigns do
%{current_user: %{} = user} ->
{:cont, assign(socket, :current_user, user)}
_ ->
socket = Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")
socket = LiveView.redirect(socket, to: ~p"/sign-in")
{:halt, socket}
end
end

View file

@ -25,6 +25,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
import Plug.Conn
import Phoenix.Controller
alias Mv.Authorization.Actor
alias Mv.Authorization.PermissionSets
require Logger
@ -37,7 +38,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
# Ensure role is loaded (load_from_session does not load it; required for permission check)
user =
conn.assigns[:current_user]
|> Mv.Authorization.Actor.ensure_loaded()
|> Actor.ensure_loaded()
conn = Plug.Conn.assign(conn, :current_user, user)
page_path = get_page_path(conn)
@ -221,7 +222,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))

View file

@ -56,6 +56,8 @@ msgstr ""
msgid "Reset password with token"
msgstr ""
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen
msgid "Sign in"
msgstr ""

View file

@ -55,6 +55,8 @@ msgstr "Anfrage låuft..."
msgid "Reset password with token"
msgstr "Neues Passwort setzen"
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen
msgid "Sign in"
msgstr "Anmelden"

View file

@ -784,7 +784,6 @@ msgid "Personal Data"
msgstr "Persönliche Daten"
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
@ -828,11 +827,6 @@ msgstr "Beispiele"
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Global Settings"
msgstr "Globale Einstellungen"
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
@ -1018,7 +1012,6 @@ msgstr "Mitgliedsfeld wurde erfolgreich %{action}"
msgid "A cycle for this period already exists"
msgstr "Ein Zyklus für diesen Zeitraum existiert bereits"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Membership Fee Types"
@ -1103,11 +1096,6 @@ msgstr "Einen neuen Zyklus manuell erstellen"
msgid "Current Cycle"
msgstr "Aktueller Zyklus"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current Cycle Payment Status"
msgstr "Aktueller Zyklus Zahlungsstatus"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Current amount"
@ -1178,7 +1166,6 @@ msgstr "Feld bearbeiten: %{field}"
msgid "Edit Membership Fee Type"
msgstr "Mitgliedsbeitragsart bearbeiten"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit membership fee type"
@ -1229,11 +1216,6 @@ msgstr "Ungültiges Datumsformat"
msgid "Last Cycle"
msgstr "Letzter Zyklus"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last Cycle Payment Status"
msgstr "Letzter Zyklus Zahlungsstatus"
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Manage membership fee types for membership fees."
@ -1446,11 +1428,6 @@ msgstr "Dieses Feld ist erforderlich"
msgid "This is a technical field and cannot be changed"
msgstr "Dies ist ein technisches Feld und kann nicht verändert werden."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
msgstr "Diese Mitgliedsbeitragsart wird automatisch allen neuen Mitgliedern zugewiesen. Kann individuell pro Mitglied geändert werden."
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
@ -1473,16 +1450,6 @@ msgstr "Warnung"
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
msgstr "Warnung: Wechsel von %{old_interval} zu %{new_interval} ist nicht erlaubt. Bitte wähle eine Mitgliedsbeitragsart mit demselben Intervall."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "When active: Members pay from the cycle of their joining."
msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "When inactive: Members pay from the next full cycle after joining."
msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Yearly Interval - Joining Cycle Excluded"
@ -3235,3 +3202,38 @@ msgstr "Nicht angegeben"
#, elixir-autogen, elixir-format, fuzzy
msgid "All datafield values will be permanently deleted when you delete this datafield."
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Click to edit membership fee type"
msgstr "Klicken zum Bearbeiten der Mitgliedsbeitragsart"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current payment cycle"
msgstr "Aktueller Zahlungszyklus"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last payment cycle"
msgstr "Letzter Zahlungszyklus"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
msgstr "Legt fest, ob Bezahlstatusfilter und Mitgliedsbeitragsstatus-Spalte den letzten abgeschlossenen oder den aktuellen Zahlungszyklus verwenden."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Default settings"
msgstr "Standardeinstellungen"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Default type: Assigned to new members; can be changed per member."
msgstr "Standardart: Wird neuen Mitgliedern zugewiesen; pro Mitglied änderbar."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
msgstr "Beitrittszyklus einbeziehen: Aktiv = Zahlung ab Beitrittszyklus; inaktiv = ab dem nächsten vollen Zyklus."

View file

@ -785,7 +785,6 @@ msgid "Personal Data"
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
@ -829,11 +828,6 @@ msgstr ""
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Global Settings"
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
@ -1019,7 +1013,6 @@ msgstr ""
msgid "A cycle for this period already exists"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Membership Fee Types"
@ -1104,11 +1097,6 @@ msgstr ""
msgid "Current Cycle"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current Cycle Payment Status"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Current amount"
@ -1179,7 +1167,6 @@ msgstr ""
msgid "Edit Membership Fee Type"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Edit membership fee type"
@ -1230,11 +1217,6 @@ msgstr ""
msgid "Last Cycle"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last Cycle Payment Status"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Manage membership fee types for membership fees."
@ -1447,11 +1429,6 @@ msgstr ""
msgid "This is a technical field and cannot be changed"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
@ -1474,16 +1451,6 @@ msgstr ""
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When active: Members pay from the cycle of their joining."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When inactive: Members pay from the next full cycle after joining."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Cycle Excluded"
@ -3235,3 +3202,38 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "All datafield values will be permanently deleted when you delete this datafield."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Click to edit membership fee type"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current payment cycle"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last payment cycle"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Default settings"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Default type: Assigned to new members; can be changed per member."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
msgstr ""

View file

@ -52,6 +52,8 @@ msgstr ""
msgid "Reset password with token"
msgstr ""
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen
msgid "Sign in"
msgstr ""

View file

@ -785,7 +785,6 @@ msgid "Personal Data"
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
@ -829,11 +828,6 @@ msgstr ""
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Global Settings"
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
@ -1019,7 +1013,6 @@ msgstr ""
msgid "A cycle for this period already exists"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Membership Fee Types"
@ -1104,11 +1097,6 @@ msgstr ""
msgid "Current Cycle"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current Cycle Payment Status"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Current amount"
@ -1179,7 +1167,6 @@ msgstr ""
msgid "Edit Membership Fee Type"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit membership fee type"
@ -1230,11 +1217,6 @@ msgstr ""
msgid "Last Cycle"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last Cycle Payment Status"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Manage membership fee types for membership fees."
@ -1447,11 +1429,6 @@ msgstr ""
msgid "This is a technical field and cannot be changed"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
@ -1474,16 +1451,6 @@ msgstr ""
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When active: Members pay from the cycle of their joining."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When inactive: Members pay from the next full cycle after joining."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Cycle Excluded"
@ -3235,3 +3202,38 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy
msgid "All datafield values will be permanently deleted when you delete this datafield."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Click to edit membership fee type"
msgstr "Click to edit membership fee type"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current payment cycle"
msgstr "Current payment cycle"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last payment cycle"
msgstr "Last payment cycle"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
msgstr "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Default settings"
msgstr "Default settings"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Default type: Assigned to new members; can be changed per member."
msgstr "Default type: Assigned to new members; can be changed per member."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
msgstr "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."

View file

@ -2,811 +2,26 @@
#
# mix run priv/repo/seeds.exs
#
# Bootstrap runs in all environments. Dev seeds (members, groups, sample data)
# run only in dev and test.
#
# Compiler option ignore_module_conflict is set only during seed evaluation
# so that eval_file of bootstrap/dev does not emit "redefining module" warnings;
# it is always restored in `after` to avoid hiding real conflicts elsewhere.
alias Mv.Accounts
alias Mv.Membership
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeType
prev = Code.compiler_options()
Code.compiler_options(ignore_module_conflict: true)
require Ash.Query
try do
# Always run bootstrap (fee types, custom fields, roles, admin, system user, settings)
Code.eval_file("priv/repo/seeds_bootstrap.exs")
# 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
IO.puts("✅ All seeds completed.")
after
Code.compiler_options(prev)
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!")

View file

@ -0,0 +1,314 @@
# 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
# Filter by name and interval to avoid ambiguity if multiple "Standard" types exist
default_fee_type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Query.filter(name == "Standard" and interval == :yearly)
|> 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)")

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

@ -0,0 +1,488 @@
# Dev/local seeds: run only in dev and test (Mix.env in [:dev, :test]).
# Creates 20 sample members, groups, and optional custom field values.
# Requires bootstrap seeds to have run first.
alias Mv.Accounts
alias Mv.Membership
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
admin_user_with_role =
case Accounts.User
|> Ash.Query.filter(email == ^admin_email)
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
{:ok, user} when not is_nil(user) ->
user |> Ash.load!(:role, authorize?: false)
_ ->
raise "Dev seeds require bootstrap: admin user not found (#{admin_email})"
end
all_fee_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|> Enum.to_list()
# Countries: mostly Germany, 12 exceptions (index 7 = Österreich, index 14 = Schweiz)
countries_list =
List.duplicate("Deutschland", 20)
|> List.replace_at(7, "Österreich")
|> List.replace_at(14, "Schweiz")
# 20 members: varied names, cities, join dates; fee types by index (last 2 without fee type)
member_configs = [
%{
first_name: "Anna",
last_name: "Schmidt",
city: "München",
street: "Hauptstraße",
house_number: "1",
postal_code: "80331",
join_date: ~D[2022-01-10]
},
%{
first_name: "Bruno",
last_name: "Müller",
city: "Hamburg",
street: "Lindenstraße",
house_number: "5",
postal_code: "20095",
join_date: ~D[2022-03-15]
},
%{
first_name: "Clara",
last_name: "Fischer",
city: "Berlin",
street: "Kastanienallee",
house_number: "12",
postal_code: "10435",
join_date: ~D[2022-05-20]
},
%{
first_name: "David",
last_name: "Weber",
city: "Köln",
street: "Rheinstraße",
house_number: "8",
postal_code: "50667",
join_date: ~D[2022-07-01]
},
%{
first_name: "Elena",
last_name: "Wagner",
city: "Frankfurt",
street: "Goetheplatz",
house_number: "3",
postal_code: "60313",
join_date: ~D[2022-09-12]
},
%{
first_name: "Felix",
last_name: "Becker",
city: "Stuttgart",
street: "Königstraße",
house_number: "22",
postal_code: "70173",
join_date: ~D[2023-01-05]
},
%{
first_name: "Greta",
last_name: "Schulz",
city: "Düsseldorf",
street: "Schadowstraße",
house_number: "14",
postal_code: "40212",
join_date: ~D[2023-02-14]
},
%{
first_name: "Henrik",
last_name: "Hoffmann",
city: "Leipzig",
street: "Nikolaistraße",
house_number: "7",
postal_code: "04109",
join_date: ~D[2023-04-20]
},
%{
first_name: "Ines",
last_name: "Koch",
city: "Dortmund",
street: "Westenhellweg",
house_number: "45",
postal_code: "44137",
join_date: ~D[2023-06-08]
},
%{
first_name: "Jakob",
last_name: "Richter",
city: "Essen",
street: "Kettwiger Straße",
house_number: "2",
postal_code: "45127",
join_date: ~D[2023-08-11]
},
%{
first_name: "Laura",
last_name: "Klein",
city: "Dresden",
street: "Prager Straße",
house_number: "9",
postal_code: "01069",
join_date: ~D[2023-10-01]
},
%{
first_name: "Max",
last_name: "Wolf",
city: "Hannover",
street: "Georgstraße",
house_number: "50",
postal_code: "30159",
join_date: ~D[2023-11-15]
},
%{
first_name: "Nina",
last_name: "Schröder",
city: "Nürnberg",
street: "Königstraße",
house_number: "73",
postal_code: "90402",
join_date: ~D[2024-01-20]
},
%{
first_name: "Oliver",
last_name: "Neumann",
city: "Bremen",
street: "Obernstraße",
house_number: "31",
postal_code: "28195",
join_date: ~D[2024-03-10]
},
%{
first_name: "Paula",
last_name: "Schwarz",
city: "Mannheim",
street: "Planken",
house_number: "11",
postal_code: "68161",
join_date: ~D[2024-05-22]
},
%{
first_name: "Quirin",
last_name: "Zimmermann",
city: "Karlsruhe",
street: "Kaiserstraße",
house_number: "145",
postal_code: "76133",
join_date: ~D[2024-07-07]
},
%{
first_name: "Rosa",
last_name: "Braun",
city: "Wiesbaden",
street: "Wilhelmstraße",
house_number: "6",
postal_code: "65183",
join_date: ~D[2024-09-01]
},
%{
first_name: "Stefan",
last_name: "Krüger",
city: "Münster",
street: "Ludgeristraße",
house_number: "18",
postal_code: "48143",
join_date: ~D[2024-10-15]
},
%{
first_name: "Thea",
last_name: "Hartmann",
city: "Augsburg",
street: "Maximilianstraße",
house_number: "4",
postal_code: "86150",
join_date: ~D[2024-11-20]
},
%{
first_name: "Uwe",
last_name: "Lange",
city: "Bonn",
street: "Remigiusstraße",
house_number: "27",
postal_code: "53111",
join_date: ~D[2024-12-01]
}
]
# Fee type index per member: 0..4 round-robin for first 18, nil for last 2
# Cycle status: all_paid, all_unpaid, mixed (varied)
cycle_statuses = [
:all_paid,
:all_unpaid,
:mixed,
:all_paid,
:mixed,
:all_unpaid,
:all_paid,
:mixed,
:all_unpaid,
:all_paid,
:mixed,
:all_paid,
:all_unpaid,
:mixed,
:all_paid,
:mixed,
:all_unpaid,
:all_paid,
:mixed,
nil
]
Enum.with_index(member_configs)
|> Enum.each(fn {config, index} ->
email = "mitglied#{index + 1}@example.de"
fee_type_index = if index >= 18, do: nil, else: rem(index, length(all_fee_types))
fee_type_id = if fee_type_index, do: Enum.at(all_fee_types, fee_type_index).id, else: nil
cycle_status = Enum.at(cycle_statuses, index)
# Do not include membership_fee_type_id in upsert so re-runs do not overwrite
# existing assignments; set via update below only when member has none
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 =
Membership.create_member!(base_attrs,
upsert?: true,
upsert_identity: :unique_email,
actor: admin_user_with_role
)
final_member =
if is_nil(member.membership_fee_type_id) and fee_type_id do
{:ok, updated} =
Membership.update_member(member, %{membership_fee_type_id: fee_type_id},
actor: admin_user_with_role
)
updated
else
member
end
if not is_nil(final_member.membership_fee_type_id) and not is_nil(cycle_status) do
member_with_cycles =
Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role)
cycles =
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
{:ok, new_cycles, _} =
CycleGenerator.generate_cycles_for_member(final_member.id,
skip_lock?: true,
actor: admin_user_with_role
)
new_cycles
else
member_with_cycles.membership_fee_cycles
end
cycles
|> Enum.sort_by(& &1.cycle_start, Date)
|> Enum.with_index()
|> Enum.each(fn {cycle, idx} ->
status =
case cycle_status do
:all_paid ->
:paid
:all_unpaid ->
:unpaid
:mixed ->
case rem(idx, 3) do
0 -> :paid
1 -> :unpaid
2 -> :suspended
end
_ ->
cycle.status
end
if cycle.status != status do
cycle
|> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees)
end
end)
end
end)
# Groups (idempotent)
group_configs = [
%{name: "Vorstand", description: "Gremium Vorstand"},
%{name: "Jugend", description: "Jugendbereich"},
%{name: "Newsletter", description: "Empfänger*innen Newsletter"}
]
existing_groups =
case Membership.list_groups(actor: admin_user_with_role) do
{:ok, list} -> list
{:error, _} -> []
end
existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name))
seed_groups =
Enum.reduce(group_configs, %{}, fn config, acc ->
name = config.name
if MapSet.member?(existing_names_lower, String.downcase(name)) do
group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name)))
Map.put(acc, name, group)
else
{:ok, group} =
Membership.create_group(%{name: name, description: config.description},
actor: admin_user_with_role
)
Map.put(acc, name, group)
end
end)
# Test users: create users linked to members (same email as member so sync is no-op),
# each with a different role for testing authorization.
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
test_users_config = [
{"mitglied1@example.de", "Mitglied"},
{"mitglied2@example.de", "Vorstand"},
{"mitglied3@example.de", "Kassenwart"},
{"mitglied4@example.de", "Buchhaltung"}
]
roles_by_name =
Mv.Authorization.Role
|> Ash.read!(authorize?: false, domain: Mv.Authorization)
|> Map.new(&{&1.name, &1})
Enum.each(test_users_config, fn {email, role_name} ->
member = Enum.find(all_members, &(&1.email == email))
role = roles_by_name[role_name]
if not is_nil(member) and not is_nil(role) do
user =
Accounts.create_user!(
%{email: email, member: %{id: member.id}},
upsert?: true,
upsert_identity: :unique_email,
actor: admin_user_with_role
)
user
|> Ash.Changeset.for_update(:update_user, %{role_id: role.id}, domain: Mv.Accounts)
|> Ash.update!(actor: admin_user_with_role)
end
end)
# Assign some members to groups (mitglied15 to Vorstand/Newsletter etc.)
member_group_assignments = [
{"mitglied1@example.de", ["Vorstand", "Newsletter"]},
{"mitglied2@example.de", ["Jugend", "Newsletter"]},
{"mitglied3@example.de", ["Vorstand"]},
{"mitglied4@example.de", ["Newsletter"]},
{"mitglied5@example.de", ["Newsletter"]}
]
find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end
Enum.each(member_group_assignments, fn {email, group_names} ->
member = find_member.(email)
if member do
Enum.each(group_names, fn group_name ->
group = seed_groups[group_name]
if group do
Membership.create_member_group(
%{member_id: member.id, group_id: group.id},
actor: admin_user_with_role
)
end
end)
end
end)
# Custom field values for ~80% of members (16 of 20): most of the 6 fields filled per member
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
# 16 members with 46 custom field values each (Geburtsdatum, Datenschutz, SEPA, Rechnungs-E-Mail, IBAN, Stunden)
custom_value_assignments =
Enum.map(1..16, fn n ->
email = "mitglied#{n}@example.de"
# Vary birth dates and values per index
base_date = Date.add(~D[1970-01-01], 365 * (n + 10) + rem(n * 31, 365))
values = [
{"Geburtsdatum", %{"_union_type" => "date", "_union_value" => base_date}},
{"Datenschutzerklärung akzeptiert",
%{"_union_type" => "boolean", "_union_value" => n in [1, 2, 3, 5, 7, 9, 11, 13, 15]}},
{"SEPA-Mandat", %{"_union_type" => "boolean", "_union_value" => rem(n, 3) != 0}},
{"Rechnungs-E-Mail",
%{"_union_type" => "email", "_union_value" => "rechnung#{n}@example.de"}},
{"IBAN",
%{
"_union_type" => "string",
"_union_value" =>
"DE8937040044#{String.pad_leading(to_string(rem(532013000 + n, 1_000_000_000)), 10, "0")}"
}},
{"Stunden ehrenamtlich", %{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}}
]
# Drop 02 fields per member so not all have 6 (still ~80% overall filled)
drop_count = rem(n, 3)
{email, Enum.take(values, 6 - drop_count)}
end)
for {email, values} <- custom_value_assignments do
member = find_member.(email)
if member do
Enum.each(values, fn {field_name, value} ->
field = find_field.(field_name)
if field do
Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: field.id,
value: value
})
|> Ash.create!(
upsert?: true,
upsert_identity: :unique_custom_field_per_member,
actor: admin_user_with_role
)
end
end)
end
end
IO.puts("✅ Dev seeds completed.")
IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz)")
IO.puts(" - Test users: 4 linked to mitglied14 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung")
IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)")
IO.puts(" - Custom field values: ~80% filled (16 members, 46 fields each)")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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} =

View file

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

View file

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

View file

@ -1 +1,378 @@
defmodule Mv.Membership.MemberExport.BuildTest do
@moduledoc """
Tests for MemberExport.Build module.
Tests verify that the module correctly:
- Loads and filters members based on query/selected_ids
- Builds column specifications (without labels)
- Generates row data as cell strings
- Handles member fields, custom fields, and computed fields
- Applies sorting and filtering consistently
"""
use Mv.DataCase, async: true
alias Mv.Constants
alias Mv.Fixtures
alias Mv.Membership.{CustomField, MemberExport.Build}
@custom_field_prefix Constants.custom_field_prefix()
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
member1 =
Fixtures.member_fixture(%{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
member2 =
Fixtures.member_fixture(%{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
%{actor: system_actor, member1: member1, member2: member2}
end
describe "build/3 - standard member fields" do
test "returns columns and rows for standard member fields", %{
actor: actor,
member1: m1,
member2: m2
} do
parsed = %{
selected_ids: [m1.id, m2.id],
member_fields: ["first_name", "last_name", "email"],
selectable_member_fields: ["first_name", "last_name", "email"],
computed_fields: [],
custom_field_ids: [],
boolean_filters: %{},
cycle_status_filter: nil,
query: nil,
sort_field: nil,
sort_order: nil,
show_current_cycle: false
}
result = Build.build(actor, parsed, fn _key -> "Label" end)
assert {:ok, data} = result
assert %{columns: columns, rows: rows, meta: meta} = data
# Check columns structure
assert length(columns) == 3
first_name_col = Enum.find(columns, &(&1.key == "first_name" && &1.kind == :member_field))
assert first_name_col
assert first_name_col.label == "Label"
assert Enum.find(columns, &(&1.key == "last_name" && &1.kind == :member_field))
assert Enum.find(columns, &(&1.key == "email" && &1.kind == :member_field))
# Check rows - should have 2 members
assert length(rows) == 2
# Check first row (member1)
row1 = Enum.at(rows, 0)
assert length(row1) == 3
assert "Alice" in row1
assert "Anderson" in row1
assert "alice@example.com" in row1
# Check meta
assert %{generated_at: _timestamp, member_count: 2} = meta
assert is_binary(meta.generated_at)
end
test "filters members by selected_ids", %{actor: actor, member1: m1, member2: _m2} do
parsed = %{
selected_ids: [m1.id],
member_fields: ["first_name"],
selectable_member_fields: ["first_name"],
computed_fields: [],
custom_field_ids: [],
boolean_filters: %{},
cycle_status_filter: nil,
query: nil,
sort_field: nil,
sort_order: nil,
show_current_cycle: false
}
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
assert length(data.rows) == 1
row = hd(data.rows)
assert "Alice" in row
assert data.meta.member_count == 1
end
test "applies search query when selected_ids is empty", %{
actor: actor,
member1: _m1,
member2: _m2
} do
parsed = %{
selected_ids: [],
member_fields: ["first_name", "last_name"],
selectable_member_fields: ["first_name", "last_name"],
computed_fields: [],
custom_field_ids: [],
boolean_filters: %{},
cycle_status_filter: nil,
query: "Alice",
sort_field: nil,
sort_order: nil,
show_current_cycle: false
}
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
assert length(data.rows) == 1
row = hd(data.rows)
assert "Alice" in row
end
end
describe "build/3 - custom fields" do
test "includes custom field columns and values", %{
actor: actor,
member1: m1
} do
# Create custom field
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Membership Number",
value_type: :string
})
|> Ash.create(actor: actor)
# Create custom field value for member
{:ok, _cfv} =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: m1.id,
custom_field_id: custom_field.id,
value: "M12345"
})
|> Ash.create(actor: actor)
parsed = %{
selected_ids: [m1.id],
member_fields: ["first_name"],
selectable_member_fields: ["first_name"],
computed_fields: [],
custom_field_ids: [custom_field.id],
boolean_filters: %{},
cycle_status_filter: nil,
query: nil,
sort_field: nil,
sort_order: nil,
show_current_cycle: false
}
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
# Should have 2 columns: first_name + custom field
assert length(data.columns) == 2
custom_col =
Enum.find(
data.columns,
&(&1.kind == :custom_field && &1.key == to_string(custom_field.id))
)
assert custom_col
assert custom_col.custom_field.id == custom_field.id
# Label comes from custom field name when provided via label_fn key
assert custom_col.label in ["Label", "Membership Number"]
# Check row has custom field value
row = hd(data.rows)
assert length(row) == 2
assert "M12345" in row
end
test "handles members without custom field values", %{
actor: actor,
member1: m1
} do
# Create custom field but no value for member
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Optional Field",
value_type: :string
})
|> Ash.create(actor: actor)
parsed = %{
selected_ids: [m1.id],
member_fields: ["first_name"],
selectable_member_fields: ["first_name"],
computed_fields: [],
custom_field_ids: [custom_field.id],
boolean_filters: %{},
cycle_status_filter: nil,
query: nil,
sort_field: nil,
sort_order: nil,
show_current_cycle: false
}
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
row = hd(data.rows)
# Custom field value should be empty string
assert "" in row
end
end
describe "build/3 - computed fields" do
test "includes computed field columns and values", %{
actor: actor,
member1: m1
} do
parsed = %{
selected_ids: [m1.id],
member_fields: ["first_name", "membership_fee_status"],
selectable_member_fields: ["first_name"],
computed_fields: ["membership_fee_status"],
custom_field_ids: [],
boolean_filters: %{},
cycle_status_filter: nil,
query: nil,
sort_field: nil,
sort_order: nil,
show_current_cycle: false
}
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
# Should have 2 columns: first_name + computed field
assert length(data.columns) == 2
computed_col =
Enum.find(data.columns, &(&1.kind == :computed && &1.key == :membership_fee_status))
assert computed_col
assert computed_col.label == "Label"
# Check row has computed field value (may be empty if no cycles)
row = hd(data.rows)
assert length(row) == 2
# membership_fee_status should be present (even if empty)
end
end
describe "build/3 - sorting" do
test "sorts by member field", %{actor: actor, member1: m1, member2: m2} do
parsed = %{
selected_ids: [m1.id, m2.id],
member_fields: ["first_name"],
selectable_member_fields: ["first_name"],
computed_fields: [],
custom_field_ids: [],
boolean_filters: %{},
cycle_status_filter: nil,
query: nil,
sort_field: "first_name",
sort_order: "asc",
show_current_cycle: false
}
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
# Should be sorted: Alice, Bob
[row1, row2] = data.rows
assert "Alice" in row1
assert "Bob" in row2
end
test "sorts by custom field", %{actor: actor, member1: m1, member2: m2} do
# Create custom field
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Sort Field",
value_type: :string
})
|> Ash.create(actor: actor)
# Add values: m1="Zebra", m2="Alpha"
{:ok, _cfv1} =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: m1.id,
custom_field_id: custom_field.id,
value: "Zebra"
})
|> Ash.create(actor: actor)
{:ok, _cfv2} =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: m2.id,
custom_field_id: custom_field.id,
value: "Alpha"
})
|> Ash.create(actor: actor)
sort_field = "#{@custom_field_prefix}#{custom_field.id}"
parsed = %{
selected_ids: [m1.id, m2.id],
member_fields: ["first_name"],
selectable_member_fields: ["first_name"],
computed_fields: [],
custom_field_ids: [custom_field.id],
boolean_filters: %{},
cycle_status_filter: nil,
query: nil,
sort_field: sort_field,
sort_order: "asc",
show_current_cycle: false
}
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
# Should be sorted by custom field: Alpha (Bob), Zebra (Alice)
[row1, row2] = data.rows
# Alpha
assert "Bob" in row1
# Zebra
assert "Alice" in row2
end
end
describe "build/3 - error handling" do
test "returns error when actor lacks permission", %{member1: m1} do
# User with own_data can only read linked member; m1 is not linked to this user
user = Fixtures.user_with_role_fixture("own_data")
parsed = %{
selected_ids: [m1.id],
member_fields: ["first_name"],
selectable_member_fields: ["first_name"],
computed_fields: [],
custom_field_ids: [],
boolean_filters: %{},
cycle_status_filter: nil,
query: nil,
sort_field: nil,
sort_order: nil,
show_current_cycle: false
}
result = Build.build(user, parsed, fn _key -> "Label" end)
# own_data user cannot read m1 (not linked); build returns ok with empty rows
assert {:ok, data} = result
assert data.meta.member_count == 0
assert data.rows == []
end
end
end

View file

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

View file

@ -1 +1,267 @@
defmodule Mv.Membership.MembersPDFTest do
@moduledoc """
Tests for MembersPDF module.
Tests verify that the module correctly:
- Loads the Typst template
- Converts export data to template format
- Generates valid PDF binary (starts with "%PDF")
- Handles errors gracefully
"""
# async: false so tests that manipulate the template file (e.g. "returns error when template file is missing")
# do not run in parallel and cannot leave the template removed on failure
use ExUnit.Case, async: false
alias Mv.Config
alias Mv.Membership.MembersPDF
describe "render/1" do
test "rejects export when row count exceeds limit" do
max_rows = Config.pdf_export_row_limit()
rows_over_limit = max_rows + 1
export_data = %{
columns: [
%{key: "first_name", kind: :member_field, label: "Vorname"}
],
rows: Enum.map(1..rows_over_limit, fn i -> ["Member #{i}"] end),
meta: %{
generated_at: "2024-01-15T14:30:00Z",
member_count: rows_over_limit
}
}
result = MembersPDF.render(export_data)
assert {:error, {:row_limit_exceeded, ^rows_over_limit, ^max_rows}} = result
end
test "allows export when row count equals limit" do
max_rows = Config.pdf_export_row_limit()
export_data = %{
columns: [
%{key: "first_name", kind: :member_field, label: "Vorname"}
],
rows: Enum.map(1..max_rows, fn i -> ["Member #{i}"] end),
meta: %{
generated_at: "2024-01-15T14:30:00Z",
member_count: max_rows
}
}
result = MembersPDF.render(export_data)
assert {:ok, pdf_binary} = result
assert String.starts_with?(pdf_binary, "%PDF")
end
test "allows export when row count is below limit" do
max_rows = Config.pdf_export_row_limit()
rows_below_limit = max(1, max_rows - 10)
export_data = %{
columns: [
%{key: "first_name", kind: :member_field, label: "Vorname"}
],
rows: Enum.map(1..rows_below_limit, fn i -> ["Member #{i}"] end),
meta: %{
generated_at: "2024-01-15T14:30:00Z",
member_count: rows_below_limit
}
}
result = MembersPDF.render(export_data)
assert {:ok, pdf_binary} = result
assert String.starts_with?(pdf_binary, "%PDF")
end
test "generates valid PDF from minimal dataset" do
export_data = %{
columns: [
%{key: "first_name", kind: :member_field, label: "Vorname"},
%{key: "last_name", kind: :member_field, label: "Nachname"}
],
rows: [
["Max", "Mustermann"],
["Anna", "Schmidt"]
],
meta: %{
generated_at: "2024-01-15T14:30:00Z",
member_count: 2
}
}
result = MembersPDF.render(export_data)
assert {:ok, pdf_binary} = result
assert is_binary(pdf_binary)
assert String.starts_with?(pdf_binary, "%PDF")
assert byte_size(pdf_binary) > 1000
end
test "generates valid PDF with custom fields and computed fields" do
export_data = %{
columns: [
%{key: "first_name", kind: :member_field, label: "Vorname"},
%{key: "last_name", kind: :member_field, label: "Nachname"},
%{key: "email", kind: :member_field, label: "E-Mail"},
%{key: :membership_fee_status, kind: :computed, label: "Beitragsstatus"},
%{
key: "550e8400-e29b-41d4-a716-446655440000",
kind: :custom_field,
label: "Mitgliedsnummer",
custom_field: %{
id: "550e8400-e29b-41d4-a716-446655440000",
name: "Mitgliedsnummer",
value_type: :string
}
}
],
rows: [
["Max", "Mustermann", "max@example.com", "paid", "M-2024-001"],
["Anna", "Schmidt", "anna@example.com", "unpaid", "M-2024-002"]
],
meta: %{
generated_at: "2024-01-15T14:30:00Z",
member_count: 2
}
}
result = MembersPDF.render(export_data)
assert {:ok, pdf_binary} = result
assert is_binary(pdf_binary)
assert String.starts_with?(pdf_binary, "%PDF")
end
test "maintains deterministic column and row order" do
export_data = %{
columns: [
%{key: "first_name", kind: :member_field, label: "Vorname"},
%{key: "last_name", kind: :member_field, label: "Nachname"},
%{key: "email", kind: :member_field, label: "E-Mail"}
],
rows: [
["Max", "Mustermann", "max@example.com"],
["Anna", "Schmidt", "anna@example.com"],
["Peter", "Müller", "peter@example.com"]
],
meta: %{
generated_at: "2024-01-15T14:30:00Z",
member_count: 3
}
}
# Render twice and verify identical output
{:ok, pdf1} = MembersPDF.render(export_data)
{:ok, pdf2} = MembersPDF.render(export_data)
assert pdf1 == pdf2
assert String.starts_with?(pdf1, "%PDF")
assert String.starts_with?(pdf2, "%PDF")
end
test "returns error when template file is missing" do
template_path =
Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/members_export.typ")
original_content = File.read!(template_path)
File.rm(template_path)
try do
export_data = %{
columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}],
rows: [["Max"]],
meta: %{generated_at: "2024-01-15T14:30:00Z", member_count: 1}
}
result = MembersPDF.render(export_data)
assert {:error, {:template_not_found, _reason}} = result
after
File.write!(template_path, original_content)
end
end
test "handles empty rows gracefully" do
export_data = %{
columns: [
%{key: "first_name", kind: :member_field, label: "Vorname"},
%{key: "last_name", kind: :member_field, label: "Nachname"}
],
rows: [],
meta: %{
generated_at: "2024-01-15T14:30:00Z",
member_count: 0
}
}
result = MembersPDF.render(export_data)
assert {:ok, pdf_binary} = result
assert is_binary(pdf_binary)
assert String.starts_with?(pdf_binary, "%PDF")
end
test "handles many columns correctly" do
# Test with 10 columns to ensure dynamic column width calculation works
columns =
Enum.map(1..10, fn i ->
%{key: "field_#{i}", kind: :member_field, label: "Feld #{i}"}
end)
export_data = %{
columns: columns,
rows: [Enum.map(1..10, &"Wert #{&1}")],
meta: %{
generated_at: "2024-01-15T14:30:00Z",
member_count: 1
}
}
result = MembersPDF.render(export_data)
assert {:ok, pdf_binary} = result
assert is_binary(pdf_binary)
assert String.starts_with?(pdf_binary, "%PDF")
end
test "creates and cleans up temp directory" do
export_data = %{
columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}],
rows: [["Max"]],
meta: %{
generated_at: "2024-01-15T14:30:00Z",
member_count: 1
}
}
# Get temp base directory
temp_base = System.tmp_dir!()
# Count temp directories before
before_count =
temp_base
|> File.ls!()
|> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end)
result = MembersPDF.render(export_data)
assert {:ok, _pdf_binary} = result
# Wait a bit for cleanup (async cleanup might take a moment)
Process.sleep(100)
# Count temp directories after
after_count =
temp_base
|> File.ls!()
|> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end)
# Should have same or fewer temp dirs (cleanup should have run)
assert after_count <= before_count + 1
end
end
end

View file

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

View file

@ -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}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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)", %{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -93,14 +93,14 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
assert to == "/membership_fee_settings/new_fee_type"
end
test "edit button per row navigates to edit form", %{conn: conn, current_user: admin_user} do
test "row click navigates to edit form", %{conn: conn, current_user: admin_user} do
fee_type = create_fee_type(%{interval: :yearly}, admin_user)
{:ok, view, _html} = live(conn, "/membership_fee_settings")
{:error, {:live_redirect, %{to: to}}} =
view
|> element("a[href='/membership_fee_settings/#{fee_type.id}/edit_fee_type']")
|> element("#membership_fee_types tr#mft-#{fee_type.id} td:first-of-type")
|> render_click()
assert to == "/membership_fee_settings/#{fee_type.id}/edit_fee_type"

View file

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

View file

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

View file

@ -3,8 +3,13 @@ defmodule MvWeb.MemberLive.IndexTest do
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Helpers.SystemActor
alias Mv.Membership
alias Mv.Membership.CustomField
alias Mv.Membership.CustomFieldValue
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.MemberLive.Index, as: MemberIndex
# Helper to create a membership fee type (shared across all tests)
defp create_fee_type(attrs, actor) do
@ -298,10 +303,10 @@ defmodule MvWeb.MemberLive.IndexTest do
@tag :ui
test "member index does not render Edit or Delete actions", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
{:ok, _member} =
Mv.Membership.create_member(
Membership.create_member(
%{first_name: "Test", last_name: "User", email: "test@example.com"},
actor: system_actor
)
@ -315,10 +320,10 @@ defmodule MvWeb.MemberLive.IndexTest do
@tag :ui
test "row click navigates to member show", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
{:ok, member} =
Mv.Membership.create_member(
Membership.create_member(
%{first_name: "Row", last_name: "Click", email: "rowclick@example.com"},
actor: system_actor
)
@ -338,10 +343,10 @@ defmodule MvWeb.MemberLive.IndexTest do
@describetag :ui
test "clickable rows have hover and focus-within ring classes", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
{:ok, _member} =
Mv.Membership.create_member(
Membership.create_member(
%{first_name: "Hover", last_name: "Test", email: "hover@example.com"},
actor: system_actor
)
@ -356,10 +361,10 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
{:ok, member} =
Mv.Membership.create_member(
Membership.create_member(
%{first_name: "Highlight", last_name: "Only", email: "highlight@example.com"},
actor: system_actor
)
@ -374,11 +379,11 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "copy_emails feature" do
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "Max",
last_name: "Mustermann",
@ -388,7 +393,7 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, member2} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "Erika",
last_name: "Musterfrau",
@ -398,7 +403,7 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, member3} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "Hans",
last_name: "Müller-Lüdenscheidt",
@ -485,7 +490,7 @@ defmodule MvWeb.MemberLive.IndexTest do
render_click(view, "select_member", %{"id" => member1.id})
# Delete the member from the database
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
Ash.destroy!(member1, actor: system_actor)
# Trigger copy_emails event directly - selection still contains the deleted ID
@ -526,10 +531,10 @@ defmodule MvWeb.MemberLive.IndexTest do
conn = conn_with_oidc_user(conn)
# Create a member with known data
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
{:ok, test_member} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "Test",
last_name: "Format",
@ -598,10 +603,10 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "export dropdown" do
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
{:ok, m1} =
Mv.Membership.create_member(
Membership.create_member(
%{first_name: "Export", last_name: "One", email: "export1@example.com"},
actor: system_actor
)
@ -755,12 +760,12 @@ defmodule MvWeb.MemberLive.IndexTest do
}
attrs = Map.merge(default_attrs, attrs)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
{:ok, member} = Membership.create_member(attrs, actor: actor)
member
end
test "filter shows only members with paid status in last cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
@ -807,7 +812,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
@ -854,7 +859,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "filter shows only members with paid status in current cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
@ -901,7 +906,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
@ -970,11 +975,9 @@ defmodule MvWeb.MemberLive.IndexTest do
end
describe "boolean custom field filters" do
alias Mv.Membership.CustomField
# Helper to create a boolean custom field (uses system actor for authorization)
defp create_boolean_custom_field(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
default_attrs = %{
name: "test_boolean_#{System.unique_integer([:positive])}",
@ -990,7 +993,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Helper to create a non-boolean custom field (uses system actor for authorization)
defp create_string_custom_field(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
default_attrs = %{
name: "test_string_#{System.unique_integer([:positive])}",
@ -1244,7 +1247,7 @@ defmodule MvWeb.MemberLive.IndexTest do
test "handle_params removes filter when custom field is deleted", %{conn: conn} do
conn = conn_with_oidc_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
# Set up filter via URL
@ -1359,10 +1362,10 @@ defmodule MvWeb.MemberLive.IndexTest do
}
|> Map.merge(member_attrs)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
{:ok, member} = Membership.create_member(attrs, actor: actor)
{:ok, _cfv} =
Mv.Membership.CustomFieldValue
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
@ -1377,33 +1380,33 @@ defmodule MvWeb.MemberLive.IndexTest do
# Tests for get_boolean_custom_field_value/2
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor)
# Test the function (will fail until implemented)
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == true
end
test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor)
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == false
end
test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys",
%{conn: _conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
@ -1414,7 +1417,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
{:ok, _cfv} =
Mv.Membership.CustomFieldValue
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: boolean_field.id,
@ -1425,7 +1428,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Reload member with custom field values
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == true
end
@ -1433,11 +1436,11 @@ defmodule MvWeb.MemberLive.IndexTest do
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
conn: _conn
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
@ -1449,7 +1452,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member has no custom field value for this field
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil
end
@ -1457,11 +1460,11 @@ defmodule MvWeb.MemberLive.IndexTest do
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{
conn: _conn
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
@ -1472,7 +1475,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Create CustomFieldValue with nil value (edge case)
{:ok, _cfv} =
Mv.Membership.CustomFieldValue
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: boolean_field.id,
@ -1482,7 +1485,7 @@ defmodule MvWeb.MemberLive.IndexTest do
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil
end
@ -1490,12 +1493,12 @@ defmodule MvWeb.MemberLive.IndexTest do
test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{
conn: _conn
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
string_field = create_string_custom_field()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
@ -1506,7 +1509,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Create string custom field value (not boolean)
{:ok, _cfv} =
Mv.Membership.CustomFieldValue
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: string_field.id,
@ -1517,7 +1520,7 @@ defmodule MvWeb.MemberLive.IndexTest do
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
# Try to get boolean value from string field - should return nil
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil
end
@ -1525,7 +1528,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Tests for apply_boolean_custom_field_filters/2
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
%{conn: _conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member_with_true =
@ -1545,7 +1548,7 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, member_without_value} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
@ -1559,10 +1562,10 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => true}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
@ -1576,7 +1579,7 @@ defmodule MvWeb.MemberLive.IndexTest do
test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values",
%{conn: _conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member_with_true =
@ -1596,7 +1599,7 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, member_without_value} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
@ -1610,10 +1613,10 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => false}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
@ -1628,7 +1631,7 @@ defmodule MvWeb.MemberLive.IndexTest do
test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{
conn: _conn
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member1 =
@ -1649,10 +1652,10 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member1, member2]
filters = %{}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
@ -1668,13 +1671,13 @@ defmodule MvWeb.MemberLive.IndexTest do
test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{
conn: _conn
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
# Member with both fields = true
{:ok, member_both_true} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "BothTrue",
last_name: "Member",
@ -1684,7 +1687,7 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, _cfv1} =
Mv.Membership.CustomFieldValue
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_both_true.id,
custom_field_id: boolean_field1.id,
@ -1693,7 +1696,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|> Ash.create(actor: system_actor)
{:ok, _cfv2} =
Mv.Membership.CustomFieldValue
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_both_true.id,
custom_field_id: boolean_field2.id,
@ -1705,7 +1708,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member with field1 = true, field2 = false
{:ok, member_mixed} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "Mixed",
last_name: "Member",
@ -1715,7 +1718,7 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, _cfv3} =
Mv.Membership.CustomFieldValue
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_mixed.id,
custom_field_id: boolean_field1.id,
@ -1724,7 +1727,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|> Ash.create(actor: system_actor)
{:ok, _cfv4} =
Mv.Membership.CustomFieldValue
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_mixed.id,
custom_field_id: boolean_field2.id,
@ -1741,10 +1744,10 @@ defmodule MvWeb.MemberLive.IndexTest do
to_string(boolean_field2.id) => true
}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
@ -1758,7 +1761,7 @@ defmodule MvWeb.MemberLive.IndexTest do
test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{
conn: _conn
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
fake_id = Ecto.UUID.generate()
@ -1772,10 +1775,10 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member]
filters = %{fake_id => true}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
@ -1788,7 +1791,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Integration tests for boolean custom field filters in load_members
test "boolean filter integration filters members by boolean custom field value via URL parameter",
%{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
@ -1809,7 +1812,7 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, _member_without_value} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
@ -1836,7 +1839,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
@ -1845,7 +1848,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member with true boolean value and paid status
{:ok, member_paid_true} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "PaidTrue",
last_name: "Member",
@ -1856,7 +1859,7 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, _cfv} =
Mv.Membership.CustomFieldValue
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_paid_true.id,
custom_field_id: boolean_field.id,
@ -1873,7 +1876,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member with true boolean value but unpaid status
{:ok, member_unpaid_true} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "UnpaidTrue",
last_name: "Member",
@ -1884,7 +1887,7 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, _cfv2} =
Mv.Membership.CustomFieldValue
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_unpaid_true.id,
custom_field_id: boolean_field.id,
@ -1909,7 +1912,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "boolean filter integration works together with search query", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
@ -1939,7 +1942,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "boolean filter works even when custom field is not visible in overview", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
# Create boolean field with show_in_overview: false
@ -1962,7 +1965,7 @@ defmodule MvWeb.MemberLive.IndexTest do
)
{:ok, _member_without_value} =
Mv.Membership.create_member(
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
@ -2016,7 +2019,7 @@ defmodule MvWeb.MemberLive.IndexTest do
@tag :slow
test "boolean filter performance with 150 members", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,14 @@ defmodule MvWeb.ConnCase do
use ExUnit.CaseTemplate
alias AshAuthentication.Plug.Helpers, as: AuthPlugHelpers
alias Mv.Accounts
alias Mv.Authorization.Actor
alias Mv.DataCase
alias Mv.Fixtures
alias Mv.Helpers.SystemActor
alias Phoenix.ConnTest
using do
quote do
# The default endpoint for testing
@ -92,8 +100,8 @@ defmodule MvWeb.ConnCase do
def sign_in_user_via_oidc(conn, user) do
# Mock OIDC sign-in by creating a token directly
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|> ConnTest.init_test_session(%{})
|> AuthPlugHelpers.store_in_session(user)
end
@doc """
@ -114,8 +122,8 @@ defmodule MvWeb.ConnCase do
user = create_test_user(Map.merge(default_attrs, user_attrs))
# Create admin role and assign it
admin_role = Mv.Fixtures.role_fixture("admin")
system_actor = Mv.Helpers.SystemActor.get_system_actor()
admin_role = Fixtures.role_fixture("admin")
system_actor = SystemActor.get_system_actor()
{:ok, user} =
user
@ -124,7 +132,7 @@ defmodule MvWeb.ConnCase do
|> Ash.update(actor: system_actor)
# Load role for authorization
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts, actor: system_actor)
user_with_role = Ash.load!(user, :role, domain: Accounts, actor: system_actor)
sign_in_user_via_oidc(conn, user_with_role)
end
@ -134,8 +142,8 @@ defmodule MvWeb.ConnCase do
"""
def conn_with_password_user(conn, user) do
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|> ConnTest.init_test_session(%{})
|> AuthPlugHelpers.store_in_session(user)
end
@doc """
@ -143,14 +151,14 @@ defmodule MvWeb.ConnCase do
This is useful for tests that need full access to resources.
"""
def conn_with_admin_user(conn) do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
admin_user = Fixtures.user_with_role_fixture("admin")
conn_with_password_user(conn, admin_user)
end
setup tags do
pid = Mv.DataCase.setup_sandbox(tags)
pid = DataCase.setup_sandbox(tags)
conn = Phoenix.ConnTest.build_conn()
conn = ConnTest.build_conn()
# Set metadata for Phoenix.Ecto.SQL.Sandbox plug to allow LiveView processes
# to share the test's database connection in async tests
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
@ -164,27 +172,27 @@ defmodule MvWeb.ConnCase do
:admin ->
# Create admin user with role for all tests (unless test overrides with its own user)
# This ensures all tests have an authenticated user with proper authorization
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
admin_user = Fixtures.user_with_role_fixture("admin")
authenticated_conn = conn_with_password_user(conn, admin_user)
{authenticated_conn, admin_user}
:member ->
# Create member user for role-based testing
# "member" role uses "own_data" permission set (Mitglied role)
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
member_user = Fixtures.user_with_role_fixture("own_data")
authenticated_conn = conn_with_password_user(conn, member_user)
{authenticated_conn, member_user}
:read_only ->
# Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only")
read_only_user = Mv.Authorization.Actor.ensure_loaded(read_only_user)
read_only_user = Fixtures.user_with_role_fixture("read_only")
read_only_user = Actor.ensure_loaded(read_only_user)
authenticated_conn = conn_with_password_user(conn, read_only_user)
{authenticated_conn, read_only_user}
:normal_user ->
# Kassenwart: can read/update members, groups; cannot access users/settings/admin
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
normal_user = Fixtures.user_with_role_fixture("normal_user")
authenticated_conn = conn_with_password_user(conn, normal_user)
{authenticated_conn, normal_user}
@ -194,7 +202,7 @@ defmodule MvWeb.ConnCase do
_other ->
# Fallback: treat unknown role as admin for safety
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
admin_user = Fixtures.user_with_role_fixture("admin")
authenticated_conn = conn_with_password_user(conn, admin_user)
{authenticated_conn, admin_user}
end

View file

@ -16,6 +16,9 @@ defmodule Mv.DataCase do
use ExUnit.CaseTemplate
alias Ecto.Adapters.SQL.Sandbox, as: Sandbox
alias Mv.Repo
require Ash.Query
using do
@ -30,11 +33,11 @@ defmodule Mv.DataCase do
end
setup tags do
Mv.DataCase.setup_sandbox(tags)
setup_sandbox(tags)
# Ensure "Mitglied" role exists for default role assignment to work in tests
# Note: This runs in every test because each test runs in a sandboxed database.
# The check is fast (single query) and idempotent (skips if role exists).
Mv.DataCase.ensure_default_role()
ensure_default_role()
:ok
end
@ -43,8 +46,8 @@ defmodule Mv.DataCase do
Returns the owner pid for use with Phoenix.Ecto.SQL.Sandbox.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
pid = Sandbox.start_owner!(Repo, shared: not tags[:async])
on_exit(fn -> Sandbox.stop_owner(pid) end)
pid
end

View file

@ -1,11 +1,13 @@
defmodule Mv.Fixtures do
@moduledoc """
Shared test fixtures for consistent test data creation.
This module provides factory functions for creating test data across
different test suites, ensuring consistency and reducing duplication.
"""
alias Mv.Accounts
alias Mv.Authorization
alias Mv.Helpers.SystemActor
alias Mv.Membership
@doc """
Creates a member with default or custom attributes.
@ -27,7 +29,7 @@ defmodule Mv.Fixtures do
"""
def member_fixture(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
attrs
|> Enum.into(%{
@ -35,7 +37,7 @@ defmodule Mv.Fixtures do
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Membership.create_member(actor: system_actor)
|> Membership.create_member(actor: system_actor)
|> case do
{:ok, member} -> member
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
@ -66,13 +68,13 @@ defmodule Mv.Fixtures do
"""
def user_fixture(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
attrs
|> Enum.into(%{
email: "user#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Accounts.create_user(actor: system_actor)
|> Accounts.create_user(actor: system_actor)
|> case do
{:ok, user} -> user
{:error, error} -> raise "Failed to create user: #{inspect(error)}"
@ -123,10 +125,10 @@ defmodule Mv.Fixtures do
"""
def role_fixture(permission_set_name) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
case Mv.Authorization.create_role(
case Authorization.create_role(
%{
name: role_name,
description: "Test role for #{permission_set_name}",
@ -157,7 +159,7 @@ defmodule Mv.Fixtures do
"""
def user_with_role_fixture(permission_set_name \\ "admin", user_attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
# Create role with permission set
role = role_fixture(permission_set_name)
@ -168,7 +170,7 @@ defmodule Mv.Fixtures do
|> Enum.into(%{
email: "user#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Accounts.create_user(actor: system_actor)
|> Accounts.create_user(actor: system_actor)
# Assign role to user
{:ok, user} =
@ -178,7 +180,7 @@ defmodule Mv.Fixtures do
|> Ash.update(actor: system_actor)
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: system_actor)
{:ok, user_with_role} = Ash.load(user, :role, domain: Accounts, actor: system_actor)
user_with_role
end
@ -284,14 +286,14 @@ defmodule Mv.Fixtures do
"""
def group_fixture(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
system_actor = SystemActor.get_system_actor()
attrs
|> Enum.into(%{
name: "Test Group #{System.unique_integer([:positive])}",
description: "Test description"
})
|> Mv.Membership.create_group(actor: system_actor)
|> Membership.create_group(actor: system_actor)
|> case do
{:ok, group} -> group
{:error, error} -> raise "Failed to create group: #{inspect(error)}"