Compare commits
7 commits
fd6301efab
...
0f20e459e9
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f20e459e9 | |||
| d9491dea9c | |||
| daaa4dc345 | |||
| 8ffd842c38 | |||
| 1f21afeb72 | |||
| 3cdaa75fc1 | |||
| 482a335d36 |
7 changed files with 80 additions and 42 deletions
|
|
@ -1300,12 +1300,15 @@ 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()
|
%{}
|
||||||
|
|
||||||
|
actor ->
|
||||||
opts = Helpers.ash_actor_opts(actor)
|
opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
case Ash.load(member_data, :custom_field_values, opts) do
|
case Ash.load(member_data, :custom_field_values, opts) do
|
||||||
|
|
@ -1316,6 +1319,7 @@ defmodule Mv.Membership.Member do
|
||||||
%{}
|
%{}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Extracts value from a CustomFieldValue struct
|
# Extracts value from a CustomFieldValue struct
|
||||||
defp extract_value_from_cfv(cfv, acc) do
|
defp extract_value_from_cfv(cfv, acc) do
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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} ->
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ defmodule Mv.ConfigVereinfachtTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns app contact view URL when API URL is set (derived app URL)" do
|
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")
|
set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1")
|
||||||
|
|
||||||
assert Mv.Config.vereinfacht_contact_view_url("42") ==
|
assert Mv.Config.vereinfacht_contact_view_url("42") ==
|
||||||
|
|
@ -54,7 +56,7 @@ defmodule Mv.ConfigVereinfachtTest do
|
||||||
assert Mv.Config.vereinfacht_contact_view_url("abc") ==
|
assert Mv.Config.vereinfacht_contact_view_url("abc") ==
|
||||||
"https://app.verein.visuel.dev/en/admin/finances/contacts/abc"
|
"https://app.verein.visuel.dev/en/admin/finances/contacts/abc"
|
||||||
after
|
after
|
||||||
System.delete_env("VEREINFACHT_APP_URL")
|
clear_vereinfacht_env()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -66,5 +68,16 @@ defmodule Mv.ConfigVereinfachtTest do
|
||||||
System.delete_env("VEREINFACHT_API_URL")
|
System.delete_env("VEREINFACHT_API_URL")
|
||||||
System.delete_env("VEREINFACHT_API_KEY")
|
System.delete_env("VEREINFACHT_API_KEY")
|
||||||
System.delete_env("VEREINFACHT_CLUB_ID")
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue