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
diff --git a/.env.example b/.env.example
index 04e9dbd..c9cc51e 100644
--- a/.env.example
+++ b/.env.example
@@ -36,3 +36,4 @@ ASSOCIATION_NAME="Sportsclub XYZ"
# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1
# VEREINFACHT_API_KEY=your-api-key
# VEREINFACHT_CLUB_ID=2
+# VEREINFACHT_APP_URL=https://app.verein.visuel.dev
diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index cc58ca9..439eee8 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
@@ -1258,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
@@ -1267,6 +1275,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 +1518,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 +1889,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 +2722,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 +2948,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 %>
diff --git a/Dockerfile b/Dockerfile
index 7a01d21..57d296f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,25 +7,25 @@
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
-# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image
+# - https://hub.docker.com/_/debian?tab=tags&page=1&name=trixie-20260202-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
-# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim
+# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim
#
-ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
-ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
+ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim"
+ARG RUNNER_IMAGE="debian:trixie-20260202-slim"
FROM ${BUILDER_IMAGE} AS builder
# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
- && apt-get clean && rm -f /var/lib/apt/lists/*_*
+ && apt-get clean && rm -f /var/lib/apt/lists/*_*
# prepare build dir
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force && \
- mix local.rebar --force
+ mix local.rebar --force
# set build ENV
ENV MIX_ENV="prod"
@@ -64,7 +64,7 @@ RUN mix release
FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \
- apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
+ apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale
diff --git a/assets/css/app.css b/assets/css/app.css
index 132a8f5..0149c5d 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/membership/custom_field.ex b/lib/membership/custom_field.ex
index ab4ad60..411e95d 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,19 @@ 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/membership/member.ex b/lib/membership/member.ex
index 6ab6668..76ed471 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -333,10 +333,10 @@ 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).
+ # Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change).
policy action(:set_vereinfacht_contact_id) do
- description "Allow internal sync to set Vereinfacht contact ID"
- authorize_if always()
+ description "Only system actor may set Vereinfacht contact ID"
+ authorize_if Mv.Authorization.Checks.ActorIsSystemUser
end
# CREATE/UPDATE: Forbid member–user link unless admin, then check permissions
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index 40ef985..33445d3 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -72,7 +72,8 @@ defmodule Mv.Membership.Setting do
:default_membership_fee_type_id,
:vereinfacht_api_url,
:vereinfacht_api_key,
- :vereinfacht_club_id
+ :vereinfacht_club_id,
+ :vereinfacht_app_url
]
end
@@ -87,7 +88,8 @@ defmodule Mv.Membership.Setting do
:default_membership_fee_type_id,
:vereinfacht_api_url,
:vereinfacht_api_key,
- :vereinfacht_club_id
+ :vereinfacht_club_id,
+ :vereinfacht_app_url
]
end
@@ -251,6 +253,13 @@ defmodule Mv.Membership.Setting do
description "Vereinfacht club ID for multi-tenancy"
end
+ attribute :vereinfacht_app_url, :string do
+ allow_nil? true
+ public? true
+
+ description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
+ end
+
timestamps()
end
diff --git a/lib/mv/authorization/checks/actor_is_system_user.ex b/lib/mv/authorization/checks/actor_is_system_user.ex
new file mode 100644
index 0000000..a614a83
--- /dev/null
+++ b/lib/mv/authorization/checks/actor_is_system_user.ex
@@ -0,0 +1,15 @@
+defmodule Mv.Authorization.Checks.ActorIsSystemUser do
+ @moduledoc """
+ Policy check: true only when the actor is the system user (e.g. system@mila.local).
+
+ Used to restrict internal actions (e.g. Member.set_vereinfacht_contact_id) so that
+ only code paths using SystemActor can perform them, not regular admins.
+ """
+ use Ash.Policy.SimpleCheck
+
+ @impl true
+ def describe(_opts), do: "actor is the system user"
+
+ @impl true
+ def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor)
+end
diff --git a/lib/mv/config.ex b/lib/mv/config.ex
index f6f6ec7..d2ad66c 100644
--- a/lib/mv/config.ex
+++ b/lib/mv/config.ex
@@ -178,6 +178,37 @@ defmodule Mv.Config do
env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id)
end
+ @doc """
+ Returns the Vereinfacht app base URL for contact view links (frontend, not API).
+
+ Reads from `VEREINFACHT_APP_URL` env first, then from Settings.
+ Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}.
+ If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible.
+ """
+ @spec vereinfacht_app_url() :: String.t() | nil
+ def vereinfacht_app_url do
+ env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) ||
+ derive_app_url_from_api_url(vereinfacht_api_url())
+ end
+
+ defp derive_app_url_from_api_url(nil), do: nil
+
+ defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do
+ api_url = String.trim(api_url)
+ uri = URI.parse(api_url)
+ host = uri.host || ""
+
+ if String.starts_with?(host, "api.") do
+ app_host = "app." <> String.slice(host, 4..-1//1)
+ scheme = uri.scheme || "https"
+ "#{scheme}://#{app_host}"
+ else
+ nil
+ end
+ end
+
+ defp derive_app_url_from_api_url(_), do: nil
+
@doc """
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
"""
@@ -211,6 +242,11 @@ defmodule Mv.Config do
"""
def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID")
+ @doc """
+ Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings).
+ """
+ def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL")
+
defp env_set?(key) do
case System.get_env(key) do
nil -> false
@@ -241,18 +277,22 @@ defmodule Mv.Config do
end
@doc """
- Returns the URL to view a finance contact (e.g. in Vereinfacht frontend or API).
+ Returns the URL to view a finance contact in the Vereinfacht app (frontend).
- Uses the configured API base URL and appends /finance-contacts/{id}.
- Can be extended later with a dedicated frontend URL setting.
+ Uses the configured app base URL (or derived from API URL) and appends
+ /en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined.
"""
@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()
+ base = vereinfacht_app_url()
- if present?(base),
- do: base |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}"),
- else: nil
+ if present?(base) do
+ base
+ |> String.trim_trailing("/")
+ |> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}")
+ else
+ nil
+ end
end
defp present?(nil), do: false
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..9e0cc7b 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
@@ -241,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}
@@ -260,11 +269,25 @@ defmodule Mv.Membership.MemberExport.Build do
do: []
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
+ if field == "groups" do
+ sort_members_by_groups_export(members, order)
+ else
+ 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
+ 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
@@ -277,6 +300,26 @@ defmodule Mv.Membership.MemberExport.Build do
|> 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
+ 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 find_cfv(member, custom_field) do
(member.custom_field_values || [])
|> Enum.find(fn cfv ->
@@ -294,6 +337,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 +393,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 +424,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 +454,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 +492,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/vereinfacht/changes/sync_contact.ex b/lib/mv/vereinfacht/changes/sync_contact.ex
index 4ea6cc8..99875e0 100644
--- a/lib/mv/vereinfacht/changes/sync_contact.ex
+++ b/lib/mv/vereinfacht/changes/sync_contact.ex
@@ -7,20 +7,57 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
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).
+
+ Only runs when relevant data changed: on create always; on update only when
+ first_name, last_name, email, street, house_number, postal_code, or city changed,
+ or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls).
"""
use Ash.Resource.Change
require Logger
+ @synced_attributes [
+ :first_name,
+ :last_name,
+ :email,
+ :street,
+ :house_number,
+ :postal_code,
+ :city
+ ]
+
@impl true
def change(changeset, _opts, _context) do
- if Mv.Config.vereinfacht_configured?() do
+ if Mv.Config.vereinfacht_configured?() and sync_relevant?(changeset) do
Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2)
else
changeset
end
end
+ defp sync_relevant?(changeset) do
+ case changeset.action_type do
+ :create -> true
+ :update -> relevant_update?(changeset)
+ _ -> false
+ end
+ end
+
+ defp relevant_update?(changeset) do
+ any_synced_attr_changed? =
+ Enum.any?(@synced_attributes, &Ash.Changeset.changing_attribute?(changeset, &1))
+
+ record = changeset.data
+ no_contact_id_yet? = record && blank_contact_id?(record.vereinfacht_contact_id)
+
+ any_synced_attr_changed? or no_contact_id_yet?
+ end
+
+ defp blank_contact_id?(nil), do: true
+ defp blank_contact_id?(""), do: true
+ defp blank_contact_id?(s) when is_binary(s), do: String.trim(s) == ""
+ defp blank_contact_id?(_), do: false
+
# 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
diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex
index 72859ac..2aafc7f 100644
--- a/lib/mv/vereinfacht/client.ex
+++ b/lib/mv/vereinfacht/client.ex
@@ -163,7 +163,16 @@ defmodule Mv.Vereinfacht.Client do
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) ->
+ %{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}}
+ when is_binary(att_email) ->
+ if att_email |> String.trim() |> String.downcase() == normalized do
+ normalize_contact_id(id)
+ else
+ nil
+ end
+
+ %{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}}
+ when is_binary(att_email) ->
if att_email |> String.trim() |> String.downcase() == normalized do
normalize_contact_id(id)
else
@@ -191,16 +200,34 @@ defmodule Mv.Vereinfacht.Client do
"""
@spec get_contact(String.t()) :: {:ok, map()} | {:error, term()}
def get_contact(contact_id) when is_binary(contact_id) do
+ fetch_contact(contact_id, [])
+ end
+
+ @doc """
+ Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts).
+
+ Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes
+ (and optional :type) for each receipt, or {:error, reason}.
+ """
+ @spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()}
+ def get_contact_with_receipts(contact_id) when is_binary(contact_id) do
+ case fetch_contact(contact_id, include: "receipts") do
+ {:ok, body} -> {:ok, extract_receipts_from_response(body)}
+ {:error, _} = err -> err
+ end
+ end
+
+ defp fetch_contact(contact_id, query_params) 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}")
+ path =
+ base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}")
+
+ url = build_url_with_params(path, query_params)
case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do
{:ok, %{status: 200, body: body}} when is_map(body) ->
@@ -215,6 +242,38 @@ defmodule Mv.Vereinfacht.Client do
end
end
+ defp build_url_with_params(base, []), do: base
+
+ defp build_url_with_params(base, include: value) do
+ sep = if String.contains?(base, "?"), do: "&", else: "?"
+ base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1)
+ end
+
+ 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 || %{}))
+ 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)
+ 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()
@@ -270,6 +329,7 @@ defmodule Mv.Vereinfacht.Client do
|> put_attr("zipCode", member |> Map.get(:postal_code))
|> put_attr("city", member |> Map.get(:city))
|> Map.put("contactType", "person")
+ |> Map.put("isExternal", true)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
diff --git a/lib/mv/vereinfacht/sync_flash.ex b/lib/mv/vereinfacht/sync_flash.ex
index fb062cd..874a717 100644
--- a/lib/mv/vereinfacht/sync_flash.ex
+++ b/lib/mv/vereinfacht/sync_flash.ex
@@ -35,6 +35,8 @@ defmodule Mv.Vereinfacht.SyncFlash do
@doc false
def create_table! do
+ # :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
+ # not the process that created the table). :protected would restrict writes to the creating process.
if :ets.whereis(@table) == :undefined do
:ets.new(@table, [:set, :public, :named_table])
end
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..a47fcc7 100644
--- a/lib/mv_web/components/layouts/root.html.heex
+++ b/lib/mv_web/components/layouts/root.html.heex
@@ -15,24 +15,98 @@
+
+ <.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}