diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 76ed471..7b70e89 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -1300,20 +1300,24 @@ defmodule Mv.Membership.Member do end end - # Extracts custom field values from existing member data (update scenario) + # Extracts custom field values from existing member data (update scenario). + # Actor must come from context; no system-actor fallback (per guidelines). + # When no actor is present we skip the load and return empty map. defp extract_existing_values(member_data, changeset) do - 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 - {:ok, %{custom_field_values: existing_values}} -> - Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2) - - _ -> + case Map.get(changeset.context, :actor) do + nil -> %{} + + actor -> + opts = Helpers.ash_actor_opts(actor) + + case Ash.load(member_data, :custom_field_values, opts) do + {:ok, %{custom_field_values: existing_values}} -> + Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2) + + _ -> + %{} + end end end diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 33445d3..f56daa0 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -242,7 +242,7 @@ defmodule Mv.Membership.Setting do attribute :vereinfacht_api_key, :string do allow_nil? true - public? true + public? false description "Vereinfacht API key (Bearer token)" sensitive? true end diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 2aafc7f..58e06a9 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -134,15 +134,25 @@ defmodule Mv.Vereinfacht.Client do end end + @find_contact_page_size 100 + @find_contact_max_pages 100 + defp do_find_contact_by_email(email) do - url = - base_url() - |> String.trim_trailing("/") - |> then(&"#{&1}/finance-contacts") + normalized = String.trim(email) |> String.downcase() + do_find_contact_by_email_page(1, normalized) + end + + defp do_find_contact_by_email_page(page, _normalized) when page > @find_contact_max_pages do + {:error, :not_found} + end + + defp do_find_contact_by_email_page(page, normalized) do + base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts") + url = base <> "?page[size]=#{@find_contact_page_size}&page[number]=#{page}" 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) + handle_find_contact_page_response(body, page, normalized) {:ok, %{status: status, body: body}} -> {:error, {:http, status, extract_error_message(body)}} @@ -152,15 +162,21 @@ defmodule Mv.Vereinfacht.Client do end end - defp parse_find_by_email_response(body, email) do - normalized = String.trim(email) |> String.downcase() - + defp handle_find_contact_page_response(body, page, normalized) do case find_contact_id_by_email_in_list(body, normalized) do - nil -> {:error, :not_found} - id -> {:ok, id} + id when is_binary(id) -> {:ok, id} + nil -> maybe_find_contact_next_page(body, page, normalized) end end + defp maybe_find_contact_next_page(body, page, normalized) do + data = Map.get(body, "data") || [] + + if length(data) < @find_contact_page_size, + do: {:error, :not_found}, + else: do_find_contact_by_email_page(page + 1, normalized) + 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, "isExternal" => true}} @@ -249,31 +265,28 @@ defmodule Mv.Vereinfacht.Client do base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1) end + # Allowlist of receipt attribute keys we expose (avoids String.to_atom on arbitrary API input / DoS). + @receipt_attr_allowlist ~w[amount bookingDate createdAt receiptType referenceNumber status updatedAt]a + defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do included |> Enum.filter(&match?(%{"type" => "receipts"}, &1)) |> Enum.map(fn %{"id" => id, "attributes" => attrs} = r -> - Map.merge(%{id: id, type: r["type"]}, string_keys_to_atoms(attrs || %{})) + Map.merge(%{id: id, type: r["type"]}, receipt_attrs_allowlist(attrs || %{})) end) end defp extract_receipts_from_response(_), do: [] - defp string_keys_to_atoms(map) when is_map(map) do - Map.new(map, fn {k, v} -> {to_atom_key(k), v} end) + defp receipt_attrs_allowlist(attrs) when is_map(attrs) do + Map.new(@receipt_attr_allowlist, fn key -> + str_key = to_string(key) + {key, Map.get(attrs, str_key)} + end) + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() end - defp to_atom_key(k) when is_binary(k) do - try do - String.to_existing_atom(k) - rescue - ArgumentError -> String.to_atom(k) - end - end - - defp to_atom_key(k) when is_atom(k), do: k - defp to_atom_key(k), do: to_atom_key(to_string(k)) - 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() diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex index b4b9282..ce8005d 100644 --- a/lib/mv/vereinfacht/vereinfacht.ex +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -8,6 +8,7 @@ defmodule Mv.Vereinfacht do - `sync_members_without_contact/0` – Bulk sync of members without a contact ID. """ require Ash.Query + import Ash.Expr alias Mv.Vereinfacht.Client alias Mv.Membership.Member alias Mv.Helpers.SystemActor @@ -128,7 +129,9 @@ defmodule Mv.Vereinfacht do query = Member - |> Ash.Query.filter(is_nil(vereinfacht_contact_id)) + |> Ash.Query.filter( + expr(is_nil(^ref(:vereinfacht_contact_id)) or ^ref(:vereinfacht_contact_id) == "") + ) case Ash.read(query, opts) do {:ok, members} -> 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 946f249..1ce6f77 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 @@ -52,7 +52,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <%!-- 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 %> + <%= if @vereinfacht_contact_present do %>
<.link @@ -515,6 +515,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:can_create_cycle, can_create_cycle) |> assign(:can_destroy_cycle, can_destroy_cycle) |> assign(:can_update_cycle, can_update_cycle) + |> assign(:vereinfacht_contact_present, present_contact_id?(member.vereinfacht_contact_id)) |> assign_new(:interval_warning, fn -> nil end) |> assign_new(:editing_cycle, fn -> nil end) |> assign_new(:deleting_cycle, fn -> nil end) @@ -1082,6 +1083,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_create_cycle_period(_date, _interval), do: "" + defp present_contact_id?(nil), do: false + defp present_contact_id?(id) when is_binary(id), do: String.trim(id) != "" + defp present_contact_id?(_), do: false + defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail), do: "HTTP #{status} – #{detail}" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index d42857b..d39d86b 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2682,7 +2682,7 @@ 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." +msgstr "Vereinfacht ist nicht konfiguriert. Bitte setze API-URL, API-Schlüssel und Vereins-ID." #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format @@ -2728,7 +2728,7 @@ 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." +msgstr "Synchronisiere dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichere das Mitglied erneut, um den Kontakt anzulegen." #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format diff --git a/test/mv/config_vereinfacht_test.exs b/test/mv/config_vereinfacht_test.exs index 07260c2..d7a3360 100644 --- a/test/mv/config_vereinfacht_test.exs +++ b/test/mv/config_vereinfacht_test.exs @@ -40,6 +40,8 @@ defmodule Mv.ConfigVereinfachtTest do end test "returns app contact view URL when API URL is set (derived app URL)" do + clear_vereinfacht_env() + clear_vereinfacht_app_url_from_settings() set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1") assert Mv.Config.vereinfacht_contact_view_url("42") == @@ -54,7 +56,7 @@ defmodule Mv.ConfigVereinfachtTest do assert Mv.Config.vereinfacht_contact_view_url("abc") == "https://app.verein.visuel.dev/en/admin/finances/contacts/abc" after - System.delete_env("VEREINFACHT_APP_URL") + clear_vereinfacht_env() end end @@ -66,5 +68,16 @@ defmodule Mv.ConfigVereinfachtTest do System.delete_env("VEREINFACHT_API_URL") System.delete_env("VEREINFACHT_API_KEY") System.delete_env("VEREINFACHT_CLUB_ID") + System.delete_env("VEREINFACHT_APP_URL") + end + + defp clear_vereinfacht_app_url_from_settings do + case Mv.Membership.get_settings() do + {:ok, settings} -> + Mv.Membership.update_settings(settings, %{vereinfacht_app_url: nil}) + + _ -> + :ok + end end end