Compare commits

..

7 commits

Author SHA1 Message Date
0f20e459e9
Gettext: Vereinfacht strings in du-form (i18n guidelines)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 20:49:38 +01:00
d9491dea9c
Member show: present? check for vereinfacht_contact_id in UI
Use vereinfacht_contact_present assign so empty string is not treated as present.
2026-02-23 20:49:34 +01:00
daaa4dc345
Vereinfacht: filter blank vereinfacht_contact_id in sync_members
Include members with empty string; use expr with ref for Ash filter.
2026-02-23 20:49:30 +01:00
8ffd842c38
Vereinfacht client: receipt allowlist, find_contact pagination, flatten nesting
- Receipt attrs: allowlist only (no String.to_atom on API input / DoS)
- find_contact_by_email: paginate through all pages (page[size]=100)
- Extract helpers to satisfy Credo max nesting depth
2026-02-23 20:49:19 +01:00
1f21afeb72
Setting: vereinfacht_api_key public? false
Reduce exposure of API key; keep sensitive? true.
2026-02-23 20:49:12 +01:00
3cdaa75fc1
Member: remove system-actor fallback in extract_existing_values
Per guidelines: actor must come from context. When nil, skip load and return empty map.
2026-02-23 20:49:00 +01:00
482a335d36
Fix config test: clear vereinfacht_app_url from settings so derived URL is used 2026-02-23 20:48:57 +01:00
6 changed files with 66 additions and 41 deletions

View file

@ -1300,20 +1300,24 @@ defmodule Mv.Membership.Member do
end end
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 defp extract_existing_values(member_data, changeset) do
actor = case Map.get(changeset.context, :actor) do
Map.get(changeset.context, :actor) || nil ->
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)
_ ->
%{} %{}
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
end end

View file

@ -242,7 +242,7 @@ defmodule Mv.Membership.Setting do
attribute :vereinfacht_api_key, :string do attribute :vereinfacht_api_key, :string do
allow_nil? true allow_nil? true
public? true public? false
description "Vereinfacht API key (Bearer token)" description "Vereinfacht API key (Bearer token)"
sensitive? true sensitive? true
end end

View file

@ -134,15 +134,25 @@ defmodule Mv.Vereinfacht.Client do
end end
end end
@find_contact_page_size 100
@find_contact_max_pages 100
defp do_find_contact_by_email(email) do defp do_find_contact_by_email(email) do
url = normalized = String.trim(email) |> String.downcase()
base_url() do_find_contact_by_email_page(1, normalized)
|> String.trim_trailing("/") end
|> then(&"#{&1}/finance-contacts")
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 case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do
{:ok, %{status: 200, body: body}} when is_map(body) -> {: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}} -> {:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}} {:error, {:http, status, extract_error_message(body)}}
@ -152,15 +162,21 @@ defmodule Mv.Vereinfacht.Client do
end end
end end
defp parse_find_by_email_response(body, email) do defp handle_find_contact_page_response(body, page, normalized) do
normalized = String.trim(email) |> String.downcase()
case find_contact_id_by_email_in_list(body, normalized) do case find_contact_id_by_email_in_list(body, normalized) do
nil -> {:error, :not_found} id when is_binary(id) -> {:ok, id}
id -> {:ok, id} nil -> maybe_find_contact_next_page(body, page, normalized)
end end
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 defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do
Enum.find_value(list, fn Enum.find_value(list, fn
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}} %{"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) base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1)
end 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 defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do
included included
|> Enum.filter(&match?(%{"type" => "receipts"}, &1)) |> Enum.filter(&match?(%{"type" => "receipts"}, &1))
|> Enum.map(fn %{"id" => id, "attributes" => attrs} = r -> |> 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)
end end
defp extract_receipts_from_response(_), do: [] defp extract_receipts_from_response(_), do: []
defp string_keys_to_atoms(map) when is_map(map) do defp receipt_attrs_allowlist(attrs) when is_map(attrs) do
Map.new(map, fn {k, v} -> {to_atom_key(k), v} end) 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 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 base_url, do: Mv.Config.vereinfacht_api_url()
defp api_key, do: Mv.Config.vereinfacht_api_key() defp api_key, do: Mv.Config.vereinfacht_api_key()
defp club_id, do: Mv.Config.vereinfacht_club_id() defp club_id, do: Mv.Config.vereinfacht_club_id()

View file

@ -8,6 +8,7 @@ defmodule Mv.Vereinfacht do
- `sync_members_without_contact/0` Bulk sync of members without a contact ID. - `sync_members_without_contact/0` Bulk sync of members without a contact ID.
""" """
require Ash.Query require Ash.Query
import Ash.Expr
alias Mv.Vereinfacht.Client alias Mv.Vereinfacht.Client
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
@ -128,7 +129,9 @@ defmodule Mv.Vereinfacht do
query = query =
Member 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 case Ash.read(query, opts) do
{:ok, members} -> {:ok, members} ->

View file

@ -52,7 +52,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<%!-- Vereinfacht: contact info when synced, or warning when API is configured but no contact --%> <%!-- Vereinfacht: contact info when synced, or warning when API is configured but no contact --%>
<%= if Mv.Config.vereinfacht_configured?() do %> <%= if Mv.Config.vereinfacht_configured?() do %>
<%= if @member.vereinfacht_contact_id do %> <%= if @vereinfacht_contact_present do %>
<div class="mb-4"> <div class="mb-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<.link <.link
@ -515,6 +515,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:can_create_cycle, can_create_cycle) |> assign(:can_create_cycle, can_create_cycle)
|> assign(:can_destroy_cycle, can_destroy_cycle) |> assign(:can_destroy_cycle, can_destroy_cycle)
|> assign(:can_update_cycle, can_update_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(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end) |> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_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 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), defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail),
do: "HTTP #{status} #{detail}" do: "HTTP #{status} #{detail}"

View file

@ -2682,7 +2682,7 @@ msgstr "Vereinfacht-Integration"
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." 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 #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, 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 #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." 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 #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format