From a25263b7219104dec2b01e434969ccf69a62df82 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 17 Feb 2026 19:29:49 +0100 Subject: [PATCH 01/62] fix: adds user friendly flas message --- lib/mv_web/auth_overrides.ex | 7 ++ lib/mv_web/components/layouts/root.html.heex | 1 + lib/mv_web/controllers/auth_controller.ex | 93 ++++++++++++++++--- priv/gettext/de/LC_MESSAGES/default.po | 10 ++ priv/gettext/default.pot | 10 ++ priv/gettext/en/LC_MESSAGES/default.po | 10 ++ .../controllers/auth_controller_test.exs | 44 +++++++++ 7 files changed, 163 insertions(+), 12 deletions(-) diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex index 1367150..b121c4e 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -45,4 +45,11 @@ defmodule MvWeb.AuthOverrides do Gettext.gettext(MvWeb.Gettext, "or") end) end + + # Hide AshAuthentication's Flash component since we use flash_group in root layout + # This prevents duplicate flash messages + override AshAuthentication.Phoenix.Components.Flash do + set :message_class_info, "hidden" + set :message_class_error, "hidden" + end end diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index 35e73ab..f6c3014 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -33,6 +33,7 @@ + <.flash_group flash={@flash} /> {@inner_content} diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index 20a8b20..8249a10 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -57,7 +57,9 @@ defmodule MvWeb.AuthController do handle_authentication_failed(conn, caused_by) _ -> - redirect_with_error(conn, gettext("Incorrect email or password")) + conn + |> put_flash(:error, gettext("Incorrect email or password")) + |> redirect(to: ~p"/sign-in") end end @@ -74,14 +76,39 @@ defmodule MvWeb.AuthController do handle_oidc_email_collision(conn, errors) _ -> - redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again.")) + conn + |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) + |> redirect(to: ~p"/sign-in") end end + # Handle Assent server unreachable errors (network/connectivity issues) + defp handle_rauthy_failure(conn, %Assent.ServerUnreachableError{} = err) do + # Use warning level: server unreachable is often transient, not a critical system error + Logger.warning("OIDC authentication server unreachable", safe_assent_meta(err)) + + conn + |> put_flash(:error, gettext("The authentication server is currently unavailable. Please try again later.")) + |> redirect(to: ~p"/sign-in") + end + + # Handle Assent invalid response errors (configuration or malformed responses) + defp handle_rauthy_failure(conn, %Assent.InvalidResponseError{} = err) do + # Use warning level: configuration errors are operational issues, not critical failures + Logger.warning("OIDC authentication invalid response", safe_assent_meta(err)) + + conn + |> put_flash(:error, gettext("Authentication configuration error. Please contact the administrator.")) + |> redirect(to: ~p"/sign-in") + end + # Catch-all clause for any other error types defp handle_rauthy_failure(conn, reason) do Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}") - redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again.")) + + conn + |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) + |> redirect(to: ~p"/sign-in") end # Handle generic AuthenticationFailed errors @@ -93,14 +120,20 @@ defmodule MvWeb.AuthController do You can confirm your account using the link we sent to you, or by resetting your password. """) - redirect_with_error(conn, message) + conn + |> put_flash(:error, message) + |> redirect(to: ~p"/sign-in") else - redirect_with_error(conn, gettext("Authentication failed. Please try again.")) + conn + |> put_flash(:error, gettext("Authentication failed. Please try again.")) + |> redirect(to: ~p"/sign-in") end end defp handle_authentication_failed(conn, _other) do - redirect_with_error(conn, gettext("Authentication failed. Please try again.")) + conn + |> put_flash(:error, gettext("Authentication failed. Please try again.")) + |> redirect(to: ~p"/sign-in") end # Handle OIDC email collision - user needs to verify password to link accounts @@ -112,7 +145,10 @@ defmodule MvWeb.AuthController do nil -> # Check if it's a "different OIDC account" error or email uniqueness error error_message = extract_meaningful_error_message(errors) - redirect_with_error(conn, error_message) + + conn + |> put_flash(:error, error_message) + |> redirect(to: ~p"/sign-in") end end @@ -177,13 +213,46 @@ defmodule MvWeb.AuthController do |> redirect(to: ~p"/auth/link-oidc-account") end - # Generic error redirect helper - defp redirect_with_error(conn, message) do - conn - |> put_flash(:error, message) - |> redirect(to: ~p"/sign-in") + # Extract safe metadata from Assent errors for logging + # Never logs sensitive data: no tokens, secrets, or full request URLs + # Returns keyword list for Logger.warning/2 + defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do + [ + request_url: redact_url(url), + http_adapter: Map.get(err, :http_adapter) + ] + |> Enum.filter(fn {_key, value} -> not is_nil(value) end) end + defp safe_assent_meta(%{response: %{status_code: status_code}} = err) do + [ + status_code: status_code, + http_adapter: Map.get(err, :http_adapter) + ] + |> Enum.filter(fn {_key, value} -> not is_nil(value) end) + end + + defp safe_assent_meta(err) do + # Only extract safe, simple fields + [ + http_adapter: Map.get(err, :http_adapter) + ] + |> Enum.filter(fn {_key, value} -> not is_nil(value) end) + end + + # Redact URL to only show scheme and host, hiding path, query, and fragments + defp redact_url(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) -> + "#{scheme}://#{host}" + + _ -> + "[redacted]" + end + end + + defp redact_url(_), do: "[redacted]" + def sign_out(conn, _params) do return_to = get_session(conn, :return_to) || ~p"/" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0187da6..527a849 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -582,6 +582,16 @@ msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Pa msgid "Unable to authenticate with OIDC. Please try again." msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut." +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "The authentication server is currently unavailable. Please try again later." +msgstr "Der Authentifizierungsserver ist derzeit nicht erreichbar. Bitte versuche es später erneut." + +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Authentication configuration error. Please contact the administrator." +msgstr "Authentifizierungskonfigurationsfehler. Bitte kontaktiere den Administrator." + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a05597c..8247f31 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -583,6 +583,16 @@ msgstr "" msgid "Unable to authenticate with OIDC. Please try again." msgstr "" +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "The authentication server is currently unavailable. Please try again later." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Authentication configuration error. Please contact the administrator." +msgstr "" + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 261cbe4..bff719d 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -583,6 +583,16 @@ msgstr "" msgid "Unable to authenticate with OIDC. Please try again." msgstr "" +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "The authentication server is currently unavailable. Please try again later." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Authentication configuration error. Please contact the administrator." +msgstr "" + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index 444571b..c177c96 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -248,4 +248,48 @@ defmodule MvWeb.AuthControllerTest do assert to =~ "/auth/user/password/sign_in_with_token" end + + # OIDC/Rauthy error handling tests + describe "handle_rauthy_failure/2" do + test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + # Create a mock Assent.ServerUnreachableError struct + error = %Assent.ServerUnreachableError{request_url: "https://auth.example.com/callback?token=secret123"} + + conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + + assert redirected_to(conn) == ~p"/sign-in" + assert get_flash(conn, :error) == "The authentication server is currently unavailable. Please try again later." + end + + test "Assent.InvalidResponseError redirects to sign-in with error flash", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + # Create a mock Assent.InvalidResponseError struct + error = %Assent.InvalidResponseError{ + response: %{status_code: 400, body: "invalid_request"}, + request_url: "https://auth.example.com/callback" + } + + conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + + assert redirected_to(conn) == ~p"/sign-in" + assert get_flash(conn, :error) == "Authentication configuration error. Please contact the administrator." + end + + test "unknown reason triggers catch-all and redirects to sign-in with error flash", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + unknown_reason = :oops + + conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, unknown_reason) + + assert redirected_to(conn) == ~p"/sign-in" + assert get_flash(conn, :error) == "Unable to authenticate with OIDC. Please try again." + end + end end From 002d723d0ea82831b2c84e1cd507f6bc18745b3d Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 12:53:25 +0100 Subject: [PATCH 02/62] fix: tests and flash layout --- lib/mv_web/components/layouts/root.html.heex | 39 ++++++++++++++++++- lib/mv_web/controllers/auth_controller.ex | 17 +++++--- priv/gettext/de/LC_MESSAGES/default.po | 3 ++ priv/gettext/default.pot | 3 ++ priv/gettext/en/LC_MESSAGES/default.po | 3 ++ .../controllers/auth_controller_test.exs | 35 ++++++++++++----- 6 files changed, 85 insertions(+), 15 deletions(-) diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index f6c3014..3ba099d 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -33,7 +33,44 @@ - <.flash_group flash={@flash} /> +
+ <.flash id="flash-success-root" kind={:success} flash={@flash} /> + <.flash id="flash-warning-root" kind={:warning} flash={@flash} /> + <.flash id="flash-info-root" kind={:info} flash={@flash} /> + <.flash id="flash-error-root" kind={:error} flash={@flash} /> + + <.flash + id="client-error-root" + kind={:error} + title={gettext("We can't find the internet")} + phx-disconnected={ + show(".phx-client-error #client-error-root") |> JS.remove_attribute("hidden") + } + phx-connected={hide("#client-error-root") |> JS.set_attribute({"hidden", ""})} + hidden + > + {gettext("Attempting to reconnect")} + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + + + <.flash + id="server-error-root" + kind={:error} + title={gettext("Something went wrong!")} + phx-disconnected={ + show(".phx-server-error #server-error-root") |> JS.remove_attribute("hidden") + } + phx-connected={hide("#server-error-root") |> JS.set_attribute({"hidden", ""})} + hidden + > + {gettext("Attempting to reconnect")} + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + +
{@inner_content} diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index 8249a10..24d1078 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -88,7 +88,10 @@ defmodule MvWeb.AuthController do Logger.warning("OIDC authentication server unreachable", safe_assent_meta(err)) conn - |> put_flash(:error, gettext("The authentication server is currently unavailable. Please try again later.")) + |> put_flash( + :error, + gettext("The authentication server is currently unavailable. Please try again later.") + ) |> redirect(to: ~p"/sign-in") end @@ -98,7 +101,10 @@ defmodule MvWeb.AuthController do Logger.warning("OIDC authentication invalid response", safe_assent_meta(err)) conn - |> put_flash(:error, gettext("Authentication configuration error. Please contact the administrator.")) + |> put_flash( + :error, + gettext("Authentication configuration error. Please contact the administrator.") + ) |> redirect(to: ~p"/sign-in") end @@ -224,10 +230,11 @@ defmodule MvWeb.AuthController do |> Enum.filter(fn {_key, value} -> not is_nil(value) end) end - defp safe_assent_meta(%{response: %{status_code: status_code}} = err) do + # Handle InvalidResponseError which has :response field (HTTPResponse struct) + defp safe_assent_meta(%{response: %{status: status} = response} = err) do [ - status_code: status_code, - http_adapter: Map.get(err, :http_adapter) + status: status, + http_adapter: Map.get(response, :http_adapter) || Map.get(err, :http_adapter) ] |> Enum.filter(fn {_key, value} -> not is_nil(value) end) end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 527a849..9968f16 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -27,6 +27,7 @@ msgid "Are you sure?" msgstr "Bist du sicher?" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" @@ -115,11 +116,13 @@ msgid "Show" msgstr "Anzeigen" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "Etwas ist schiefgelaufen!" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "Keine Internetverbindung gefunden" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 8247f31..a4bf53a 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -28,6 +28,7 @@ msgid "Are you sure?" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" @@ -116,11 +117,13 @@ msgid "Show" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index bff719d..5ed6eb1 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -28,6 +28,7 @@ msgid "Are you sure?" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" @@ -116,11 +117,13 @@ msgid "Show" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index c177c96..5f53e0f 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -6,7 +6,10 @@ defmodule MvWeb.AuthControllerTest do # Helper to create an unauthenticated conn (preserves sandbox metadata) defp build_unauthenticated_conn(authenticated_conn) do # Create new conn but preserve sandbox metadata for database access - new_conn = build_conn() + new_conn = + build_conn() + |> init_test_session(%{}) + |> fetch_flash() # Copy sandbox metadata from authenticated conn if authenticated_conn.private[:ecto_sandbox] do @@ -255,29 +258,41 @@ defmodule MvWeb.AuthControllerTest do conn: authenticated_conn } do conn = build_unauthenticated_conn(authenticated_conn) - # Create a mock Assent.ServerUnreachableError struct - error = %Assent.ServerUnreachableError{request_url: "https://auth.example.com/callback?token=secret123"} + # Create a mock Assent.ServerUnreachableError struct with required fields + error = %Assent.ServerUnreachableError{ + http_adapter: Assent.HTTPAdapter.Finch, + request_url: "https://auth.example.com/callback?token=secret123", + reason: %Mint.TransportError{reason: :econnrefused} + } conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == "The authentication server is currently unavailable. Please try again later." + + assert get_flash(conn, :error) == + "The authentication server is currently unavailable. Please try again later." end test "Assent.InvalidResponseError redirects to sign-in with error flash", %{ conn: authenticated_conn } do conn = build_unauthenticated_conn(authenticated_conn) - # Create a mock Assent.InvalidResponseError struct + # Create a mock Assent.InvalidResponseError struct with required field + # InvalidResponseError only has :response field (HTTPResponse struct) error = %Assent.InvalidResponseError{ - response: %{status_code: 400, body: "invalid_request"}, - request_url: "https://auth.example.com/callback" + response: %Assent.HTTPAdapter.HTTPResponse{ + status: 400, + headers: [], + body: "invalid_request" + } } conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == "Authentication configuration error. Please contact the administrator." + + assert get_flash(conn, :error) == + "Authentication configuration error. Please contact the administrator." end test "unknown reason triggers catch-all and redirects to sign-in with error flash", %{ @@ -289,7 +304,9 @@ defmodule MvWeb.AuthControllerTest do conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, unknown_reason) assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == "Unable to authenticate with OIDC. Please try again." + + assert get_flash(conn, :error) == + "Unable to authenticate with OIDC. Please try again." end end end From b5fc03e94f524c7e6c0759d87324f13bb34cde51 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 16:10:46 +0100 Subject: [PATCH 03/62] refactor --- lib/mv_web/controllers/auth_controller.ex | 78 +++++++++++-- .../controllers/auth_controller_test.exs | 104 +++++++++++++++++- 2 files changed, 168 insertions(+), 14 deletions(-) diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index 24d1078..fb760e6 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -45,9 +45,7 @@ defmodule MvWeb.AuthController do - Generic authentication failures """ def failure(conn, activity, reason) do - Logger.warning( - "Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}" - ) + log_failure_safely(activity, reason) case {activity, reason} do {{:rauthy, _action}, reason} -> @@ -63,6 +61,63 @@ defmodule MvWeb.AuthController do end end + # Log authentication failures safely, avoiding sensitive data for {:rauthy, _} activities + defp log_failure_safely({:rauthy, _action} = activity, reason) do + # For Assent errors, use safe_assent_meta to avoid logging tokens/URLs with query params + case reason do + %Assent.ServerUnreachableError{} = err -> + meta = safe_assent_meta(err) + message = format_safe_log_message("Authentication failure", activity, meta) + Logger.warning(message) + + %Assent.InvalidResponseError{} = err -> + meta = safe_assent_meta(err) + message = format_safe_log_message("Authentication failure", activity, meta) + Logger.warning(message) + + _ -> + # For other rauthy errors, log only error type, not full details + error_type = get_error_type(reason) + + Logger.warning( + "Authentication failure - Activity: #{inspect(activity)}, Error type: #{error_type}" + ) + end + end + + defp log_failure_safely(activity, reason) do + # For non-rauthy activities, safe to log full reason + Logger.warning( + "Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}" + ) + end + + # Extract safe error type identifier without sensitive data + defp get_error_type(%struct{}), do: "#{struct}" + defp get_error_type(atom) when is_atom(atom), do: inspect(atom) + defp get_error_type(_other), do: "[redacted]" + + # Format safe log message with metadata included in the message string + defp format_safe_log_message(base_message, activity, meta) when is_list(meta) do + activity_str = "Activity: #{inspect(activity)}" + meta_str = format_meta_string(meta) + "#{base_message} - #{activity_str}#{meta_str}" + end + + defp format_meta_string([]), do: "" + defp format_meta_string(meta) when is_list(meta) do + parts = + Enum.map(meta, fn + {:request_url, url} -> "Request URL: #{url}" + {:status, status} -> "Status: #{status}" + {:http_adapter, adapter} -> "HTTP Adapter: #{inspect(adapter)}" + _ -> nil + end) + |> Enum.filter(&(&1 != nil)) + + if Enum.empty?(parts), do: "", else: " - " <> Enum.join(parts, ", ") + end + # Handle all Rauthy (OIDC) authentication failures defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do handle_oidc_email_collision(conn, errors) @@ -83,9 +138,9 @@ defmodule MvWeb.AuthController do end # Handle Assent server unreachable errors (network/connectivity issues) - defp handle_rauthy_failure(conn, %Assent.ServerUnreachableError{} = err) do - # Use warning level: server unreachable is often transient, not a critical system error - Logger.warning("OIDC authentication server unreachable", safe_assent_meta(err)) + defp handle_rauthy_failure(conn, %Assent.ServerUnreachableError{} = _err) do + # Logging already done safely in failure/3 via log_failure_safely/2 + # No need to log again here to avoid duplicate logs conn |> put_flash( @@ -96,9 +151,9 @@ defmodule MvWeb.AuthController do end # Handle Assent invalid response errors (configuration or malformed responses) - defp handle_rauthy_failure(conn, %Assent.InvalidResponseError{} = err) do - # Use warning level: configuration errors are operational issues, not critical failures - Logger.warning("OIDC authentication invalid response", safe_assent_meta(err)) + defp handle_rauthy_failure(conn, %Assent.InvalidResponseError{} = _err) do + # Logging already done safely in failure/3 via log_failure_safely/2 + # No need to log again here to avoid duplicate logs conn |> put_flash( @@ -109,8 +164,9 @@ defmodule MvWeb.AuthController do end # Catch-all clause for any other error types - defp handle_rauthy_failure(conn, reason) do - Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}") + defp handle_rauthy_failure(conn, _reason) do + # Logging already done safely in failure/3 via log_failure_safely/2 + # No need to log again here to avoid duplicate logs conn |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index 5f53e0f..bac46c8 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -2,6 +2,7 @@ defmodule MvWeb.AuthControllerTest do use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest import Phoenix.ConnTest + import ExUnit.CaptureLog # Helper to create an unauthenticated conn (preserves sandbox metadata) defp build_unauthenticated_conn(authenticated_conn) do @@ -269,7 +270,7 @@ defmodule MvWeb.AuthControllerTest do assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "The authentication server is currently unavailable. Please try again later." end @@ -291,7 +292,7 @@ defmodule MvWeb.AuthControllerTest do assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Authentication configuration error. Please contact the administrator." end @@ -305,8 +306,105 @@ defmodule MvWeb.AuthControllerTest do assert redirected_to(conn) == ~p"/sign-in" - assert get_flash(conn, :error) == + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Unable to authenticate with OIDC. Please try again." end end + + # Logging security tests - ensure no sensitive data is logged + describe "failure/3 logging security" do + test "does not log full URL with query params for Assent.ServerUnreachableError", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + + error = %Assent.ServerUnreachableError{ + http_adapter: Assent.HTTPAdapter.Finch, + request_url: "https://auth.example.com/callback?token=secret123&code=abc456", + reason: %Mint.TransportError{reason: :econnrefused} + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + end) + + # Should log redacted URL (only scheme and host) + assert log =~ "https://auth.example.com" + # Should NOT log query parameters or tokens + refute log =~ "token=secret123" + refute log =~ "code=abc456" + refute log =~ "callback?token" + end + + test "does not log sensitive data for Assent.InvalidResponseError", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + + error = %Assent.InvalidResponseError{ + response: %Assent.HTTPAdapter.HTTPResponse{ + status: 400, + headers: [], + body: "invalid_request" + } + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + end) + + # Should log error type but not full error details + assert log =~ "Authentication failure" + assert log =~ "rauthy" + # Should not log full error struct with inspect + refute log =~ "Assent.InvalidResponseError" + end + + test "does not log full reason for unknown rauthy errors", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + # Simulate an error that might contain sensitive data + error_with_sensitive_data = %{ + token: "secret_token_123", + url: "https://example.com/callback?access_token=abc123", + error: :something_went_wrong + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error_with_sensitive_data) + end) + + # Should log error type but not full error details + assert log =~ "Authentication failure" + assert log =~ "rauthy" + # Should NOT log sensitive data + refute log =~ "secret_token_123" + refute log =~ "access_token=abc123" + refute log =~ "callback?access_token" + end + + test "logs full reason for non-rauthy activities (password auth)", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + + reason = %AshAuthentication.Errors.AuthenticationFailed{ + caused_by: %Ash.Error.Forbidden{errors: []} + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:password, :sign_in}, reason) + end) + + # For non-rauthy activities, full reason is safe to log + assert log =~ "Authentication failure" + assert log =~ "password" + assert log =~ "AuthenticationFailed" + end + end end From d1fefcca7ddf10e4929c5c8b1b528eeed702555c Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 16:18:26 +0100 Subject: [PATCH 04/62] formatting --- lib/mv_web/controllers/auth_controller.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index fb760e6..d9690df 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -105,6 +105,7 @@ defmodule MvWeb.AuthController do end defp format_meta_string([]), do: "" + defp format_meta_string(meta) when is_list(meta) do parts = Enum.map(meta, fn From e47e266570d29f9f481e993b046d42309236f21c Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 16:42:54 +0100 Subject: [PATCH 05/62] feat: type not editable --- lib/membership/custom_field.ex | 20 +++- .../live/custom_field_live/form_component.ex | 61 +++++++++--- priv/gettext/de/LC_MESSAGES/default.po | 5 + priv/gettext/default.pot | 5 + priv/gettext/en/LC_MESSAGES/default.po | 5 + .../custom_field_validation_test.exs | 98 +++++++++++++++++++ 6 files changed, 180 insertions(+), 14 deletions(-) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index ab4ad60..a1f564e 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomField do ## Attributes - `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday") - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) + - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation. - `description` - Optional human-readable description - `required` - If true, all members must have this custom field (future feature) - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted @@ -28,6 +28,7 @@ defmodule Mv.Membership.CustomField do ## Constraints - Name must be unique across all custom fields - Name maximum length: 100 characters + - `value_type` cannot be changed after creation (immutable) - Deleting a custom field will cascade delete all associated custom field values ## Calculations @@ -59,7 +60,7 @@ defmodule Mv.Membership.CustomField do end actions do - defaults [:read, :update] + defaults [:read] default_accept [:name, :value_type, :description, :required, :show_in_overview] create :create do @@ -68,6 +69,21 @@ defmodule Mv.Membership.CustomField do validate string_length(:slug, min: 1) end + update :update do + accept [:name, :description, :required, :show_in_overview] + require_atomic? false + + validate fn changeset, _context -> + if Ash.Changeset.changing_attribute?(changeset, :value_type) do + {:error, + field: :value_type, + message: "cannot be changed after creation"} + else + :ok + end + end + end + destroy :destroy_with_values do primary? true end diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index b809a1a..9f61ba3 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do ## Features - Create new custom field definitions - Edit existing custom fields - - Select value type from supported types + - Select value type from supported types (only on create; immutable after creation) - Set required flag - Real-time validation @@ -44,15 +44,36 @@ defmodule MvWeb.CustomFieldLive.FormComponent do > <.input field={@form[:name]} type="text" label={gettext("Name")} /> - <.input - field={@form[:value_type]} - type="select" - label={gettext("Value type")} - options={ - Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] - |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) - } - /> + <%= if @custom_field do %> + <%!-- Show value_type as read-only text when editing --%> +
+ +
+ {MvWeb.Translations.FieldTypes.label(@custom_field.value_type)} +
+ +
+ <% else %> + <%!-- Show value_type as select when creating --%> + <.input + field={@form[:value_type]} + type="select" + label={gettext("Value type")} + options={ + Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[ + :one_of + ] + |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) + } + /> + <% end %> + <.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.input @@ -85,8 +106,16 @@ defmodule MvWeb.CustomFieldLive.FormComponent do @impl true def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do + # Remove value_type from params when editing (it's immutable after creation) + cleaned_params = + if socket.assigns[:custom_field] do + Map.delete(custom_field_params, "value_type") + else + custom_field_params + end + {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))} + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, cleaned_params))} end @impl true @@ -94,7 +123,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do # Actor must be passed from parent (IndexComponent); component socket has no current_user actor = socket.assigns[:actor] - case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do + # Remove value_type from params when editing (it's immutable after creation) + cleaned_params = + if socket.assigns[:custom_field] do + Map.delete(custom_field_params, "value_type") + else + custom_field_params + end + + case MvWeb.LiveHelpers.submit_form(socket.assigns.form, cleaned_params, actor) do {:ok, custom_field} -> action = case socket.assigns.form.source.type do diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 6dbb732..0d661cf 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2604,6 +2604,11 @@ msgstr "PDF" msgid "Import" msgstr "Import" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." + #~ #: lib/mv_web/live/import_export_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Export Members (CSV)" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index df282f3..0aef1b3 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2604,3 +2604,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Import" msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 56f897d..371a028 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2605,6 +2605,11 @@ msgstr "" msgid "Import" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "" + #~ #: lib/mv_web/live/import_export_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Export Members (CSV)" diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs index d0711ad..e642d82 100644 --- a/test/membership/custom_field_validation_test.exs +++ b/test/membership/custom_field_validation_test.exs @@ -8,6 +8,7 @@ defmodule Mv.Membership.CustomFieldValidationTest do - Description length validation (max 500 characters) - Description trimming - Required vs optional fields + - Value type immutability (cannot be changed after creation) """ use Mv.DataCase, async: true @@ -207,4 +208,101 @@ defmodule Mv.Membership.CustomFieldValidationTest do assert [%{field: :value_type}] = changeset.errors end end + + describe "value_type immutability" do + test "rejects attempt to change value_type after creation", %{actor: actor} do + # Create custom field with value_type :string + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :string + + # Attempt to update value_type to :integer + assert {:error, %Ash.Error.Invalid{} = error} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + value_type: :integer + }) + |> Ash.update(actor: actor) + + # Verify error message contains expected text + error_message = Exception.message(error) + assert error_message =~ "cannot be changed" or error_message =~ "value_type" + + # Reload and verify value_type remained unchanged + reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) + assert reloaded.value_type == original_value_type + assert reloaded.value_type == :string + end + + test "allows updating other fields while value_type remains unchanged", %{actor: actor} do + # Create custom field with value_type :string + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: "Original description" + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :string + + # Update other fields (name, description) without touching value_type + {:ok, updated_custom_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "updated_name", + description: "Updated description" + }) + |> Ash.update(actor: actor) + + # Verify value_type remained unchanged + assert updated_custom_field.value_type == original_value_type + assert updated_custom_field.value_type == :string + # Verify other fields were updated + assert updated_custom_field.name == "updated_name" + assert updated_custom_field.description == "Updated description" + end + + test "rejects value_type change even when other fields are updated", %{actor: actor} do + # Create custom field with value_type :boolean + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :boolean + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :boolean + + # Attempt to update both name and value_type + assert {:error, %Ash.Error.Invalid{} = error} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "updated_name", + value_type: :date + }) + |> Ash.update(actor: actor) + + # Verify error message + error_message = Exception.message(error) + assert error_message =~ "cannot be changed" or error_message =~ "value_type" + + # Reload and verify value_type remained unchanged, but name was not updated either + reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) + assert reloaded.value_type == original_value_type + assert reloaded.value_type == :boolean + assert reloaded.name == "test_field" + end + end end From 9b1aad884ee86cb50c32fda118786c88d65fd947 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 18 Feb 2026 17:01:43 +0100 Subject: [PATCH 06/62] style: use same disabled field as for memberfield --- lib/membership/custom_field.ex | 4 +- .../live/custom_field_live/form_component.ex | 40 +++++++++++++------ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index a1f564e..411e95d 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -75,9 +75,7 @@ defmodule Mv.Membership.CustomField do validate fn changeset, _context -> if Ash.Changeset.changing_attribute?(changeset, :value_type) do - {:error, - field: :value_type, - message: "cannot be changed after creation"} + {:error, field: :value_type, message: "cannot be changed after creation"} else :ok end diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index 9f61ba3..f89f767 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -45,19 +45,33 @@ defmodule MvWeb.CustomFieldLive.FormComponent do <.input field={@form[:name]} type="text" label={gettext("Name")} /> <%= if @custom_field do %> - <%!-- Show value_type as read-only text when editing --%> -
- -
- {MvWeb.Translations.FieldTypes.label(@custom_field.value_type)} -
- + <%!-- Show value_type as read-only input when editing (matches Member Field pattern) --%> +
+
+ +
<% else %> <%!-- Show value_type as select when creating --%> From b775f5f5c484612cabe55b7c2fd37eebbef434d2 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:32 +0100 Subject: [PATCH 07/62] feat(vereinfacht): add DB schema, config and setting attributes - Migrations: vereinfacht_contact_id on members, vereinfacht_* on settings - Mv.Config: Vereinfacht ENV/Settings helpers, vereinfacht_configured?, contact_view_url - Setting: vereinfacht_api_url, api_key, club_id --- lib/membership/setting.ex | 30 ++- lib/mv/config.ex | 94 +++++++ ..._add_vereinfacht_contact_id_to_members.exs | 21 ++ ...0260218185541_add_vereinfacht_settings.exs | 25 ++ .../repo/members/20260218185510.json | 234 ++++++++++++++++++ .../repo/settings/20260218185541.json | 140 +++++++++++ 6 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 priv/repo/migrations/20260218185510_add_vereinfacht_contact_id_to_members.exs create mode 100644 priv/repo/migrations/20260218185541_add_vereinfacht_settings.exs create mode 100644 priv/resource_snapshots/repo/members/20260218185510.json create mode 100644 priv/resource_snapshots/repo/settings/20260218185541.json diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index bb7d122..40ef985 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -69,7 +69,10 @@ defmodule Mv.Membership.Setting do :club_name, :member_field_visibility, :include_joining_cycle, - :default_membership_fee_type_id + :default_membership_fee_type_id, + :vereinfacht_api_url, + :vereinfacht_api_key, + :vereinfacht_club_id ] end @@ -81,7 +84,10 @@ defmodule Mv.Membership.Setting do :club_name, :member_field_visibility, :include_joining_cycle, - :default_membership_fee_type_id + :default_membership_fee_type_id, + :vereinfacht_api_url, + :vereinfacht_api_key, + :vereinfacht_club_id ] end @@ -225,6 +231,26 @@ defmodule Mv.Membership.Setting do description "Default membership fee type ID for new members" end + # Vereinfacht accounting software integration (can be overridden by ENV) + attribute :vereinfacht_api_url, :string do + allow_nil? true + public? true + description "Vereinfacht API base URL (e.g. https://api.verein.visuel.dev/api/v1)" + end + + attribute :vereinfacht_api_key, :string do + allow_nil? true + public? true + description "Vereinfacht API key (Bearer token)" + sensitive? true + end + + attribute :vereinfacht_club_id, :string do + allow_nil? true + public? true + description "Vereinfacht club ID for multi-tenancy" + end + timestamps() end diff --git a/lib/mv/config.ex b/lib/mv/config.ex index bcbc8d9..f1c7546 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -142,4 +142,98 @@ defmodule Mv.Config do |> Keyword.get(key, default) |> parse_and_validate_integer(default) end + + # --------------------------------------------------------------------------- + # Vereinfacht accounting software integration + # ENV variables take priority; fallback to Settings from database. + # --------------------------------------------------------------------------- + + @doc """ + Returns the Vereinfacht API base URL. + + Reads from `VEREINFACHT_API_URL` env first, then from Settings. + """ + @spec vereinfacht_api_url() :: String.t() | nil + def vereinfacht_api_url do + env_or_setting("VEREINFACHT_API_URL", :vereinfacht_api_url) + end + + @doc """ + Returns the Vereinfacht API key (Bearer token). + + Reads from `VEREINFACHT_API_KEY` env first, then from Settings. + """ + @spec vereinfacht_api_key() :: String.t() | nil + def vereinfacht_api_key do + env_or_setting("VEREINFACHT_API_KEY", :vereinfacht_api_key) + end + + @doc """ + Returns the Vereinfacht club ID for multi-tenancy. + + Reads from `VEREINFACHT_CLUB_ID` env first, then from Settings. + """ + @spec vereinfacht_club_id() :: String.t() | nil + def vereinfacht_club_id do + env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id) + end + + @doc """ + Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set). + """ + @spec vereinfacht_configured?() :: boolean() + def vereinfacht_configured? do + present?(vereinfacht_api_url()) and present?(vereinfacht_api_key()) and + present?(vereinfacht_club_id()) + end + + @doc """ + Returns true if any Vereinfacht ENV variable is set (used to gray out Settings UI). + """ + @spec vereinfacht_env_configured?() :: boolean() + def vereinfacht_env_configured? do + System.get_env("VEREINFACHT_API_URL") != nil or + System.get_env("VEREINFACHT_API_KEY") != nil or + System.get_env("VEREINFACHT_CLUB_ID") != nil + end + + defp env_or_setting(env_key, setting_key) do + case System.get_env(env_key) do + nil -> get_vereinfacht_from_settings(setting_key) + value -> trim_nil(value) + end + end + + defp get_vereinfacht_from_settings(key) do + case Mv.Membership.get_settings() do + {:ok, settings} -> settings |> Map.get(key) |> trim_nil() + {:error, _} -> nil + end + end + + defp trim_nil(nil), do: nil + + defp trim_nil(s) when is_binary(s) do + t = String.trim(s) + if t == "", do: nil, else: t + end + + @doc """ + Returns the URL to view a finance contact (e.g. in Vereinfacht frontend or API). + + Uses the configured API base URL and appends /finance-contacts/{id}. + Can be extended later with a dedicated frontend URL setting. + """ + @spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil + def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do + base = vereinfacht_api_url() + + if present?(base), + do: base |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}"), + else: nil + end + + defp present?(nil), do: false + defp present?(s) when is_binary(s), do: String.trim(s) != "" + defp present?(_), do: false end diff --git a/priv/repo/migrations/20260218185510_add_vereinfacht_contact_id_to_members.exs b/priv/repo/migrations/20260218185510_add_vereinfacht_contact_id_to_members.exs new file mode 100644 index 0000000..b8bc8b4 --- /dev/null +++ b/priv/repo/migrations/20260218185510_add_vereinfacht_contact_id_to_members.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddVereinfachtContactIdToMembers do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:members) do + add :vereinfacht_contact_id, :text + end + end + + def down do + alter table(:members) do + remove :vereinfacht_contact_id + end + end +end diff --git a/priv/repo/migrations/20260218185541_add_vereinfacht_settings.exs b/priv/repo/migrations/20260218185541_add_vereinfacht_settings.exs new file mode 100644 index 0000000..72aac60 --- /dev/null +++ b/priv/repo/migrations/20260218185541_add_vereinfacht_settings.exs @@ -0,0 +1,25 @@ +defmodule Mv.Repo.Migrations.AddVereinfachtSettings do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:settings) do + add :vereinfacht_api_url, :text + add :vereinfacht_api_key, :text + add :vereinfacht_club_id, :text + end + end + + def down do + alter table(:settings) do + remove :vereinfacht_club_id + remove :vereinfacht_api_key + remove :vereinfacht_api_url + end + end +end diff --git a/priv/resource_snapshots/repo/members/20260218185510.json b/priv/resource_snapshots/repo/members/20260218185510.json new file mode 100644 index 0000000..ebfd40d --- /dev/null +++ b/priv/resource_snapshots/repo/members/20260218185510.json @@ -0,0 +1,234 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "search_vector", + "type": "tsvector" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "membership_fee_start_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_contact_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "members_membership_fee_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "membership_fee_types" + }, + "scale": null, + "size": null, + "source": "membership_fee_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "4DF7F20D4C8D91E229906D6ADF87A4B5EB410672799753012DE4F0F49B470A51", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "members_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/settings/20260218185541.json b/priv/resource_snapshots/repo/settings/20260218185541.json new file mode 100644 index 0000000..4334f9a --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20260218185541.json @@ -0,0 +1,140 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "club_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "member_field_visibility", + "type": "map" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "include_joining_cycle", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "default_membership_fee_type_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_api_url", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_api_key", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_club_id", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "1038A37F021DFC347E325042D613B0359FEB7DAFAE3286CBCEAA940A52B71217", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "settings" +} \ No newline at end of file From 3a61699dd234c19ceb40e83391e43e02fc51a9c7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:39 +0100 Subject: [PATCH 08/62] feat(vereinfacht): add client, sync flash and SyncContact change - Application: create SyncFlash ETS table on start - Vereinfacht: Client, SyncFlash, sync_member, format_error, sync_members_without_contact - SyncContact change on Member create_member and update_member - Member: attribute vereinfacht_contact_id, internal action set_vereinfacht_contact_id --- lib/membership/member.ex | 32 ++- lib/mv/application.ex | 2 + lib/mv/vereinfacht/changes/sync_contact.ex | 54 +++++ .../sync_linked_member_after_user_change.ex | 64 +++++ lib/mv/vereinfacht/client.ex | 222 ++++++++++++++++++ lib/mv/vereinfacht/sync_flash.ex | 44 ++++ lib/mv/vereinfacht/vereinfacht.ex | 134 +++++++++++ 7 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 lib/mv/vereinfacht/changes/sync_contact.ex create mode 100644 lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex create mode 100644 lib/mv/vereinfacht/client.ex create mode 100644 lib/mv/vereinfacht/sync_flash.ex create mode 100644 lib/mv/vereinfacht/vereinfacht.ex diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 476501c..6ab6668 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -117,6 +117,9 @@ defmodule Mv.Membership.Member do # Requires both join_date and membership_fee_type_id to be present change Mv.MembershipFees.Changes.SetMembershipFeeStartDate + # Sync member to Vereinfacht as finance contact (if configured) + change Mv.Vereinfacht.Changes.SyncContact + # Trigger cycle generation after member creation # Only runs if membership_fee_type_id is set # Note: Cycle generation runs asynchronously to not block the action, @@ -190,6 +193,9 @@ defmodule Mv.Membership.Member do where [changing(:membership_fee_type_id)] end + # Sync member to Vereinfacht as finance contact (if configured) + change Mv.Vereinfacht.Changes.SyncContact + # Trigger cycle regeneration when membership_fee_type_id changes # This deletes future unpaid cycles and regenerates them with the new type/amount # Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity @@ -243,6 +249,13 @@ defmodule Mv.Membership.Member do end) end + # Internal: set vereinfacht_contact_id after syncing with Vereinfacht API. + # Not exposed via code interface; used only by Mv.Vereinfacht.Changes.SyncContact. + update :set_vereinfacht_contact_id do + require_atomic? false + accept [:vereinfacht_contact_id] + end + # Action to handle fuzzy search on specific fields read :search do argument :query, :string, allow_nil?: true @@ -320,6 +333,12 @@ defmodule Mv.Membership.Member do authorize_if Mv.Authorization.Checks.HasPermission end + # Internal sync action: allow setting vereinfacht_contact_id (used only by SyncContact change). + policy action(:set_vereinfacht_contact_id) do + description "Allow internal sync to set Vereinfacht contact ID" + authorize_if always() + end + # CREATE/UPDATE: Forbid member–user link unless admin, then check permissions # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty). # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all. @@ -593,6 +612,14 @@ defmodule Mv.Membership.Member do public? true description "Date from which membership fees should be calculated" end + + # Vereinfacht accounting software integration: ID of the finance contact synced via API. + # Set by Mv.Vereinfacht.Changes.SyncContact; not accepted in create/update actions. + attribute :vereinfacht_contact_id, :string do + allow_nil? true + public? true + description "ID of the finance contact in Vereinfacht (set by sync)" + end end relationships do @@ -1275,7 +1302,10 @@ defmodule Mv.Membership.Member do # Extracts custom field values from existing member data (update scenario) defp extract_existing_values(member_data, changeset) do - actor = Map.get(changeset.context, :actor) + actor = + Map.get(changeset.context, :actor) || + Mv.Helpers.SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(actor) case Ash.load(member_data, :custom_field_values, opts) do diff --git a/lib/mv/application.ex b/lib/mv/application.ex index ea0c78e..1967ddd 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -7,6 +7,8 @@ defmodule Mv.Application do @impl true def start(_type, _args) do + Mv.Vereinfacht.SyncFlash.create_table!() + children = [ MvWeb.Telemetry, Mv.Repo, diff --git a/lib/mv/vereinfacht/changes/sync_contact.ex b/lib/mv/vereinfacht/changes/sync_contact.ex new file mode 100644 index 0000000..4ea6cc8 --- /dev/null +++ b/lib/mv/vereinfacht/changes/sync_contact.ex @@ -0,0 +1,54 @@ +defmodule Mv.Vereinfacht.Changes.SyncContact do + @moduledoc """ + Syncs a member to Vereinfacht as a finance contact after create/update. + + - If the member has no `vereinfacht_contact_id`, creates a contact via API and saves the ID. + - If the member already has an ID, updates the contact via API. + Runs in `after_transaction` so the member is persisted first. API failures are logged + but do not block the member operation. Requires Vereinfacht to be configured + (Mv.Config.vereinfacht_configured?/0). + """ + use Ash.Resource.Change + + require Logger + + @impl true + def change(changeset, _opts, _context) do + if Mv.Config.vereinfacht_configured?() do + Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2) + else + changeset + end + end + + # 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 + :ok -> + Mv.Vereinfacht.SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.") + {:ok, member} + + {:ok, member_updated} -> + Mv.Vereinfacht.SyncFlash.store( + to_string(member_updated.id), + :ok, + "Synced to Vereinfacht." + ) + + {:ok, member_updated} + + {:error, reason} -> + Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}") + + Mv.Vereinfacht.SyncFlash.store( + to_string(member.id), + :warning, + Mv.Vereinfacht.format_error(reason) + ) + + {:ok, member} + end + end + + defp sync_after_transaction(_changeset, error), do: error +end diff --git a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex new file mode 100644 index 0000000..e5cb599 --- /dev/null +++ b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex @@ -0,0 +1,64 @@ +defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do + @moduledoc """ + Syncs the linked Member to Vereinfacht after a User action that may have updated + the member's email via Ecto (e.g. User email change → SyncUserEmailToMember). + + Attach to any User action that uses SyncUserEmailToMember. After the transaction + commits, if the user has a linked member and Vereinfacht is configured, syncs + that member to the API. Failures are logged but do not affect the User result. + """ + use Ash.Resource.Change + + require Logger + alias Mv.Membership.Member + alias Mv.Membership + alias Mv.Helpers.SystemActor + alias Mv.Helpers + + @impl true + def change(changeset, _opts, _context) do + if Mv.Config.vereinfacht_configured?() do + Ash.Changeset.after_transaction(changeset, &sync_linked_member_after_transaction/2) + else + changeset + end + end + + defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do + case load_linked_member(user) do + nil -> + {:ok, user} + + member -> + case Mv.Vereinfacht.sync_member(member) do + :ok -> + {:ok, user} + + {:ok, _} -> + {:ok, user} + + {:error, reason} -> + Logger.warning( + "Vereinfacht sync failed for member #{member.id} (linked to user #{user.id}): #{inspect(reason)}" + ) + + {:ok, user} + end + end + end + + defp sync_linked_member_after_transaction(_changeset, result), do: result + + defp load_linked_member(%{member_id: nil}), do: nil + defp load_linked_member(%{member_id: ""}), do: nil + + defp load_linked_member(user) do + actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(actor) + + case Ash.get(Member, user.member_id, [domain: Membership] ++ opts) do + {:ok, %Member{} = member} -> member + _ -> nil + end + end +end diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex new file mode 100644 index 0000000..05eff58 --- /dev/null +++ b/lib/mv/vereinfacht/client.ex @@ -0,0 +1,222 @@ +defmodule Mv.Vereinfacht.Client do + @moduledoc """ + HTTP client for the Vereinfacht accounting software JSON:API. + + Creates and updates finance contacts. Uses Bearer token authentication and + requires club ID for multi-tenancy. Configuration via ENV or Settings + (see Mv.Config). + """ + require Logger + + @content_type "application/vnd.api+json" + + @doc """ + Creates a finance contact in Vereinfacht for the given member. + + Returns the contact ID on success. Does not update the member record; + the caller (e.g. SyncContact change) must persist `vereinfacht_contact_id`. + + ## Options + - None; URL, API key, and club ID are read from Mv.Config. + + ## Examples + + iex> create_contact(member) + {:ok, "242"} + + iex> create_contact(member) + {:error, {:http, 401, "Unauthenticated."}} + """ + @spec create_contact(struct()) :: {:ok, String.t()} | {:error, term()} + def create_contact(member) do + base_url = base_url() + api_key = api_key() + club_id = club_id() + + if is_nil(base_url) or is_nil(api_key) or is_nil(club_id) do + {:error, :not_configured} + else + body = build_create_body(member, club_id) + url = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts") + post_and_parse_contact(url, body, api_key) + end + end + + defp post_and_parse_contact(url, body, api_key) do + # Req expects body to be iodata (e.g. string); a raw map causes ArgumentError. + encoded_body = Jason.encode!(body) + + case Req.post(url, + body: encoded_body, + headers: headers(api_key), + receive_timeout: 15_000 + ) do + {:ok, %{status: 201, body: resp_body}} -> + case get_contact_id_from_response(resp_body) do + nil -> {:error, {:invalid_response, resp_body}} + id -> {:ok, id} + end + + {:ok, %{status: status, body: resp_body}} -> + {:error, {:http, status, extract_error_message(resp_body)}} + + {:error, reason} -> + {:error, {:request_failed, reason}} + end + end + + @doc """ + Updates an existing finance contact in Vereinfacht. + + Only sends attributes that are typically synced from the member (name, email, + address fields). Returns the same contact_id on success. + + ## Examples + + iex> update_contact("242", member) + {:ok, "242"} + + iex> update_contact("242", member) + {:error, {:http, 404, "Not Found"}} + """ + @spec update_contact(String.t(), struct()) :: {:ok, String.t()} | {:error, term()} + def update_contact(contact_id, member) when is_binary(contact_id) do + base_url = base_url() + api_key = api_key() + + if is_nil(base_url) or is_nil(api_key) do + {:error, :not_configured} + else + body = build_update_body(contact_id, member) + encoded_body = Jason.encode!(body) + + url = + base_url + |> String.trim_trailing("/") + |> then(&"#{&1}/finance-contacts/#{contact_id}") + + case Req.patch(url, + body: encoded_body, + headers: headers(api_key), + receive_timeout: 15_000 + ) do + {:ok, %{status: 200, body: _resp_body}} -> + {:ok, contact_id} + + {:ok, %{status: status, body: body}} -> + {:error, {:http, status, extract_error_message(body)}} + + {:error, reason} -> + {:error, {:request_failed, reason}} + end + end + end + + @doc """ + Fetches a single finance contact from Vereinfacht (GET /finance-contacts/:id). + + Returns the full response body (decoded JSON) for debugging/display. + """ + @spec get_contact(String.t()) :: {:ok, map()} | {:error, term()} + def get_contact(contact_id) when is_binary(contact_id) do + base_url = base_url() + api_key = api_key() + + if is_nil(base_url) or is_nil(api_key) do + {:error, :not_configured} + else + url = + base_url + |> String.trim_trailing("/") + |> then(&"#{&1}/finance-contacts/#{contact_id}") + + case Req.get(url, + headers: headers(api_key), + receive_timeout: 15_000 + ) do + {:ok, %{status: 200, body: body}} when is_map(body) -> + {:ok, body} + + {:ok, %{status: status, body: body}} -> + {:error, {:http, status, extract_error_message(body)}} + + {:error, reason} -> + {:error, {:request_failed, reason}} + end + end + end + + defp base_url, do: Mv.Config.vereinfacht_api_url() + defp api_key, do: Mv.Config.vereinfacht_api_key() + defp club_id, do: Mv.Config.vereinfacht_club_id() + + defp headers(api_key) do + [ + {"Accept", @content_type}, + {"Content-Type", @content_type}, + {"Authorization", "Bearer #{api_key}"} + ] + end + + defp build_create_body(member, club_id) do + attributes = member_to_attributes(member) + + %{ + "data" => %{ + "type" => "finance-contacts", + "attributes" => attributes, + "relationships" => %{ + "club" => %{ + "data" => %{"type" => "clubs", "id" => club_id} + } + } + } + } + end + + defp build_update_body(contact_id, member) do + attributes = member_to_attributes(member) + + %{ + "data" => %{ + "type" => "finance-contacts", + "id" => contact_id, + "attributes" => attributes + } + } + end + + defp member_to_attributes(member) do + address = + [member |> Map.get(:street), member |> Map.get(:house_number)] + |> Enum.reject(&is_nil/1) + |> Enum.map_join(" ", &to_string/1) + |> then(fn s -> if s == "", do: nil, else: s end) + + %{} + |> put_attr("lastName", member |> Map.get(:last_name)) + |> put_attr("firstName", member |> Map.get(:first_name)) + |> put_attr("email", member |> Map.get(:email)) + |> put_attr("address", address) + |> put_attr("zipCode", member |> Map.get(:postal_code)) + |> put_attr("city", member |> Map.get(:city)) + |> Map.put("contactType", "person") + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + end + + defp put_attr(acc, _key, nil), do: acc + defp put_attr(acc, key, value), do: Map.put(acc, key, to_string(value)) + + defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_binary(id), do: id + + defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_integer(id), + do: to_string(id) + + defp get_contact_id_from_response(_), do: nil + + defp extract_error_message(%{"errors" => [%{"detail" => d} | _]}) when is_binary(d), do: d + defp extract_error_message(%{"errors" => [%{"title" => t} | _]}) when is_binary(t), do: t + defp extract_error_message(body) when is_map(body), do: inspect(body) + defp extract_error_message(other), do: inspect(other) +end diff --git a/lib/mv/vereinfacht/sync_flash.ex b/lib/mv/vereinfacht/sync_flash.ex new file mode 100644 index 0000000..fb062cd --- /dev/null +++ b/lib/mv/vereinfacht/sync_flash.ex @@ -0,0 +1,44 @@ +defmodule Mv.Vereinfacht.SyncFlash do + @moduledoc """ + Short-lived store for Vereinfacht sync results so the UI can show them after save. + + The SyncContact change runs in after_transaction and cannot access the LiveView + socket. This module stores a message keyed by member_id; the form LiveView + calls `take/1` after a successful save and displays the message in flash. + """ + @table :vereinfacht_sync_flash + + @doc """ + Stores a sync result for the given member. Overwrites any previous message. + + - `:ok` - Sync succeeded (optional user message). + - `:warning` - Sync failed; message should be shown as a warning. + """ + @spec store(String.t(), :ok | :warning, String.t()) :: :ok + def store(member_id, kind, message) when is_binary(member_id) do + :ets.insert(@table, {member_id, {kind, message}}) + :ok + end + + @doc """ + Takes and removes the stored sync message for the given member. + + Returns `{kind, message}` if present, otherwise `nil`. + """ + @spec take(String.t()) :: {:ok | :warning, String.t()} | nil + def take(member_id) when is_binary(member_id) do + case :ets.take(@table, member_id) do + [{^member_id, value}] -> value + [] -> nil + end + end + + @doc false + def create_table! do + if :ets.whereis(@table) == :undefined do + :ets.new(@table, [:set, :public, :named_table]) + end + + :ok + end +end diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex new file mode 100644 index 0000000..7ca6d37 --- /dev/null +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -0,0 +1,134 @@ +defmodule Mv.Vereinfacht do + @moduledoc """ + Business logic for Vereinfacht accounting software integration. + + - `sync_member/1` – Sync a single member to the API (create or update contact). + Used by Member create/update (SyncContact) and by User actions that update + the linked member's email via Ecto (e.g. user email change). + - `sync_members_without_contact/0` – Bulk sync of members without a contact ID. + """ + require Ash.Query + alias Mv.Vereinfacht.Client + alias Mv.Membership.Member + alias Mv.Helpers.SystemActor + alias Mv.Helpers + + @doc """ + Syncs a single member to Vereinfacht (create or update finance contact). + + If the member has no `vereinfacht_contact_id`, creates a contact and updates + the member with the new ID. If they already have an ID, updates the contact. + Uses system actor for any Ash update. Does nothing if Vereinfacht is not configured. + + Returns: + - `:ok` – Contact was updated. + - `{:ok, member}` – Contact was created and member was updated with the new ID. + - `{:error, reason}` – API or update failed. + """ + @spec sync_member(struct()) :: :ok | {:ok, struct()} | {:error, term()} + def sync_member(member) do + if Mv.Config.vereinfacht_configured?() do + do_sync_member(member) + else + :ok + end + end + + defp do_sync_member(member) do + if present_contact_id?(member.vereinfacht_contact_id) do + case Client.update_contact(member.vereinfacht_contact_id, member) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + else + case Client.create_contact(member) do + {:ok, contact_id} -> + save_contact_id(member, contact_id) + + {:error, reason} -> + {:error, reason} + end + end + end + + defp save_contact_id(member, contact_id) do + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) + + case Ash.update(member, %{vereinfacht_contact_id: contact_id}, [ + {:action, :set_vereinfacht_contact_id} | opts + ]) do + {:ok, updated} -> {:ok, updated} + {:error, reason} -> {:error, reason} + end + end + + defp present_contact_id?(nil), do: false + defp present_contact_id?(""), do: false + defp present_contact_id?(s) when is_binary(s), do: String.trim(s) != "" + defp present_contact_id?(_), do: false + + @doc """ + Formats an API/request error reason into a short user-facing message. + + Used by SyncContact (flash) and GlobalSettingsLive (sync result list). + """ + @spec format_error(term()) :: String.t() + def format_error({:http, _status, detail}) when is_binary(detail), do: "Vereinfacht: " <> detail + def format_error({:http, status, _}), do: "Vereinfacht: API error (HTTP #{status})." + + def format_error({:request_failed, _}), + do: "Vereinfacht: Request failed (e.g. connection error)." + + def format_error({:invalid_response, _}), do: "Vereinfacht: Invalid API response." + def format_error(other), do: "Vereinfacht: " <> inspect(other) + + @doc """ + Creates Vereinfacht contacts for all members that do not yet have a + `vereinfacht_contact_id`. Uses system actor for reads and updates. + + Returns `{:ok, %{synced: count, errors: list}}` where errors is a list of + `{member_id, reason}`. Does nothing if Vereinfacht is not configured. + """ + @spec sync_members_without_contact() :: + {:ok, %{synced: non_neg_integer(), errors: [{String.t(), term()}]}} + | {:error, :not_configured} + def sync_members_without_contact do + if Mv.Config.vereinfacht_configured?() do + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) + + query = + Member + |> Ash.Query.filter(is_nil(vereinfacht_contact_id)) + + case Ash.read(query, opts) do + {:ok, members} -> + do_sync_members(members, opts) + + {:error, _} = err -> + err + end + else + {:error, :not_configured} + end + end + + defp do_sync_members(members, opts) do + {synced, errors} = + Enum.reduce(members, {0, []}, fn member, {acc_synced, acc_errors} -> + {inc, new_errors} = sync_one_member(member, opts) + {acc_synced + inc, acc_errors ++ new_errors} + end) + + {:ok, %{synced: synced, errors: errors}} + end + + defp sync_one_member(member, _opts) do + case sync_member(member) do + :ok -> {1, []} + {:ok, _} -> {1, []} + {:error, reason} -> {0, [{member.id, reason}]} + end + end +end From 9808dba007708b6532a733834ec7340d0856df30 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:46 +0100 Subject: [PATCH 09/62] feat(vereinfacht): sync linked member after user email/link changes - SyncLinkedMemberAfterUserChange on update, create_user, update_user, admin_set_password, link_oidc_id, register_with_rauthy --- lib/accounts/user.ex | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 92b9ef2..9ac7605 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -118,6 +118,8 @@ defmodule Mv.Accounts.User do change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:email)] end + + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange end create :create_user do @@ -145,6 +147,8 @@ defmodule Mv.Accounts.User do # Sync user email to member when linking (User → Member) change Mv.EmailSync.Changes.SyncUserEmailToMember + + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange end update :update_user do @@ -178,6 +182,8 @@ defmodule Mv.Accounts.User do change Mv.EmailSync.Changes.SyncUserEmailToMember do where any([changing(:email), changing(:member)]) end + + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange end # Internal update used only by SystemActor/bootstrap and tests to assign role to system user. @@ -211,6 +217,8 @@ defmodule Mv.Accounts.User do change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:email)] end + + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange end # Action to link an OIDC account to an existing password-only user @@ -248,6 +256,8 @@ defmodule Mv.Accounts.User do change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:email)] end + + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange end read :get_by_subject do @@ -328,6 +338,8 @@ defmodule Mv.Accounts.User do # Sync user email to member when linking (User → Member) change Mv.EmailSync.Changes.SyncUserEmailToMember + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange + # Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated change fn changeset, _ctx -> user_info = Ash.Changeset.get_argument(changeset, :user_info) From 81bcd2bc4d32826522dc64a9e4d5d4641547efd6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:51 +0100 Subject: [PATCH 10/62] feat(vereinfacht): Settings UI and bulk sync - GlobalSettingsLive: Vereinfacht section, sync button, last sync result - Test: Vereinfacht Integration section visible --- lib/mv_web/live/global_settings_live.ex | 173 ++++++++++++++++++ .../live/global_settings_live_config_test.exs | 14 ++ 2 files changed, 187 insertions(+) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index fafc955..fc91b03 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -23,6 +23,9 @@ defmodule MvWeb.GlobalSettingsLive do """ use MvWeb, :live_view + require Ash.Query + import Ash.Expr + alias Mv.Membership on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} @@ -41,6 +44,8 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:settings, settings) |> assign(:active_editing_section, nil) |> assign(:locale, locale) + |> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?()) + |> assign(:last_vereinfacht_sync_result, nil) |> assign_form() {:ok, socket} @@ -74,6 +79,70 @@ defmodule MvWeb.GlobalSettingsLive do + <%!-- Vereinfacht Integration Section --%> + <.form_section title={gettext("Vereinfacht Integration")}> + <%= if @vereinfacht_env_configured do %> +

+ {gettext( + "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." + )} +

+ <% end %> + <.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save"> +
+ <.input + field={@form[:vereinfacht_api_url]} + type="text" + label={gettext("API URL")} + disabled={@vereinfacht_env_configured} + placeholder={ + if(@vereinfacht_env_configured, + do: gettext("From VEREINFACHT_API_URL"), + else: "https://api.verein.visuel.dev/api/v1" + ) + } + /> + <.input + field={@form[:vereinfacht_api_key]} + type="password" + label={gettext("API Key")} + disabled={@vereinfacht_env_configured} + placeholder={ + if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_API_KEY"), else: nil) + } + /> + <.input + field={@form[:vereinfacht_club_id]} + type="text" + label={gettext("Club ID")} + disabled={@vereinfacht_env_configured} + placeholder={ + if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") + } + /> +
+ <.button + :if={not @vereinfacht_env_configured} + phx-disable-with={gettext("Saving...")} + variant="primary" + class="mt-2" + > + {gettext("Save Vereinfacht Settings")} + + <.button + :if={Mv.Config.vereinfacht_configured?()} + type="button" + phx-click="sync_vereinfacht_contacts" + phx-disable-with={gettext("Syncing...")} + class="mt-4 btn-outline" + > + {gettext("Sync all members without Vereinfacht contact")} + + <%= if @last_vereinfacht_sync_result do %> + <.vereinfacht_sync_result result={@last_vereinfacht_sync_result} /> + <% end %> + + <%!-- Memberdata Section --%> <.form_section title={gettext("Memberdata")}> <.live_component @@ -100,6 +169,40 @@ defmodule MvWeb.GlobalSettingsLive do assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} end + @impl true + def handle_event("sync_vereinfacht_contacts", _params, socket) do + case Mv.Vereinfacht.sync_members_without_contact() do + {:ok, %{synced: synced, errors: errors}} -> + errors_with_names = enrich_sync_errors(errors) + result = %{synced: synced, errors: errors_with_names} + + socket = + socket + |> assign(:last_vereinfacht_sync_result, result) + |> put_flash( + :info, + if(errors_with_names == [], + do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced), + else: + gettext("Synced %{count} member(s). %{error_count} failed.", + count: synced, + error_count: length(errors_with_names) + ) + ) + ) + + {:noreply, socket} + + {:error, :not_configured} -> + {:noreply, + put_flash( + socket, + :error, + gettext("Vereinfacht is not configured. Set API URL, API Key, and Club ID.") + )} + end + end + @impl true def handle_event("save", %{"setting" => setting_params}, socket) do actor = MvWeb.LiveHelpers.current_actor(socket) @@ -213,4 +316,74 @@ defmodule MvWeb.GlobalSettingsLive do assign(socket, form: to_form(form)) end + + defp enrich_sync_errors([]), do: [] + + defp enrich_sync_errors(errors) when is_list(errors) do + name_by_id = fetch_member_names_by_ids(Enum.map(errors, fn {id, _} -> id end)) + + Enum.map(errors, fn {member_id, reason} -> + %{ + member_id: member_id, + member_name: Map.get(name_by_id, member_id) || to_string(member_id), + message: Mv.Vereinfacht.format_error(reason), + detail: extract_vereinfacht_detail(reason) + } + end) + 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)) + + case Ash.read(query, opts) do + {:ok, members} -> + Map.new(members, fn m -> {m.id, MvWeb.Helpers.MemberHelpers.display_name(m)} end) + + _ -> + %{} + end + end + + defp extract_vereinfacht_detail({:http, _status, detail}) when is_binary(detail), do: detail + defp extract_vereinfacht_detail(_), do: nil + + defp translate_vereinfacht_message(%{detail: detail}) when is_binary(detail) do + gettext("Vereinfacht: %{detail}", + detail: Gettext.dgettext(MvWeb.Gettext, "default", detail) + ) + end + + defp translate_vereinfacht_message(%{message: message}) do + Gettext.dgettext(MvWeb.Gettext, "default", message) + end + + attr :result, :map, required: true + + defp vereinfacht_sync_result(assigns) do + ~H""" +
+

+ {gettext("Last sync result:")} + {gettext("%{count} synced", count: @result.synced)} + <%= if @result.errors != [] do %> + + {gettext("%{count} failed", count: length(@result.errors))} + + <% end %> +

+ <%= if @result.errors != [] do %> +

{gettext("Failed members:")}

+
    + <%= for err <- @result.errors do %> +
  • + {err.member_name}: {translate_vereinfacht_message(err)} +
  • + <% end %> +
+ <% end %> +
+ """ + end end diff --git a/test/mv_web/live/global_settings_live_config_test.exs b/test/mv_web/live/global_settings_live_config_test.exs index 9ac75fd..106a020 100644 --- a/test/mv_web/live/global_settings_live_config_test.exs +++ b/test/mv_web/live/global_settings_live_config_test.exs @@ -71,4 +71,18 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do end end end + + describe "Vereinfacht Integration section" do + setup %{conn: conn} do + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) + {:ok, conn: conn} + end + + @tag :ui + test "settings page shows Vereinfacht Integration section", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/settings") + assert html =~ "Vereinfacht" + end + end end From d0fa3991f724463d9377a563fd2fbfbfa97461aa Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:55 +0100 Subject: [PATCH 11/62] feat(vereinfacht): member form flash and show page - Form: show Vereinfacht sync warning after save via SyncFlash - Show: load API debug response; MembershipFees: contact ID, link, no-contact warning --- lib/mv_web/live/member_live/form.ex | 29 ++++++++ lib/mv_web/live/member_live/show.ex | 16 +++- .../show/membership_fees_component.ex | 74 ++++++++++++++++++- 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index f9588c0..7c138c4 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -319,11 +319,40 @@ defmodule MvWeb.MemberLive.Form do socket = socket |> put_flash(:info, flash_message) + |> maybe_put_vereinfacht_sync_flash(member.id) |> push_navigate(to: return_path(socket.assigns.return_to, member)) {:noreply, socket} end + defp maybe_put_vereinfacht_sync_flash(socket, member_id) do + case Mv.Vereinfacht.SyncFlash.take(to_string(member_id)) do + {:warning, message} -> + put_flash(socket, :warning, translate_vereinfacht_flash(message)) + + {:ok, _message} -> + # Optionally show sync success; for now we keep only the main success message + socket + + nil -> + socket + end + end + + defp translate_vereinfacht_flash(message) when is_binary(message) do + prefix = "Vereinfacht: " + + if String.starts_with?(message, prefix) do + detail = message |> String.trim_leading(prefix) |> String.trim() + + Gettext.dgettext(MvWeb.Gettext, "default", "Vereinfacht: %{detail}", + detail: Gettext.dgettext(MvWeb.Gettext, "default", detail) + ) + else + Gettext.dgettext(MvWeb.Gettext, "default", message) + end + end + defp handle_save_error(socket, form) do # Always show a flash message when save fails # Field-level validation errors are displayed in form fields, but flash provides additional feedback diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 47e8878..93e18b4 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -256,6 +256,7 @@ defmodule MvWeb.MemberLive.Show do id={"membership-fees-#{@member.id}"} member={@member} current_user={@current_user} + vereinfacht_debug_response={@vereinfacht_debug_response} /> <% end %> @@ -264,7 +265,10 @@ defmodule MvWeb.MemberLive.Show do @impl true def mount(_params, _session, socket) do - {:ok, assign(socket, :active_tab, :contact)} + {:ok, + socket + |> assign(:active_tab, :contact) + |> assign(:vereinfacht_debug_response, nil)} end @impl true @@ -316,6 +320,16 @@ defmodule MvWeb.MemberLive.Show do {:noreply, assign(socket, :active_tab, :membership_fees)} end + def handle_event("load_vereinfacht_debug", %{"contact_id" => contact_id}, socket) do + response = + case Mv.Vereinfacht.Client.get_contact(contact_id) do + {:ok, body} -> {:ok, body} + {:error, reason} -> {:error, reason} + end + + {:noreply, assign(socket, :vereinfacht_debug_response, response)} + end + # Flash set in LiveComponent is not shown in parent layout; child sends this to display flash @impl true def handle_info({:put_flash, type, message}, socket) do diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 0739b5e..ce14317 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -50,6 +50,60 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <% end %>
+ <%!-- Vereinfacht: contact info when synced, or warning when API is configured but no contact --%> + <%= if Mv.Config.vereinfacht_configured?() do %> + <%= if @member.vereinfacht_contact_id do %> +
+ +
+ + {gettext("Contact ID: %{id}", id: @member.vereinfacht_contact_id)} + + <.link + :if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} + href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} + target="_blank" + rel="noopener noreferrer" + class="link link-primary inline-flex items-center gap-1" + > + {gettext("View contact in Vereinfacht")} + <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" /> + +
+ {gettext("Debug:")} + +
+ <%= if @vereinfacht_debug_response do %> +
+
<%= format_vereinfacht_debug_response(@vereinfacht_debug_response) %>
+
+ <% end %> +
+
+ <% else %> +
+

+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" /> + {gettext("No Vereinfacht contact exists for this member.")} +

+

+ {gettext( + "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." + )} +

+
+ <% end %> + <% end %> + <%!-- Action Buttons (only when user has permission) --%>
<.button @@ -439,7 +493,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign_new(:creating_cycle, fn -> false end) |> assign_new(:create_cycle_date, fn -> nil end) |> assign_new(:create_cycle_error, fn -> nil end) - |> assign_new(:regenerating, fn -> false end)} + |> assign_new(:regenerating, fn -> false end) + |> assign_new(:vereinfacht_debug_response, fn -> nil end)} end @impl true @@ -997,6 +1052,23 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_create_cycle_period(_date, _interval), do: "" + defp format_vereinfacht_debug_response({:ok, body}) when is_map(body) do + Jason.encode!(body, pretty: true) + end + + defp format_vereinfacht_debug_response({:error, {:http, status, detail}}) + when is_binary(detail) do + "Error: HTTP #{status} – #{detail}" + end + + defp format_vereinfacht_debug_response({:error, {:http, status, _}}) do + "Error: HTTP #{status}" + end + + defp format_vereinfacht_debug_response({:error, reason}) do + "Error: " <> inspect(reason) + end + # Helper component for section box attr :title, :string, required: true slot :inner_block, required: true From 5628de7bc698e486614fe4f7a5014a307e56a2c5 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:29:00 +0100 Subject: [PATCH 12/62] feat(vereinfacht): gettext and German translations - POT/PO: Vereinfacht UI and API error message strings --- priv/gettext/de/LC_MESSAGES/default.po | 180 +++++++++++++++++++++++-- priv/gettext/default.pot | 130 ++++++++++++++++++ priv/gettext/en/LC_MESSAGES/default.po | 179 ++++++++++++++++++++++-- 3 files changed, 465 insertions(+), 24 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 6dbb732..ff321b6 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2604,17 +2604,173 @@ msgstr "PDF" msgid "Import" msgstr "Import" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Export Members (CSV)" -#~ msgstr "Mitglieder exportieren (CSV)" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API Key" +msgstr "API-Schlüssel" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Export functionality will be available in a future release." -#~ msgstr "Export-Funktionalität ist im nächsten release verfügbar." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API URL" +msgstr "API-URL" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Import members from CSV files or export member data." -#~ msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Club ID" +msgstr "Vereins-ID" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." +msgstr "Konfiguriert über Umgebungsvariablen (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Die Felder sind schreibgeschützt." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Contact ID: %{id}" +msgstr "Kontakt-ID: %{id}" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_KEY" +msgstr "Aus VEREINFACHT_API_KEY" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_URL" +msgstr "Aus VEREINFACHT_API_URL" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_CLUB_ID" +msgstr "Aus VEREINFACHT_CLUB_ID" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save Vereinfacht Settings" +msgstr "Vereinfacht-Einstellungen speichern" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sync all members without Vereinfacht contact" +msgstr "Alle Mitglieder ohne Vereinfacht-Kontakt synchronisieren" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Synced %{count} member(s) to Vereinfacht." +msgstr "%{count} Mitglied(er) mit Vereinfacht synchronisiert." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Syncing..." +msgstr "Synchronisiere..." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht" +msgstr "Vereinfacht" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht Integration" +msgstr "Vereinfacht-Integration" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." +msgstr "Vereinfacht ist nicht konfiguriert. Bitte API-URL, API-Schlüssel und Vereins-ID setzen." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "View contact in Vereinfacht" +msgstr "Kontakt in Vereinfacht anzeigen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Debug:" +msgstr "Debug:" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Load API response" +msgstr "API-Antwort laden" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} failed" +msgstr "%{count} fehlgeschlagen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} synced" +msgstr "%{count} synchronisiert" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Failed members:" +msgstr "Fehlgeschlagene Mitglieder:" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Last sync result:" +msgstr "Letztes Sync-Ergebnis:" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Synced %{count} member(s). %{error_count} failed." +msgstr "%{count} Mitglied(er) synchronisiert. %{error_count} Fehler." + +# Vereinfacht API error messages (translated for UI) +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht: %{detail}" +msgstr "Vereinfacht: %{detail}" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No Vereinfacht contact exists for this member." +msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." +msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichern Sie das Mitglied erneut, um den Kontakt anzulegen." + +# Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) +msgid "The address field is required." +msgstr "Das Adressfeld ist erforderlich." + +msgid "The city field is required." +msgstr "Das Ortsfeld ist erforderlich." + +msgid "The city field must be at least 2 characters." +msgstr "Das Ortsfeld muss mindestens 2 Zeichen haben." + +msgid "The country field is required." +msgstr "Das Ländfeld ist erforderlich." + +msgid "The email field is required." +msgstr "Das E-Mail-Feld ist erforderlich." + +msgid "The email field must be a valid email address." +msgstr "Das E-Mail-Feld muss eine gültige E-Mail-Adresse sein." + +msgid "The first name field is required." +msgstr "Das Vornamenfeld ist erforderlich." + +msgid "The first name field must be at least 2 characters." +msgstr "Das Vornamenfeld muss mindestens 2 Zeichen haben." + +msgid "The last name field is required." +msgstr "Das Nachnamenfeld ist erforderlich." + +msgid "The last name field must be at least 2 characters." +msgstr "Das Nachnamenfeld muss mindestens 2 Zeichen haben." + +msgid "The street field is required." +msgstr "Das Straßenfeld ist erforderlich." + +msgid "The zip code field is required." +msgstr "Das Postleitzahlenfeld ist erforderlich." + +msgid "The zip code field must be at least 2 characters." +msgstr "Das Postleitzahlenfeld muss mindestens 2 Zeichen haben." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index df282f3..70c462d 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2604,3 +2604,133 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Import" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API Key" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Club ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Contact ID: %{id}" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_KEY" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_CLUB_ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save Vereinfacht Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sync all members without Vereinfacht contact" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Synced %{count} member(s) to Vereinfacht." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Syncing..." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht Integration" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "View contact in Vereinfacht" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Debug:" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Load API response" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} failed" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} synced" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Failed members:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Last sync result:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Synced %{count} member(s). %{error_count} failed." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht: %{detail}" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No Vereinfacht contact exists for this member." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 56f897d..71cbe28 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2605,17 +2605,172 @@ msgstr "" msgid "Import" msgstr "" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Export Members (CSV)" -#~ msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API Key" +msgstr "" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Export functionality will be available in a future release." -#~ msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API URL" +msgstr "" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Import members from CSV files or export member data." -#~ msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Club ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Contact ID: %{id}" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_KEY" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_CLUB_ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save Vereinfacht Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sync all members without Vereinfacht contact" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Synced %{count} member(s) to Vereinfacht." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Syncing..." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht Integration" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "View contact in Vereinfacht" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Debug:" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Load API response" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} failed" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} synced" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Failed members:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Last sync result:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Synced %{count} member(s). %{error_count} failed." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Vereinfacht: %{detail}" +msgstr "Vereinfacht: %{detail}" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No Vereinfacht contact exists for this member." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." +msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." + +# Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) +msgid "The address field is required." +msgstr "The address field is required." + +msgid "The city field is required." +msgstr "The city field is required." + +msgid "The city field must be at least 2 characters." +msgstr "The city field must be at least 2 characters." + +msgid "The country field is required." +msgstr "The country field is required." + +msgid "The email field is required." +msgstr "The email field is required." + +msgid "The email field must be a valid email address." +msgstr "The email field must be a valid email address." + +msgid "The first name field is required." +msgstr "The first name field is required." + +msgid "The first name field must be at least 2 characters." +msgstr "The first name field must be at least 2 characters." + +msgid "The last name field is required." +msgstr "The last name field is required." + +msgid "The last name field must be at least 2 characters." +msgstr "The last name field must be at least 2 characters." + +msgid "The street field is required." +msgstr "The street field is required." + +msgid "The zip code field is required." +msgstr "The zip code field is required." + +msgid "The zip code field must be at least 2 characters." +msgstr "The zip code field must be at least 2 characters." From f168d3f0934d9ac88cf9d0dcea68f42086f23869 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:29:05 +0100 Subject: [PATCH 13/62] test(vereinfacht): add tests and scope README - Config, Client, SyncContact, Vereinfacht module tests (no real API) - vereinfacht_test_README: document test scope --- test/mv/config_vereinfacht_test.exs | 61 ++++++++++++ .../vereinfacht/changes/sync_contact_test.exs | 92 +++++++++++++++++++ test/mv/vereinfacht/client_test.exs | 50 ++++++++++ test/mv/vereinfacht/vereinfacht_test.exs | 59 ++++++++++++ .../mv/vereinfacht/vereinfacht_test_README.md | 29 ++++++ 5 files changed, 291 insertions(+) create mode 100644 test/mv/config_vereinfacht_test.exs create mode 100644 test/mv/vereinfacht/changes/sync_contact_test.exs create mode 100644 test/mv/vereinfacht/client_test.exs create mode 100644 test/mv/vereinfacht/vereinfacht_test.exs create mode 100644 test/mv/vereinfacht/vereinfacht_test_README.md diff --git a/test/mv/config_vereinfacht_test.exs b/test/mv/config_vereinfacht_test.exs new file mode 100644 index 0000000..08b8104 --- /dev/null +++ b/test/mv/config_vereinfacht_test.exs @@ -0,0 +1,61 @@ +defmodule Mv.ConfigVereinfachtTest do + @moduledoc """ + Tests for Mv.Config Vereinfacht-related helpers. + """ + use Mv.DataCase, async: false + + describe "vereinfacht_env_configured?/0" do + test "returns false when no Vereinfacht ENV variables are set" do + clear_vereinfacht_env() + refute Mv.Config.vereinfacht_env_configured?() + end + + test "returns true when VEREINFACHT_API_URL is set" do + set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com") + assert Mv.Config.vereinfacht_env_configured?() + after + clear_vereinfacht_env() + end + + test "returns true when VEREINFACHT_CLUB_ID is set" do + set_vereinfacht_env("VEREINFACHT_CLUB_ID", "2") + assert Mv.Config.vereinfacht_env_configured?() + after + clear_vereinfacht_env() + end + end + + describe "vereinfacht_configured?/0" do + test "returns false when no config is set" do + clear_vereinfacht_env() + # Settings may have nil for vereinfacht fields + refute Mv.Config.vereinfacht_configured?() + end + end + + describe "vereinfacht_contact_view_url/1" do + test "returns nil when API URL is not configured" do + clear_vereinfacht_env() + assert Mv.Config.vereinfacht_contact_view_url("123") == nil + end + + test "returns URL when API URL is set" do + set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1") + + assert Mv.Config.vereinfacht_contact_view_url("42") == + "https://api.example.com/api/v1/finance-contacts/42" + after + clear_vereinfacht_env() + end + end + + defp set_vereinfacht_env(key, value) do + System.put_env(key, value) + end + + defp clear_vereinfacht_env do + System.delete_env("VEREINFACHT_API_URL") + System.delete_env("VEREINFACHT_API_KEY") + System.delete_env("VEREINFACHT_CLUB_ID") + end +end diff --git a/test/mv/vereinfacht/changes/sync_contact_test.exs b/test/mv/vereinfacht/changes/sync_contact_test.exs new file mode 100644 index 0000000..aa102a5 --- /dev/null +++ b/test/mv/vereinfacht/changes/sync_contact_test.exs @@ -0,0 +1,92 @@ +defmodule Mv.Vereinfacht.Changes.SyncContactTest do + @moduledoc """ + Tests for Mv.Vereinfacht.Changes.SyncContact. + + When Vereinfacht is not configured, member create/update should succeed + and vereinfacht_contact_id remains nil. + """ + use Mv.DataCase, async: false + + alias Mv.Membership + + setup do + clear_vereinfacht_env() + :ok + end + + describe "member create when Vereinfacht not configured" do + test "member is created and vereinfacht_contact_id is nil" do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + attrs = %{ + first_name: "Sync", + last_name: "Test", + email: "sync_test_#{System.unique_integer([:positive])}@example.com" + } + + assert {:ok, member} = Membership.create_member(attrs, actor: system_actor) + assert member.vereinfacht_contact_id == nil + end + end + + describe "member update when Vereinfacht not configured" do + test "member is updated and vereinfacht_contact_id is unchanged" do + member = Mv.Fixtures.member_fixture() + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + assert {:ok, updated} = + Membership.update_member(member, %{first_name: "Updated"}, actor: system_actor) + + assert updated.vereinfacht_contact_id == nil + end + end + + describe "when Vereinfacht is configured" do + # Regression: after_transaction callback receives 2 args (changeset, result), not 3. + # If the callback had arity 3, create_member would raise BadArityError. + # Also: Client must send JSON-encoded body (iodata); raw map causes ArgumentError + # when the request is sent. With an unreachable URL we get :econnrefused before + # that, so this test would not catch the iodata bug; a Bypass/stub server would. + test "create_member succeeds and after_transaction runs without error (API may fail)" do + set_vereinfacht_env() + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + attrs = %{ + first_name: "API", + last_name: "Test", + email: "api_test_#{System.unique_integer([:positive])}@example.com" + } + + assert {:ok, member} = Membership.create_member(attrs, actor: system_actor) + assert member.id + # Sync may fail (e.g. connection refused), so contact_id can stay nil + after + clear_vereinfacht_env() + end + + test "update_member succeeds and after_transaction runs without error (API may fail)" do + set_vereinfacht_env() + member = Mv.Fixtures.member_fixture() + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + assert {:ok, updated} = + Membership.update_member(member, %{first_name: "Updated"}, actor: system_actor) + + assert updated.id == member.id + after + clear_vereinfacht_env() + end + end + + defp set_vereinfacht_env do + System.put_env("VEREINFACHT_API_URL", "http://127.0.0.1:1/api/v1") + System.put_env("VEREINFACHT_API_KEY", "test-key") + System.put_env("VEREINFACHT_CLUB_ID", "2") + end + + defp clear_vereinfacht_env do + System.delete_env("VEREINFACHT_API_URL") + System.delete_env("VEREINFACHT_API_KEY") + System.delete_env("VEREINFACHT_CLUB_ID") + end +end diff --git a/test/mv/vereinfacht/client_test.exs b/test/mv/vereinfacht/client_test.exs new file mode 100644 index 0000000..d936adc --- /dev/null +++ b/test/mv/vereinfacht/client_test.exs @@ -0,0 +1,50 @@ +defmodule Mv.Vereinfacht.ClientTest do + @moduledoc """ + Tests for Mv.Vereinfacht.Client. + + Only tests the "not configured" path; no real HTTP calls. Config reads from + ENV first, then from Settings (DB), so we use DataCase so get_settings() is available. + """ + use Mv.DataCase, async: false + + alias Mv.Vereinfacht.Client + + setup do + clear_vereinfacht_env() + :ok + end + + describe "create_contact/1" do + test "returns {:error, :not_configured} when Vereinfacht is not configured" do + member = build_member_struct() + + assert Client.create_contact(member) == {:error, :not_configured} + end + end + + describe "update_contact/2" do + test "returns {:error, :not_configured} when Vereinfacht is not configured" do + member = build_member_struct() + + assert Client.update_contact("123", member) == {:error, :not_configured} + end + end + + defp build_member_struct do + %{ + first_name: "Test", + last_name: "User", + email: "test@example.com", + street: "Street 1", + house_number: "2", + postal_code: "12345", + city: "Berlin" + } + end + + defp clear_vereinfacht_env do + System.delete_env("VEREINFACHT_API_URL") + System.delete_env("VEREINFACHT_API_KEY") + System.delete_env("VEREINFACHT_CLUB_ID") + end +end diff --git a/test/mv/vereinfacht/vereinfacht_test.exs b/test/mv/vereinfacht/vereinfacht_test.exs new file mode 100644 index 0000000..08f73b9 --- /dev/null +++ b/test/mv/vereinfacht/vereinfacht_test.exs @@ -0,0 +1,59 @@ +defmodule Mv.VereinfachtTest do + @moduledoc """ + Tests for Mv.Vereinfacht business logic. + + No real API calls; tests "not configured" path and pure helpers (format_error). + """ + use Mv.DataCase, async: false + + alias Mv.Vereinfacht + + setup do + clear_vereinfacht_env() + :ok + end + + describe "sync_member/1" do + test "returns :ok when Vereinfacht is not configured (no-op)" do + member = Mv.Fixtures.member_fixture() + + assert Vereinfacht.sync_member(member) == :ok + end + end + + describe "sync_members_without_contact/0" do + test "returns {:error, :not_configured} when Vereinfacht is not configured" do + assert Vereinfacht.sync_members_without_contact() == {:error, :not_configured} + end + end + + describe "format_error/1" do + test "formats HTTP error with detail" do + assert Vereinfacht.format_error({:http, 422, "The email field is required."}) == + "Vereinfacht: The email field is required." + end + + test "formats HTTP error without detail" do + assert Vereinfacht.format_error({:http, 500, nil}) == + "Vereinfacht: API error (HTTP 500)." + end + + test "formats request_failed" do + assert Vereinfacht.format_error({:request_failed, %{reason: :econnrefused}}) == + "Vereinfacht: Request failed (e.g. connection error)." + end + + test "formats invalid_response and other terms" do + assert Vereinfacht.format_error({:invalid_response, %{}}) == + "Vereinfacht: Invalid API response." + + assert Vereinfacht.format_error(:timeout) == "Vereinfacht: :timeout" + end + end + + defp clear_vereinfacht_env do + System.delete_env("VEREINFACHT_API_URL") + System.delete_env("VEREINFACHT_API_KEY") + System.delete_env("VEREINFACHT_CLUB_ID") + end +end diff --git a/test/mv/vereinfacht/vereinfacht_test_README.md b/test/mv/vereinfacht/vereinfacht_test_README.md new file mode 100644 index 0000000..993af47 --- /dev/null +++ b/test/mv/vereinfacht/vereinfacht_test_README.md @@ -0,0 +1,29 @@ +# Vereinfacht tests – scope and rationale + +## Constraint: no real API in CI + +Tests do **not** call the real Vereinfacht API or a shared test endpoint. All tests use dummy data and either: + +- Assert behaviour when **Vereinfacht is not configured** (ENV + Settings unset), or +- Run the **full Member/User flow** with a **unreachable URL** (e.g. `http://127.0.0.1:1`) so the HTTP client fails fast (e.g. `:econnrefused`) and we only assert that the application path does not crash. + +## What the tests cover + +| Test file | What it tests | Why it’s enough without an API | +|-----------|----------------|---------------------------------| +| **ConfigVereinfachtTest** | `vereinfacht_env_configured?`, `vereinfacht_configured?`, `vereinfacht_contact_view_url` with ENV set/cleared | Pure config logic; no HTTP. | +| **ClientTest** | `create_contact/1` and `update_contact/2` return `{:error, :not_configured}` when nothing is configured | Ensures the client does not call Req when config is missing. | +| **VereinfachtTest** | `sync_members_without_contact/0` returns `{:error, :not_configured}` when not configured | Ensures bulk sync is a no-op when config is missing. | +| **SyncContactTest** | Member create/update with SyncContact change: not configured → no sync; configured with bad URL → action still succeeds, sync may fail | Ensures the Ash change and after_transaction arity are correct and the action result is not broken by sync failures. | + +## What is *not* tested (and would need a stub or real endpoint) + +- Actual HTTP request shape (body, headers) and response handling (201/200, error codes). +- Persistence of `vereinfacht_contact_id` after a successful create. +- Translation of specific API error payloads into user messages. + +Those would require either a **Bypass** (or similar) stub in front of Req or a dedicated test endpoint; both are out of scope for the current “no real API” setup. + +## Conclusion + +Given the constraint that the API is not called in CI, the tests are **meaningful**: they cover config, “not configured” paths, and integration of SyncContact with Member create/update without crashing. They are **sufficient** for regression safety and refactoring; extending them with a Bypass stub would be an optional next step if we want to assert on request/response shape without hitting the real API. From 81f62a7c85dabb34cf84eb001d0a9701007a3cb0 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:48:56 +0100 Subject: [PATCH 14/62] fix(a11y): WCAG 2 AA contrast and keyboard access --- assets/css/app.css | 19 ++++++ lib/mv_web/live/global_settings_live.ex | 4 +- .../show/membership_fees_component.ex | 9 ++- priv/gettext/de/LC_MESSAGES/default.po | 59 ++++++++++--------- priv/gettext/default.pot | 5 ++ priv/gettext/en/LC_MESSAGES/default.po | 59 ++++++++++--------- 6 files changed, 97 insertions(+), 58 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index b754a08..132a8f5 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -99,6 +99,25 @@ /* Make LiveView wrapper divs transparent for layout */ [data-phx-session] { display: contents } +/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers. + Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline + spacing; use inherited values so custom stylesheets can override. */ +[popover] { + line-height: inherit; + letter-spacing: inherit; + 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. */ +.text-success-aa { + color: oklch(0.35 0.12 165); +} + +.text-error-aa { + color: oklch(0.45 0.2 25); +} + /* ============================================ Sidebar Base Styles ============================================ */ diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index fc91b03..1a7e13b 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -366,9 +366,9 @@ defmodule MvWeb.GlobalSettingsLive do

{gettext("Last sync result:")} - {gettext("%{count} synced", count: @result.synced)} + {gettext("%{count} synced", count: @result.synced)} <%= if @result.errors != [] do %> - + {gettext("%{count} failed", count: length(@result.errors))} <% end %> diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index ce14317..02c9d66 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -66,7 +66,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} target="_blank" rel="noopener noreferrer" - class="link link-primary inline-flex items-center gap-1" + class="link link-accent underline inline-flex items-center gap-1" > {gettext("View contact in Vereinfacht")} <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" /> @@ -83,7 +83,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do

<%= if @vereinfacht_debug_response do %> -
+
<%= format_vereinfacht_debug_response(@vereinfacht_debug_response) %>
<% end %> diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index ff321b6..3ed3f3d 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2735,42 +2735,47 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt." msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichern Sie das Mitglied erneut, um den Kontakt anzulegen." -# Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) -msgid "The address field is required." -msgstr "Das Adressfeld ist erforderlich." +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Vereinfacht API response" +msgstr "Vereinfacht" -msgid "The city field is required." -msgstr "Das Ortsfeld ist erforderlich." +#~ # Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) +#~ msgid "The address field is required." +#~ msgstr "Das Adressfeld ist erforderlich." -msgid "The city field must be at least 2 characters." -msgstr "Das Ortsfeld muss mindestens 2 Zeichen haben." +#~ msgid "The city field is required." +#~ msgstr "Das Ortsfeld ist erforderlich." -msgid "The country field is required." -msgstr "Das Ländfeld ist erforderlich." +#~ msgid "The city field must be at least 2 characters." +#~ msgstr "Das Ortsfeld muss mindestens 2 Zeichen haben." -msgid "The email field is required." -msgstr "Das E-Mail-Feld ist erforderlich." +#~ msgid "The country field is required." +#~ msgstr "Das Ländfeld ist erforderlich." -msgid "The email field must be a valid email address." -msgstr "Das E-Mail-Feld muss eine gültige E-Mail-Adresse sein." +#~ msgid "The email field is required." +#~ msgstr "Das E-Mail-Feld ist erforderlich." -msgid "The first name field is required." -msgstr "Das Vornamenfeld ist erforderlich." +#~ msgid "The email field must be a valid email address." +#~ msgstr "Das E-Mail-Feld muss eine gültige E-Mail-Adresse sein." -msgid "The first name field must be at least 2 characters." -msgstr "Das Vornamenfeld muss mindestens 2 Zeichen haben." +#~ msgid "The first name field is required." +#~ msgstr "Das Vornamenfeld ist erforderlich." -msgid "The last name field is required." -msgstr "Das Nachnamenfeld ist erforderlich." +#~ msgid "The first name field must be at least 2 characters." +#~ msgstr "Das Vornamenfeld muss mindestens 2 Zeichen haben." -msgid "The last name field must be at least 2 characters." -msgstr "Das Nachnamenfeld muss mindestens 2 Zeichen haben." +#~ msgid "The last name field is required." +#~ msgstr "Das Nachnamenfeld ist erforderlich." -msgid "The street field is required." -msgstr "Das Straßenfeld ist erforderlich." +#~ msgid "The last name field must be at least 2 characters." +#~ msgstr "Das Nachnamenfeld muss mindestens 2 Zeichen haben." -msgid "The zip code field is required." -msgstr "Das Postleitzahlenfeld ist erforderlich." +#~ msgid "The street field is required." +#~ msgstr "Das Straßenfeld ist erforderlich." -msgid "The zip code field must be at least 2 characters." -msgstr "Das Postleitzahlenfeld muss mindestens 2 Zeichen haben." +#~ msgid "The zip code field is required." +#~ msgstr "Das Postleitzahlenfeld ist erforderlich." + +#~ msgid "The zip code field must be at least 2 characters." +#~ msgstr "Das Postleitzahlenfeld muss mindestens 2 Zeichen haben." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 70c462d..b151433 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2734,3 +2734,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht API response" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 71cbe28..07caeb7 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2735,42 +2735,47 @@ msgstr "" msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." -# Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) -msgid "The address field is required." -msgstr "The address field is required." +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Vereinfacht API response" +msgstr "" -msgid "The city field is required." -msgstr "The city field is required." +#~ # Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) +#~ msgid "The address field is required." +#~ msgstr "The address field is required." -msgid "The city field must be at least 2 characters." -msgstr "The city field must be at least 2 characters." +#~ msgid "The city field is required." +#~ msgstr "The city field is required." -msgid "The country field is required." -msgstr "The country field is required." +#~ msgid "The city field must be at least 2 characters." +#~ msgstr "The city field must be at least 2 characters." -msgid "The email field is required." -msgstr "The email field is required." +#~ msgid "The country field is required." +#~ msgstr "The country field is required." -msgid "The email field must be a valid email address." -msgstr "The email field must be a valid email address." +#~ msgid "The email field is required." +#~ msgstr "The email field is required." -msgid "The first name field is required." -msgstr "The first name field is required." +#~ msgid "The email field must be a valid email address." +#~ msgstr "The email field must be a valid email address." -msgid "The first name field must be at least 2 characters." -msgstr "The first name field must be at least 2 characters." +#~ msgid "The first name field is required." +#~ msgstr "The first name field is required." -msgid "The last name field is required." -msgstr "The last name field is required." +#~ msgid "The first name field must be at least 2 characters." +#~ msgstr "The first name field must be at least 2 characters." -msgid "The last name field must be at least 2 characters." -msgstr "The last name field must be at least 2 characters." +#~ msgid "The last name field is required." +#~ msgstr "The last name field is required." -msgid "The street field is required." -msgstr "The street field is required." +#~ msgid "The last name field must be at least 2 characters." +#~ msgstr "The last name field must be at least 2 characters." -msgid "The zip code field is required." -msgstr "The zip code field is required." +#~ msgid "The street field is required." +#~ msgstr "The street field is required." -msgid "The zip code field must be at least 2 characters." -msgstr "The zip code field must be at least 2 characters." +#~ msgid "The zip code field is required." +#~ msgstr "The zip code field is required." + +#~ msgid "The zip code field must be at least 2 characters." +#~ msgstr "The zip code field must be at least 2 characters." From 9db5b7f2920df4cebc2994be64a47f599712eeea Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 23:27:40 +0100 Subject: [PATCH 15/62] Vereinfacht: sync linked member only when email or member changed Run SyncLinkedMemberAfterUserChange only when email or member relationship changed to avoid unnecessary API calls. --- .../changes/sync_linked_member_after_user_change.ex | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex index e5cb599..cffb079 100644 --- a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex +++ b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex @@ -17,13 +17,20 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do @impl true def change(changeset, _opts, _context) do - if Mv.Config.vereinfacht_configured?() do + if Mv.Config.vereinfacht_configured?() and relevant_change?(changeset) do Ash.Changeset.after_transaction(changeset, &sync_linked_member_after_transaction/2) else changeset end end + # Only sync when something that affects the linked member's data actually changed + # (email sync or member link), to avoid unnecessary API calls on every user update. + defp relevant_change?(changeset) do + Ash.Changeset.changing_attribute?(changeset, :email) or + Ash.Changeset.changing_relationship?(changeset, :member) + end + defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do case load_linked_member(user) do nil -> From fb7d7589bb260ebddb25c45829bce5e749bbc661 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:13:47 +0100 Subject: [PATCH 16/62] Add Vereinfacht ENV vars to .env.example VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID with short comment that they override Settings when set. --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.example b/.env.example index d5d35ed..04e9dbd 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,9 @@ ASSOCIATION_NAME="Sportsclub XYZ" # OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list). # OIDC_ADMIN_GROUP_NAME=admin # OIDC_GROUPS_CLAIM=groups + +# Optional: Vereinfacht accounting integration (finance-contacts sync) +# If set, these override values from Settings UI; those fields become read-only. +# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1 +# VEREINFACHT_API_KEY=your-api-key +# VEREINFACHT_CLUB_ID=2 From e864dee8fe4d74d6fdc92612ec404c8298da4d40 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:13:53 +0100 Subject: [PATCH 17/62] Config: per-field Vereinfacht ENV helpers vereinfacht_api_url_env_set?, vereinfacht_api_key_env_set?, vereinfacht_club_id_env_set? for read-only Settings fields when set. --- lib/mv/config.ex | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/mv/config.ex b/lib/mv/config.ex index f1c7546..f6f6ec7 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -188,13 +188,35 @@ defmodule Mv.Config do end @doc """ - Returns true if any Vereinfacht ENV variable is set (used to gray out Settings UI). + Returns true if any Vereinfacht ENV variable is set (used to show hint in Settings UI). """ @spec vereinfacht_env_configured?() :: boolean() def vereinfacht_env_configured? do - System.get_env("VEREINFACHT_API_URL") != nil or - System.get_env("VEREINFACHT_API_KEY") != nil or - System.get_env("VEREINFACHT_CLUB_ID") != nil + vereinfacht_api_url_env_set?() or vereinfacht_api_key_env_set?() or + vereinfacht_club_id_env_set?() + end + + @doc """ + Returns true if VEREINFACHT_API_URL is set (field is read-only in Settings). + """ + def vereinfacht_api_url_env_set?, do: env_set?("VEREINFACHT_API_URL") + + @doc """ + Returns true if VEREINFACHT_API_KEY is set (field is read-only in Settings). + """ + def vereinfacht_api_key_env_set?, do: env_set?("VEREINFACHT_API_KEY") + + @doc """ + Returns true if VEREINFACHT_CLUB_ID is set (field is read-only in Settings). + """ + def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID") + + defp env_set?(key) do + case System.get_env(key) do + nil -> false + v when is_binary(v) -> String.trim(v) != "" + _ -> false + end end defp env_or_setting(env_key, setting_key) do From 329c2d50ec6253f516425f0e34e1be067b0c5709 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:01 +0100 Subject: [PATCH 18/62] Global settings: API key redaction and per-field ENV Never put API key in form/DOM; show (set) badge, drop blank on save. Per-field disabled when ENV set; save button only when not all from ENV. --- lib/mv_web/live/global_settings_live.ex | 80 ++++++++++++++++++------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 1a7e13b..3da4aa6 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -45,12 +45,21 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:active_editing_section, nil) |> assign(:locale, locale) |> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?()) + |> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?()) + |> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?()) + |> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?()) + |> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key)) |> assign(:last_vereinfacht_sync_result, nil) |> assign_form() {:ok, socket} end + defp present?(nil), do: false + defp present?(""), do: false + defp present?(s) when is_binary(s), do: String.trim(s) != "" + defp present?(_), do: false + @impl true def render(assigns) do ~H""" @@ -83,9 +92,7 @@ defmodule MvWeb.GlobalSettingsLive do <.form_section title={gettext("Vereinfacht Integration")}> <%= if @vereinfacht_env_configured do %>

- {gettext( - "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." - )} + {gettext("Some values are set via environment variables. Those fields are read-only.")}

<% end %> <.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save"> @@ -94,35 +101,53 @@ defmodule MvWeb.GlobalSettingsLive do field={@form[:vereinfacht_api_url]} type="text" label={gettext("API URL")} - disabled={@vereinfacht_env_configured} + disabled={@vereinfacht_api_url_env_set} placeholder={ - if(@vereinfacht_env_configured, + if(@vereinfacht_api_url_env_set, do: gettext("From VEREINFACHT_API_URL"), else: "https://api.verein.visuel.dev/api/v1" ) } /> - <.input - field={@form[:vereinfacht_api_key]} - type="password" - label={gettext("API Key")} - disabled={@vereinfacht_env_configured} - placeholder={ - if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_API_KEY"), else: nil) - } - /> +
+ + <.input + field={@form[:vereinfacht_api_key]} + type="password" + label="" + disabled={@vereinfacht_api_key_env_set} + placeholder={ + if(@vereinfacht_api_key_env_set, + do: gettext("From VEREINFACHT_API_KEY"), + else: + if(@vereinfacht_api_key_set, + do: gettext("Leave blank to keep current"), + else: nil + ) + ) + } + /> +
<.input field={@form[:vereinfacht_club_id]} type="text" label={gettext("Club ID")} - disabled={@vereinfacht_env_configured} + disabled={@vereinfacht_club_id_env_set} placeholder={ - if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") + if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") } />
<.button - :if={not @vereinfacht_env_configured} + :if={ + not (@vereinfacht_api_url_env_set and @vereinfacht_api_key_env_set and + @vereinfacht_club_id_env_set) + } phx-disable-with={gettext("Saving...")} variant="primary" class="mt-2" @@ -206,15 +231,17 @@ defmodule MvWeb.GlobalSettingsLive do @impl true def handle_event("save", %{"setting" => setting_params}, socket) do actor = MvWeb.LiveHelpers.current_actor(socket) + # Never send blank API key so we do not overwrite the stored secret (security) + setting_params_clean = drop_blank_vereinfacht_api_key(setting_params) - case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do + case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do {:ok, _updated_settings} -> - # Reload settings from database to ensure all dependent data is updated {:ok, fresh_settings} = Membership.get_settings() socket = socket |> assign(:settings, fresh_settings) + |> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key)) |> put_flash(:info, gettext("Settings updated successfully")) |> assign_form() @@ -225,6 +252,16 @@ defmodule MvWeb.GlobalSettingsLive do end end + defp drop_blank_vereinfacht_api_key(params) when is_map(params) do + case params do + %{"vereinfacht_api_key" => v} when v in [nil, ""] -> + Map.delete(params, "vereinfacht_api_key") + + _ -> + params + end + end + @impl true def handle_info({:custom_field_saved, _custom_field, action}, socket) do send_update(MvWeb.CustomFieldLive.IndexComponent, @@ -305,9 +342,12 @@ defmodule MvWeb.GlobalSettingsLive do end defp assign_form(%{assigns: %{settings: settings}} = socket) do + # Never put API key into form/DOM to avoid secret leak in source or DevTools + settings_for_form = %{settings | vereinfacht_api_key: nil} + form = AshPhoenix.Form.for_update( - settings, + settings_for_form, :update, api: Membership, as: "setting", From 4cdd187b433f2e15751236519aadd8d9a3d1ad9b Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:03 +0100 Subject: [PATCH 19/62] Gettext: new Vereinfacht UI strings and German translations (set), Leave blank to keep current, env hint; DE msgstr added. --- priv/gettext/de/LC_MESSAGES/default.po | 54 ++++++-------------------- priv/gettext/default.pot | 20 +++++++--- priv/gettext/en/LC_MESSAGES/default.po | 54 ++++++-------------------- 3 files changed, 39 insertions(+), 89 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 3ed3f3d..4ce5662 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2619,11 +2619,6 @@ msgstr "API-URL" msgid "Club ID" msgstr "Vereins-ID" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." -msgstr "Konfiguriert über Umgebungsvariablen (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Die Felder sind schreibgeschützt." - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Contact ID: %{id}" @@ -2740,42 +2735,17 @@ msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinf msgid "Vereinfacht API response" msgstr "Vereinfacht" -#~ # Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) -#~ msgid "The address field is required." -#~ msgstr "Das Adressfeld ist erforderlich." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "(set)" +msgstr "(gesetzt)" -#~ msgid "The city field is required." -#~ msgstr "Das Ortsfeld ist erforderlich." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Leave blank to keep current" +msgstr "Leer lassen, um den aktuellen Wert beizubehalten" -#~ msgid "The city field must be at least 2 characters." -#~ msgstr "Das Ortsfeld muss mindestens 2 Zeichen haben." - -#~ msgid "The country field is required." -#~ msgstr "Das Ländfeld ist erforderlich." - -#~ msgid "The email field is required." -#~ msgstr "Das E-Mail-Feld ist erforderlich." - -#~ msgid "The email field must be a valid email address." -#~ msgstr "Das E-Mail-Feld muss eine gültige E-Mail-Adresse sein." - -#~ msgid "The first name field is required." -#~ msgstr "Das Vornamenfeld ist erforderlich." - -#~ msgid "The first name field must be at least 2 characters." -#~ msgstr "Das Vornamenfeld muss mindestens 2 Zeichen haben." - -#~ msgid "The last name field is required." -#~ msgstr "Das Nachnamenfeld ist erforderlich." - -#~ msgid "The last name field must be at least 2 characters." -#~ msgstr "Das Nachnamenfeld muss mindestens 2 Zeichen haben." - -#~ msgid "The street field is required." -#~ msgstr "Das Straßenfeld ist erforderlich." - -#~ msgid "The zip code field is required." -#~ msgstr "Das Postleitzahlenfeld ist erforderlich." - -#~ msgid "The zip code field must be at least 2 characters." -#~ msgstr "Das Postleitzahlenfeld muss mindestens 2 Zeichen haben." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Some values are set via environment variables. Those fields are read-only." +msgstr "Einige Werte werden über Umgebungsvariablen gesetzt. Diese Felder sind schreibgeschützt." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index b151433..3de9e8e 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2620,11 +2620,6 @@ msgstr "" msgid "Club ID" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." -msgstr "" - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Contact ID: %{id}" @@ -2739,3 +2734,18 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Vereinfacht API response" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "(set)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Leave blank to keep current" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Some values are set via environment variables. Those fields are read-only." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 07caeb7..edfa720 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2620,11 +2620,6 @@ msgstr "" msgid "Club ID" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." -msgstr "" - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Contact ID: %{id}" @@ -2740,42 +2735,17 @@ msgstr "Sync this member from Settings (Vereinfacht section) or save the member msgid "Vereinfacht API response" msgstr "" -#~ # Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) -#~ msgid "The address field is required." -#~ msgstr "The address field is required." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "(set)" +msgstr "" -#~ msgid "The city field is required." -#~ msgstr "The city field is required." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Leave blank to keep current" +msgstr "" -#~ msgid "The city field must be at least 2 characters." -#~ msgstr "The city field must be at least 2 characters." - -#~ msgid "The country field is required." -#~ msgstr "The country field is required." - -#~ msgid "The email field is required." -#~ msgstr "The email field is required." - -#~ msgid "The email field must be a valid email address." -#~ msgstr "The email field must be a valid email address." - -#~ msgid "The first name field is required." -#~ msgstr "The first name field is required." - -#~ msgid "The first name field must be at least 2 characters." -#~ msgstr "The first name field must be at least 2 characters." - -#~ msgid "The last name field is required." -#~ msgstr "The last name field is required." - -#~ msgid "The last name field must be at least 2 characters." -#~ msgstr "The last name field must be at least 2 characters." - -#~ msgid "The street field is required." -#~ msgstr "The street field is required." - -#~ msgid "The zip code field is required." -#~ msgstr "The zip code field is required." - -#~ msgid "The zip code field must be at least 2 characters." -#~ msgstr "The zip code field must be at least 2 characters." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Some values are set via environment variables. Those fields are read-only." +msgstr "" From 62000562f0731b50f32c2eb57c8074f5a4af07f9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:13 +0100 Subject: [PATCH 20/62] Vereinfacht client: find by email in response, no retries in test API does not allow filter[email]; fetch list and match client-side. Disable Req retries in test for fast failure and less log noise. --- lib/mv/vereinfacht/client.ex | 97 ++++++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 05eff58..72859ac 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -42,15 +42,18 @@ defmodule Mv.Vereinfacht.Client do end end + @sync_timeout_ms 5_000 + + # In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits). + defp req_http_options do + opts = [receive_timeout: @sync_timeout_ms] + if Mix.env() == :test, do: [retry: false] ++ opts, else: opts + end + defp post_and_parse_contact(url, body, api_key) do - # Req expects body to be iodata (e.g. string); a raw map causes ArgumentError. encoded_body = Jason.encode!(body) - case Req.post(url, - body: encoded_body, - headers: headers(api_key), - receive_timeout: 15_000 - ) do + case Req.post(url, [body: encoded_body, headers: headers(api_key)] ++ req_http_options()) do {:ok, %{status: 201, body: resp_body}} -> case get_contact_id_from_response(resp_body) do nil -> {:error, {:invalid_response, resp_body}} @@ -95,10 +98,12 @@ defmodule Mv.Vereinfacht.Client do |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") - case Req.patch(url, - body: encoded_body, - headers: headers(api_key), - receive_timeout: 15_000 + case Req.patch( + url, + [ + body: encoded_body, + headers: headers(api_key) + ] ++ req_http_options() ) do {:ok, %{status: 200, body: _resp_body}} -> {:ok, contact_id} @@ -112,6 +117,73 @@ defmodule Mv.Vereinfacht.Client do end end + @doc """ + Finds a finance contact by email (GET /finance-contacts, then match in response). + + The Vereinfacht API does not allow filter by email on this endpoint, so we + fetch the first page and find the contact client-side. Returns {:ok, contact_id} + if a contact with that email exists, {:error, :not_found} if none, or + {:error, reason} on API/network failure. Used before create for idempotency. + """ + @spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()} + def find_contact_by_email(email) when is_binary(email) do + if is_nil(base_url()) or is_nil(api_key()) or is_nil(club_id()) do + {:error, :not_configured} + else + do_find_contact_by_email(email) + end + end + + defp do_find_contact_by_email(email) do + url = + base_url() + |> String.trim_trailing("/") + |> then(&"#{&1}/finance-contacts") + + case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do + {:ok, %{status: 200, body: body}} when is_map(body) -> + parse_find_by_email_response(body, email) + + {:ok, %{status: status, body: body}} -> + {:error, {:http, status, extract_error_message(body)}} + + {:error, reason} -> + {:error, {:request_failed, reason}} + end + end + + defp parse_find_by_email_response(body, email) do + normalized = String.trim(email) |> String.downcase() + + case find_contact_id_by_email_in_list(body, normalized) do + nil -> {:error, :not_found} + id -> {:ok, id} + end + end + + defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do + Enum.find_value(list, fn + %{"id" => id, "attributes" => %{"email" => att_email}} when is_binary(att_email) -> + if att_email |> String.trim() |> String.downcase() == normalized do + normalize_contact_id(id) + else + nil + end + + %{"id" => _id, "attributes" => _} -> + nil + + _ -> + nil + end) + end + + defp find_contact_id_by_email_in_list(_, _), do: nil + + defp normalize_contact_id(id) when is_binary(id), do: id + defp normalize_contact_id(id) when is_integer(id), do: to_string(id) + defp normalize_contact_id(_), do: nil + @doc """ Fetches a single finance contact from Vereinfacht (GET /finance-contacts/:id). @@ -130,10 +202,7 @@ defmodule Mv.Vereinfacht.Client do |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") - case Req.get(url, - headers: headers(api_key), - receive_timeout: 15_000 - ) do + case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do {:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, body} From 361e33adaf1f289c8da2f9ac6be28c0482bdd3cb Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:15 +0100 Subject: [PATCH 21/62] Vereinfacht: update existing contact when found by email Before saving contact_id to member, sync current data to the existing contact so Vereinfacht stays up to date. --- lib/mv/vereinfacht/vereinfacht.ex | 46 +++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex index 7ca6d37..b4b9282 100644 --- a/lib/mv/vereinfacht/vereinfacht.ex +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -36,21 +36,49 @@ defmodule Mv.Vereinfacht do defp do_sync_member(member) do if present_contact_id?(member.vereinfacht_contact_id) do - case Client.update_contact(member.vereinfacht_contact_id, member) do - {:ok, _} -> :ok - {:error, reason} -> {:error, reason} - end + sync_existing_contact(member) else - case Client.create_contact(member) do - {:ok, contact_id} -> - save_contact_id(member, contact_id) + ensure_contact_then_save(member) + end + end - {:error, reason} -> - {:error, reason} + defp sync_existing_contact(member) do + case Client.update_contact(member.vereinfacht_contact_id, member) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp ensure_contact_then_save(member) do + case get_or_create_contact_id(member) do + {:ok, contact_id} -> save_contact_id(member, contact_id) + {:error, _} = err -> err + end + end + + # Before create: find by email to avoid duplicate contacts (idempotency). + # When an existing contact is found, update it with current member data. + defp get_or_create_contact_id(member) do + email = member |> Map.get(:email) |> to_string() |> String.trim() + + if email == "" do + Client.create_contact(member) + else + case Client.find_contact_by_email(email) do + {:ok, existing_id} -> update_existing_contact_and_return_id(existing_id, member) + {:error, :not_found} -> Client.create_contact(member) + {:error, _} = err -> err end end end + defp update_existing_contact_and_return_id(contact_id, member) do + case Client.update_contact(contact_id, member) do + {:ok, _} -> {:ok, contact_id} + {:error, _} = err -> err + end + end + defp save_contact_id(member, contact_id) do system_actor = SystemActor.get_system_actor() opts = Helpers.ash_actor_opts(system_actor) From 75567a1c0a661f1e4ccd133bc3af4f1e006940bb Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:53:41 +0100 Subject: [PATCH 22/62] Clear Vereinfacht ENV in test_helper so tests never hit real API --- test/test_helper.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/test_helper.exs b/test/test_helper.exs index c6cb1b8..01a0613 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,9 @@ +# Ensure tests never hit the real Vereinfacht API (e.g. when .env is loaded by just). +# Tests that need "configured" sync set a fake URL (127.0.0.1:1) in their own setup. +System.delete_env("VEREINFACHT_API_URL") +System.delete_env("VEREINFACHT_API_KEY") +System.delete_env("VEREINFACHT_CLUB_ID") + ExUnit.start( # shows 10 slowest tests at the end of the test run # slowest: 10 From 17ef8982743afd8c979f76cb85da18b4afeb93d7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:54:30 +0100 Subject: [PATCH 23/62] Gettext: translate Vereinfacht API validation messages to German --- priv/gettext/de/LC_MESSAGES/default.po | 22 ++++++++++++++++++++++ priv/gettext/default.pot | 22 ++++++++++++++++++++++ priv/gettext/en/LC_MESSAGES/default.po | 22 ++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 4ce5662..fb706db 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2749,3 +2749,25 @@ msgstr "Leer lassen, um den aktuellen Wert beizubehalten" #, elixir-autogen, elixir-format msgid "Some values are set via environment variables. Those fields are read-only." msgstr "Einige Werte werden über Umgebungsvariablen gesetzt. Diese Felder sind schreibgeschützt." + +# Vereinfacht API validation messages (looked up at runtime via dgettext) +msgid "The address field is required." +msgstr "Das Adressfeld ist erforderlich." + +msgid "The city field is required." +msgstr "Das Stadtfeld ist erforderlich." + +msgid "The email field is required." +msgstr "Das E-Mail-Feld ist erforderlich." + +msgid "The first name field is required." +msgstr "Das Vornamenfeld ist erforderlich." + +msgid "The last name field is required." +msgstr "Das Nachnamenfeld ist erforderlich." + +msgid "The zip code field is required." +msgstr "Das Postleitzahlenfeld ist erforderlich." + +msgid "Too Many Attempts." +msgstr "Zu viele Versuche." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 3de9e8e..ec9563b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2749,3 +2749,25 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Some values are set via environment variables. Those fields are read-only." msgstr "" + +# Vereinfacht API validation messages (looked up at runtime via dgettext) +msgid "The address field is required." +msgstr "" + +msgid "The city field is required." +msgstr "" + +msgid "The email field is required." +msgstr "" + +msgid "The first name field is required." +msgstr "" + +msgid "The last name field is required." +msgstr "" + +msgid "The zip code field is required." +msgstr "" + +msgid "Too Many Attempts." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index edfa720..ff14c32 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2749,3 +2749,25 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Some values are set via environment variables. Those fields are read-only." msgstr "" + +# Vereinfacht API validation messages (looked up at runtime via dgettext) +msgid "The address field is required." +msgstr "" + +msgid "The city field is required." +msgstr "" + +msgid "The email field is required." +msgstr "" + +msgid "The first name field is required." +msgstr "" + +msgid "The last name field is required." +msgstr "" + +msgid "The zip code field is required." +msgstr "" + +msgid "Too Many Attempts." +msgstr "" From 0333f9e722c37d42552798436201682f5a301a45 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 08:55:55 +0100 Subject: [PATCH 24/62] fix: tests failing in ci --- .../live/components/sort_header_component.ex | 7 -- lib/mv_web/live/member_live/index.ex | 81 ++++++++++--------- .../components/sort_header_component_test.exs | 4 +- 3 files changed, 43 insertions(+), 49 deletions(-) diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index 3817d90..d548efa 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -26,7 +26,6 @@ defmodule MvWeb.Components.SortHeaderComponent do class="btn btn-ghost select-none" phx-click="sort" phx-value-field={@field} - phx-target={@myself} data-testid={@field} > {@label} @@ -43,12 +42,6 @@ defmodule MvWeb.Components.SortHeaderComponent do """ end - @impl true - def handle_event("sort", %{"field" => field_str}, socket) do - send(self(), {:sort, field_str}) - {:noreply, socket} - end - # ------------------------------------------------- # Hilfsfunktionen für ARIA Attribute & Icon SVG # ------------------------------------------------- diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 59ee8f9..b370e9a 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -163,6 +163,7 @@ defmodule MvWeb.MemberLive.Index do - `"delete"` - Removes a member from the database - `"select_member"` - Toggles individual member selection - `"select_all"` - Toggles selection of all visible members + - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL """ @impl true def handle_event("delete", %{"id" => id}, socket) do @@ -305,6 +306,46 @@ defmodule MvWeb.MemberLive.Index do end end + @impl true + def handle_event("sort", %{"field" => field_str}, socket) do + # Handle both atom and string field names (for custom fields) + field = + try do + String.to_existing_atom(field_str) + rescue + ArgumentError -> field_str + end + + {new_field, new_order} = determine_new_sort(field, socket) + old_field = socket.assigns.sort_field + + socket = + socket + |> assign(:sort_field, new_field) + |> assign(:sort_order, new_order) + |> update_sort_components(old_field, new_field, new_order) + |> load_members() + |> update_selection_assigns() + + # URL sync - push_patch happens synchronously in the event handler + query_params = + build_query_params( + socket.assigns.query, + export_sort_field(socket.assigns.sort_field), + export_sort_order(socket.assigns.sort_order), + socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters + ) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} + end + # Helper to format errors for display defp format_error(%Ash.Error.Invalid{errors: errors}) do error_messages = @@ -329,50 +370,10 @@ defmodule MvWeb.MemberLive.Index do Handles messages from child components. ## Supported messages: - - `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ - @impl true - def handle_info({:sort, field_str}, socket) do - # Handle both atom and string field names (for custom fields) - field = - try do - String.to_existing_atom(field_str) - rescue - ArgumentError -> field_str - end - - {new_field, new_order} = determine_new_sort(field, socket) - old_field = socket.assigns.sort_field - - socket = - socket - |> assign(:sort_field, new_field) - |> assign(:sort_order, new_order) - |> update_sort_components(old_field, new_field, new_order) - |> load_members() - |> update_selection_assigns() - - # URL sync - query_params = - build_query_params( - socket.assigns.query, - export_sort_field(socket.assigns.sort_field), - export_sort_order(socket.assigns.sort_order), - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters - ) - |> maybe_add_field_selection( - socket.assigns[:user_field_selection], - socket.assigns[:fields_in_url?] || false - ) - - {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} - end @impl true def handle_info({:search_changed, q}, socket) do diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index 6d23ab4..bdde4ae 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -223,7 +223,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end describe "component behavior" do - test "clicking sends sort message to parent", %{conn: conn} do + test "clicking triggers sort event on parent LiveView", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -232,7 +232,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do |> element("button[phx-value-field='first_name']") |> render_click() - # The component should send a message to the parent LiveView + # The component triggers a "sort" event on the parent LiveView # This is tested indirectly through the URL change in integration tests end From 0fd1b7e142e4948bd445126e235e22b1fff08164 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 09:40:02 +0100 Subject: [PATCH 25/62] fix testsand load performance --- lib/mv_web/live/member_live/index.ex | 11 ++++------- test/mv_web/member_live/index_test.exs | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index b370e9a..d391cd2 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -68,18 +68,15 @@ defmodule MvWeb.MemberLive.Index do # This is appropriate for initialization errors that should be visible to the user. actor = current_actor(socket) - custom_fields_visible = - Mv.Membership.CustomField - |> Ash.Query.filter(expr(show_in_overview == true)) - |> Ash.Query.sort(name: :asc) - |> Ash.read!(actor: actor) - - # Load ALL custom fields for the dropdown (to show all available fields) all_custom_fields = Mv.Membership.CustomField |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) + custom_fields_visible = + all_custom_fields + |> Enum.filter(& &1.show_in_overview) + # Load boolean custom fields (filtered and sorted from all_custom_fields) boolean_custom_fields = all_custom_fields diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 4f36795..53a2815 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -1,5 +1,5 @@ defmodule MvWeb.MemberLive.IndexTest do - use MvWeb.ConnCase, async: true + use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest require Ash.Query From 3491b4b1ba357c8aaa7cde76398744c00959c5c8 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 12:55:14 +0100 Subject: [PATCH 26/62] chore: set max cases testing for drone lower --- .drone.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.drone.yml b/.drone.yml index 2c8d504..9eb78f0 100644 --- a/.drone.yml +++ b/.drone.yml @@ -84,7 +84,7 @@ steps: # Fetch dependencies - mix deps.get # Run fast tests (excludes slow/performance and UI tests) - - mix test --exclude slow --exclude ui + - mix test --exclude slow --exclude ui --max-cases 2 - name: rebuild-cache image: drillster/drone-volume-cache From cbed65de669ac670ebcf5a714fd3798325356708 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 12:55:29 +0100 Subject: [PATCH 27/62] feat: fix light dark mode issue --- assets/css/app.css | 2 +- lib/mv_web/components/layouts/root.html.heex | 60 +++++++++++++++---- lib/mv_web/components/layouts/sidebar.ex | 17 ++++-- .../components/layouts/sidebar_test.exs | 12 ++-- 4 files changed, 66 insertions(+), 25 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index b754a08..0219e1e 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -24,7 +24,7 @@ @plugin "../vendor/daisyui-theme" { name: "dark"; default: false; - prefersdark: true; + prefersdark: false; color-scheme: "dark"; --color-base-100: oklch(30.33% 0.016 252.42); --color-base-200: oklch(25.26% 0.014 253.1); diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index 35e73ab..c05d2c0 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -15,20 +15,56 @@ diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex index 8ed7f03..7d4cce6 100644 --- a/lib/mv_web/components/layouts/sidebar.ex +++ b/lib/mv_web/components/layouts/sidebar.ex @@ -248,12 +248,17 @@ defmodule MvWeb.Layouts.Sidebar do aria-label={gettext("Toggle dark mode")} > <.icon name="hero-sun" class="size-5" aria-hidden="true" /> - +
+ +
+ <.icon name="hero-moon" class="size-5" aria-hidden="true" /> """ diff --git a/test/mv_web/components/layouts/sidebar_test.exs b/test/mv_web/components/layouts/sidebar_test.exs index ff81f24..325f19e 100644 --- a/test/mv_web/components/layouts/sidebar_test.exs +++ b/test/mv_web/components/layouts/sidebar_test.exs @@ -132,7 +132,7 @@ defmodule MvWeb.Layouts.SidebarTest do refute html =~ ~s(role="menuitem") # Footer section should not be rendered - refute html =~ "theme-controller" + refute html =~ "data-theme-toggle" refute html =~ "locale-select" end @@ -253,8 +253,8 @@ defmodule MvWeb.Layouts.SidebarTest do # Check for language selector form assert html =~ ~s(action="/set_locale") - # Check for theme toggle - assert has_class?(html, "theme-controller") + # Check for theme toggle (using data attribute instead of class) + assert html =~ "data-theme-toggle" # Check for user menu/avatar assert has_class?(html, "avatar") @@ -536,7 +536,7 @@ defmodule MvWeb.Layouts.SidebarTest do assert html =~ ~s(role="group") # Footer section - assert html =~ "theme-controller" + assert html =~ "data-theme-toggle" assert html =~ ~s(action="/set_locale") # Check that critical navigation exists (at least /members) @@ -694,8 +694,8 @@ defmodule MvWeb.Layouts.SidebarTest do test "renders theme toggle" do html = render_sidebar(authenticated_assigns()) - # Toggle is always visible - assert has_class?(html, "theme-controller") + # Toggle is always visible (using data attribute instead of class) + assert html =~ "data-theme-toggle" assert html =~ "hero-sun" assert html =~ "hero-moon" end From 01f62297fce5197230744c661f8f36f9b1334b41 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 14:36:35 +0100 Subject: [PATCH 28/62] feat: add groups to export --- lib/mv/membership/member_export.ex | 8 +++- lib/mv/membership/member_export/build.ex | 39 ++++++++++++++++++- lib/mv/membership/members_csv.ex | 14 +++++++ .../controllers/member_export_controller.ex | 33 +++++++++++++++- lib/mv_web/live/member_live/index.ex | 9 ++++- lib/mv_web/translations/member_fields.ex | 1 + 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index e243d40..b4272b0 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do alias MvWeb.MemberLive.Index.MembershipFeeStatus @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ - ["membership_fee_status"] + ["membership_fee_status", "groups"] @computed_export_fields ["membership_fee_status"] @computed_insert_after "membership_fee_start_date" @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -323,10 +323,14 @@ defmodule Mv.Membership.MemberExport do |> Enum.filter(&(&1 in @domain_member_field_strings)) |> order_member_fields_like_table() - # final member_fields list (used for column specs order): table order + computed inserted + # Separate groups from other fields (groups is handled as a special field, not a member field) + groups_field = if "groups" in member_fields, do: ["groups"], else: [] + + # final member_fields list (used for column specs order): table order + computed inserted + groups ordered_member_fields = selectable_member_fields |> insert_computed_fields_like_table(computed_fields) + |> then(fn fields -> fields ++ groups_field end) %{ selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")), diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index ce1e98c..323681d 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -132,12 +132,15 @@ defmodule Mv.Membership.MemberExport.Build do parsed.computed_fields != [] or "membership_fee_status" in parsed.member_fields + need_groups = "groups" in parsed.member_fields + query = Member |> Ash.Query.new() |> Ash.Query.select(select_fields) |> load_custom_field_values_query(custom_field_ids_union) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) + |> maybe_load_groups(need_groups) query = if parsed.selected_ids != [] do @@ -294,6 +297,13 @@ defmodule Mv.Membership.MemberExport.Build do MembershipFeeStatus.load_cycles_for_members(query, show_current) end + defp maybe_load_groups(query, false), do: query + + defp maybe_load_groups(query, true) do + # Load groups with id and name only (for export formatting) + Ash.Query.load(query, groups: [:id, :name]) + end + defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do @@ -343,6 +353,19 @@ defmodule Mv.Membership.MemberExport.Build do } end) + groups_col = + if "groups" in parsed.member_fields do + [ + %{ + key: :groups, + kind: :groups, + label: label_fn.(:groups) + } + ] + else + [] + end + custom_cols = parsed.custom_field_ids |> Enum.map(fn id -> @@ -361,7 +384,7 @@ defmodule Mv.Membership.MemberExport.Build do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ custom_cols + member_cols ++ computed_cols ++ groups_col ++ custom_cols end defp build_rows(members, columns, custom_fields_by_id) do @@ -391,6 +414,11 @@ defmodule Mv.Membership.MemberExport.Build do if is_binary(value), do: value, else: "" end + defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do + groups = Map.get(member, :groups) || [] + format_groups(groups) + end + defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_binary(k) do @@ -424,6 +452,15 @@ defmodule Mv.Membership.MemberExport.Build do defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(value), do: to_string(value) + defp format_groups([]), do: "" + + defp format_groups(groups) when is_list(groups) do + groups + |> Enum.map(fn group -> Map.get(group, :name) || "" end) + |> Enum.reject(&(&1 == "")) + |> Enum.join(", ") + end + defp build_meta(members) do %{ generated_at: DateTime.utc_now() |> DateTime.to_iso8601(), diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex index a0fd463..a47af8d 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -59,6 +59,11 @@ defmodule Mv.Membership.MembersCSV do if is_binary(value), do: value, else: "" end + defp cell_value(member, %{kind: :groups, key: :groups}) do + groups = Map.get(member, :groups) || [] + format_groups(groups) + end + defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_binary(k) do @@ -97,4 +102,13 @@ defmodule Mv.Membership.MembersCSV do defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt) defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(value), do: to_string(value) + + defp format_groups([]), do: "" + + defp format_groups(groups) when is_list(groups) do + groups + |> Enum.map(fn group -> Map.get(group, :name) || "" end) + |> Enum.reject(&(&1 == "")) + |> Enum.join(", ") + end end diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 009a985..911e6d9 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -18,7 +18,8 @@ defmodule MvWeb.MemberExportController do alias MvWeb.MemberLive.Index.MembershipFeeStatus use Gettext, backend: MvWeb.Gettext - @member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ + ["groups"] @computed_export_fields ["membership_fee_status"] @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -83,6 +84,7 @@ defmodule MvWeb.MemberExportController do domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) selectable = Enum.filter(member_fields, fn f -> f in domain_fields end) computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end) + # "groups" is neither a domain field nor a computed field, it's handled separately {selectable, computed} end @@ -235,12 +237,15 @@ defmodule MvWeb.MemberExportController do need_cycles = parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields + need_groups = "groups" in parsed.member_fields + query = Member |> Ash.Query.new() |> Ash.Query.select(select_fields) |> load_custom_field_values_query(parsed.custom_field_ids) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) + |> maybe_load_groups(need_groups) query = if parsed.selected_ids != [] do @@ -284,6 +289,13 @@ defmodule MvWeb.MemberExportController do MembershipFeeStatus.load_cycles_for_members(query, show_current) end + defp maybe_load_groups(query, false), do: query + + defp maybe_load_groups(query, true) do + # Load groups with id and name only (for export formatting) + Ash.Query.load(query, groups: [:id, :name]) + end + # Adds computed field values to members (e.g. membership_fee_status) defp add_computed_fields(members, computed_fields, show_current_cycle) do if "membership_fee_status" in computed_fields do @@ -441,6 +453,19 @@ defmodule MvWeb.MemberExportController do } end) + groups_col = + if "groups" in parsed.member_fields do + [ + %{ + header: groups_field_header(conn), + kind: :groups, + key: :groups + } + ] + else + [] + end + custom_cols = parsed.custom_field_ids |> Enum.map(fn id -> @@ -459,7 +484,7 @@ defmodule MvWeb.MemberExportController do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ custom_cols + member_cols ++ computed_cols ++ groups_col ++ custom_cols end # --- headers: use MemberFields.label for translations --- @@ -499,6 +524,10 @@ defmodule MvWeb.MemberExportController do cf.name end + defp groups_field_header(_conn) do + MemberFields.label(:groups) + end + defp humanize_field(str) do str |> String.replace("_", " ") diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index d391cd2..eef871f 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1620,6 +1620,10 @@ defmodule MvWeb.MemberLive.Index do FieldVisibility.computed_member_fields() |> Enum.filter(&(&1 in member_fields_computed)) + # Groups is always included in export if it's visible in the table + # (groups column is always shown in the table, so we always include it in export) + member_fields_with_groups = ordered_member_fields_db ++ ["groups"] + # Order custom fields like the table (same as dynamic_cols / all_custom_fields order) ordered_custom_field_ids = socket.assigns.all_custom_fields @@ -1628,7 +1632,10 @@ defmodule MvWeb.MemberLive.Index do %{ selected_ids: socket.assigns.selected_members |> MapSet.to_list(), - member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1), + member_fields: Enum.map(member_fields_with_groups, fn + f when is_atom(f) -> Atom.to_string(f) + f when is_binary(f) -> f + end), computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1), custom_field_ids: ordered_custom_field_ids, column_order: diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index 83ab139..c9b8cad 100644 --- a/lib/mv_web/translations/member_fields.ex +++ b/lib/mv_web/translations/member_fields.ex @@ -29,6 +29,7 @@ defmodule MvWeb.Translations.MemberFields do def label(:postal_code), do: gettext("Postal Code") def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date") def label(:membership_fee_status), do: gettext("Membership Fee Status") + def label(:groups), do: gettext("Groups") # Fallback for unknown fields def label(field) do From dbdac5870a0717710c8bdba35b9ced8bb898d157 Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 20 Feb 2026 08:45:21 +0100 Subject: [PATCH 29/62] fix: adds shoe/hide for group column --- lib/mv_web/live/member_live/index.ex | 79 ++++++++++++++----- lib/mv_web/live/member_live/index.html.heex | 1 + .../member_live/index/field_visibility.ex | 5 +- priv/gettext/de/LC_MESSAGES/default.po | 1 + priv/gettext/default.pot | 1 + priv/gettext/en/LC_MESSAGES/default.po | 1 + 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index eef871f..526f858 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -682,6 +682,18 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() end + # Update sort components after rendering + socket = + if socket.assigns[:sort_needs_update] do + old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field + socket + |> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order) + |> assign(:sort_needs_update, false) + |> assign(:previous_sort_field, nil) + else + socket + end + {:noreply, socket} end @@ -940,9 +952,10 @@ defmodule MvWeb.MemberLive.Index do ) # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) + # Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status members = if sort_after_load and - socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do + socket.assigns.sort_field != :membership_fee_status do sort_members_in_memory( members, socket.assigns.sort_field, @@ -1044,7 +1057,9 @@ defmodule MvWeb.MemberLive.Index do defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false} defp maybe_sort(query, field, order, _custom_fields) do - if computed_field?(field) do + # :groups is in computed_member_fields() but can be sorted in-memory + # Only :membership_fee_status should be blocked from sorting + if field == :membership_fee_status or field == "membership_fee_status" do {query, false} else apply_sort_to_query(query, field, order) @@ -1086,13 +1101,19 @@ defmodule MvWeb.MemberLive.Index do end defp valid_sort_field?(field) when is_atom(field) do - if field in FieldVisibility.computed_member_fields(), - do: false, - else: valid_sort_field_db_or_custom?(field) + # :groups is in computed_member_fields() but can be sorted + # Only :membership_fee_status should be blocked + if field == :membership_fee_status do + false + else + valid_sort_field_db_or_custom?(field) + end end defp valid_sort_field?(field) when is_binary(field) do - if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do + # "groups" is in computed_member_fields() but can be sorted + # Only "membership_fee_status" should be blocked + if field == "membership_fee_status" do false else valid_sort_field_db_or_custom?(field) @@ -1249,10 +1270,13 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) + old_field = socket.assigns.sort_field socket |> assign(:sort_field, field) |> assign(:sort_order, order) + |> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order) + |> assign(:previous_sort_field, old_field) end defp maybe_update_sort(socket, _), do: socket @@ -1261,17 +1285,27 @@ defmodule MvWeb.MemberLive.Index do defp determine_field(default, nil), do: default defp determine_field(default, sf) when is_binary(sf) do - computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) + # Handle "groups" specially - it's in computed_member_fields() but can be sorted + if sf == "groups" do + :groups + else + computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) - if sf in computed_strings, - do: default, - else: determine_field_after_computed_check(default, sf) + if sf in computed_strings, + do: default, + else: determine_field_after_computed_check(default, sf) + end end defp determine_field(default, sf) when is_atom(sf) do - if sf in FieldVisibility.computed_member_fields(), - do: default, - else: determine_field_after_computed_check(default, sf) + # Handle :groups specially - it's in computed_member_fields() but can be sorted + if sf == :groups do + :groups + else + if sf in FieldVisibility.computed_member_fields(), + do: default, + else: determine_field_after_computed_check(default, sf) + end end defp determine_field(default, _), do: default @@ -1620,9 +1654,13 @@ defmodule MvWeb.MemberLive.Index do FieldVisibility.computed_member_fields() |> Enum.filter(&(&1 in member_fields_computed)) - # Groups is always included in export if it's visible in the table - # (groups column is always shown in the table, so we always include it in export) - member_fields_with_groups = ordered_member_fields_db ++ ["groups"] + # Include groups in export only if it's visible in the table + member_fields_with_groups = + if :groups in socket.assigns[:member_fields_visible] do + ordered_member_fields_db ++ ["groups"] + else + ordered_member_fields_db + end # Order custom fields like the table (same as dynamic_cols / all_custom_fields order) ordered_custom_field_ids = @@ -1632,10 +1670,11 @@ defmodule MvWeb.MemberLive.Index do %{ selected_ids: socket.assigns.selected_members |> MapSet.to_list(), - member_fields: Enum.map(member_fields_with_groups, fn - f when is_atom(f) -> Atom.to_string(f) - f when is_binary(f) -> f - end), + member_fields: + Enum.map(member_fields_with_groups, fn + f when is_atom(f) -> Atom.to_string(f) + f when is_binary(f) -> f + end), computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1), custom_field_ids: ordered_custom_field_ids, column_order: diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index f8be88d..fa0f43a 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -331,6 +331,7 @@ <:col :let={member} + :if={:groups in @member_fields_visible} label={ ~H""" <.live_component diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index 0b0cb67..6427d4c 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -28,7 +28,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do alias Mv.Membership.Helpers.VisibilityConfig # Single UI key for "Membership Fee Status"; only this appears in the dropdown. - @pseudo_member_fields [:membership_fee_status] + # Groups is also a pseudo field (not a DB attribute, but displayed in the table). + @pseudo_member_fields [:membership_fee_status, :groups] # Export/API may accept this as alias; must not appear in the UI options list. @export_only_alias :payment_status @@ -201,7 +202,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do """ @spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()] def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do - computed_set = MapSet.new(@pseudo_member_fields) + computed_set = MapSet.new([:membership_fee_status]) field_selection |> Enum.filter(fn {field_string, visible} -> diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0d661cf..c7e438a 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2205,6 +2205,7 @@ msgstr "Gruppe erfolgreich gespeichert." #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "Gruppen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 0aef1b3..0ad9170 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2206,6 +2206,7 @@ msgstr "" #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 371a028..bfa6bcc 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2206,6 +2206,7 @@ msgstr "" #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" From cb932ad6ef56f77513872f1e26603fc27732060e Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 20 Feb 2026 08:45:55 +0100 Subject: [PATCH 30/62] feat: respects sorting groups for export --- lib/mv/membership/member_export/build.ex | 68 ++++++++++---- .../controllers/member_export_controller.ex | 93 ++++++++++++------- lib/mv_web/live/member_live/index.ex | 7 -- 3 files changed, 111 insertions(+), 57 deletions(-) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index 323681d..ff8cf76 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -244,16 +244,22 @@ defmodule Mv.Membership.MemberExport.Build do defp maybe_sort(query, _field, nil), do: {query, false} defp maybe_sort(query, field, order) when is_binary(field) do - if custom_field_sort?(field) do - {query, true} - else - field_atom = String.to_existing_atom(field) + cond do + field == "groups" -> + # Groups sort → in-memory nach dem Read (wie Tabelle) + {query, true} - if field_atom in (Mv.Constants.member_fields() -- [:notes]) do - {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} - else - {query, false} - end + custom_field_sort?(field) -> + {query, true} + + true -> + field_atom = String.to_existing_atom(field) + + if field_atom in (Mv.Constants.member_fields() -- [:notes]) do + {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} + else + {query, false} + end end rescue ArgumentError -> {query, false} @@ -263,21 +269,45 @@ defmodule Mv.Membership.MemberExport.Build do do: [] defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do - id_str = String.trim_leading(field, @custom_field_prefix) - custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + if field == "groups" do + sort_members_by_groups_export(members, order) + else + id_str = String.trim_leading(field, @custom_field_prefix) + custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - if is_nil(custom_field), do: members + if is_nil(custom_field), do: members - key_fn = fn member -> - cfv = find_cfv(member, custom_field) - raw = if cfv, do: cfv.value, else: nil - MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + key_fn = fn member -> + cfv = find_cfv(member, custom_field) + raw = if cfv, do: cfv.value, else: nil + MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + end + + members + |> Enum.map(fn m -> {m, key_fn.(m)} end) + |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) + |> Enum.map(fn {m, _} -> m end) + end + end + + defp sort_members_by_groups_export(members, order) do + # Members with groups first, then by first group name alphabetically (min = first by sort order) + # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 + first_group_name = fn member -> + (member.groups || []) + |> Enum.map(& &1.name) + |> Enum.min(fn -> nil end) end members - |> Enum.map(fn m -> {m, key_fn.(m)} end) - |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) - |> Enum.map(fn {m, _} -> m end) + |> Enum.sort_by(fn member -> + name = first_group_name.(member) + # Nil (no groups) sorts last in asc, first in desc + {name == nil, name || ""} + end) + |> then(fn list -> + if order == "desc", do: Enum.reverse(list), else: list + end) end defp find_cfv(member, custom_field) do diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 911e6d9..4ed8f2d 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -341,17 +341,23 @@ defmodule MvWeb.MemberExportController do defp maybe_sort_export(query, _field, nil), do: {query, false} defp maybe_sort_export(query, field, order) when is_binary(field) do - if custom_field_sort?(field) do - # Custom field sort → in-memory nach dem Read (wie Tabelle) - {query, true} - else - field_atom = String.to_existing_atom(field) + cond do + field == "groups" -> + # Groups sort → in-memory nach dem Read (wie Tabelle) + {query, true} - if field_atom in (Mv.Constants.member_fields() -- [:notes]) do - {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} - else - {query, false} - end + custom_field_sort?(field) -> + # Custom field sort → in-memory nach dem Read (wie Tabelle) + {query, true} + + true -> + field_atom = String.to_existing_atom(field) + + if field_atom in (Mv.Constants.member_fields() -- [:notes]) do + {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} + else + {query, false} + end end rescue ArgumentError -> {query, false} @@ -370,35 +376,60 @@ defmodule MvWeb.MemberExportController do defp sort_members_by_custom_field_export(members, field, order, custom_fields) when is_binary(field) do order = order || "asc" - id_str = String.trim_leading(field, @custom_field_prefix) - custom_field = - Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - - if is_nil(custom_field) do - members + if field == "groups" do + sort_members_by_groups_export(members, order) else - # Match table: - # 1) values first, empty last - # 2) sort only values - # 3) for desc, reverse only the values-part - {with_values, without_values} = - Enum.split_with(members, fn member -> - has_non_empty_custom_field_value?(member, custom_field) - end) + id_str = String.trim_leading(field, @custom_field_prefix) - sorted_with_values = - Enum.sort_by(with_values, fn member -> - extract_member_sort_value(member, custom_field) - end) + custom_field = + Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - sorted_with_values = - if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + if is_nil(custom_field) do + members + else + # Match table: + # 1) values first, empty last + # 2) sort only values + # 3) for desc, reverse only the values-part + {with_values, without_values} = + Enum.split_with(members, fn member -> + has_non_empty_custom_field_value?(member, custom_field) + end) - sorted_with_values ++ without_values + sorted_with_values = + Enum.sort_by(with_values, fn member -> + extract_member_sort_value(member, custom_field) + end) + + sorted_with_values = + if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + + sorted_with_values ++ without_values + end end end + defp sort_members_by_groups_export(members, order) do + # Members with groups first, then by first group name alphabetically (min = first by sort order) + # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 + first_group_name = fn member -> + (member.groups || []) + |> Enum.map(& &1.name) + |> Enum.min(fn -> nil end) + end + + members + |> Enum.sort_by(fn member -> + name = first_group_name.(member) + # Nil (no groups) sorts last in asc, first in desc + {name == nil, name || ""} + end) + |> then(fn list -> + if order == "desc", do: Enum.reverse(list), else: list + end) + end + defp has_non_empty_custom_field_value?(member, custom_field) do case find_cfv(member, custom_field) do nil -> diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 526f858..9d93b08 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1066,13 +1066,6 @@ defmodule MvWeb.MemberLive.Index do end end - defp computed_field?(field) do - computed_atoms = FieldVisibility.computed_member_fields() - computed_strings = Enum.map(computed_atoms, &Atom.to_string/1) - - (is_atom(field) and field in computed_atoms) or - (is_binary(field) and field in computed_strings) - end defp apply_sort_to_query(query, field, order) do cond do From 397f7a7975bc1ffd8d2bad816a92d43343be2dc3 Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 20 Feb 2026 09:16:38 +0100 Subject: [PATCH 31/62] fix linting --- lib/mv/membership/member_export/build.ex | 40 ++++++++++------ .../controllers/member_export_controller.ex | 48 ++++++++++--------- lib/mv_web/live/member_live/index.ex | 2 +- 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index ff8cf76..9e0cc7b 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -272,24 +272,34 @@ defmodule Mv.Membership.MemberExport.Build do if field == "groups" do sort_members_by_groups_export(members, order) else - id_str = String.trim_leading(field, @custom_field_prefix) - custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - - if is_nil(custom_field), do: members - - key_fn = fn member -> - cfv = find_cfv(member, custom_field) - raw = if cfv, do: cfv.value, else: nil - MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) - end - - members - |> Enum.map(fn m -> {m, key_fn.(m)} end) - |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) - |> Enum.map(fn {m, _} -> m end) + sort_by_custom_field_value(members, field, order, custom_fields) end end + defp sort_by_custom_field_value(members, field, order, custom_fields) do + id_str = String.trim_leading(field, @custom_field_prefix) + custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + + if is_nil(custom_field) do + members + else + sort_members_with_custom_field(members, custom_field, order) + end + end + + defp sort_members_with_custom_field(members, custom_field, order) do + key_fn = fn member -> + cfv = find_cfv(member, custom_field) + raw = if cfv, do: cfv.value, else: nil + MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + end + + members + |> Enum.map(fn m -> {m, key_fn.(m)} end) + |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) + |> Enum.map(fn {m, _} -> m end) + end + defp sort_members_by_groups_export(members, order) do # Members with groups first, then by first group name alphabetically (min = first by sort order) # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 4ed8f2d..08bcba7 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -380,33 +380,37 @@ defmodule MvWeb.MemberExportController do if field == "groups" do sort_members_by_groups_export(members, order) else - id_str = String.trim_leading(field, @custom_field_prefix) + sort_by_custom_field_value(members, field, order, custom_fields) + end + end - custom_field = - Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + defp sort_by_custom_field_value(members, field, order, custom_fields) do + id_str = String.trim_leading(field, @custom_field_prefix) - if is_nil(custom_field) do - members - else - # Match table: - # 1) values first, empty last - # 2) sort only values - # 3) for desc, reverse only the values-part - {with_values, without_values} = - Enum.split_with(members, fn member -> - has_non_empty_custom_field_value?(member, custom_field) - end) + custom_field = + Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - sorted_with_values = - Enum.sort_by(with_values, fn member -> - extract_member_sort_value(member, custom_field) - end) + if is_nil(custom_field) do + members + else + # Match table: + # 1) values first, empty last + # 2) sort only values + # 3) for desc, reverse only the values-part + {with_values, without_values} = + Enum.split_with(members, fn member -> + has_non_empty_custom_field_value?(member, custom_field) + end) - sorted_with_values = - if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + sorted_with_values = + Enum.sort_by(with_values, fn member -> + extract_member_sort_value(member, custom_field) + end) - sorted_with_values ++ without_values - end + sorted_with_values = + if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + + sorted_with_values ++ without_values end end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 9d93b08..218fa6f 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -686,6 +686,7 @@ defmodule MvWeb.MemberLive.Index do socket = if socket.assigns[:sort_needs_update] do old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field + socket |> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order) |> assign(:sort_needs_update, false) @@ -1066,7 +1067,6 @@ defmodule MvWeb.MemberLive.Index do end end - defp apply_sort_to_query(query, field, order) do cond do # Groups sort -> after load (in memory) From f4554b8a4bd2b8587f0a0b5211220c128e8a1069 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 14:01:14 +0100 Subject: [PATCH 32/62] docs: update Code Guidelines with issues from meta review analysis --- CODE_GUIDELINES.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index cc58ca9..70e1596 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -385,6 +385,8 @@ def process_user(user), do: {:ok, perform_action(user)} ### 2.3 Error Handling +**No silent failures:** When an error path assigns a fallback (e.g. empty list, unchanged assigns), always log the error with enough context (e.g. `inspect(error)`, slug, action) and/or set a user-visible flash. Do not only assign the fallback without logging. + **Use Tagged Tuples:** ```elixir @@ -623,6 +625,10 @@ defmodule MvWeb.MemberLive.Index do end ``` +**LiveView load budget:** Keep UI-triggered events cheap. `phx-change`, `phx-focus`, and `phx-keydown` must **not** perform database reads by default; work from assigns (e.g. filter in memory) or defer reads to an explicit commit step (e.g. "Add", "Save", "Submit"). Perform DB reads or reloads only on commit events, not on every keystroke or focus. If a read in an event is unavoidable, do at most one deliberate read, document why, and prefer debounce/throttle. + +**LiveView size:** When a LiveView accumulates many features and event handlers (CRUD + add/remove + search + keyboard + modals), extract sub-flows into LiveComponents. The parent keeps auth, initial load, and a single reload after child actions; the component owns the sub-flow and notifies the parent when data changes. + **Component Design:** ```elixir @@ -1267,6 +1273,9 @@ gettext("Welcome to Mila") # With interpolation gettext("Hello, %{name}!", name: user.name) +# Plural: always pass count binding when message uses %{count} +ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) + # Domain-specific translations dgettext("auth", "Sign in with email") ``` @@ -1507,6 +1516,8 @@ defmodule MvWeb.MemberLive.IndexTest do end ``` +**LiveView test standards:** Prefer selector-based assertions (`has_element?` on `data-testid`, stable IDs, or semantic markers) over free-text matching (`html =~ "..."`) or broad regex. For repeated flows (e.g. "open add member", "search", "select"), use helpers in `test/support/`. If delete is a LiveView event, test that event and assert both UI and data; if delete uses JS `data-confirm` + non-LV submit, cover the real delete path in a context/service test and add at most one smoke test in the UI. Do not assert or document "single request" or "DB-level sort" without measuring (e.g. query count or timing). + #### 4.3.5 Component Tests Test function components: @@ -1876,6 +1887,8 @@ policies do end ``` +**Record-based authorization:** When a concrete record is available, use `can?(actor, :action, record)` (e.g. `can?(@current_user, :update, @group)`). Use `can?(actor, :action, Resource)` only when no specific record exists (e.g. "can create any group"). In events: resolve the record from assigns, run `can?`, then mutate; avoid an extra DB read just for a "freshness" check if the assign is already the source of truth. + **Actor Handling in LiveViews:** Always use the `current_actor/1` helper for consistent actor access: @@ -2707,7 +2720,9 @@ Building accessible applications ensures that all users, including those with di ### 8.2 ARIA Labels and Roles -**Use ARIA Attributes When Necessary:** +**Terminology and semantics:** Use the same terms in UI, tests, and docs (e.g. "modal" vs "inline confirmation"). If the implementation is inline, do not call it a modal in tests or docs. + +**Use ARIA Attributes When Necessary:** Use `role="status"` only for live regions that present advisory status (e.g. search result count, progress). Do not use it on links, buttons, or purely static labels; use semantic HTML or the correct role for the widget. Attach `phx-debounce` / `phx-throttle` to the element that triggers the event (e.g. input), not to the whole form, unless the intent is form-wide. ```heex @@ -2931,11 +2946,11 @@ end **Announce Dynamic Content:** ```heex - +
<%= if @searched do %> - <%= ngettext("Found %{count} member", "Found %{count} members", @count) %> + <%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %> <% end %>
From ec814a8c94fc245de6de21a93c8135596bbb8b9f Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 15:09:37 +0100 Subject: [PATCH 33/62] refactor: remove db read on focus for groups view --- lib/mv_web/live/group_live/show.ex | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 0251fb6..d9a5d98 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -431,11 +431,7 @@ defmodule MvWeb.GroupLive.Show do # Add Member Events @impl true def handle_event("show_add_member_input", _params, socket) do - # Reload group to ensure we have the latest members list - actor = current_actor(socket) - group = socket.assigns.group - socket = reload_group(socket, group.slug, actor) - + # Use existing @group from assigns; no DB read on focus. Reload only on commit (add/remove). {:noreply, socket |> assign(:show_add_member_input, true) From 83b104ecf38fbf15e8ebf3846d9140d6f409b130 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 20 Feb 2026 15:56:12 +0100 Subject: [PATCH 34/62] refactor: when adding group members, search in-memory on typing --- CODE_GUIDELINES.md | 2 + lib/mv_web/live/group_live/show.ex | 120 ++++++++++++++++--------- priv/gettext/de/LC_MESSAGES/default.po | 23 +---- priv/gettext/default.pot | 10 +-- priv/gettext/en/LC_MESSAGES/default.po | 23 +---- 5 files changed, 93 insertions(+), 85 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 70e1596..439eee8 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1264,6 +1264,8 @@ end ### 3.12 Internationalization: Gettext +**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”. + **Define Translations:** ```elixir diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index d9a5d98..dc0a922 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -17,10 +17,12 @@ defmodule MvWeb.GroupLive.Show do require Logger + import Ash.Expr import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.Authorization alias Mv.Membership + alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers @impl true def mount(_params, _session, socket) do @@ -29,6 +31,7 @@ defmodule MvWeb.GroupLive.Show do |> assign(:show_add_member_input, false) |> assign(:member_search_query, "") |> assign(:available_members, []) + |> assign(:add_member_candidates, []) |> assign(:selected_member_ids, []) |> assign(:selected_members, []) |> assign(:show_member_dropdown, false) @@ -94,12 +97,12 @@ defmodule MvWeb.GroupLive.Show do
- <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <%= if can?(@current_user, :update, @group) do %> <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}> {gettext("Edit")} <% end %> - <%= if can?(@current_user, :destroy, Mv.Membership.Group) do %> + <%= if can?(@current_user, :destroy, @group) do %> <.button class="btn-error" phx-click="open_delete_modal"> {gettext("Delete")} @@ -132,7 +135,7 @@ defmodule MvWeb.GroupLive.Show do )}

- <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <%= if can?(@current_user, :update, @group) do %>
<%= if assigns[:show_add_member_input] do %>
@@ -263,7 +266,7 @@ defmodule MvWeb.GroupLive.Show do {gettext("Name")} {gettext("Email")} - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <%= if can?(@current_user, :update, @group) do %> {gettext("Actions")} <% end %> @@ -291,7 +294,7 @@ defmodule MvWeb.GroupLive.Show do <% end %> - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <%= if can?(@current_user, :update, @group) do %>