From 3dc3a2b8ef0d565e631298a1a89b3346cb8b8dd3 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 8 Jun 2026 12:17:02 +0200 Subject: [PATCH 01/50] feat(member): deactivate and reactivate members via an exit-date dialog --- lib/mv_web/live/member_live/show.ex | 8 + .../member_live/show/deactivate_component.ex | 203 ++++ priv/gettext/de/LC_MESSAGES/default.po | 924 +++++++++--------- priv/gettext/default.pot | 894 +++++++++-------- priv/gettext/en/LC_MESSAGES/default.po | 924 +++++++++--------- .../live/member_live/deactivate_test.exs | 108 ++ 6 files changed, 1735 insertions(+), 1326 deletions(-) create mode 100644 lib/mv_web/live/member_live/show/deactivate_component.ex create mode 100644 test/mv_web/live/member_live/deactivate_test.exs diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 5ac4fee..521bb38 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -329,6 +329,14 @@ defmodule MvWeb.MemberLive.Show do <% end %> + <%!-- Deactivate/reactivate sub-flow (gated on :update, owns its own modal) --%> + <.live_component + module={MvWeb.MemberLive.Show.DeactivateComponent} + id="member-deactivate" + member={@member} + current_user={@current_user} + /> + <%!-- Danger zone: same section pattern as section_box (h2 outside border) --%> <%= if can?(@current_user, :destroy, @member) do %>
diff --git a/lib/mv_web/live/member_live/show/deactivate_component.ex b/lib/mv_web/live/member_live/show/deactivate_component.ex new file mode 100644 index 0000000..d3a1dfe --- /dev/null +++ b/lib/mv_web/live/member_live/show/deactivate_component.ex @@ -0,0 +1,203 @@ +defmodule MvWeb.MemberLive.Show.DeactivateComponent do + @moduledoc """ + LiveComponent owning the member deactivate/reactivate sub-flow on the member show page. + + ## Features + - Deactivate control (shown when the member has no exit_date) + - Reactivate control (shown when the member has an exit_date) + - Date-selection modal (default: today, future dates allowed) for deactivation + - Routes both actions through `Membership.update_member/2` with the real user actor, + inheriting the `exit_date > join_date` validation and the cycle-regeneration hook. + + Controls are gated on `:update` permission for the member. On success the component + notifies the parent with `{:member_updated, member}`, which the parent already handles. + """ + use MvWeb, :live_component + + import MvWeb.Authorization, only: [can?: 3] + + alias Mv.Membership + alias MvWeb.Helpers.MemberHelpers + + @impl true + def render(assigns) do + ~H""" +
+ <%= if @can_update do %> +
+

+ {gettext("Membership status")} +

+
+ <%= if @member.exit_date do %> +

+ {gettext( + "This member is deactivated (exit date set). Reactivating clears the exit date." + )} +

+ <.button + id="reactivate-member-trigger" + data-testid="member-reactivate" + variant="primary" + phx-click="reactivate" + phx-target={@myself} + aria-label={ + gettext("Reactivate member %{name}", name: MemberHelpers.display_name(@member)) + } + > + <.icon name="hero-arrow-uturn-left" class="size-4" /> + {gettext("Reactivate member")} + + <% else %> +

+ {gettext( + "Deactivating this member records an exit date. You can reactivate them later." + )} +

+ <.button + id="deactivate-member-trigger" + data-testid="member-deactivate" + variant="outline" + phx-click="open_modal" + phx-target={@myself} + aria-label={ + gettext("Deactivate member %{name}", name: MemberHelpers.display_name(@member)) + } + > + <.icon name="hero-arrow-right-on-rectangle" class="size-4" /> + {gettext("Deactivate member")} + + <% end %> +
+
+ <% end %> + + <%= if @show_modal do %> + + + + <% end %> +
+ """ + end + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign(:can_update, can?(assigns.current_user, :update, assigns.member)) + |> assign_new(:show_modal, fn -> false end) + |> assign_new(:exit_date, fn -> Date.utc_today() end) + |> assign_new(:error, fn -> nil end)} + end + + @impl true + def handle_event("open_modal", _params, socket) do + {:noreply, + socket + |> assign(:show_modal, true) + |> assign(:exit_date, Date.utc_today()) + |> assign(:error, nil)} + end + + def handle_event("cancel_modal", _params, socket) do + {:noreply, close_modal(socket)} + end + + def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do + {:noreply, close_modal(socket)} + end + + def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket} + + def handle_event("deactivate", %{"exit_date" => exit_date_str}, socket) do + case Date.from_iso8601(exit_date_str) do + {:ok, exit_date} -> + apply_exit_date(socket, exit_date, show_inline_error: true) + + {:error, _reason} -> + {:noreply, assign(socket, :error, gettext("Invalid date format"))} + end + end + + def handle_event("reactivate", _params, socket) do + apply_exit_date(socket, nil, show_inline_error: false) + end + + defp apply_exit_date(socket, exit_date, opts) do + member = socket.assigns.member + actor = socket.assigns.current_user + + case Membership.update_member(member, %{exit_date: exit_date}, actor: actor) do + {:ok, updated_member} -> + send(self(), {:member_updated, updated_member}) + + {:noreply, + socket + |> assign(:member, updated_member) + |> close_modal()} + + {:error, error} -> + if opts[:show_inline_error] do + {:noreply, assign(socket, :error, format_error(error))} + else + send(self(), {:put_flash, :error, format_error(error)}) + {:noreply, socket} + end + end + end + + defp close_modal(socket) do + socket + |> assign(:show_modal, false) + |> assign(:error, nil) + end + + defp format_error(%Ash.Error.Invalid{errors: errors}) do + Enum.map_join(errors, ", ", fn + %{message: message} -> message + other -> inspect(other) + end) + end + + defp format_error(%Ash.Error.Forbidden{}) do + gettext("You are not allowed to perform this action.") + end + + defp format_error(_error), do: gettext("An error occurred") +end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index cf12baf..c8713e9 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -39,6 +39,16 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z msgid "%{count} synced" msgstr "%{count} synchronisiert" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} from" +msgstr "%{field} von" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} to" +msgstr "%{field} bis" + #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -96,6 +106,11 @@ msgstr "Aktionen" msgid "Active members" msgstr "Aktive Mitglieder" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Active only" +msgstr "Nur aktive" + #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Add Member" @@ -153,6 +168,11 @@ msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerd msgid "All years combined (pie)" msgstr "Alle Jahre zusammengefasst (Kreis)" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Allow direct registration (/register)" +msgstr "Direkte Registrierung erlauben (/register)" + #: lib/mv_web/live/membership_fee_type_live/form.ex #, elixir-autogen, elixir-format msgid "Already paid cycles will remain with the old amount." @@ -171,6 +191,7 @@ msgstr "Betrag" msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen." +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex @@ -184,6 +205,11 @@ msgstr "Ein Fehler ist aufgetreten" msgid "App URL (contact view link)" msgstr "App-URL (Link zur Kontaktansicht)" +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Applicant data" +msgstr "Angaben des Antragstellers" + #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format msgid "Apply filters" @@ -273,6 +299,11 @@ msgstr "Verbindung wird wiederhergestellt" msgid "Aug." msgstr "Aug." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Authentication" +msgstr "Anmeldung" + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Authentication configuration error. Please contact the administrator." @@ -320,6 +351,11 @@ msgstr "Zurück" msgid "Back to groups list" msgstr "Zurück zur Gruppenübersicht" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Back to join form" +msgstr "Zurück zu den Mitgliedsanträgen" + #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Back to join requests" @@ -390,6 +426,7 @@ msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur z #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/show.ex @@ -533,6 +570,11 @@ msgstr "Vereins-ID" msgid "Club Settings" msgstr "Vereinsdaten" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Column" +msgstr "Spalte" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Completed" @@ -563,6 +605,11 @@ msgstr "Änderung bestätigen" msgid "Confirm Password" msgstr "Passwort bestätigen" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Confirm and Import" +msgstr "Bestätigen und importieren" + #: lib/mv_web/templates/emails/user_confirmation.html.heex #, elixir-autogen, elixir-format msgid "Confirm my email" @@ -666,11 +713,21 @@ msgid_plural "Copied %{count} email addresses to clipboard" msgstr[0] "%{count} E-Mail-Adresse in die Zwischenablage kopiert" msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Copy" +msgstr "Kopieren" + #: lib/mv_web/components/bulk_actions_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Copy email addresses" msgstr "E-Mail-Adressen kopieren" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Copy join page URL" +msgstr "URL der Beitrittsseite kopieren" + #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Could not load data fields. Please check your permissions." @@ -719,6 +776,16 @@ msgstr "Mitglied erstellen" msgid "Create a new cycle manually" msgstr "Einen neuen Zyklus manuell erstellen" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create custom field" +msgstr "Datenfeld erstellen" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create fee type" +msgstr "Beitragsart erstellen" + #: lib/mv/membership/members_pdf.ex #, elixir-autogen, elixir-format msgid "Created at:" @@ -759,6 +826,16 @@ msgstr "Benutzerdefiniert" msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Custom date fields" +msgstr "Benutzerdefinierte Datumsfelder" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Custom field" +msgstr "Benutzerdefiniertes Feld" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Cycle" @@ -833,6 +910,31 @@ msgstr "Datenfelder" msgid "Date" msgstr "Datum" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Dates" +msgstr "Daten" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Deactivate" +msgstr "Deaktivieren" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Deactivate member" +msgstr "Mitglied deaktivieren" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Deactivate member %{name}" +msgstr "Mitglied %{name} deaktivieren" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Deactivating this member records an exit date. You can reactivate them later." +msgstr "Wenn du dieses Mitglied deaktivierst, wird ein Austrittsdatum gesetzt. Du kannst es später wieder reaktivieren." + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Dec." @@ -1030,6 +1132,16 @@ msgstr "Löschen" msgid "Description" msgstr "Beschreibung" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Description for join form" +msgstr "Beschreibung für das Beitrittsformular" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Direct registration" +msgstr "Direkte Registrierung" + #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Download CSV templates:" @@ -1179,6 +1291,22 @@ msgstr "Beispiele" msgid "Exit Date" msgstr "Austrittsdatum" +#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date" +msgstr "Austrittsdatum" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date from" +msgstr "Austrittsdatum von" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date to" +msgstr "Austrittsdatum bis" + #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1204,6 +1332,16 @@ msgstr "Mitglieder als CSV exportieren" msgid "Export members to PDF" msgstr "Mitglieder als PDF exportieren" +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Export to CSV" +msgstr "Mitglieder als CSV exportieren" + +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Export to PDF" +msgstr "Mitglieder als PDF exportieren" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Failed members:" @@ -1300,6 +1438,11 @@ msgstr "Test-E-Mail konnte nicht gesendet werden. Bitte prüfe deine SMTP-Konfig msgid "Failed to update cycle status: %{errors}" msgstr "Fehler beim Aktualisieren des Zyklenstatus: %{errors}" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Failed to update setting." +msgstr "Einstellung konnte nicht gespeichert werden." + #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Failed: %{count} row(s)" @@ -1316,12 +1459,27 @@ msgstr "Feb." msgid "Fee Type" msgstr "Beitragsart" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." +msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden." + #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" msgstr "Beitragsart" +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Fee type '%{name}' not found; using the default fee type." +msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwendet." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." +msgstr "Beitragsart-Spalte (erkannte Spaltennamen): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unbekannte Beitragsarten erhalten die Standard-Beitragsart." + #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format msgid "Fee types" @@ -1367,6 +1525,11 @@ msgstr "Vorname" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln." +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "From" +msgstr "Von" + #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "From %{first} to %{last} (relevant years with membership data)" @@ -1452,6 +1615,21 @@ msgstr "Generierte Zyklen" msgid "German Template" msgstr "Deutsche Vorlage" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Go to join form" +msgstr "Zum Antragsformular" + +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Group %{name}" +msgstr "Gruppe %{name}" + +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Group assignment failed: %{reason}" +msgstr "Gruppenzuordnung fehlgeschlagen: %{reason}" + #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Group deleted successfully." @@ -1489,6 +1667,11 @@ msgstr "Gruppen" msgid "Groups claim" msgstr "Gruppenclaim" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." +msgstr "Gruppen-Spalte (erkannte Spaltennamen): Groups, Gruppen, Gruppe. Mehrere durch Komma getrennte Gruppennamen werden unterstützt; fehlende Gruppen werden automatisch erstellt." + #: lib/mv_web/helpers/membership_fee_helpers.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex @@ -1501,6 +1684,11 @@ msgstr "Halbjährlich" msgid "History" msgstr "Historie" +#: lib/mv_web/controllers/page_controller.ex +#, elixir-autogen, elixir-format +msgid "Home" +msgstr "Startseite" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Host" @@ -1512,6 +1700,11 @@ msgstr "Host" msgid "House Number" msgstr "Hausnummer" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." +msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmelden; Anmeldung und Beitrittsformular bleiben verfügbar." + #: lib/mv_web/templates/emails/user_confirmation.html.heex #, elixir-autogen, elixir-format msgid "If you did not create an account, you can ignore this email." @@ -1527,6 +1720,17 @@ msgstr "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren. D msgid "If you did not submit this request, you can ignore this email." msgstr "Wenn du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren." +#: lib/mv_web/templates/emails/join_already_member.html.heex +#: lib/mv_web/templates/emails/join_already_pending.html.heex +#, elixir-autogen, elixir-format +msgid "If you have any questions, please contact us." +msgstr "Bei Fragen kannst du dich gerne an uns wenden." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Ignored (system-computed field)" +msgstr "Ignoriert (vom System berechnetes Feld)" + #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/import_live.ex #, elixir-autogen, elixir-format, fuzzy @@ -1563,6 +1767,11 @@ msgstr "Import-Status fehlt. Chunk %{idx} kann nicht verarbeitet werden." msgid "Inactive members" msgstr "Inaktive Mitglieder" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Inactive only" +msgstr "Nur ehemalige" + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1632,6 +1841,7 @@ msgstr "Ungültiges Betragsformat" msgid "Invalid chunk index: %{idx}" msgstr "Ungültiger Chunk-Index: %{idx}" +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Invalid date format" @@ -1642,6 +1852,16 @@ msgstr "Ungültiges Datumsformat" msgid "Invalid email address. Please enter a valid recipient address." msgstr "Ungültige E-Mail-Adresse. Bitte gib eine gültige Empfängeradresse ein." +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Invalid link" +msgstr "Ungültiger oder abgelaufener Link." + +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Invalid or expired link" +msgstr "Ungültiger oder abgelaufener Link." + #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex #, elixir-autogen, elixir-format msgid "Invalid or expired link." @@ -1657,6 +1877,11 @@ msgstr "Rechnung" msgid "Jan." msgstr "Jan." +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Join" +msgstr "Beitritt" + #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex @@ -1670,11 +1895,46 @@ msgstr "Beitrittsdatum" msgid "Join Form" msgstr "Beitrittsformular" +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format +msgid "Join confirmation" +msgstr "Beitrittsbestätigung" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date" +msgstr "Beitrittsdatum" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date from" +msgstr "Beitrittsdatum von" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date to" +msgstr "Beitrittsdatum bis" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" msgstr "Beitrittsformular aktiv" +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join form:" +msgstr "Beitrittsformular:" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join page URL" +msgstr "URL der Beitrittsseite" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join page URL copied to clipboard." +msgstr "URL der Beitrittsseite in die Zwischenablage kopiert." + #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Join request" @@ -1764,6 +2024,17 @@ msgstr "Leer lassen, um den aktuellen Wert beizubehalten." msgid "Line %{line}: %{message}" msgstr "Zeile %{line}: %{message}" +#: lib/mv_web/controllers/join_confirm_controller.ex +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Link expired" +msgstr "Link abgelaufen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Link to the public join page (share this with applicants):" +msgstr "Link zur öffentlichen Beitrittsseite (diesen Link mit Interessent*innen teilen):" + #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy @@ -1847,6 +2118,11 @@ msgstr "Mitglied wurde erfolgreich %{action}" msgid "Member %{club_name}" msgstr "Mitglieder %{club_name}" +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Member %{name}" +msgstr "Mitglied %{name}" + #: lib/mv/membership/members_pdf.ex #, elixir-autogen, elixir-format msgid "Member count:" @@ -1863,6 +2139,11 @@ msgstr "Mitglied wurde erfolgreich erstellt" msgid "Member deleted successfully" msgstr "Mitglied wurde erfolgreich gelöscht" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Member field" +msgstr "Mitgliedsfeld" + #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Member field %{action} successfully" @@ -1981,6 +2262,16 @@ msgstr "Mitgliedsbeitragsarten" msgid "Membership Fees" msgstr "Mitgliedsbeiträge" +#: lib/mv_web/emails/join_already_member_email.ex +#, elixir-autogen, elixir-format +msgid "Membership application – already a member" +msgstr "Mitgliedsantrag – bereits Mitglied" + +#: lib/mv_web/emails/join_already_pending_email.ex +#, elixir-autogen, elixir-format +msgid "Membership application – already under review" +msgstr "Mitgliedsantrag – wird bereits geprüft" + #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex @@ -2026,6 +2317,11 @@ msgstr "Mitgliedsbeitragsart aktualisiert. Zyklen regeneriert." msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." msgstr "Mitgliedsbeitragsarten definieren verschiedene Mitgliedsbeitragsstrukturen. Jede Art hat ein festes Intervall (monatlich, vierteljährlich, halbjährlich, jährlich), das nach der Erstellung nicht geändert werden kann." +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Membership status" +msgstr "Mitgliedsstatus" + #: lib/mv/mailer.ex #, elixir-autogen, elixir-format msgid "Mila – Test email" @@ -2203,6 +2499,11 @@ msgstr "Keine Mitgliedsbeitragszyklen gefunden. Zyklen werden automatisch generi msgid "No membership fee type assigned" msgstr "Keine Mitgliedsbeitragsart zugewiesen" +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "No prepared import to confirm. Please upload again." +msgstr "Kein vorbereiteter Import zum Bestätigen. Bitte erneut hochladen." + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "No receipts" @@ -2329,6 +2630,11 @@ msgstr "Okt." msgid "Only OIDC sign-in (hide password login)" msgstr "Nur OIDC-Anmeldung (Passwort-Login ausblenden)" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Only OIDC sign-in is active. This option is disabled." +msgstr "Nur OIDC-Anmeldung ist aktiv. Diese Option ist deaktiviert." + #: lib/mv_web/live/import_live.ex #, elixir-autogen, elixir-format msgid "Only administrators can import members from CSV files." @@ -2345,6 +2651,11 @@ msgstr "Nur Administrator*innen oder die verknüpfte*n Benutzer*in(nen) können msgid "Only possible if no members are assigned to this type." msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind." +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Only sign-in via Single Sign-On (SSO) is allowed." +msgstr "Nur Anmeldung per Single Sign-On (SSO) ist erlaubt." + #: lib/mv_web/components/bulk_actions_dropdown.ex #, elixir-autogen, elixir-format msgid "Open in email program" @@ -2489,6 +2800,11 @@ msgstr "Port" msgid "Postal Code" msgstr "Postleitzahl" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Preview import" +msgstr "Importvorschau" + #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Processing chunk %{current} of %{total}..." @@ -2511,6 +2827,21 @@ msgstr "Vierteljährlich" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Range" +msgstr "Zeitraum" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Reactivate member" +msgstr "Mitglied reaktivieren" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Reactivate member %{name}" +msgstr "Mitglied %{name} reaktivieren" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Receipt" @@ -2626,6 +2957,11 @@ msgstr "Geprüft am" msgid "Role" msgstr "Rolle" +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Role %{name}" +msgstr "Rolle %{name}" + #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role deleted successfully." @@ -2653,16 +2989,46 @@ msgstr "Rolle erfolgreich gespeichert." msgid "Roles" msgstr "Rollen" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 1" +msgstr "Zeile 1" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 2" +msgstr "Zeile 2" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 3" +msgstr "Zeile 3" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Rows with an empty fee type will get the default fee type." +msgstr "Zeilen ohne Beitragsart erhalten die Standard-Beitragsart." + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "SMTP / E-Mail" msgstr "SMTP / E-Mail" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" +msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "SMTP error:" msgstr "SMTP-Fehler:" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." +msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt." + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "SMTP is not configured. Please set at least the SMTP host." @@ -2918,6 +3284,11 @@ msgstr "In der Übersicht anzeigen" msgid "Show/Hide Columns" msgstr "Spalten ein-/ausblenden" +#: lib/mv_web/live/auth/sign_in_live.ex +#, elixir-autogen, elixir-format +msgid "Sign in" +msgstr "Anmelden" + #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." @@ -2962,6 +3333,11 @@ msgstr "Statistik" msgid "Status" msgstr "Status" +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Status and review" +msgstr "Status und Prüfung" + #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex @@ -2969,6 +3345,11 @@ msgstr "Status" msgid "Street" msgstr "Straße" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Submit new request" +msgstr "Antrag absenden" + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Submit request" @@ -3091,6 +3472,11 @@ msgstr "Wird getestet..." msgid "Text" msgstr "Textfeld" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Thank you" +msgstr "Vielen Dank" + #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex #, elixir-autogen, elixir-format msgid "Thank you, we have received your request." @@ -3121,6 +3507,11 @@ msgstr "Die Absender-E-Mail muss auf den meisten SMTP-Servern dem SMTP-Nutzer ge msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "Diese Datenfelder sind für MILA notwendig, um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden." +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "These groups will be created automatically: %{names}" +msgstr "Diese Gruppen werden automatisch erstellt: %{names}" + #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "These will appear in addition to other data when adding new members." @@ -3169,6 +3560,11 @@ msgstr "Dies ist eine Test-E-Mail von Mila. Wenn du diese erhalten hast, funktio msgid "This link has expired. Please submit the form again." msgstr "Dieser Link ist abgelaufen. Bitte sende das Formular erneut ab." +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "This member is deactivated (exit date set). Reactivating clears the exit date." +msgstr "Dieses Mitglied ist deaktiviert (Austrittsdatum gesetzt). Beim Reaktivieren wird das Austrittsdatum entfernt." + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "This user cannot be edited." @@ -3189,6 +3585,11 @@ msgstr "Diese*r Benutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier f msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen, für Datenschutzkonformität" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "To" +msgstr "Bis" + #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "To confirm deletion, please enter the group name:" @@ -3209,6 +3610,11 @@ msgstr "Dunklen Modus umschalten" msgid "Toggle sidebar" msgstr "Sidebar umschalten" +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format +msgid "Too many recipients for this function. Copy the addresses or export the list." +msgstr "Zu viele Empfänger für diese Funktion. Kopiere die Adressen oder exportiere die Liste." + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Too many requests. Please try again later." @@ -3247,6 +3653,11 @@ msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut." msgid "Unable to sign in. Please try again." msgstr "Anmeldung fehlgeschlagen. Bitte versuche es erneut." +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown (ignored)" +msgstr "Unbekannt (ignoriert)" + #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing." @@ -3258,6 +3669,11 @@ msgstr "Unbekannte Spalte '%{header}' wird ignoriert. Falls dies ein Datenfeld i msgid "Unknown error" msgstr "Unbekannter Fehler" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown fee types (members get the default): %{names}" +msgstr "Unbekannte Beitragsarten (Mitglieder erhalten den Standard): %{names}" + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Unlink Member" @@ -3278,6 +3694,11 @@ msgstr "Aufhebung der Verknüpfung geplant" msgid "Unpaid" msgstr "Unbezahlt" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." +msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden." + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "User" @@ -3288,6 +3709,11 @@ msgstr "Benutzer*in" msgid "User %{action} successfully" msgstr "Benutzer*in wurde erfolgreich %{action}" +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "User %{email}" +msgstr "Benutzer*in %{email}" + #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -3402,11 +3828,26 @@ msgstr "Warnungen" msgid "We can't find the internet" msgstr "Keine Internetverbindung gefunden" +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We could not send the confirmation email. Please try again later or contact support." +msgstr "Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut oder wende dich an den Support." + #: lib/mv_web/templates/emails/join_confirmation.html.heex #, elixir-autogen, elixir-format msgid "We have received your membership request. To complete it, please click the link below." msgstr "Wir haben deine Mitgliedschaftsanfrage erhalten. Bitte klicke zur Bestätigung auf den folgenden Link." +#: lib/mv_web/templates/emails/join_already_member.html.heex +#, elixir-autogen, elixir-format +msgid "We have received your request. The email address you entered is already registered as a member." +msgstr "Wir haben deine Anfrage erhalten. Die angegebene E-Mail-Adresse ist bereits als Mitglied registriert." + +#: lib/mv_web/templates/emails/join_already_pending.html.heex +#, elixir-autogen, elixir-format +msgid "We have received your request. You already have a membership application that is being reviewed." +msgstr "Wir haben deine Anfrage erhalten. Du hast bereits einen Mitgliedsantrag, der geprüft wird." + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "We have saved your details. To complete your request, please click the link we sent to your email." @@ -3417,6 +3858,11 @@ msgstr "Wir haben deine Angaben gespeichert. Um deinen Antrag abzuschließen, kl msgid "Website" msgstr "Webseite" +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "When did this member leave?" +msgstr "Wann ist dieses Mitglied ausgetreten?" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." @@ -3461,11 +3907,17 @@ msgstr "Ja" msgid "Yes/No-Selection" msgstr "Ja/Nein-Auswahl" +#: lib/mv_web/templates/emails/join_confirmation.html.heex +#, elixir-autogen, elixir-format +msgid "You already had a pending request. Here is a new confirmation link." +msgstr "Du hattest bereits einen offenen Antrag. Hier ist ein neuer Bestätigungslink." + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "You are about to delete all %{count} cycles for this member." msgstr "Du bist dabei, alle %{count} Zyklen für dieses Mitglied zu löschen." +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "You are not allowed to perform this action." @@ -3481,6 +3933,11 @@ msgstr "Du bist jetzt angemeldet" msgid "You are now signed out" msgstr "Du bist jetzt abgemeldet" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "You can add links: full addresses (https://…) or as [link text](https://…)." +msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linktext](https://…)." + #: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "You do not have permission to %{action} members." @@ -3525,6 +3982,11 @@ msgstr "Du hast dich bereits auf andere Weise angemeldet, aber dein Konto noch n msgid "You requested a password reset. Click the link below to set a new password." msgstr "Du hast die Zurücksetzung deines Passworts angefordert. Klicke auf den folgenden Link, um ein neues Passwort zu setzen." +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "You will receive an email once your application has been reviewed." +msgstr "Du erhältst eine E-Mail, sobald dein Antrag geprüft wurde." + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." @@ -3603,6 +4065,16 @@ msgstr "E-Mail" msgid "email %{email} has already been taken" msgstr "E-Mail %{email} wurde bereits verwendet" +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format +msgid "filtered" +msgstr "gefiltert" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "join page URL in a new tab" +msgstr "Beitrittslink in einem neuen Tab" + #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "normal_user - Create/Read/Update access" @@ -3649,193 +4121,6 @@ msgstr "aktualisiert" msgid "without %{name}" msgstr "ohne %{name}" -#: lib/mv_web/live/join_request_live/show.ex -#, elixir-autogen, elixir-format -msgid "Applicant data" -msgstr "Angaben des Antragstellers" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Copy" -msgstr "Kopieren" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Copy join page URL" -msgstr "URL der Beitrittsseite kopieren" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Join page URL" -msgstr "URL der Beitrittsseite" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Join page URL copied to clipboard." -msgstr "URL der Beitrittsseite in die Zwischenablage kopiert." - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Link to the public join page (share this with applicants):" -msgstr "Link zur öffentlichen Beitrittsseite (diesen Link mit Interessent*innen teilen):" - -#: lib/mv_web/live/join_request_live/show.ex -#, elixir-autogen, elixir-format -msgid "Status and review" -msgstr "Status und Prüfung" - -#: lib/mv_web/live/join_live.ex -#, elixir-autogen, elixir-format -msgid "We could not send the confirmation email. Please try again later or contact support." -msgstr "Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut oder wende dich an den Support." - -#: lib/mv_web/templates/emails/join_already_member.html.heex -#: lib/mv_web/templates/emails/join_already_pending.html.heex -#, elixir-autogen, elixir-format -msgid "If you have any questions, please contact us." -msgstr "Bei Fragen kannst du dich gerne an uns wenden." - -#: lib/mv_web/emails/join_already_member_email.ex -#, elixir-autogen, elixir-format -msgid "Membership application – already a member" -msgstr "Mitgliedsantrag – bereits Mitglied" - -#: lib/mv_web/emails/join_already_pending_email.ex -#, elixir-autogen, elixir-format -msgid "Membership application – already under review" -msgstr "Mitgliedsantrag – wird bereits geprüft" - -#: lib/mv_web/templates/emails/join_already_member.html.heex -#, elixir-autogen, elixir-format -msgid "We have received your request. The email address you entered is already registered as a member." -msgstr "Wir haben deine Anfrage erhalten. Die angegebene E-Mail-Adresse ist bereits als Mitglied registriert." - -#: lib/mv_web/templates/emails/join_already_pending.html.heex -#, elixir-autogen, elixir-format -msgid "We have received your request. You already have a membership application that is being reviewed." -msgstr "Wir haben deine Anfrage erhalten. Du hast bereits einen Mitgliedsantrag, der geprüft wird." - -#: lib/mv_web/templates/emails/join_confirmation.html.heex -#, elixir-autogen, elixir-format -msgid "You already had a pending request. Here is a new confirmation link." -msgstr "Du hattest bereits einen offenen Antrag. Hier ist ein neuer Bestätigungslink." - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Back to join form" -msgstr "Zurück zu den Mitgliedsanträgen" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Go to join form" -msgstr "Zum Antragsformular" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Invalid or expired link" -msgstr "Ungültiger oder abgelaufener Link." - -#: lib/mv_web/controllers/join_confirm_controller.ex -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Link expired" -msgstr "Link abgelaufen" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Submit new request" -msgstr "Antrag absenden" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Thank you" -msgstr "Vielen Dank" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "You will receive an email once your application has been reviewed." -msgstr "Du erhältst eine E-Mail, sobald dein Antrag geprüft wurde." - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Allow direct registration (/register)" -msgstr "Direkte Registrierung erlauben (/register)" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Authentication" -msgstr "Anmeldung" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Direct registration" -msgstr "Direkte Registrierung" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Failed to update setting." -msgstr "Einstellung konnte nicht gespeichert werden." - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." -msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmelden; Anmeldung und Beitrittsformular bleiben verfügbar." - -#: lib/mv_web/controllers/page_controller.ex -#, elixir-autogen, elixir-format -msgid "Home" -msgstr "Startseite" - -#: lib/mv_web/controllers/join_confirm_controller.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Invalid link" -msgstr "Ungültiger oder abgelaufener Link." - -#: lib/mv_web/live/join_live.ex -#, elixir-autogen, elixir-format -msgid "Join" -msgstr "Beitritt" - -#: lib/mv_web/controllers/join_confirm_controller.ex -#, elixir-autogen, elixir-format -msgid "Join confirmation" -msgstr "Beitrittsbestätigung" - -#: lib/mv_web/live/auth/sign_in_live.ex -#, elixir-autogen, elixir-format -msgid "Sign in" -msgstr "Anmelden" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Group %{name}" -msgstr "Gruppe %{name}" - -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Member %{name}" -msgstr "Mitglied %{name}" - -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Role %{name}" -msgstr "Rolle %{name}" - -#: lib/mv_web/live/user_live/show.ex -#, elixir-autogen, elixir-format -msgid "User %{email}" -msgstr "Benutzer*in %{email}" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Only OIDC sign-in is active. This option is disabled." -msgstr "Nur OIDC-Anmeldung ist aktiv. Diese Option ist deaktiviert." - -#: lib/mv_web/controllers/auth_controller.ex -#, elixir-autogen, elixir-format -msgid "Only sign-in via Single Sign-On (SSO) is allowed." -msgstr "Nur Anmeldung per Single Sign-On (SSO) ist erlaubt." - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -3847,268 +4132,3 @@ msgstr "Öffnen" msgctxt "status" msgid "Open" msgstr "Offen" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "join page URL in a new tab" -msgstr "Beitrittslink in einem neuen Tab" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" -msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." -msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt." - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} from" -msgstr "%{field} von" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} to" -msgstr "%{field} bis" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Active only" -msgstr "Nur aktive" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Custom date fields" -msgstr "Benutzerdefinierte Datumsfelder" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Dates" -msgstr "Daten" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date" -msgstr "Austrittsdatum" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date from" -msgstr "Austrittsdatum von" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date to" -msgstr "Austrittsdatum bis" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "From" -msgstr "Von" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "Nur ehemalige" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date" -msgstr "Beitrittsdatum" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date from" -msgstr "Beitrittsdatum von" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date to" -msgstr "Beitrittsdatum bis" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Range" -msgstr "Zeitraum" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "To" -msgstr "Bis" - -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Join form:" -msgstr "Beitrittsformular:" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Description for join form" -msgstr "Beschreibung für das Beitrittsformular" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "You can add links: full addresses (https://…) or as [link text](https://…)." -msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linktext](https://…)." - -#: lib/mv/membership/import/member_csv.ex -#, elixir-autogen, elixir-format -msgid "Fee type '%{name}' not found; using the default fee type." -msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwendet." - -#: lib/mv/membership/import/member_csv.ex -#, elixir-autogen, elixir-format -msgid "Group assignment failed: %{reason}" -msgstr "Gruppenzuordnung fehlgeschlagen: %{reason}" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Confirm and Import" -msgstr "Bestätigen und importieren" - -#: lib/mv_web/live/import_live.ex -#, elixir-autogen, elixir-format -msgid "No prepared import to confirm. Please upload again." -msgstr "Kein vorbereiteter Import zum Bestätigen. Bitte erneut hochladen." - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Preview import" -msgstr "Importvorschau" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Column" -msgstr "Spalte" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Custom field" -msgstr "Benutzerdefiniertes Feld" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Ignored (system-computed field)" -msgstr "Ignoriert (vom System berechnetes Feld)" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Member field" -msgstr "Mitgliedsfeld" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Rows with an empty fee type will get the default fee type." -msgstr "Zeilen ohne Beitragsart erhalten die Standard-Beitragsart." - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "These groups will be created automatically: %{names}" -msgstr "Diese Gruppen werden automatisch erstellt: %{names}" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Unknown (ignored)" -msgstr "Unbekannt (ignoriert)" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Unknown fee types (members get the default): %{names}" -msgstr "Unbekannte Beitragsarten (Mitglieder erhalten den Standard): %{names}" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." -msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden." - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." -msgstr "Gruppen-Spalte (erkannte Spaltennamen): Groups, Gruppen, Gruppe. Mehrere durch Komma getrennte Gruppennamen werden unterstützt; fehlende Gruppen werden automatisch erstellt." - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." -msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden." - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." -msgstr "Beitragsart-Spalte (erkannte Spaltennamen): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unbekannte Beitragsarten erhalten die Standard-Beitragsart." - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Create custom field" -msgstr "Datenfeld erstellen" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Create fee type" -msgstr "Beitragsart erstellen" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Row 1" -msgstr "Zeile 1" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Row 2" -msgstr "Zeile 2" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Row 3" -msgstr "Zeile 3" - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Export to CSV" -msgstr "Mitglieder als CSV exportieren" - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Export to PDF" -msgstr "Mitglieder als PDF exportieren" - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format -msgid "Too many recipients for this function. Copy the addresses or export the list." -msgstr "Zu viele Empfänger für diese Funktion. Kopiere die Adressen oder exportiere die Liste." - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format -msgid "filtered" -msgstr "gefiltert" - -#~ #: lib/mv_web/components/export_dropdown.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "CSV" -#~ msgstr "CSV" - -#~ #: lib/mv_web/live/member_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "Copy email addresses of selected members" -#~ msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren" - -#~ #: lib/mv_web/components/export_dropdown.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Export" -#~ msgstr "Export" - -#~ #: lib/mv_web/live/member_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "No members selected" -#~ msgstr "Keine Mitglieder ausgewählt" - -#~ #: lib/mv_web/live/member_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "Open email program with BCC recipients" -#~ msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen" - -#~ #: lib/mv_web/components/export_dropdown.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "PDF" -#~ msgstr "PDF" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 73cb815..7a90bee 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -40,6 +40,16 @@ msgstr[1] "" msgid "%{count} synced" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} to" +msgstr "" + #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -97,6 +107,11 @@ msgstr "" msgid "Active members" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Active only" +msgstr "" + #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Add Member" @@ -154,6 +169,11 @@ msgstr "" msgid "All years combined (pie)" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Allow direct registration (/register)" +msgstr "" + #: lib/mv_web/live/membership_fee_type_live/form.ex #, elixir-autogen, elixir-format msgid "Already paid cycles will remain with the old amount." @@ -172,6 +192,7 @@ msgstr "" msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "" +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex @@ -185,6 +206,11 @@ msgstr "" msgid "App URL (contact view link)" msgstr "" +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Applicant data" +msgstr "" + #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format msgid "Apply filters" @@ -274,6 +300,11 @@ msgstr "" msgid "Aug." msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Authentication" +msgstr "" + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Authentication configuration error. Please contact the administrator." @@ -321,6 +352,11 @@ msgstr "" msgid "Back to groups list" msgstr "" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Back to join form" +msgstr "" + #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Back to join requests" @@ -391,6 +427,7 @@ msgstr "" #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/show.ex @@ -534,6 +571,11 @@ msgstr "" msgid "Club Settings" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Column" +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Completed" @@ -564,6 +606,11 @@ msgstr "" msgid "Confirm Password" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Confirm and Import" +msgstr "" + #: lib/mv_web/templates/emails/user_confirmation.html.heex #, elixir-autogen, elixir-format msgid "Confirm my email" @@ -667,11 +714,21 @@ msgid_plural "Copied %{count} email addresses to clipboard" msgstr[0] "" msgstr[1] "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Copy" +msgstr "" + #: lib/mv_web/components/bulk_actions_dropdown.ex #, elixir-autogen, elixir-format msgid "Copy email addresses" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Copy join page URL" +msgstr "" + #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Could not load data fields. Please check your permissions." @@ -720,6 +777,16 @@ msgstr "" msgid "Create a new cycle manually" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create custom field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create fee type" +msgstr "" + #: lib/mv/membership/members_pdf.ex #, elixir-autogen, elixir-format msgid "Created at:" @@ -760,6 +827,16 @@ msgstr "" msgid "Custom Fields" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Custom date fields" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Custom field" +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Cycle" @@ -834,6 +911,31 @@ msgstr "" msgid "Date" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Dates" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Deactivate" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Deactivate member" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Deactivate member %{name}" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Deactivating this member records an exit date. You can reactivate them later." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Dec." @@ -1031,6 +1133,16 @@ msgstr "" msgid "Description" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Description for join form" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Direct registration" +msgstr "" + #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Download CSV templates:" @@ -1180,6 +1292,22 @@ msgstr "" msgid "Exit Date" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date to" +msgstr "" + #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1205,6 +1333,16 @@ msgstr "" msgid "Export members to PDF" msgstr "" +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format +msgid "Export to CSV" +msgstr "" + +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format +msgid "Export to PDF" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Failed members:" @@ -1301,6 +1439,11 @@ msgstr "" msgid "Failed to update cycle status: %{errors}" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Failed to update setting." +msgstr "" + #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Failed: %{count} row(s)" @@ -1317,12 +1460,27 @@ msgstr "" msgid "Fee Type" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." +msgstr "" + #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" msgstr "" +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Fee type '%{name}' not found; using the default fee type." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." +msgstr "" + #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format msgid "Fee types" @@ -1368,6 +1526,11 @@ msgstr "" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "From" +msgstr "" + #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "From %{first} to %{last} (relevant years with membership data)" @@ -1453,6 +1616,21 @@ msgstr "" msgid "German Template" msgstr "" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Go to join form" +msgstr "" + +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Group %{name}" +msgstr "" + +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Group assignment failed: %{reason}" +msgstr "" + #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Group deleted successfully." @@ -1490,6 +1668,11 @@ msgstr "" msgid "Groups claim" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." +msgstr "" + #: lib/mv_web/helpers/membership_fee_helpers.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex @@ -1502,6 +1685,11 @@ msgstr "" msgid "History" msgstr "" +#: lib/mv_web/controllers/page_controller.ex +#, elixir-autogen, elixir-format +msgid "Home" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Host" @@ -1513,6 +1701,11 @@ msgstr "" msgid "House Number" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." +msgstr "" + #: lib/mv_web/templates/emails/user_confirmation.html.heex #, elixir-autogen, elixir-format msgid "If you did not create an account, you can ignore this email." @@ -1528,6 +1721,17 @@ msgstr "" msgid "If you did not submit this request, you can ignore this email." msgstr "" +#: lib/mv_web/templates/emails/join_already_member.html.heex +#: lib/mv_web/templates/emails/join_already_pending.html.heex +#, elixir-autogen, elixir-format +msgid "If you have any questions, please contact us." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Ignored (system-computed field)" +msgstr "" + #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/import_live.ex #, elixir-autogen, elixir-format @@ -1564,6 +1768,11 @@ msgstr "" msgid "Inactive members" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Inactive only" +msgstr "" + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1633,6 +1842,7 @@ msgstr "" msgid "Invalid chunk index: %{idx}" msgstr "" +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Invalid date format" @@ -1643,6 +1853,16 @@ msgstr "" msgid "Invalid email address. Please enter a valid recipient address." msgstr "" +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format +msgid "Invalid link" +msgstr "" + +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Invalid or expired link" +msgstr "" + #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex #, elixir-autogen, elixir-format msgid "Invalid or expired link." @@ -1658,6 +1878,11 @@ msgstr "" msgid "Jan." msgstr "" +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Join" +msgstr "" + #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex @@ -1671,11 +1896,46 @@ msgstr "" msgid "Join Form" msgstr "" +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format +msgid "Join confirmation" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date to" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" msgstr "" +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Join form:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join page URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join page URL copied to clipboard." +msgstr "" + #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Join request" @@ -1765,6 +2025,17 @@ msgstr "" msgid "Line %{line}: %{message}" msgstr "" +#: lib/mv_web/controllers/join_confirm_controller.ex +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Link expired" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Link to the public join page (share this with applicants):" +msgstr "" + #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -1848,6 +2119,11 @@ msgstr "" msgid "Member %{club_name}" msgstr "" +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Member %{name}" +msgstr "" + #: lib/mv/membership/members_pdf.ex #, elixir-autogen, elixir-format msgid "Member count:" @@ -1864,6 +2140,11 @@ msgstr "" msgid "Member deleted successfully" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Member field" +msgstr "" + #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Member field %{action} successfully" @@ -1982,6 +2263,16 @@ msgstr "" msgid "Membership Fees" msgstr "" +#: lib/mv_web/emails/join_already_member_email.ex +#, elixir-autogen, elixir-format +msgid "Membership application – already a member" +msgstr "" + +#: lib/mv_web/emails/join_already_pending_email.ex +#, elixir-autogen, elixir-format +msgid "Membership application – already under review" +msgstr "" + #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex @@ -2027,6 +2318,11 @@ msgstr "" msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." msgstr "" +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Membership status" +msgstr "" + #: lib/mv/mailer.ex #, elixir-autogen, elixir-format msgid "Mila – Test email" @@ -2204,6 +2500,11 @@ msgstr "" msgid "No membership fee type assigned" msgstr "" +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "No prepared import to confirm. Please upload again." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "No receipts" @@ -2330,6 +2631,11 @@ msgstr "" msgid "Only OIDC sign-in (hide password login)" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Only OIDC sign-in is active. This option is disabled." +msgstr "" + #: lib/mv_web/live/import_live.ex #, elixir-autogen, elixir-format msgid "Only administrators can import members from CSV files." @@ -2346,6 +2652,11 @@ msgstr "" msgid "Only possible if no members are assigned to this type." msgstr "" +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Only sign-in via Single Sign-On (SSO) is allowed." +msgstr "" + #: lib/mv_web/components/bulk_actions_dropdown.ex #, elixir-autogen, elixir-format msgid "Open in email program" @@ -2490,6 +2801,11 @@ msgstr "" msgid "Postal Code" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Preview import" +msgstr "" + #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Processing chunk %{current} of %{total}..." @@ -2512,6 +2828,21 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Range" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Reactivate member" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Reactivate member %{name}" +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Receipt" @@ -2627,6 +2958,11 @@ msgstr "" msgid "Role" msgstr "" +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Role %{name}" +msgstr "" + #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role deleted successfully." @@ -2654,16 +2990,46 @@ msgstr "" msgid "Roles" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 1" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 2" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 3" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Rows with an empty fee type will get the default fee type." +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "SMTP / E-Mail" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "SMTP error:" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "SMTP is not configured. Please set at least the SMTP host." @@ -2919,6 +3285,11 @@ msgstr "" msgid "Show/Hide Columns" msgstr "" +#: lib/mv_web/live/auth/sign_in_live.ex +#, elixir-autogen, elixir-format +msgid "Sign in" +msgstr "" + #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." @@ -2963,6 +3334,11 @@ msgstr "" msgid "Status" msgstr "" +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Status and review" +msgstr "" + #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex @@ -2970,6 +3346,11 @@ msgstr "" msgid "Street" msgstr "" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Submit new request" +msgstr "" + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Submit request" @@ -3092,6 +3473,11 @@ msgstr "" msgid "Text" msgstr "" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Thank you" +msgstr "" + #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex #, elixir-autogen, elixir-format msgid "Thank you, we have received your request." @@ -3122,6 +3508,11 @@ msgstr "" msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "These groups will be created automatically: %{names}" +msgstr "" + #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "These will appear in addition to other data when adding new members." @@ -3170,6 +3561,11 @@ msgstr "" msgid "This link has expired. Please submit the form again." msgstr "" +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "This member is deactivated (exit date set). Reactivating clears the exit date." +msgstr "" + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "This user cannot be edited." @@ -3190,6 +3586,11 @@ msgstr "" msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "To" +msgstr "" + #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "To confirm deletion, please enter the group name:" @@ -3210,6 +3611,11 @@ msgstr "" msgid "Toggle sidebar" msgstr "" +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format +msgid "Too many recipients for this function. Copy the addresses or export the list." +msgstr "" + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Too many requests. Please try again later." @@ -3248,6 +3654,11 @@ msgstr "" msgid "Unable to sign in. Please try again." msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown (ignored)" +msgstr "" + #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing." @@ -3259,6 +3670,11 @@ msgstr "" msgid "Unknown error" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown fee types (members get the default): %{names}" +msgstr "" + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Unlink Member" @@ -3279,6 +3695,11 @@ msgstr "" msgid "Unpaid" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." +msgstr "" + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "User" @@ -3289,6 +3710,11 @@ msgstr "" msgid "User %{action} successfully" msgstr "" +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "User %{email}" +msgstr "" + #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format @@ -3402,11 +3828,26 @@ msgstr "" msgid "We can't find the internet" msgstr "" +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We could not send the confirmation email. Please try again later or contact support." +msgstr "" + #: lib/mv_web/templates/emails/join_confirmation.html.heex #, elixir-autogen, elixir-format msgid "We have received your membership request. To complete it, please click the link below." msgstr "" +#: lib/mv_web/templates/emails/join_already_member.html.heex +#, elixir-autogen, elixir-format +msgid "We have received your request. The email address you entered is already registered as a member." +msgstr "" + +#: lib/mv_web/templates/emails/join_already_pending.html.heex +#, elixir-autogen, elixir-format +msgid "We have received your request. You already have a membership application that is being reviewed." +msgstr "" + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "We have saved your details. To complete your request, please click the link we sent to your email." @@ -3417,6 +3858,11 @@ msgstr "" msgid "Website" msgstr "" +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "When did this member leave?" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." @@ -3461,11 +3907,17 @@ msgstr "" msgid "Yes/No-Selection" msgstr "" +#: lib/mv_web/templates/emails/join_confirmation.html.heex +#, elixir-autogen, elixir-format +msgid "You already had a pending request. Here is a new confirmation link." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "You are about to delete all %{count} cycles for this member." msgstr "" +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "You are not allowed to perform this action." @@ -3481,6 +3933,11 @@ msgstr "" msgid "You are now signed out" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "You can add links: full addresses (https://…) or as [link text](https://…)." +msgstr "" + #: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "You do not have permission to %{action} members." @@ -3525,6 +3982,11 @@ msgstr "" msgid "You requested a password reset. Click the link below to set a new password." msgstr "" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "You will receive an email once your application has been reviewed." +msgstr "" + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." @@ -3603,6 +4065,16 @@ msgstr "" msgid "email %{email} has already been taken" msgstr "" +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format +msgid "filtered" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "join page URL in a new tab" +msgstr "" + #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "normal_user - Create/Read/Update access" @@ -3649,193 +4121,6 @@ msgstr "" msgid "without %{name}" msgstr "" -#: lib/mv_web/live/join_request_live/show.ex -#, elixir-autogen, elixir-format -msgid "Applicant data" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Copy" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Copy join page URL" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Join page URL" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Join page URL copied to clipboard." -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Link to the public join page (share this with applicants):" -msgstr "" - -#: lib/mv_web/live/join_request_live/show.ex -#, elixir-autogen, elixir-format -msgid "Status and review" -msgstr "" - -#: lib/mv_web/live/join_live.ex -#, elixir-autogen, elixir-format -msgid "We could not send the confirmation email. Please try again later or contact support." -msgstr "" - -#: lib/mv_web/templates/emails/join_already_member.html.heex -#: lib/mv_web/templates/emails/join_already_pending.html.heex -#, elixir-autogen, elixir-format -msgid "If you have any questions, please contact us." -msgstr "" - -#: lib/mv_web/emails/join_already_member_email.ex -#, elixir-autogen, elixir-format -msgid "Membership application – already a member" -msgstr "" - -#: lib/mv_web/emails/join_already_pending_email.ex -#, elixir-autogen, elixir-format -msgid "Membership application – already under review" -msgstr "" - -#: lib/mv_web/templates/emails/join_already_member.html.heex -#, elixir-autogen, elixir-format -msgid "We have received your request. The email address you entered is already registered as a member." -msgstr "" - -#: lib/mv_web/templates/emails/join_already_pending.html.heex -#, elixir-autogen, elixir-format -msgid "We have received your request. You already have a membership application that is being reviewed." -msgstr "" - -#: lib/mv_web/templates/emails/join_confirmation.html.heex -#, elixir-autogen, elixir-format -msgid "You already had a pending request. Here is a new confirmation link." -msgstr "" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Back to join form" -msgstr "" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Go to join form" -msgstr "" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Invalid or expired link" -msgstr "" - -#: lib/mv_web/controllers/join_confirm_controller.ex -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Link expired" -msgstr "" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Submit new request" -msgstr "" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Thank you" -msgstr "" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "You will receive an email once your application has been reviewed." -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Allow direct registration (/register)" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Authentication" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Direct registration" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Failed to update setting." -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." -msgstr "" - -#: lib/mv_web/controllers/page_controller.ex -#, elixir-autogen, elixir-format -msgid "Home" -msgstr "" - -#: lib/mv_web/controllers/join_confirm_controller.ex -#, elixir-autogen, elixir-format -msgid "Invalid link" -msgstr "" - -#: lib/mv_web/live/join_live.ex -#, elixir-autogen, elixir-format -msgid "Join" -msgstr "" - -#: lib/mv_web/controllers/join_confirm_controller.ex -#, elixir-autogen, elixir-format -msgid "Join confirmation" -msgstr "" - -#: lib/mv_web/live/auth/sign_in_live.ex -#, elixir-autogen, elixir-format -msgid "Sign in" -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Group %{name}" -msgstr "" - -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Member %{name}" -msgstr "" - -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Role %{name}" -msgstr "" - -#: lib/mv_web/live/user_live/show.ex -#, elixir-autogen, elixir-format -msgid "User %{email}" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Only OIDC sign-in is active. This option is disabled." -msgstr "" - -#: lib/mv_web/controllers/auth_controller.ex -#, elixir-autogen, elixir-format -msgid "Only sign-in via Single Sign-On (SSO) is allowed." -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -3847,238 +4132,3 @@ msgstr "" msgctxt "status" msgid "Open" msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "join page URL in a new tab" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} to" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Active only" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Custom date fields" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Dates" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date to" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "From" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date to" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Range" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "To" -msgstr "" - -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Join form:" -msgstr "" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Description for join form" -msgstr "" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "You can add links: full addresses (https://…) or as [link text](https://…)." -msgstr "" - -#: lib/mv/membership/import/member_csv.ex -#, elixir-autogen, elixir-format -msgid "Fee type '%{name}' not found; using the default fee type." -msgstr "" - -#: lib/mv/membership/import/member_csv.ex -#, elixir-autogen, elixir-format -msgid "Group assignment failed: %{reason}" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Confirm and Import" -msgstr "" - -#: lib/mv_web/live/import_live.ex -#, elixir-autogen, elixir-format -msgid "No prepared import to confirm. Please upload again." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Preview import" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Column" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Custom field" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Ignored (system-computed field)" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Member field" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Rows with an empty fee type will get the default fee type." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "These groups will be created automatically: %{names}" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Unknown (ignored)" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Unknown fee types (members get the default): %{names}" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Create custom field" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Create fee type" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Row 1" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Row 2" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Row 3" -msgstr "" - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format -msgid "Export to CSV" -msgstr "" - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format -msgid "Export to PDF" -msgstr "" - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format -msgid "Too many recipients for this function. Copy the addresses or export the list." -msgstr "" - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format -msgid "filtered" -msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 185be8b..cf1faea 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -40,6 +40,16 @@ msgstr[1] "" msgid "%{count} synced" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} to" +msgstr "" + #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -97,6 +107,11 @@ msgstr "" msgid "Active members" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Active only" +msgstr "" + #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Add Member" @@ -154,6 +169,11 @@ msgstr "" msgid "All years combined (pie)" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Allow direct registration (/register)" +msgstr "Allow direct registration (/register)" + #: lib/mv_web/live/membership_fee_type_live/form.ex #, elixir-autogen, elixir-format msgid "Already paid cycles will remain with the old amount." @@ -172,6 +192,7 @@ msgstr "" msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "" +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex @@ -185,6 +206,11 @@ msgstr "" msgid "App URL (contact view link)" msgstr "" +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Applicant data" +msgstr "Applicant data" + #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format msgid "Apply filters" @@ -274,6 +300,11 @@ msgstr "" msgid "Aug." msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Authentication" +msgstr "Authentication" + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Authentication configuration error. Please contact the administrator." @@ -321,6 +352,11 @@ msgstr "" msgid "Back to groups list" msgstr "" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Back to join form" +msgstr "Back to membership applications" + #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Back to join requests" @@ -391,6 +427,7 @@ msgstr "" #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/show.ex @@ -534,6 +571,11 @@ msgstr "" msgid "Club Settings" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Column" +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Completed" @@ -564,6 +606,11 @@ msgstr "" msgid "Confirm Password" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Confirm and Import" +msgstr "" + #: lib/mv_web/templates/emails/user_confirmation.html.heex #, elixir-autogen, elixir-format msgid "Confirm my email" @@ -667,11 +714,21 @@ msgid_plural "Copied %{count} email addresses to clipboard" msgstr[0] "" msgstr[1] "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Copy" +msgstr "Copy" + #: lib/mv_web/components/bulk_actions_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Copy email addresses" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Copy join page URL" +msgstr "Copy join page URL" + #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Could not load data fields. Please check your permissions." @@ -720,6 +777,16 @@ msgstr "" msgid "Create a new cycle manually" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create custom field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create fee type" +msgstr "" + #: lib/mv/membership/members_pdf.ex #, elixir-autogen, elixir-format msgid "Created at:" @@ -760,6 +827,16 @@ msgstr "" msgid "Custom Fields" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Custom date fields" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Custom field" +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Cycle" @@ -834,6 +911,31 @@ msgstr "" msgid "Date" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Dates" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Deactivate" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Deactivate member" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Deactivate member %{name}" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Deactivating this member records an exit date. You can reactivate them later." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Dec." @@ -1031,6 +1133,16 @@ msgstr "" msgid "Description" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Description for join form" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Direct registration" +msgstr "Direct registration" + #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Download CSV templates:" @@ -1180,6 +1292,22 @@ msgstr "" msgid "Exit Date" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Exit date" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Exit date from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Exit date to" +msgstr "" + #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1205,6 +1333,16 @@ msgstr "" msgid "Export members to PDF" msgstr "" +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Export to CSV" +msgstr "" + +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Export to PDF" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Failed members:" @@ -1301,6 +1439,11 @@ msgstr "" msgid "Failed to update cycle status: %{errors}" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Failed to update setting." +msgstr "Failed to update setting." + #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Failed: %{count} row(s)" @@ -1317,12 +1460,27 @@ msgstr "" msgid "Fee Type" msgstr "Fee Type" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." +msgstr "" + #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Fee type" msgstr "" +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Fee type '%{name}' not found; using the default fee type." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." +msgstr "" + #: lib/mv_web/live/components/member_filter_component.ex #, elixir-autogen, elixir-format msgid "Fee types" @@ -1368,6 +1526,11 @@ msgstr "" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "From" +msgstr "" + #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "From %{first} to %{last} (relevant years with membership data)" @@ -1453,6 +1616,21 @@ msgstr "" msgid "German Template" msgstr "" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Go to join form" +msgstr "Go to join form" + +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Group %{name}" +msgstr "Group %{name}" + +#: lib/mv/membership/import/member_csv.ex +#, elixir-autogen, elixir-format +msgid "Group assignment failed: %{reason}" +msgstr "" + #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Group deleted successfully." @@ -1490,6 +1668,11 @@ msgstr "" msgid "Groups claim" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." +msgstr "" + #: lib/mv_web/helpers/membership_fee_helpers.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex @@ -1502,6 +1685,11 @@ msgstr "" msgid "History" msgstr "" +#: lib/mv_web/controllers/page_controller.ex +#, elixir-autogen, elixir-format +msgid "Home" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Host" @@ -1513,6 +1701,11 @@ msgstr "" msgid "House Number" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." +msgstr "If disabled, users cannot sign up via /register; sign-in and the join form remain available." + #: lib/mv_web/templates/emails/user_confirmation.html.heex #, elixir-autogen, elixir-format msgid "If you did not create an account, you can ignore this email." @@ -1528,6 +1721,17 @@ msgstr "If you did not request this, you can ignore this email. Your password wi msgid "If you did not submit this request, you can ignore this email." msgstr "If you did not submit this request, you can ignore this email." +#: lib/mv_web/templates/emails/join_already_member.html.heex +#: lib/mv_web/templates/emails/join_already_pending.html.heex +#, elixir-autogen, elixir-format +msgid "If you have any questions, please contact us." +msgstr "If you have any questions, please contact us." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Ignored (system-computed field)" +msgstr "" + #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/import_live.ex #, elixir-autogen, elixir-format, fuzzy @@ -1564,6 +1768,11 @@ msgstr "" msgid "Inactive members" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Inactive only" +msgstr "" + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1633,6 +1842,7 @@ msgstr "" msgid "Invalid chunk index: %{idx}" msgstr "" +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Invalid date format" @@ -1643,6 +1853,16 @@ msgstr "" msgid "Invalid email address. Please enter a valid recipient address." msgstr "" +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Invalid link" +msgstr "Invalid or expired link." + +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Invalid or expired link" +msgstr "Invalid or expired link." + #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex #, elixir-autogen, elixir-format msgid "Invalid or expired link." @@ -1658,6 +1878,11 @@ msgstr "" msgid "Jan." msgstr "" +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Join" +msgstr "" + #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex @@ -1671,11 +1896,46 @@ msgstr "" msgid "Join Form" msgstr "" +#: lib/mv_web/controllers/join_confirm_controller.ex +#, elixir-autogen, elixir-format +msgid "Join confirmation" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Join date" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Join date from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Join date to" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" msgstr "" +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Join form:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join page URL" +msgstr "Join page URL" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Join page URL copied to clipboard." +msgstr "Join page URL copied to clipboard." + #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Join request" @@ -1765,6 +2025,17 @@ msgstr "" msgid "Line %{line}: %{message}" msgstr "" +#: lib/mv_web/controllers/join_confirm_controller.ex +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Link expired" +msgstr "Link expired" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Link to the public join page (share this with applicants):" +msgstr "Link to the public join page (share this with applicants):" + #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy @@ -1848,6 +2119,11 @@ msgstr "" msgid "Member %{club_name}" msgstr "Member %{club_name}" +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Member %{name}" +msgstr "Member %{name}" + #: lib/mv/membership/members_pdf.ex #, elixir-autogen, elixir-format msgid "Member count:" @@ -1864,6 +2140,11 @@ msgstr "Member created successfully" msgid "Member deleted successfully" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Member field" +msgstr "" + #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Member field %{action} successfully" @@ -1982,6 +2263,16 @@ msgstr "" msgid "Membership Fees" msgstr "" +#: lib/mv_web/emails/join_already_member_email.ex +#, elixir-autogen, elixir-format +msgid "Membership application – already a member" +msgstr "Membership application – already a member" + +#: lib/mv_web/emails/join_already_pending_email.ex +#, elixir-autogen, elixir-format +msgid "Membership application – already under review" +msgstr "Membership application – already under review" + #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex @@ -2027,6 +2318,11 @@ msgstr "" msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." msgstr "" +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Membership status" +msgstr "" + #: lib/mv/mailer.ex #, elixir-autogen, elixir-format msgid "Mila – Test email" @@ -2204,6 +2500,11 @@ msgstr "" msgid "No membership fee type assigned" msgstr "" +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "No prepared import to confirm. Please upload again." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "No receipts" @@ -2330,6 +2631,11 @@ msgstr "" msgid "Only OIDC sign-in (hide password login)" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Only OIDC sign-in is active. This option is disabled." +msgstr "" + #: lib/mv_web/live/import_live.ex #, elixir-autogen, elixir-format msgid "Only administrators can import members from CSV files." @@ -2346,6 +2652,11 @@ msgstr "" msgid "Only possible if no members are assigned to this type." msgstr "" +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Only sign-in via Single Sign-On (SSO) is allowed." +msgstr "" + #: lib/mv_web/components/bulk_actions_dropdown.ex #, elixir-autogen, elixir-format msgid "Open in email program" @@ -2490,6 +2801,11 @@ msgstr "" msgid "Postal Code" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Preview import" +msgstr "" + #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Processing chunk %{current} of %{total}..." @@ -2512,6 +2828,21 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Range" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Reactivate member" +msgstr "" + +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "Reactivate member %{name}" +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Receipt" @@ -2627,6 +2958,11 @@ msgstr "Review date" msgid "Role" msgstr "" +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Role %{name}" +msgstr "Role %{name}" + #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Role deleted successfully." @@ -2654,16 +2990,46 @@ msgstr "" msgid "Roles" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 1" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 2" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 3" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Rows with an empty fee type will get the default fee type." +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "SMTP / E-Mail" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "SMTP error:" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." +msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only." + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "SMTP is not configured. Please set at least the SMTP host." @@ -2919,6 +3285,11 @@ msgstr "" msgid "Show/Hide Columns" msgstr "" +#: lib/mv_web/live/auth/sign_in_live.ex +#, elixir-autogen, elixir-format +msgid "Sign in" +msgstr "" + #: lib/mv_web/live/datafields_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." @@ -2963,6 +3334,11 @@ msgstr "" msgid "Status" msgstr "" +#: lib/mv_web/live/join_request_live/show.ex +#, elixir-autogen, elixir-format +msgid "Status and review" +msgstr "Status and review" + #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex @@ -2970,6 +3346,11 @@ msgstr "" msgid "Street" msgstr "" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Submit new request" +msgstr "Submit new request" + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Submit request" @@ -3092,6 +3473,11 @@ msgstr "" msgid "Text" msgstr "" +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "Thank you" +msgstr "Thank you" + #: lib/mv_web/controllers/join_confirm_html/confirm.html.heex #, elixir-autogen, elixir-format msgid "Thank you, we have received your request." @@ -3122,6 +3508,11 @@ msgstr "" msgid "These fields are necessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "These groups will be created automatically: %{names}" +msgstr "" + #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "These will appear in addition to other data when adding new members." @@ -3170,6 +3561,11 @@ msgstr "" msgid "This link has expired. Please submit the form again." msgstr "This link has expired. Please submit the form again." +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "This member is deactivated (exit date set). Reactivating clears the exit date." +msgstr "" + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "This user cannot be edited." @@ -3190,6 +3586,11 @@ msgstr "" msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "To" +msgstr "" + #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "To confirm deletion, please enter the group name:" @@ -3210,6 +3611,11 @@ msgstr "" msgid "Toggle sidebar" msgstr "" +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format +msgid "Too many recipients for this function. Copy the addresses or export the list." +msgstr "" + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Too many requests. Please try again later." @@ -3248,6 +3654,11 @@ msgstr "" msgid "Unable to sign in. Please try again." msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Unknown (ignored)" +msgstr "" + #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing." @@ -3259,6 +3670,11 @@ msgstr "Unknown column '%{header}' will be ignored. If this is a custom field, c msgid "Unknown error" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown fee types (members get the default): %{names}" +msgstr "" + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Unlink Member" @@ -3279,6 +3695,11 @@ msgstr "" msgid "Unpaid" msgstr "" +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." +msgstr "" + #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "User" @@ -3289,6 +3710,11 @@ msgstr "" msgid "User %{action} successfully" msgstr "" +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "User %{email}" +msgstr "User %{email}" + #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy @@ -3402,11 +3828,26 @@ msgstr "" msgid "We can't find the internet" msgstr "" +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We could not send the confirmation email. Please try again later or contact support." +msgstr "" + #: lib/mv_web/templates/emails/join_confirmation.html.heex #, elixir-autogen, elixir-format msgid "We have received your membership request. To complete it, please click the link below." msgstr "We have received your membership request. To complete it, please click the link below." +#: lib/mv_web/templates/emails/join_already_member.html.heex +#, elixir-autogen, elixir-format +msgid "We have received your request. The email address you entered is already registered as a member." +msgstr "We have received your request. The email address you entered is already registered as a member." + +#: lib/mv_web/templates/emails/join_already_pending.html.heex +#, elixir-autogen, elixir-format +msgid "We have received your request. You already have a membership application that is being reviewed." +msgstr "We have received your request. You already have a membership application that is being reviewed." + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "We have saved your details. To complete your request, please click the link we sent to your email." @@ -3417,6 +3858,11 @@ msgstr "" msgid "Website" msgstr "" +#: lib/mv_web/live/member_live/show/deactivate_component.ex +#, elixir-autogen, elixir-format +msgid "When did this member leave?" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." @@ -3461,11 +3907,17 @@ msgstr "" msgid "Yes/No-Selection" msgstr "" +#: lib/mv_web/templates/emails/join_confirmation.html.heex +#, elixir-autogen, elixir-format +msgid "You already had a pending request. Here is a new confirmation link." +msgstr "You already had a pending request. Here is a new confirmation link." + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "You are about to delete all %{count} cycles for this member." msgstr "" +#: lib/mv_web/live/member_live/show/deactivate_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "You are not allowed to perform this action." @@ -3481,6 +3933,11 @@ msgstr "" msgid "You are now signed out" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "You can add links: full addresses (https://…) or as [link text](https://…)." +msgstr "" + #: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "You do not have permission to %{action} members." @@ -3525,6 +3982,11 @@ msgstr "" msgid "You requested a password reset. Click the link below to set a new password." msgstr "You requested a password reset. Click the link below to set a new password." +#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex +#, elixir-autogen, elixir-format +msgid "You will receive an email once your application has been reviewed." +msgstr "You will receive an email once your application has been reviewed." + #: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." @@ -3603,6 +4065,16 @@ msgstr "" msgid "email %{email} has already been taken" msgstr "" +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format +msgid "filtered" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "join page URL in a new tab" +msgstr "join page URL in a new tab" + #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "normal_user - Create/Read/Update access" @@ -3649,193 +4121,6 @@ msgstr "" msgid "without %{name}" msgstr "without %{name}" -#: lib/mv_web/live/join_request_live/show.ex -#, elixir-autogen, elixir-format -msgid "Applicant data" -msgstr "Applicant data" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Copy" -msgstr "Copy" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Copy join page URL" -msgstr "Copy join page URL" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Join page URL" -msgstr "Join page URL" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Join page URL copied to clipboard." -msgstr "Join page URL copied to clipboard." - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Link to the public join page (share this with applicants):" -msgstr "Link to the public join page (share this with applicants):" - -#: lib/mv_web/live/join_request_live/show.ex -#, elixir-autogen, elixir-format -msgid "Status and review" -msgstr "Status and review" - -#: lib/mv_web/live/join_live.ex -#, elixir-autogen, elixir-format -msgid "We could not send the confirmation email. Please try again later or contact support." -msgstr "" - -#: lib/mv_web/templates/emails/join_already_member.html.heex -#: lib/mv_web/templates/emails/join_already_pending.html.heex -#, elixir-autogen, elixir-format -msgid "If you have any questions, please contact us." -msgstr "If you have any questions, please contact us." - -#: lib/mv_web/emails/join_already_member_email.ex -#, elixir-autogen, elixir-format -msgid "Membership application – already a member" -msgstr "Membership application – already a member" - -#: lib/mv_web/emails/join_already_pending_email.ex -#, elixir-autogen, elixir-format -msgid "Membership application – already under review" -msgstr "Membership application – already under review" - -#: lib/mv_web/templates/emails/join_already_member.html.heex -#, elixir-autogen, elixir-format -msgid "We have received your request. The email address you entered is already registered as a member." -msgstr "We have received your request. The email address you entered is already registered as a member." - -#: lib/mv_web/templates/emails/join_already_pending.html.heex -#, elixir-autogen, elixir-format -msgid "We have received your request. You already have a membership application that is being reviewed." -msgstr "We have received your request. You already have a membership application that is being reviewed." - -#: lib/mv_web/templates/emails/join_confirmation.html.heex -#, elixir-autogen, elixir-format -msgid "You already had a pending request. Here is a new confirmation link." -msgstr "You already had a pending request. Here is a new confirmation link." - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Back to join form" -msgstr "Back to membership applications" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Go to join form" -msgstr "Go to join form" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Invalid or expired link" -msgstr "Invalid or expired link." - -#: lib/mv_web/controllers/join_confirm_controller.ex -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Link expired" -msgstr "Link expired" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Submit new request" -msgstr "Submit new request" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "Thank you" -msgstr "Thank you" - -#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex -#, elixir-autogen, elixir-format -msgid "You will receive an email once your application has been reviewed." -msgstr "You will receive an email once your application has been reviewed." - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Allow direct registration (/register)" -msgstr "Allow direct registration (/register)" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Authentication" -msgstr "Authentication" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Direct registration" -msgstr "Direct registration" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Failed to update setting." -msgstr "Failed to update setting." - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." -msgstr "If disabled, users cannot sign up via /register; sign-in and the join form remain available." - -#: lib/mv_web/controllers/page_controller.ex -#, elixir-autogen, elixir-format -msgid "Home" -msgstr "" - -#: lib/mv_web/controllers/join_confirm_controller.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Invalid link" -msgstr "Invalid or expired link." - -#: lib/mv_web/live/join_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Join" -msgstr "" - -#: lib/mv_web/controllers/join_confirm_controller.ex -#, elixir-autogen, elixir-format -msgid "Join confirmation" -msgstr "" - -#: lib/mv_web/live/auth/sign_in_live.ex -#, elixir-autogen, elixir-format -msgid "Sign in" -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Group %{name}" -msgstr "Group %{name}" - -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Member %{name}" -msgstr "Member %{name}" - -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Role %{name}" -msgstr "Role %{name}" - -#: lib/mv_web/live/user_live/show.ex -#, elixir-autogen, elixir-format -msgid "User %{email}" -msgstr "User %{email}" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Only OIDC sign-in is active. This option is disabled." -msgstr "" - -#: lib/mv_web/controllers/auth_controller.ex -#, elixir-autogen, elixir-format -msgid "Only sign-in via Single Sign-On (SSO) is allowed." -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -3847,268 +4132,3 @@ msgstr "Open" msgctxt "status" msgid "Open" msgstr "Open" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "join page URL in a new tab" -msgstr "join page URL in a new tab" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." -msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only." - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} to" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Active only" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom date fields" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Dates" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Exit date" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Exit date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Exit date to" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "From" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Join date" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Join date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Join date to" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Range" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "To" -msgstr "" - -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Join form:" -msgstr "" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Description for join form" -msgstr "" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "You can add links: full addresses (https://…) or as [link text](https://…)." -msgstr "" - -#: lib/mv/membership/import/member_csv.ex -#, elixir-autogen, elixir-format -msgid "Fee type '%{name}' not found; using the default fee type." -msgstr "" - -#: lib/mv/membership/import/member_csv.ex -#, elixir-autogen, elixir-format -msgid "Group assignment failed: %{reason}" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Confirm and Import" -msgstr "" - -#: lib/mv_web/live/import_live.ex -#, elixir-autogen, elixir-format -msgid "No prepared import to confirm. Please upload again." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Preview import" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Column" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom field" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Ignored (system-computed field)" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Member field" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Rows with an empty fee type will get the default fee type." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "These groups will be created automatically: %{names}" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Unknown (ignored)" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Unknown fee types (members get the default): %{names}" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Create custom field" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Create fee type" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Row 1" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Row 2" -msgstr "" - -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Row 3" -msgstr "" - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Export to CSV" -msgstr "" - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Export to PDF" -msgstr "" - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format -msgid "Too many recipients for this function. Copy the addresses or export the list." -msgstr "" - -#: lib/mv_web/components/bulk_actions_dropdown.ex -#, elixir-autogen, elixir-format -msgid "filtered" -msgstr "" - -#~ #: lib/mv_web/components/export_dropdown.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "CSV" -#~ msgstr "" - -#~ #: lib/mv_web/live/member_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "Copy email addresses of selected members" -#~ msgstr "" - -#~ #: lib/mv_web/components/export_dropdown.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Export" -#~ msgstr "" - -#~ #: lib/mv_web/live/member_live/index.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "No members selected" -#~ msgstr "" - -#~ #: lib/mv_web/live/member_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "Open email program with BCC recipients" -#~ msgstr "" - -#~ #: lib/mv_web/components/export_dropdown.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "PDF" -#~ msgstr "" diff --git a/test/mv_web/live/member_live/deactivate_test.exs b/test/mv_web/live/member_live/deactivate_test.exs new file mode 100644 index 0000000..2e7a6a7 --- /dev/null +++ b/test/mv_web/live/member_live/deactivate_test.exs @@ -0,0 +1,108 @@ +defmodule MvWeb.MemberLive.DeactivateTest do + @moduledoc """ + Tests for the member deactivate/reactivate sub-flow on the member show page, + driven through the parent LiveView (the DeactivateComponent is stateful). + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + use Gettext, backend: MvWeb.Gettext + + alias Mv.Fixtures + + defp reload_member(member) do + Ash.get!(Mv.Membership.Member, member.id, actor: Mv.Helpers.SystemActor.get_system_actor()) + end + + describe "deactivate/reactivate control visibility (§1.6, §1.8)" do + @tag role: :admin + test "shows Deactivate and hides Reactivate when member has no exit_date", %{conn: conn} do + member = Fixtures.member_fixture() + + {:ok, view, _html} = live(conn, ~p"/members/#{member.id}") + + assert has_element?(view, "[data-testid=member-deactivate]") + refute has_element?(view, "[data-testid=member-reactivate]") + end + + @tag role: :admin + test "shows Reactivate and hides Deactivate when member has an exit_date", %{conn: conn} do + member = Fixtures.member_fixture(%{exit_date: Date.utc_today()}) + + {:ok, view, _html} = live(conn, ~p"/members/#{member.id}") + + assert has_element?(view, "[data-testid=member-reactivate]") + refute has_element?(view, "[data-testid=member-deactivate]") + end + + @tag role: :read_only + test "hides the deactivate/reactivate control for a user without :update permission", %{ + conn: conn + } do + member = Fixtures.member_fixture() + + {:ok, view, _html} = live(conn, ~p"/members/#{member.id}") + + refute has_element?(view, "[data-testid=member-deactivate]") + refute has_element?(view, "[data-testid=member-reactivate]") + end + end + + describe "deactivate modal (§1.3)" do + @tag role: :admin + test "opening the deactivate modal prefills the date input with today", %{conn: conn} do + member = Fixtures.member_fixture() + + {:ok, view, _html} = live(conn, ~p"/members/#{member.id}") + + view + |> element("[data-testid=member-deactivate]") + |> render_click() + + assert has_element?( + view, + ~s(#deactivate-exit-date[value="#{Date.to_iso8601(Date.utc_today())}"]) + ) + end + end + + describe "submitting the deactivate modal (§1.2)" do + @tag role: :admin + test "submitting the deactivate modal with a future date sets exit_date", %{conn: conn} do + member = Fixtures.member_fixture(%{join_date: Date.utc_today()}) + future_date = Date.add(Date.utc_today(), 30) + + {:ok, view, _html} = live(conn, ~p"/members/#{member.id}") + + view + |> element("[data-testid=member-deactivate]") + |> render_click() + + view + |> element("#deactivate-member-modal form") + |> render_submit(%{"exit_date" => Date.to_iso8601(future_date)}) + + assert reload_member(member).exit_date == future_date + + # UI flips to offering Reactivate + assert has_element?(view, "[data-testid=member-reactivate]") + end + end + + describe "reactivate (§1.7)" do + @tag role: :admin + test "reactivating a member clears exit_date", %{conn: conn} do + member = Fixtures.member_fixture(%{exit_date: Date.utc_today()}) + + {:ok, view, _html} = live(conn, ~p"/members/#{member.id}") + + view + |> element("[data-testid=member-reactivate]") + |> render_click() + + assert reload_member(member).exit_date == nil + + # UI flips back to offering Deactivate + assert has_element?(view, "[data-testid=member-deactivate]") + end + end +end From bec49f0771bc335980c638c0df11c13942277884 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 8 Jun 2026 12:25:07 +0200 Subject: [PATCH 02/50] feat(settings): explain that OIDC enables single sign-on --- lib/mv_web/live/global_settings_live.ex | 5 +++++ priv/gettext/de/LC_MESSAGES/default.po | 5 +++++ priv/gettext/default.pot | 5 +++++ priv/gettext/en/LC_MESSAGES/default.po | 5 +++++ test/mv_web/live/global_settings_live_test.exs | 17 +++++++++++++++++ 5 files changed, 37 insertions(+) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 6a456fe..c7a36b1 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -646,6 +646,11 @@ defmodule MvWeb.GlobalSettingsLive do

{gettext("OIDC (Single Sign-On)")}

+

+ {gettext( + "OIDC enables Single Sign-On: once configured, members sign in through your identity provider instead of a separate Mila password." + )} +

<%= if @oidc_env_configured do %>

{gettext("Some values are set via environment variables. Those fields are read-only.")} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index c8713e9..0334332 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2620,6 +2620,11 @@ msgstr "OIDC" msgid "OIDC (Single Sign-On)" msgstr "OIDC (Single Sign-On)" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "OIDC enables Single Sign-On: once configured, members sign in through your identity provider instead of a separate Mila password." +msgstr "OIDC aktiviert Single Sign-On: Sobald es eingerichtet ist, melden sich Mitglieder über deinen Identity-Provider an statt mit einem separaten Mila-Passwort." + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Oct." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 7a90bee..c989f7b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2621,6 +2621,11 @@ msgstr "" msgid "OIDC (Single Sign-On)" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "OIDC enables Single Sign-On: once configured, members sign in through your identity provider instead of a separate Mila password." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Oct." diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index cf1faea..489020f 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2621,6 +2621,11 @@ msgstr "" msgid "OIDC (Single Sign-On)" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "OIDC enables Single Sign-On: once configured, members sign in through your identity provider instead of a separate Mila password." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Oct." diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index 9be12b9..6cb8f5b 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -283,4 +283,21 @@ defmodule MvWeb.GlobalSettingsLiveTest do end end end + + describe "OIDC section explanation (§1.1)" do + setup %{conn: conn} do + user = create_test_user(%{email: "admin@example.com"}) + conn = conn_with_oidc_user(conn, user) + {:ok, conn: conn} + end + + test "OIDC section shows a Single Sign-On explanation", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/settings") + + assert has_element?(view, "[data-testid='oidc-sso-description']") + + assert view |> element("[data-testid='oidc-sso-description']") |> render() =~ + "Single Sign-On" + end + end end From 035edae522a4dafbb06a45971593fc471d9dad69 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 8 Jun 2026 12:35:30 +0200 Subject: [PATCH 03/50] feat(web): add tooltips to icon-only action buttons --- lib/mv_web/live/global_settings_live.ex | 24 ++++++++++++----- lib/mv_web/live/group_live/show.ex | 24 +++++++++-------- .../show/membership_fees_component.ex | 21 +++++++++------ .../live/membership_fee_settings_live.ex | 23 +++++++++------- .../live/membership_fee_type_live/index.ex | 23 +++++++++------- priv/gettext/de/LC_MESSAGES/default.po | 10 +++++++ priv/gettext/default.pot | 10 +++++++ priv/gettext/en/LC_MESSAGES/default.po | 10 +++++++ .../mv_web/live/global_settings_live_test.exs | 27 +++++++++++++++++++ .../member_live/show_membership_fees_test.exs | 16 +++++++++++ 10 files changed, 144 insertions(+), 44 deletions(-) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index c7a36b1..735c165 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -586,15 +586,25 @@ defmodule MvWeb.GlobalSettingsLive do > {gettext("Test Integration")} - <.button + <.tooltip :if={Mv.Config.vereinfacht_configured?()} - type="button" - variant="secondary" - phx-click="sync_vereinfacht_contacts" - phx-disable-with={gettext("Syncing...")} + content={ + gettext( + "Creates a Vereinfacht finance contact for every member that does not have one yet." + ) + } + position="top" > - {gettext("Sync all members without Vereinfacht contact")} - + <.button + type="button" + variant="secondary" + phx-click="sync_vereinfacht_contacts" + phx-disable-with={gettext("Syncing...")} + aria-label={gettext("Sync all members without Vereinfacht contact")} + > + {gettext("Sync all members without Vereinfacht contact")} + + <%= if @vereinfacht_test_result do %> <.vereinfacht_test_result result={@vereinfacht_test_result} /> diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 7cd4378..ad24110 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -250,17 +250,19 @@ defmodule MvWeb.GroupLive.Show do <% end %> - <.button - type="button" - variant="primary" - phx-click="add_selected_members" - data-testid="group-show-add-selected-members-btn" - disabled={Enum.empty?(@selected_member_ids)} - aria-label={gettext("Add members")} - class="join-item" - > - <.icon name="hero-plus" class="size-5" /> - + <.tooltip content={gettext("Add members")} position="top"> + <.button + type="button" + variant="primary" + phx-click="add_selected_members" + data-testid="group-show-add-selected-members-btn" + disabled={Enum.empty?(@selected_member_ids)} + aria-label={gettext("Add members")} + class="join-item" + > + <.icon name="hero-plus" class="size-5" /> + + <.button type="button" variant="neutral" 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 0cba316..33b0456 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 @@ -143,16 +143,21 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <%!-- Action Buttons (only when user has permission) --%>

- <.button + <.tooltip :if={@member.membership_fee_type != nil and @can_create_cycle} - phx-click="regenerate_cycles" - phx-target={@myself} - class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]} - title={gettext("Generate cycles from the last existing cycle to today")} + content={gettext("Generate cycles from the last existing cycle to today")} + position="top" > - <.icon name="hero-arrow-path" class="size-4" /> - {if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))} - + <.button + phx-click="regenerate_cycles" + phx-target={@myself} + class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]} + aria-label={gettext("Regenerate membership fee cycles")} + > + <.icon name="hero-arrow-path" class="size-4" /> + {if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))} + + <.button :if={Enum.any?(@cycles) and @can_destroy_cycle} variant="outline" diff --git a/lib/mv_web/live/membership_fee_settings_live.ex b/lib/mv_web/live/membership_fee_settings_live.ex index 15030c1..4df6608 100644 --- a/lib/mv_web/live/membership_fee_settings_live.ex +++ b/lib/mv_web/live/membership_fee_settings_live.ex @@ -298,17 +298,22 @@ defmodule MvWeb.MembershipFeeSettingsLive do <.icon name="hero-trash" class="size-4" /> - <.button + <.tooltip :if={get_member_count(mft, @member_counts) == 0} - variant="danger" - size="sm" - phx-click="delete" - phx-value-id={mft.id} - data-confirm={gettext("Are you sure?")} - aria-label={gettext("Delete Membership Fee Type")} + content={gettext("Delete Membership Fee Type")} + position="left" > - <.icon name="hero-trash" class="size-4" /> - + <.button + variant="danger" + size="sm" + phx-click="delete" + phx-value-id={mft.id} + data-confirm={gettext("Are you sure?")} + aria-label={gettext("Delete Membership Fee Type")} + > + <.icon name="hero-trash" class="size-4" /> + +
diff --git a/lib/mv_web/live/membership_fee_type_live/index.ex b/lib/mv_web/live/membership_fee_type_live/index.ex index 65f840d..1d51ce1 100644 --- a/lib/mv_web/live/membership_fee_type_live/index.ex +++ b/lib/mv_web/live/membership_fee_type_live/index.ex @@ -115,17 +115,22 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do <.icon name="hero-trash" class="size-4" /> - <.button + <.tooltip :if={get_member_count(mft, @member_counts) == 0} - variant="danger" - size="sm" - phx-click="delete" - phx-value-id={mft.id} - data-confirm={gettext("Are you sure?")} - aria-label={gettext("Delete Membership Fee Type")} + content={gettext("Delete Membership Fee Type")} + position="left" > - <.icon name="hero-trash" class="size-4" /> - + <.button + variant="danger" + size="sm" + phx-click="delete" + phx-value-id={mft.id} + data-confirm={gettext("Are you sure?")} + aria-label={gettext("Delete Membership Fee Type")} + > + <.icon name="hero-trash" class="size-4" /> + + diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0334332..a5ffa81 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -791,6 +791,11 @@ msgstr "Beitragsart erstellen" msgid "Created at:" msgstr "Erstellt am:" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Creates a Vereinfacht finance contact for every member that does not have one yet." +msgstr "Legt für jedes Mitglied ohne Vereinfacht-Kontakt einen Finanzkontakt an." + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Credit" @@ -2872,6 +2877,11 @@ msgstr "Weiterleitungs-URI" msgid "Regenerate Cycles" msgstr "Zyklen regenerieren" +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Regenerate membership fee cycles" +msgstr "Beitragszyklen neu generieren" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Regenerating..." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index c989f7b..91a111d 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -792,6 +792,11 @@ msgstr "" msgid "Created at:" msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Creates a Vereinfacht finance contact for every member that does not have one yet." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Credit" @@ -2873,6 +2878,11 @@ msgstr "" msgid "Regenerate Cycles" msgstr "" +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Regenerate membership fee cycles" +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Regenerating..." diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 489020f..d8a7fe9 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -792,6 +792,11 @@ msgstr "" msgid "Created at:" msgstr "Created at:" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Creates a Vereinfacht finance contact for every member that does not have one yet." +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Credit" @@ -2873,6 +2878,11 @@ msgstr "" msgid "Regenerate Cycles" msgstr "" +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Regenerate membership fee cycles" +msgstr "" + #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Regenerating..." diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index 6cb8f5b..6b886ae 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -300,4 +300,31 @@ defmodule MvWeb.GlobalSettingsLiveTest do "Single Sign-On" end end + + describe "Vereinfacht sync control tooltip (§1.9)" do + setup %{conn: conn} do + user = create_test_user(%{email: "admin@example.com"}) + conn = conn_with_oidc_user(conn, user) + + System.put_env("VEREINFACHT_API_URL", "https://example.test/api/v1") + System.put_env("VEREINFACHT_API_KEY", "test-key") + System.put_env("VEREINFACHT_CLUB_ID", "club-1") + + on_exit(fn -> + System.delete_env("VEREINFACHT_API_URL") + System.delete_env("VEREINFACHT_API_KEY") + System.delete_env("VEREINFACHT_CLUB_ID") + end) + + {:ok, conn: conn} + end + + test "global Vereinfacht sync control carries a tooltip and accessible label", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/settings") + + # The sync button is wrapped in a <.tooltip> (data-tip) and carries an aria-label + assert has_element?(view, ".tooltip[data-tip] button[phx-click=sync_vereinfacht_contacts]") + assert has_element?(view, "button[phx-click=sync_vereinfacht_contacts][aria-label]") + end + end end diff --git a/test/mv_web/member_live/show_membership_fees_test.exs b/test/mv_web/member_live/show_membership_fees_test.exs index 59dc471..394d743 100644 --- a/test/mv_web/member_live/show_membership_fees_test.exs +++ b/test/mv_web/member_live/show_membership_fees_test.exs @@ -55,6 +55,22 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do |> Ash.create!(actor: system_actor) end + describe "cycle-regeneration control tooltip (§3.5 icon/tooltip audit)" do + test "the regenerate_cycles control carries a tooltip and accessible label", %{conn: conn} do + fee_type = create_fee_type(%{interval: :yearly}) + member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id}) + + {:ok, view, _html} = live(conn, "/members/#{member.id}") + + view + |> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']") + |> render_click() + + assert has_element?(view, ".tooltip[data-tip] button[phx-click=regenerate_cycles]") + assert has_element?(view, "button[phx-click=regenerate_cycles][aria-label]") + end + end + describe "cycles table display" do test "displays all cycles for member", %{conn: conn} do fee_type = create_fee_type(%{interval: :yearly}) From be57dcfce808e479c9edaee3ff63dced84f8ea0c Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 8 Jun 2026 12:39:45 +0200 Subject: [PATCH 04/50] fix(web): prevent sortable-header tooltips from being clipped by the sticky header --- lib/mv_web/components/core_components.ex | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 13c69a8..2ed22fd 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -1160,17 +1160,21 @@ defmodule MvWeb.CoreComponents do end # Combines column class with optional sticky header classes (desktop only; theme-friendly bg). + # hover:z-20/focus-within:z-20 lift the active header cell above sibling sticky cells (shared z-10) + # so its hover tooltip bubble is not clipped by an adjacent opaque bg-base-100 header background. defp table_th_class(col, sticky_header) do base = Map.get(col, :class) - sticky = if sticky_header, do: "lg:sticky lg:top-0 bg-base-100 z-10", else: nil + sticky = if sticky_header, do: sticky_th_classes(), else: nil [base, sticky] |> Enum.filter(& &1) |> Enum.join(" ") end - defp table_th_sticky_class(true), - do: "lg:sticky lg:top-0 bg-base-100 z-10" + defp table_th_sticky_class(true), do: sticky_th_classes() defp table_th_sticky_class(_), do: nil + defp sticky_th_classes, + do: "lg:sticky lg:top-0 bg-base-100 z-10 hover:z-20 focus-within:z-20" + @doc """ Renders a reorderable table (sortable list) with drag handle and keyboard support. From 24f67bea8068ec747f6766a809f2f6426fd2ef13 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 8 Jun 2026 12:43:40 +0200 Subject: [PATCH 05/50] feat(member): keep text selection in the overview table from opening the member --- assets/js/app.js | 23 +++++++++++++++++++++ lib/mv_web/live/member_live/index.html.heex | 2 ++ test/mv_web/member_live/index_test.exs | 11 ++++++++++ 3 files changed, 36 insertions(+) diff --git a/assets/js/app.js b/assets/js/app.js index 4c7e3c5..a003e27 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -103,6 +103,29 @@ Hooks.TableRowKeydown = { } } +// RowSelectionGuard: distinguish drag-to-select-text from a plain click on the members table. +// LiveView fires the row navigation push (select_row_and_navigate) on any click. When the user +// drags across a cell to select text (e.g. an email to copy) and releases, the mouseup produces a +// non-empty text selection; in that case we swallow the click in the capture phase so navigation is +// suppressed. A plain click leaves the selection collapsed and navigates as before. +Hooks.RowSelectionGuard = { + mounted() { + this.handleClickCapture = (e) => { + const selection = window.getSelection() + if (selection && !selection.isCollapsed && selection.toString().trim() !== "") { + e.preventDefault() + e.stopPropagation() + } + } + // Capture phase so this runs before LiveView's bubbling phx-click handler. + this.el.addEventListener("click", this.handleClickCapture, true) + }, + + destroyed() { + this.el.removeEventListener("click", this.handleClickCapture, true) + } +} + // FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button) Hooks.FocusRestore = { mounted() { diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index cd2ef32..eb7085d 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -82,6 +82,8 @@ <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
Date: Mon, 8 Jun 2026 12:48:22 +0200 Subject: [PATCH 06/50] refactor(member): share Ash error formatting across member-show components --- lib/mv_web/helpers/ash_error_helpers.ex | 34 ++++++++++ .../member_live/show/deactivate_component.ex | 14 +---- .../show/membership_fees_component.ex | 12 +--- priv/gettext/de/LC_MESSAGES/default.po | 6 +- priv/gettext/default.pot | 6 +- priv/gettext/en/LC_MESSAGES/default.po | 6 +- .../mv_web/helpers/ash_error_helpers_test.exs | 63 +++++++++++++++++++ 7 files changed, 105 insertions(+), 36 deletions(-) create mode 100644 lib/mv_web/helpers/ash_error_helpers.ex create mode 100644 test/mv_web/helpers/ash_error_helpers_test.exs diff --git a/lib/mv_web/helpers/ash_error_helpers.ex b/lib/mv_web/helpers/ash_error_helpers.ex new file mode 100644 index 0000000..2b9f2ae --- /dev/null +++ b/lib/mv_web/helpers/ash_error_helpers.ex @@ -0,0 +1,34 @@ +defmodule MvWeb.Helpers.AshErrorHelpers do + @moduledoc """ + Shared formatting for Ash errors surfaced as flash messages in the + member show LiveComponents. + + Centralizes the translation of `Ash.Error.Invalid` / `Ash.Error.Forbidden` + (and plain string/unknown errors) into user-facing text so the components do + not each carry their own copy. + """ + use Gettext, backend: MvWeb.Gettext + + @doc """ + Turns an Ash error into a human-readable, localized string. + + - `Ash.Error.Invalid` — joins the individual error messages, falling back to + `inspect/1` for sub-errors that carry no `:message`. + - `Ash.Error.Forbidden` — a localized "not allowed" message. + - a binary — passed through unchanged (already a ready-to-show message). + - anything else — a localized generic error message. + """ + def format_error(%Ash.Error.Invalid{errors: errors}) do + Enum.map_join(errors, ", ", fn + %{message: message} -> message + other -> inspect(other) + end) + end + + def format_error(%Ash.Error.Forbidden{}) do + gettext("You are not allowed to perform this action.") + end + + def format_error(error) when is_binary(error), do: error + def format_error(_error), do: gettext("An error occurred") +end diff --git a/lib/mv_web/live/member_live/show/deactivate_component.ex b/lib/mv_web/live/member_live/show/deactivate_component.ex index d3a1dfe..dad6008 100644 --- a/lib/mv_web/live/member_live/show/deactivate_component.ex +++ b/lib/mv_web/live/member_live/show/deactivate_component.ex @@ -15,6 +15,7 @@ defmodule MvWeb.MemberLive.Show.DeactivateComponent do use MvWeb, :live_component import MvWeb.Authorization, only: [can?: 3] + import MvWeb.Helpers.AshErrorHelpers, only: [format_error: 1] alias Mv.Membership alias MvWeb.Helpers.MemberHelpers @@ -187,17 +188,4 @@ defmodule MvWeb.MemberLive.Show.DeactivateComponent do |> assign(:show_modal, false) |> assign(:error, nil) end - - defp format_error(%Ash.Error.Invalid{errors: errors}) do - Enum.map_join(errors, ", ", fn - %{message: message} -> message - other -> inspect(other) - end) - end - - defp format_error(%Ash.Error.Forbidden{}) do - gettext("You are not allowed to perform this action.") - end - - defp format_error(_error), do: gettext("An error occurred") 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 33b0456..16ee5dc 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 @@ -15,6 +15,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do require Ash.Query import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.Authorization, only: [can?: 3] + import MvWeb.Helpers.AshErrorHelpers, only: [format_error: 1] alias Mv.Membership alias Mv.MembershipFees @@ -1144,17 +1145,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_status_label(:unpaid), do: gettext("Unpaid") defp format_status_label(:suspended), do: gettext("Suspended") - defp format_error(%Ash.Error.Invalid{} = error) do - Enum.map_join(error.errors, ", ", fn e -> e.message end) - end - - defp format_error(%Ash.Error.Forbidden{}) do - gettext("You are not allowed to perform this action.") - end - - defp format_error(error) when is_binary(error), do: error - defp format_error(_error), do: gettext("An error occurred") - defp validate_cycle_not_exists(cycles, cycle_start) do if Enum.any?(cycles, &(&1.cycle_start == cycle_start)) do {:error, :cycle_exists} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index a5ffa81..38ad3e5 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -191,8 +191,7 @@ msgstr "Betrag" msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen." -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex @@ -3932,8 +3931,7 @@ msgstr "Du hattest bereits einen offenen Antrag. Hier ist ein neuer Bestätigung msgid "You are about to delete all %{count} cycles for this member." msgstr "Du bist dabei, alle %{count} Zyklen für dieses Mitglied zu löschen." -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #, elixir-autogen, elixir-format msgid "You are not allowed to perform this action." msgstr "Du hast keine Berechtigung, diese Aktion auszuführen." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 91a111d..3c21f67 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -192,8 +192,7 @@ msgstr "" msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "" -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex @@ -3932,8 +3931,7 @@ msgstr "" msgid "You are about to delete all %{count} cycles for this member." msgstr "" -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #, elixir-autogen, elixir-format msgid "You are not allowed to perform this action." msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index d8a7fe9..cda87b5 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -192,8 +192,7 @@ msgstr "" msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "" -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex @@ -3932,8 +3931,7 @@ msgstr "You already had a pending request. Here is a new confirmation link." msgid "You are about to delete all %{count} cycles for this member." msgstr "" -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #, elixir-autogen, elixir-format msgid "You are not allowed to perform this action." msgstr "" diff --git a/test/mv_web/helpers/ash_error_helpers_test.exs b/test/mv_web/helpers/ash_error_helpers_test.exs new file mode 100644 index 0000000..921f07d --- /dev/null +++ b/test/mv_web/helpers/ash_error_helpers_test.exs @@ -0,0 +1,63 @@ +defmodule MvWeb.Helpers.AshErrorHelpersTest do + @moduledoc """ + Tests for format_error/1, the shared Ash error formatter used by the + member show LiveComponents. + """ + use Mv.DataCase, async: true + + import MvWeb.Helpers.AshErrorHelpers + + describe "format_error/1" do + test "joins messages of an Ash.Error.Invalid with commas" do + error = %Ash.Error.Invalid{ + errors: [%{message: "exit_date must be after join_date"}, %{message: "another"}] + } + + assert format_error(error) == "exit_date must be after join_date, another" + end + + test "falls back to inspect for invalid sub-errors without a message" do + error = %Ash.Error.Invalid{errors: [:boom]} + + assert format_error(error) == inspect(:boom) + end + + test "returns a localized message for an Ash.Error.Forbidden" do + assert format_error(%Ash.Error.Forbidden{}) =~ "allowed" + end + + test "passes through a plain binary unchanged" do + assert format_error("custom message") == "custom message" + end + + test "returns a generic localized message for anything else" do + assert format_error(:unexpected) == "An error occurred" + end + + test "renders the genuine update_member validation error as a user-facing message" do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{ + first_name: "Exit", + last_name: "Validation", + email: "exit-validation-#{System.unique_integer([:positive])}@example.com", + join_date: ~D[2024-01-01] + }, + actor: system_actor + ) + + # exit_date earlier than join_date triggers the resource validation + {:error, %Ash.Error.Invalid{} = error} = + Mv.Membership.update_member(member, %{exit_date: ~D[2023-12-31]}, actor: system_actor) + + formatted = format_error(error) + + # The real Ash sub-error must surface its localized :message, not an inspect()'d struct. + assert formatted == "cannot be before join date" + refute formatted =~ "InvalidAttribute" + refute formatted =~ "%{" + end + end +end From 0745eece25bdde4f42ef822c07e083d9fd955b53 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 8 Jun 2026 12:49:40 +0200 Subject: [PATCH 07/50] docs(changelog): record member UI improvements under Unreleased --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index adbe7e7..3c0339a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **CSV import – membership fee type column** – A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it. - **CSV import – mapping preview** – After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm. - **Dynamic CSV import templates** – The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set. +- **Deactivate and reactivate members** – Members can be deactivated directly from the member page: a dialog picks the exit date (prefilled to today, future dates allowed); deactivated members can be reactivated, which clears the exit date. +- **Tooltips and OIDC explanation** – Icon-only action buttons (including the Vereinfacht sync control) now carry tooltips and accessible labels, and the OIDC settings section explains that it enables single sign-on. ### Changed - **Member bulk actions in one menu** – The actions above the member overview (open in email program, copy email addresses, export to CSV, export to PDF) are now collected in a single "Aktionen" dropdown instead of separate buttons. Without a selection they apply to all members, or to the currently filtered members; the trigger shows the active scope. Opening the email program is disabled when too many recipients are selected, with a hint to copy the addresses or use the export instead. @@ -24,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors. - **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists. +- **Column-header tooltips clipped** – Tooltips on the members-overview column headers are no longer clipped by the sticky table header. +- **Text selection opens member** – Dragging to select text in a members-overview row (for example to copy an email) no longer opens the member details; a plain click still opens them. ## [1.2.0] - 2026-05-08 From 1aaa0ece5df51b2d1f3cc8043de78ed857a653c7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 16:10:14 +0200 Subject: [PATCH 08/50] fix(membership): add chronological sort key for custom :date fields Custom :date values are real Date structs; sorting them by Erlang term order compares day, then month, then year, so the member list ordered them like day-first text instead of chronologically. Derive the sort key from a single shared helper that maps a date to its Gregorian day count, leaving the other value types at their already-correct natural order. --- lib/mv/membership/custom_field_sort.ex | 30 +++++++++ .../custom_field_sort_property_test.exs | 65 +++++++++++++++++++ test/mv/membership/custom_field_sort_test.exs | 29 +++++++++ 3 files changed, 124 insertions(+) create mode 100644 lib/mv/membership/custom_field_sort.ex create mode 100644 test/mv/membership/custom_field_sort_property_test.exs create mode 100644 test/mv/membership/custom_field_sort_test.exs diff --git a/lib/mv/membership/custom_field_sort.ex b/lib/mv/membership/custom_field_sort.ex new file mode 100644 index 0000000..45eaf4b --- /dev/null +++ b/lib/mv/membership/custom_field_sort.ex @@ -0,0 +1,30 @@ +defmodule Mv.Membership.CustomFieldSort do + @moduledoc """ + Derives a term-order-comparable sort key from a custom-field value. + + Custom-field values are stored in an Ash `:union` attribute, so a value + arrives either as an `%Ash.Union{}` or as its already-unwrapped term. This + module is the single source of truth for turning such a value into a key that + `Enum.sort_by/2` can order correctly per `value_type`. + + nil / empty handling is intentionally NOT this function's concern — the call + sites split present from absent values before sorting. + """ + + @doc """ + Returns a term-order-comparable sort key for `value`, given its `value_type`. + + For every non-date type the natural unwrapped value is returned, so + `:integer` sorts numerically and `:string` / `:email` sort lexicographically. + """ + def sort_key(%Ash.Union{value: value, type: type}, _expected_type), + do: sort_key(value, type) + + def sort_key(value, :string) when is_binary(value), do: value + def sort_key(value, :integer) when is_integer(value), do: value + def sort_key(value, :boolean) when is_boolean(value), do: value + # Gregorian day count is a chronological integer key (earlier date ⇒ smaller). + def sort_key(%Date{} = date, :date), do: Date.to_gregorian_days(date) + def sort_key(value, :email) when is_binary(value), do: value + def sort_key(value, _type), do: to_string(value) +end diff --git a/test/mv/membership/custom_field_sort_property_test.exs b/test/mv/membership/custom_field_sort_property_test.exs new file mode 100644 index 0000000..345a299 --- /dev/null +++ b/test/mv/membership/custom_field_sort_property_test.exs @@ -0,0 +1,65 @@ +defmodule Mv.Membership.CustomFieldSortPropertyTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Mv.Membership.CustomFieldSort + + defp date_generator do + gen all( + year <- integer(1..9999), + month <- integer(1..12), + day <- integer(1..28) + ) do + Date.new!(year, month, day) + end + end + + # The production load path always delivers a :date value as + # %Ash.Union{type: :date, value: %Date{}}, so the property exercises both the + # bare %Date{} form and the union-wrapped form to pin the union-dispatch clause. + defp shape_generator do + member_of([:bare, :union]) + end + + defp wrap_date(date, :bare), do: date + defp wrap_date(date, :union), do: %Ash.Union{type: :date, value: date} + + property "sort_key/2 orders :date values chronologically in both directions" do + check all( + raw_dates <- list_of(date_generator(), min_length: 1), + shape <- shape_generator() + ) do + values = Enum.map(raw_dates, &wrap_date(&1, shape)) + + ascending = Enum.sort_by(values, &CustomFieldSort.sort_key(&1, :date)) + ascending_dates = Enum.map(ascending, &unwrap/1) + descending_dates = Enum.reverse(ascending_dates) + + assert non_decreasing?(ascending_dates) + assert non_increasing?(descending_dates) + + # The key must be an integer Gregorian-day count, not a stringified date: + # this pins the dedicated :date branch and guards against a string-coerced + # key whose chronological correctness would silently depend on zero-padded + # ISO formatting. + Enum.each(values, fn value -> + assert CustomFieldSort.sort_key(value, :date) == Date.to_gregorian_days(unwrap(value)) + end) + end + end + + defp unwrap(%Ash.Union{value: value}), do: value + defp unwrap(%Date{} = date), do: date + + defp non_decreasing?(dates) do + dates + |> Enum.chunk_every(2, 1, :discard) + |> Enum.all?(fn [a, b] -> Date.compare(a, b) != :gt end) + end + + defp non_increasing?(dates) do + dates + |> Enum.chunk_every(2, 1, :discard) + |> Enum.all?(fn [a, b] -> Date.compare(a, b) != :lt end) + end +end diff --git a/test/mv/membership/custom_field_sort_test.exs b/test/mv/membership/custom_field_sort_test.exs new file mode 100644 index 0000000..6d8a8e6 --- /dev/null +++ b/test/mv/membership/custom_field_sort_test.exs @@ -0,0 +1,29 @@ +defmodule Mv.Membership.CustomFieldSortTest do + use ExUnit.Case, async: true + + alias Mv.Membership.CustomFieldSort + + describe "sort_key/2" do + test "keeps :integer values numerically comparable" do + values = [10, 100, 2] + + sorted = Enum.sort_by(values, &CustomFieldSort.sort_key(&1, :integer)) + + assert sorted == [2, 10, 100] + end + + test "passes :string values through to their natural term-order key" do + assert CustomFieldSort.sort_key("Zebra", :string) == "Zebra" + end + + test "passes :email values through to their natural term-order key" do + assert CustomFieldSort.sort_key("a@example.com", :email) == "a@example.com" + end + + test "unwraps an %Ash.Union{} value before deriving the key" do + union = %Ash.Union{type: :integer, value: 42} + + assert CustomFieldSort.sort_key(union, :integer) == 42 + end + end +end From 6d4629ef5bce5dbcadd2c61999bcf73e51e1b7ad Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 16:14:14 +0200 Subject: [PATCH 09/50] fix(member): order member list chronologically by custom :date fields --- lib/mv_web/live/member_live/index.ex | 25 +++----- .../index_custom_fields_sorting_test.exs | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index dc15ba0..53a5705 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -32,6 +32,7 @@ defmodule MvWeb.MemberLive.Index do import MvWeb.LiveHelpers, only: [current_actor: 1] alias Mv.Membership + alias Mv.Membership.CustomFieldSort alias Mv.Membership.Member, as: MemberResource alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType @@ -1414,8 +1415,7 @@ defmodule MvWeb.MemberLive.Index do false cfv -> - extracted = extract_sort_value(cfv.value, custom_field.value_type) - not empty_value?(extracted, custom_field.value_type) + not empty_value?(cfv.value, custom_field.value_type) end end @@ -1423,29 +1423,22 @@ defmodule MvWeb.MemberLive.Index do sorted = Enum.sort_by(members_with_values, fn member -> cfv = get_custom_field_value(member, custom_field) - extracted = extract_sort_value(cfv.value, custom_field.value_type) - normalize_sort_value(extracted, order) + CustomFieldSort.sort_key(cfv.value, custom_field.value_type) end) if order == :desc, do: Enum.reverse(sorted), else: sorted end - defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type), - do: extract_sort_value(value, type) + defp empty_value?(%Ash.Union{value: value, type: type}, _expected_type), + do: empty_value?(value, type) - defp extract_sort_value(value, :string) when is_binary(value), do: value - defp extract_sort_value(value, :integer) when is_integer(value), do: value - defp extract_sort_value(value, :boolean) when is_boolean(value), do: value - defp extract_sort_value(%Date{} = date, :date), do: date - defp extract_sort_value(value, :email) when is_binary(value), do: value - defp extract_sort_value(value, _type), do: to_string(value) + defp empty_value?(nil, _type), do: true + + defp empty_value?(value, type) when type in [:string, :email] and is_binary(value), + do: String.trim(value) == "" - defp empty_value?(value, :string) when is_binary(value), do: String.trim(value) == "" - defp empty_value?(value, :email) when is_binary(value), do: String.trim(value) == "" defp empty_value?(_value, _type), do: false - defp normalize_sort_value(value, _order), do: value - 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) diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs index 2f12fcc..afce67b 100644 --- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -231,6 +231,63 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']") end + test "sorts members chronologically by a :date custom field (ascending)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + # Dates chosen to expose the day-first term-ordering trap: term order of the + # %Date{} structs compares day, then month, then year, which would place + # 02.07.1986 (day 02) before 29.01.1981 (day 29). Chronologically 1981 < 1982 < 1986. + members_and_dates = [ + {"EightySix", ~D[1986-07-02]}, + {"EightyOne", ~D[1981-01-29]}, + {"EightyTwo", ~D[1982-03-01]} + ] + + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "birth_date", + value_type: :date, + show_in_overview: true + }) + |> Ash.create(actor: system_actor) + + for {first_name, date} <- members_and_dates do + {:ok, member} = + Mv.Membership.create_member( + %{ + first_name: first_name, + last_name: "Test", + email: "#{String.downcase(first_name)}@example.com" + }, + actor: system_actor + ) + + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "date", "_union_value" => Date.to_iso8601(date)} + }) + |> Ash.create(actor: system_actor) + end + + conn = conn_with_oidc_user(conn) + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + html = render(view) + + {one_idx, _} = :binary.match(html, "EightyOne") + {two_idx, _} = :binary.match(html, "EightyTwo") + {six_idx, _} = :binary.match(html, "EightySix") + + assert one_idx < two_idx, "29.01.1981 must come before 01.03.1982 in ascending order" + assert two_idx < six_idx, "01.03.1982 must come before 02.07.1986 in ascending order" + end + test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() From 2eda661e375164deb7fc2ca1afa5c17dd68db9d9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 16:18:13 +0200 Subject: [PATCH 10/50] fix(export): order member export chronologically by custom :date fields --- .../controllers/member_export_controller.ex | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index e9c4a2a..1f70a18 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -12,6 +12,7 @@ defmodule MvWeb.MemberExportController do alias Mv.Authorization.Actor alias Mv.Membership.CustomField + alias Mv.Membership.CustomFieldSort alias Mv.Membership.Member alias Mv.Membership.MemberExport alias Mv.Membership.MembersCSV @@ -523,18 +524,22 @@ defmodule MvWeb.MemberExportController do false cfv -> - extracted = extract_sort_value(cfv.value, custom_field.value_type) - not empty_value?(extracted, custom_field.value_type) + not empty_custom_field_value?(cfv.value, custom_field.value_type) end end - defp empty_value?(nil, _type), do: true + defp empty_custom_field_value?(%Ash.Union{value: value, type: type}, _expected_type) do + empty_custom_field_value?(value, type) + end - defp empty_value?(value, type) when type in [:string, :email] and is_binary(value) do + defp empty_custom_field_value?(nil, _type), do: true + + defp empty_custom_field_value?(value, type) + when type in [:string, :email] and is_binary(value) do String.trim(value) == "" end - defp empty_value?(_value, _type), do: false + defp empty_custom_field_value?(_value, _type), do: false defp find_cfv(member, custom_field) do (member.custom_field_values || []) @@ -548,7 +553,7 @@ defmodule MvWeb.MemberExportController do defp extract_member_sort_value(member, custom_field) do case find_cfv(member, custom_field) do nil -> nil - cfv -> extract_sort_value(cfv.value, custom_field.value_type) + cfv -> CustomFieldSort.sort_key(cfv.value, custom_field.value_type) end end @@ -670,15 +675,4 @@ defmodule MvWeb.MemberExportController do |> String.split() |> Enum.map_join(" ", &String.capitalize/1) end - - defp extract_sort_value(%Ash.Union{value: value, type: type}, _), - do: extract_sort_value(value, type) - - defp extract_sort_value(nil, _), do: nil - defp extract_sort_value(value, :string) when is_binary(value), do: value - defp extract_sort_value(value, :integer) when is_integer(value), do: value - defp extract_sort_value(value, :boolean) when is_boolean(value), do: value - defp extract_sort_value(%Date{} = d, :date), do: d - defp extract_sort_value(value, :email) when is_binary(value), do: value - defp extract_sort_value(value, _), do: to_string(value) end From 346291cc0d786a8a12386a95ee821b4fd583f5ef Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 16:18:42 +0200 Subject: [PATCH 11/50] docs(changelog): record custom-date sorting fix under Unreleased --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c0339a..293d07c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists. - **Column-header tooltips clipped** – Tooltips on the members-overview column headers are no longer clipped by the sticky table header. - **Text selection opens member** – Dragging to select text in a members-overview row (for example to copy an email) no longer opens the member details; a plain click still opens them. +- **Sort by custom date** – Sorting the member list or member export by a custom date field now orders rows chronologically instead of like text, so e.g. 29.01.1981 correctly comes before 01.03.1982. ## [1.2.0] - 2026-05-08 From 365ff10fd86a3b7483d35fe72cf9eb7d5ad20059 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 17:48:53 +0200 Subject: [PATCH 12/50] feat(docker): parametrize host ports and project name for parallel dev stacks Several isolated stacks can now coexist: host ports come from DB_PORT/RAUTHY_PORT/MAILCRAB_PORT (defaulting to today's values) and the container namespace from COMPOSE_PROJECT_NAME. Drops the fixed rauthy-dev container_name that blocked a second stack. --- docker-compose.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index cbd2e9e..44db148 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,19 +12,21 @@ services: volumes: - postgres-data:/var/lib/postgresql ports: - - "5000:5432" + - "${DB_PORT:-5000}:5432" networks: - local mailcrab: image: marlonb/mailcrab:latest ports: - - "1080:1080" + - "${MAILCRAB_PORT:-1080}:1080" networks: - rauthy-dev rauthy: - container_name: rauthy-dev + # No fixed container_name — Compose derives it from COMPOSE_PROJECT_NAME so + # several isolated stacks coexist (e.g. mv--rauthy-1). A plain + # checkout gets -rauthy-1. image: ghcr.io/sebadob/rauthy:0.35.2 environment: - LOCAL_TEST=true @@ -32,7 +34,8 @@ services: - SMTP_PORT=1025 - SMTP_DANGER_INSECURE=true - LISTEN_SCHEME=http - - PUB_URL=localhost:8080 + # Advertised URL must match the host-mapped port below. + - PUB_URL=localhost:${RAUTHY_PORT:-8080} - BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345 # Disable strict IP validation to allow access from multiple Docker networks - SESSION_VALIDATE_IP=false @@ -40,7 +43,7 @@ services: # Re-runs after `docker compose down -v` because the DB is empty again. - BOOTSTRAP_DIR=/app/bootstrap ports: - - "8080:8080" + - "${RAUTHY_PORT:-8080}:8080" depends_on: - mailcrab - db From 0a53e11cc4c1aa6192ad96187e019ee16e16a968 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 17:48:53 +0200 Subject: [PATCH 13/50] feat(config): read database host port from DB_PORT in dev and test --- config/dev.exs | 6 +++--- config/test.exs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/dev.exs b/config/dev.exs index 139b816..d96bd7e 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -5,7 +5,7 @@ config :mv, Mv.Repo, username: "postgres", password: "postgres", hostname: "localhost", - port: 5000, + port: String.to_integer(System.get_env("DB_PORT") || "5000"), database: "mv_dev", stacktrace: true, show_sensitive_data_on_connection_error: true, @@ -97,9 +97,9 @@ config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL" # do not set defaults here so the SSO button stays hidden and no MissingSecret occurs. # config :mv, :oidc, # client_id: "mv", -# base_url: "http://localhost:8080/auth/v1", +# base_url: "http://localhost:#{System.get_env("RAUTHY_PORT") || "8080"}/auth/v1", # client_secret: System.get_env("OIDC_CLIENT_SECRET"), -# redirect_uri: "http://localhost:4000/auth/user/oidc/callback" +# redirect_uri: "http://localhost:#{System.get_env("PORT") || "4000"}/auth/user/oidc/callback" # AshAuthentication development configuration config :mv, :session_identifier, :jti diff --git a/config/test.exs b/config/test.exs index 7343a6a..10ab4e8 100644 --- a/config/test.exs +++ b/config/test.exs @@ -9,7 +9,7 @@ config :mv, Mv.Repo, username: "postgres", password: "postgres", hostname: System.get_env("TEST_POSTGRES_HOST", "localhost"), - port: System.get_env("TEST_POSTGRES_PORT", "5000"), + port: System.get_env("TEST_POSTGRES_PORT") || System.get_env("DB_PORT") || "5000", database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}", pool: Ecto.Adapters.SQL.Sandbox, pool_size: System.schedulers_online() * 8, From c332a4dde27ff9cefb241a3801d187e089f03bb9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 17:48:53 +0200 Subject: [PATCH 14/50] feat(dialyzer): allow overriding PLT paths via PLT_CORE_PATH/PLT_LOCAL_PATH --- mix.exs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index b20572a..23e8a6f 100644 --- a/mix.exs +++ b/mix.exs @@ -117,8 +117,11 @@ defmodule Mv.MixProject do defp dialyzer do [ - plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, - plt_core_path: "priv/plts/core.plt", + # PLT paths are overridable so the pipeline can point all worktrees/clones + # at a shared, machine-global PLT cache (avoids each rebuilding the PLT). + # Default to the in-repo priv/plts for a plain checkout. + plt_file: {:no_warn, System.get_env("PLT_LOCAL_PATH") || "priv/plts/dialyzer.plt"}, + plt_core_path: System.get_env("PLT_CORE_PATH") || "priv/plts/core.plt", plt_add_apps: [:mix, :ex_unit], flags: [ :error_handling, From 2363ef69e3d8b10b4e367cf7fd99194091692b50 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 17:48:53 +0200 Subject: [PATCH 15/50] feat(justfile): add start-test-db recipe for an isolated test database --- Justfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Justfile b/Justfile index 9b0be65..576b4f3 100644 --- a/Justfile +++ b/Justfile @@ -29,6 +29,9 @@ seed-database: start-database: docker compose up -d +start-test-db: + docker compose up -d db + # Full check suite: lint + audit + the fast tests (slow/ui excluded). No Dialyzer. ci-dev: install-dependencies lint audit test-fast From 8d783276d0a32167020ffd8f8a63e81f0408cef3 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 21:53:36 +0200 Subject: [PATCH 16/50] docs(roles): condense roles/permissions/auth docs and align with the code --- docs/admin-bootstrap-and-oidc-role-sync.md | 2 +- docs/oidc-account-linking.md | 45 +- docs/page-permission-route-coverage.md | 65 +- docs/policy-bypass-vs-haspermission.md | 287 +-- docs/roles-and-permissions-architecture.md | 1664 ++-------------- ...les-and-permissions-implementation-plan.md | 1684 +---------------- docs/roles-and-permissions-overview.md | 168 +- ...esource-policies-implementation-summary.md | 269 --- 8 files changed, 348 insertions(+), 3836 deletions(-) delete mode 100644 docs/user-resource-policies-implementation-summary.md diff --git a/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md index 5413f91..ee78069 100644 --- a/docs/admin-bootstrap-and-oidc-role-sync.md +++ b/docs/admin-bootstrap-and-oidc-role-sync.md @@ -26,7 +26,7 @@ ### Seeds (Dev/Test) -- priv/repo/seeds.exs – Uses ADMIN_PASSWORD or ADMIN_PASSWORD_FILE when set; otherwise fallback "testpassword" only in dev/test. +- priv/repo/seeds_bootstrap.exs – Uses ADMIN_PASSWORD or ADMIN_PASSWORD_FILE when set; otherwise fallback "testpassword" only in dev/test. ## OIDC Role Sync (Part B) diff --git a/docs/oidc-account-linking.md b/docs/oidc-account-linking.md index 570d4e8..48e82d8 100644 --- a/docs/oidc-account-linking.md +++ b/docs/oidc-account-linking.md @@ -102,12 +102,12 @@ Interactive UI for password verification and account linking. **Changes**: -- `lib/mv_web/locale_controller.ex`: Sets locale cookie with `http_only` and `secure` flags +- `MvWeb.LocaleController`: Sets locale cookie with `http_only` and a config-driven `secure` flag - `lib/mv_web/router.ex`: Reads locale from cookie if session empty **Security Features**: - `http_only: true` - Cookie not accessible via JavaScript (XSS protection) -- `secure: true` - Cookie only transmitted over HTTPS in production +- `secure: Application.get_env(:mv, :use_secure_cookies, false)` - the `secure` flag is config-driven (defaults to `false`; enabled in production) so the cookie is only transmitted over HTTPS in production - `same_site: "Lax"` - CSRF protection ## Security Considerations @@ -139,47 +139,6 @@ Interactive UI for password verification and account linking. - `Logger.warning` for failed authentication attempts - `Logger.error` for system errors -## Usage Examples - -### Scenario 1: New OIDC User - -```elixir -# User signs in with OIDC for the first time -# → New user created with oidc_id -``` - -### Scenario 2: Existing OIDC User - -```elixir -# User with oidc_id signs in via OIDC -# → Matched by oidc_id, email updated if changed -``` - -### Scenario 3: Password User + OIDC Login - -```elixir -# User with password account tries OIDC login -# → PasswordVerificationRequired raised -# → Redirected to /auth/link-oidc-account -# → User enters password -# → Password verified and logged -# → oidc_id linked to account -# → Successful linking logged -# → Redirected to complete OIDC login -``` - -### Scenario 4: Passwordless User + OIDC Login - -```elixir -# User without password (invited user) tries OIDC login -# → PasswordVerificationRequired raised -# → Redirected to /auth/link-oidc-account -# → System detects passwordless user -# → oidc_id automatically linked (no password prompt) -# → Auto-linking logged -# → Redirected to complete OIDC login -``` - ## API ### Custom Actions diff --git a/docs/page-permission-route-coverage.md b/docs/page-permission-route-coverage.md index 6571a39..1302594 100644 --- a/docs/page-permission-route-coverage.md +++ b/docs/page-permission-route-coverage.md @@ -19,9 +19,8 @@ This document lists all protected routes, which permission set may access them, | `/users/:id/show/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ | | `/settings` | ✗ | ✗ | ✗ | ✓ | | `/membership_fee_settings` | ✗ | ✗ | ✗ | ✓ | -| `/membership_fee_types` | ✗ | ✗ | ✗ | ✓ | -| `/membership_fee_types/new` | ✗ | ✗ | ✗ | ✓ | -| `/membership_fee_types/:id/edit` | ✗ | ✗ | ✗ | ✓ | +| `/membership_fee_settings/new_fee_type` | ✗ | ✗ | ✗ | ✓ | +| `/membership_fee_settings/:id/edit_fee_type` | ✗ | ✗ | ✗ | ✓ | | `/groups` | ✗ | ✓ | ✓ | ✓ | | `/groups/new` | ✗ | ✗ | ✗ | ✓ | | `/groups/:slug` | ✗ | ✓ | ✓ | ✓ | @@ -31,10 +30,18 @@ This document lists all protected routes, which permission set may access them, | `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ | | `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ | | `/admin/roles/:id/edit` | ✗ | ✗ | ✗ | ✓ | -| `/join_requests` (Step 2) | ✗ | ✗ | ✓ | ✓ | -| `/join_requests/:id` (Step 2) | ✗ | ✗ | ✓ | ✓ | +| `/join_requests` | ✗ | ✗ | ✓ | ✓ | +| `/join_requests/:id` | ✗ | ✗ | ✓ | ✓ | +| `/admin/datafields` | ✗ | ✗ | ✗ | ✓ | +| `/admin/import` | ✗ | ✗ | ✗ | ✓ | +| `/admin/import/template/en` | ✗ | ✗ | ✗ | ✓ | +| `/admin/import/template/de` | ✗ | ✗ | ✗ | ✓ | +| `/members/export.csv` | ✗ | ✓ | ✓ | ✓ | +| `/members/export.pdf` | ✗ | ✗ | ✗ | ✓ | -**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use. Step 2 (Approval UI) adds `/join_requests` and `/join_requests/:id` for normal_user and admin; routes and permission set entries are not yet implemented; tests exist in `check_page_permission_test.exs` (describe "join_requests routes" and integration blocks). +**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use. The Approval UI routes `/join_requests` and `/join_requests/:id` are implemented and routed: `normal_user` lists them explicitly in its permission set, and `admin` reaches them through the `*` wildcard. + +**Note on admin-only routes:** `/admin/datafields`, `/admin/import`, `/admin/import/template/en`, `/admin/import/template/de`, and `/members/export.pdf` are not listed explicitly in any permission set; only `admin` can reach them, via the `*` wildcard. `/members/export.csv` is additionally granted explicitly to `read_only` and `normal_user`. ## Public Paths (no permission check) @@ -46,50 +53,12 @@ The join confirmation route `GET /confirm_join/:token` is public (matched by `/c ## Test Coverage -**File:** `test/mv_web/plugs/check_page_permission_test.exs` +**File:** `test/mv_web/plugs/check_page_permission_test.exs` covers both unit tests (plug called directly with a mock conn) and full-router integration tests. The route→permission-set matrix above is the source of truth; each permission set (own_data/Mitglied, read_only, normal_user/Kassenwart, admin) is exercised there. Allowed routes return 200; denied routes return 302 → `/users/:id`. `GET /` redirects own_data to its profile. Unauthenticated access is denied and redirected to `/sign-in`; public paths (`/auth/sign-in`, `/register`) are allowed. Error cases (no role, invalid permission_set_name) deny. -### Unit tests (plug called directly with mock conn) +Two coverage notes: -- Static: own_data denied `/members`; read_only allowed `/members`; flash on denial. -- Dynamic: read_only allowed `/members/123`; normal_user allowed `/members/456/edit`; read_only denied `/members/123/edit`. -- read_only / normal_user: denied `/admin/roles`; read_only denied `/members/new`. -- Wildcard: admin allowed `/admin/roles`, `/members/999/edit`. -- Unauthenticated: nil user denied, redirect `/sign-in`. -- Public: unauthenticated allowed `/auth/sign-in`, `/register`. -- Error: no role, invalid permission_set_name → denied. -- **Join requests (Step 2):** normal_user and admin allowed `/join_requests`, `/join_requests/:id`; read_only and own_data denied. Tests fail (red) until routes and permission set are added. - -### Integration tests (full router, Mitglied = own_data) - -**Denied (Mitglied gets 302 → `/users/:id`):** - -- `/members`, `/members/new`, `/users`, `/users/new`, `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/membership_fee_types/new`, `/groups`, `/groups/new`, `/admin/roles`, `/admin/roles/new` -- `/members/:id/edit`, `/members/:id/show/edit`, `/users/:id` (other user), `/users/:id/edit` (other), `/users/:id/show/edit` (other), `/membership_fee_types/:id/edit`, `/groups/:slug`, `/admin/roles/:id`, `/admin/roles/:id/edit` - -**Allowed (Mitglied gets 200):** - -- `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit` -- `/members/:id`, `/members/:id/edit`, `/members/:id/show/edit` for linked member (plug unit tests; full-router tests for linked member skipped: session/LiveView constraints) - -**Root:** `GET /` redirects Mitglied to profile (root not allowed for own_data). - -All protected routes above are either covered by integration “denied” tests for Mitglied or by unit tests for the relevant permission set. - -### Integration tests (full router, read_only = Vorstand/Buchhaltung) - -**Allowed (200):** `/`, `/members`, `/members/:id`, `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`, `/groups`, `/groups/:slug`. - -**Denied (302 → `/users/:id`):** `/members/new`, `/members/:id/edit`, `/members/:id/show/edit`, `/users`, `/users/new`, `/users/:id` (other user), `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/groups/new`, `/groups/:slug/edit`, `/admin/roles`, `/admin/roles/:id`. - -### Integration tests (full router, normal_user = Kassenwart) - -**Allowed (200):** `/`, `/members`, `/members/new`, `/members/:id`, `/members/:id/edit`, `/members/:id/show/edit`, `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`, `/groups`, `/groups/:slug`. - -**Denied (302 → `/users/:id`):** `/users`, `/users/new`, `/users/:id` (other user), `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/groups/new`, `/groups/:slug/edit`, `/admin/roles`, `/admin/roles/:id`. - -### Integration tests (full router, admin) - -**Allowed (200):** All protected routes (sample covered: `/`, `/members`, `/users`, `/settings`, `/membership_fee_settings`, `/admin/roles`, `/members/:id`, `/admin/roles/:id`, `/groups/:slug`). +- **Linked-member routes** (`/members/:id*` for own_data) are covered by plug unit tests; full-router integration tests for the linked member are skipped due to session/LiveView constraints. +- **Join requests:** normal_user and admin are allowed `/join_requests` and `/join_requests/:id` (normal_user via its explicit permission-set pages, admin via the `*` wildcard); read_only and own_data are denied. ## Plug behaviour: reserved segments diff --git a/docs/policy-bypass-vs-haspermission.md b/docs/policy-bypass-vs-haspermission.md index 31bb737..124d623 100644 --- a/docs/policy-bypass-vs-haspermission.md +++ b/docs/policy-bypass-vs-haspermission.md @@ -1,69 +1,56 @@ # Policy Pattern: Bypass vs. HasPermission -**Date:** 2026-01-22 -**Status:** Implemented and Tested +**Date:** 2026-01-22 +**Status:** Implemented and Tested **Applies to:** User Resource, Member Resource ---- - ## Summary -For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier authorization pattern**: +For filter-based permissions (`scope :own`, `scope :linked`) we use a **two-tier authorization pattern**: -1. **Bypass with `expr()` for READ operations** - Handles list queries via auto_filter -2. **HasPermission for UPDATE/CREATE/DESTROY** - Uses scope from PermissionSets when record is present +1. **Bypass with `expr()` for READ** — handles list queries via `auto_filter`. +2. **HasPermission for UPDATE/CREATE/DESTROY** — uses scope from PermissionSets when a record is present. -This pattern ensures that the scope concept in PermissionSets is actually used and not redundant. - ---- +This ensures the scope concept in PermissionSets is actually used and not redundant. ## The Problem -### Initial Assumption (INCORRECT) +The initial assumption was that `HasPermission` returning `{:filter, expr(...)}` would automatically trigger Ash's `auto_filter` for list queries. It does not: -> "No separate Own Credentials Bypass needed, as all permission sets already have User read/update :own. HasPermission with scope :own handles this correctly." +1. `strict_check` is called first. +2. For list queries (no record yet), `strict_check` returns `{:ok, false}`. +3. Ash **STOPS** evaluation and does **NOT** call `auto_filter`. +4. List queries fail with empty results. -This assumption was based on the idea that `HasPermission` returning `{:filter, expr(...)}` would automatically trigger Ash's `auto_filter` for list queries. - -### Reality - -**When HasPermission returns `{:filter, expr(...)}`:** - -1. `strict_check` is called first -2. For list queries (no record yet), `strict_check` returns `{:ok, false}` -3. Ash **STOPS** evaluation and does **NOT** call `auto_filter` -4. Result: List queries fail with empty results ❌ - -**Example:** ```elixir # This FAILS for list queries: policy action_type([:read, :update]) do authorize_if Mv.Authorization.Checks.HasPermission end -# User tries to list all users: -Ash.read(User, actor: user) -# Expected: Returns [user] (filtered to own record) -# Actual: Returns [] (empty list) +# Ash.read(User, actor: user) +# Expected: [user] (filtered to own record) +# Actual: [] (empty list) ``` ---- - ## The Solution -### Pattern: Bypass for READ, HasPermission for UPDATE - -**User Resource Example:** +Bypass for READ, HasPermission for everything else: ```elixir policies do - # Bypass for READ (handles list queries via auto_filter) + # AshAuthentication (registration/login) + bypass AshAuthentication.Checks.AshAuthenticationInteraction do + authorize_if always() + end + + # Bypass for READ — handles list queries via auto_filter bypass action_type(:read) do description "Users can always read their own account" authorize_if expr(id == ^actor(:id)) end - - # HasPermission for UPDATE (scope :own works with changesets) + + # HasPermission — scope from PermissionSets, used when a record is present policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role and permission set" authorize_if Mv.Authorization.Checks.HasPermission @@ -71,260 +58,100 @@ policies do end ``` -**Why This Works:** +Why it works: -| Operation | Record Available? | Method | Result | -|-----------|-------------------|--------|--------| -| **READ (list)** | ❌ No | `bypass` with `expr()` | Ash applies expr as SQL WHERE → ✅ Filtered list | -| **READ (single)** | ✅ Yes | `bypass` with `expr()` | Ash evaluates expr → ✅ true/false | -| **UPDATE** | ✅ Yes (changeset) | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized | -| **CREATE** | ✅ Yes (changeset) | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized | -| **DESTROY** | ✅ Yes | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized | +| Operation | Record? | Method | Result | +|-----------|---------|--------|--------| +| READ (list) | No | `bypass` + `expr()` | Ash compiles expr to SQL WHERE → filtered list | +| READ (single) | Yes | `bypass` + `expr()` | Ash evaluates expr → true/false | +| UPDATE / CREATE / DESTROY | Yes (changeset) | `HasPermission` + scope | `strict_check` evaluates record → authorized | -**Important: UPDATE Strategy** +### UPDATE is controlled by PermissionSets, not hardcoded -UPDATE is **NOT** a hardcoded bypass. It is controlled by **PermissionSets**: +UPDATE is **not** a hardcoded bypass. All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant `User.update :own`; `HasPermission` evaluates `scope :own` when a changeset with a record is present. Removing `User.update :own` from a set would remove credential-update ability for that set — intentional. -- All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant `User.update :own` -- `HasPermission` evaluates `scope :own` when a changeset with record is present -- If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials -- This is intentional - UPDATE is controlled by PermissionSets, not hardcoded +**Decision: `read_only` grants `User.update :own`** even though it is "read-only" for member data, so password changes work while member data stays read-only. -**Example:** The `read_only` permission set grants `User.update :own` even though it's "read-only" for member data. This allows password changes while keeping member data read-only. +### No explicit `forbid_if always()` ---- +We do **not** add a trailing `forbid_if always()`. Ash fails closed implicitly — it forbids when no policy authorizes. An explicit terminal forbid breaks tests because it forbids valid operations that earlier policies should authorize. ## Why `scope :own` Is NOT Redundant -### The Question - -> "If we use a bypass with `expr(id == ^actor(:id))` for READ, isn't `scope :own` in PermissionSets redundant?" - -### The Answer: NO! ✅ - -**`scope :own` is ONLY used for operations where a record is present:** +`scope :own` is used for operations where a record is present (UPDATE/CREATE/DESTROY), even though the bypass handles READ: ```elixir # PermissionSets.ex -%{resource: "User", action: :read, scope: :own, granted: true}, # Not used (bypass handles it) -%{resource: "User", action: :update, scope: :own, granted: true}, # USED by HasPermission ✅ +%{resource: "User", action: :read, scope: :own, granted: true}, # not used (bypass handles it) +%{resource: "User", action: :update, scope: :own, granted: true}, # USED by HasPermission ``` -**Test Proof:** - -```elixir -# test/mv/accounts/user_policies_test.exs:82 -test "can update own email", %{user: user} do - new_email = "updated@example.com" - - # This works via HasPermission with scope :own (NOT via bypass) - {:ok, updated_user} = - user - |> Ash.Changeset.for_update(:update_user, %{email: new_email}) - |> Ash.update(actor: user) - - assert updated_user.email == Ash.CiString.new(new_email) -end -# ✅ Test passes - proves scope :own is used! -``` - ---- +Proven by `test/mv/accounts/user_policies_test.exs` ("can update own email"): the update succeeds via `HasPermission` with `scope :own` (not via bypass). ## Consistency Across Resources +Both User and Member follow the same shape — bypass for READ, HasPermission for UPDATE/CREATE/DESTROY — differing only in the actor key and scope: + ### User Resource ```elixir -# Bypass for READ list queries bypass action_type(:read) do authorize_if expr(id == ^actor(:id)) end - -# HasPermission for UPDATE (uses scope :own from PermissionSets) -policy action_type([:read, :create, :update, :destroy]) do - authorize_if Mv.Authorization.Checks.HasPermission -end ``` -**PermissionSets:** -- `own_data`, `read_only`, `normal_user`: `scope :own` for read/update -- `admin`: `scope :all` for all operations +PermissionSets: `own_data` / `read_only` / `normal_user` use `scope :own` for read/update; `admin` uses `scope :all`. ### Member Resource ```elixir -# Bypass for READ list queries bypass action_type(:read) do authorize_if expr(id == ^actor(:member_id)) end - -# HasPermission for UPDATE (uses scope :linked from PermissionSets) -policy action_type([:read, :create, :update, :destroy]) do - authorize_if Mv.Authorization.Checks.HasPermission -end ``` -**PermissionSets:** -- `own_data`: `scope :linked` for read/update -- `read_only`: `scope :all` for read (no update permission) -- `normal_user`, `admin`: `scope :all` for all operations - ---- +PermissionSets: `own_data` uses `scope :linked` for read/update; `read_only` uses `scope :all` for read (no update); `normal_user` and `admin` use `scope :all`. ## Technical Deep Dive -### Why Does `expr()` in Bypass Work? +### Why `expr()` in bypass works -**Ash treats `expr()` natively in two contexts:** +Ash treats `expr()` natively in both contexts: -1. **strict_check** (single record): - - Ash evaluates the expression against the record - - Returns true/false based on match +- **strict_check** (single record): evaluates the expression against the record → true/false. +- **auto_filter** (list queries): compiles the expression to a SQL WHERE clause applied in the DB query. -2. **auto_filter** (list queries): - - Ash compiles the expression to SQL WHERE clause - - Applies filter directly in database query - -**Example:** ```elixir -bypass action_type(:read) do - authorize_if expr(id == ^actor(:id)) -end - -# For list query: Ash.read(User, actor: user) -# Compiled SQL: SELECT * FROM users WHERE id = $1 (user.id) -# Result: [user] ✅ +# Ash.read(User, actor: user) +# Compiled SQL: SELECT * FROM users WHERE id = $1 → [user] ``` -### Why Doesn't HasPermission Trigger auto_filter? - -**HasPermission.strict_check logic:** +### Why HasPermission doesn't trigger auto_filter ```elixir def strict_check(actor, authorizer, _opts) do - # ... case check_permission(...) do {:filter, filter_expr} -> if record do - # Evaluate filter against record evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name) else - # No record (list query) - return false - # Ash STOPS here, does NOT call auto_filter + # No record (list query) → return false. Ash STOPS, does NOT call auto_filter. {:ok, false} end end end ``` -**Why return false instead of :unknown?** - -We tested returning `:unknown`, but Ash's policy evaluation still didn't reliably call `auto_filter`. The `bypass` with `expr()` is the only consistent solution. - ---- - -## Design Principles - -### 1. Consistency - -Both User and Member follow the same pattern: -- Bypass for READ (list queries) -- HasPermission for UPDATE/CREATE/DESTROY (with scope) - -### 2. Scope Concept Is Essential - -PermissionSets define scopes for all operations: -- `:own` - User can access their own records -- `:linked` - User can access linked records (e.g., their member) -- `:all` - User can access all records (admin) - -**These scopes are NOT redundant** - they are used for UPDATE/CREATE/DESTROY. - -### 3. Bypass Is a Technical Workaround - -The bypass is not a design choice but a **technical necessity** due to Ash's policy evaluation behavior: -- Ash doesn't call `auto_filter` when `strict_check` returns `false` -- `expr()` in bypass is handled natively by Ash for both contexts -- This is consistent with Ash's documentation and best practices - ---- - -## Test Coverage - -### User Resource Tests - -**File:** `test/mv/accounts/user_policies_test.exs` - -**Coverage:** -- ✅ 31 tests: 30 passing, 1 skipped -- ✅ All 4 permission sets: `own_data`, `read_only`, `normal_user`, `admin` -- ✅ READ operations (list and single) via bypass -- ✅ UPDATE operations via HasPermission with `scope :own` -- ✅ Admin operations via HasPermission with `scope :all` -- ✅ AshAuthentication bypass (registration/login) -- ✅ Tests use system_actor for authorization - -**Key Tests Proving Pattern:** - -```elixir -# Test 1: READ list uses bypass (returns filtered list) -test "list users returns only own user", %{user: user} do - {:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts) - assert length(users) == 1 # Filtered to own user ✅ - assert hd(users).id == user.id -end - -# Test 2: UPDATE uses HasPermission with scope :own -test "can update own email", %{user: user} do - {:ok, updated_user} = - user - |> Ash.Changeset.for_update(:update_user, %{email: "new@example.com"}) - |> Ash.update(actor: user) - - assert updated_user.email # Uses scope :own from PermissionSets ✅ -end - -# Test 3: Admin uses HasPermission with scope :all -test "admin can update other users", %{admin: admin, other_user: other_user} do - {:ok, updated_user} = - other_user - |> Ash.Changeset.for_update(:update_user, %{email: "admin-changed@example.com"}) - |> Ash.update(actor: admin) - - assert updated_user.email # Uses scope :all from PermissionSets ✅ -end -``` - ---- - -## Lessons Learned - -1. **Don't assume** that returning a filter from `strict_check` will trigger `auto_filter` - test it! -2. **Bypass with `expr()` is necessary** for list queries with filter-based permissions -3. **Scope concept is NOT redundant** - it's used for operations with records (UPDATE/CREATE/DESTROY) -4. **Consistency matters** - following the same pattern across resources improves maintainability -5. **Documentation is key** - explaining WHY the pattern exists prevents future confusion - ---- +**Why return `false`, not `:unknown`?** We tested returning `:unknown`; Ash's policy evaluation still did **not** reliably call `auto_filter`. The `bypass` with `expr()` is the only consistent solution. (`has_permission_test.exs` accordingly expects `false`, not `:unknown`.) ## Future Considerations -### If Ash Changes Policy Evaluation - -If a future version of Ash reliably calls `auto_filter` when `strict_check` returns `:unknown` or `{:filter, expr}`: - -1. We could **remove** the bypass for READ -2. Keep only the HasPermission policy for all operations -3. Update tests to verify the new behavior - -**However, for now (Ash 3.13.1), the bypass pattern is necessary and correct.** - ---- +If a future Ash version reliably calls `auto_filter` when `strict_check` returns `:unknown` or `{:filter, expr}`, the READ bypass could be removed and a single HasPermission policy kept for all operations (with tests updated). **This workaround was first identified under Ash 3.13.x and is still required as of the Ash version pinned in `mix.lock`; the bypass pattern remains necessary and correct.** ## References -- **Ash Policy Documentation**: [https://hexdocs.pm/ash/policies.html](https://hexdocs.pm/ash/policies.html) -- **Implementation**: `lib/accounts/user.ex` (lines 271-315) -- **Tests**: `test/mv/accounts/user_policies_test.exs` -- **Architecture Doc**: `docs/roles-and-permissions-architecture.md` -- **Permission Sets**: `lib/mv/authorization/permission_sets.ex` +- Ash policies: +- Implementation: see the `policies do` block in `Mv.Accounts.User` (`lib/accounts/user.ex`) +- Tests: `test/mv/accounts/user_policies_test.exs`, `test/mv/authorization/checks/has_permission_test.exs` +- Architecture: `docs/roles-and-permissions-architecture.md` +- Permission sets: `lib/mv/authorization/permission_sets.ex` diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md index 216c6c9..864c328 100644 --- a/docs/roles-and-permissions-architecture.md +++ b/docs/roles-and-permissions-architecture.md @@ -101,6 +101,7 @@ Control CRUD operations on: - MemberGroup (member–group associations; own_data read :linked, read_only read :all, normal_user/admin create/destroy) - MembershipFeeType (fee type definitions; all read, admin-only create/update/destroy) - MembershipFeeCycle (fee cycles; own_data read :linked, read_only read :all, normal_user/admin read+create+update+destroy; manual "Regenerate Cycles" for normal_user and admin) +- JoinRequest (membership join requests; normal_user read+update, admin full CRUD) **4. Page-Level Permissions** @@ -123,7 +124,7 @@ Three scope levels for permissions: **6. Special Cases** - **Own Credentials:** Every user can always read/update their own credentials -- **Linked Member Email:** Only admins can edit email of member linked to user +- **Linked Member Email:** Only administrators or the linked user can change the email for members linked to users (see `Mv.Membership.Member.Validations.EmailChangePermission`) - **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag) - **User-Member Linking:** Only admins can link/unlink users and members - **User Role Assignment:** Only admins can change a user's role (via `update_user` with `role_id`). Last-admin validation ensures at least one user keeps the Admin role. @@ -151,20 +152,12 @@ Role (stored in DB: "Vorstand" → "read_only") User (each user has one role) ``` -**Why This Approach?** +**Why This Approach?** Fast (2-3 weeks vs. 4-5 for DB-backed), maximum performance (< 1μs per +check, no permission queries/joins/cache), Git-tracked permission changes, deterministic +functional tests, and a well-defined Phase 3 migration path. -✅ **Fast Implementation:** 2-3 weeks vs. 4-5 weeks for DB-backed -✅ **Maximum Performance:** < 1 microsecond per check (pure function call) -✅ **Zero DB Overhead:** No permission queries, no joins, no cache needed -✅ **Git-Tracked Changes:** All permission changes in version control -✅ **Deterministic Testing:** No DB setup, purely functional tests -✅ **Clear Migration Path:** Well-defined Phase 3 for DB-backed permissions - -**Trade-offs:** - -⚠️ **Deployment Required:** Permission changes need code deployment -⚠️ **Four Fixed Sets:** Cannot add new permission sets without code change -✔️ **Acceptable for MVP:** Requirements specify 4 fixed sets, rare changes expected +**Trade-offs:** Permission changes need a code deployment and new sets cannot be added without +a code change — acceptable for the MVP, which specifies 4 fixed sets with rare changes. ### System Architecture Diagram @@ -292,11 +285,14 @@ The MVP requires **only ONE new table**: `roles` #### roles -Stores role definitions that reference permission sets by name. +Stores role definitions that reference permission sets by name. The SQL below is +**illustrative** — see `priv/repo/migrations/*_add_authorization_domain.exs` for the exact DDL +(notably the primary key uses the custom `uuid_generate_v7()` SQL function, and the unique index +is named `roles_unique_name_index`). ```sql CREATE TABLE roles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), name VARCHAR(255) NOT NULL UNIQUE, description TEXT, permission_set_name VARCHAR(50) NOT NULL, @@ -308,7 +304,7 @@ CREATE TABLE roles ( CHECK (permission_set_name IN ('own_data', 'read_only', 'normal_user', 'admin')) ); -CREATE UNIQUE INDEX roles_name_index ON roles (name); +CREATE UNIQUE INDEX roles_unique_name_index ON roles (name); CREATE INDEX roles_permission_set_name_index ON roles (permission_set_name); ``` @@ -338,64 +334,20 @@ CREATE INDEX users_role_id_index ON users (role_id); ### Seed Data -Five predefined roles created during initial setup: +The five predefined roles are seeded in `priv/repo/seeds_bootstrap.exs` (the canonical source — +do not duplicate the data here). Each role is created idempotently via the Role resource's +`:create_role_with_system_flag` action, and the seeds map roles to permission sets as: -```elixir -# priv/repo/seeds/authorization_seeds.exs +| Role | permission_set_name | is_system_role | +|------|---------------------|----------------| +| Mitglied | own_data | true (cannot be deleted) | +| Vorstand | read_only | false | +| Kassenwart | normal_user | false | +| Buchhaltung | read_only | false | +| Admin | admin | false | -roles = [ - %{ - name: "Mitglied", - description: "Default member role with access to own data only", - permission_set_name: "own_data", - is_system_role: true # Cannot be deleted! - }, - %{ - name: "Vorstand", - description: "Board member with read access to all member data", - permission_set_name: "read_only", - is_system_role: false - }, - %{ - name: "Kassenwart", - description: "Treasurer with full member and payment management", - permission_set_name: "normal_user", - is_system_role: false - }, - %{ - name: "Buchhaltung", - description: "Accounting with read-only access for auditing", - permission_set_name: "read_only", - is_system_role: false - }, - %{ - name: "Admin", - description: "Administrator with unrestricted access", - permission_set_name: "admin", - is_system_role: false - } -] - -# Create roles with idempotent logic -Enum.each(roles, fn role_data -> - case Ash.get(Mv.Authorization.Role, name: role_data.name) do - {:ok, existing_role} -> - # Update if exists - Ash.update!(existing_role, role_data) - {:error, _} -> - # Create if not exists - Ash.create!(Mv.Authorization.Role, role_data) - end -end) - -# Assign "Mitglied" role to users without role -mitglied_role = Ash.get!(Mv.Authorization.Role, name: "Mitglied") -users_without_role = Ash.read!(Mv.Accounts.User, filter: expr(is_nil(role_id))) - -Enum.each(users_without_role, fn user -> - Ash.update!(user, %{role_id: mitglied_role.id}) -end) -``` +Assigning the default "Mitglied" role to users that have no role is handled separately by the +`assign_mitglied_role_to_existing_users` migration, not by the seed script. --- @@ -405,277 +357,41 @@ end) **Location:** `lib/mv/authorization/permission_sets.ex` -This module is the **single source of truth** for all permissions in the MVP. It defines what each permission set can do. +This module is the **single source of truth** for all permissions in the MVP. It defines what +each permission set can do, as pure compile-time functions (lookups < 1μs). -#### Module Structure +**Types & public API:** -```elixir -defmodule Mv.Authorization.PermissionSets do - @moduledoc """ - Defines the four hardcoded permission sets for the application. - - Each permission set specifies: - - Resource permissions (what CRUD operations on which resources) - - Page permissions (which LiveView pages can be accessed) - - Scopes (own, linked, all) - - ## Permission Sets - - 1. **own_data** - Default for "Mitglied" role - - Can only access own user data and linked member/custom field values - - Cannot create new members or manage system - - 2. **read_only** - For "Vorstand" and "Buchhaltung" roles - - Can read all member data - - Cannot create, update, or delete - - 3. **normal_user** - For "Kassenwart" role - - Create/Read/Update members (no delete), full CRUD on properties - - Cannot manage custom fields or users - - 4. **admin** - For "Admin" role - - Unrestricted access to all resources - - Can manage users, roles, custom fields - - ## Usage - - # Get permissions for a role's permission set - permissions = PermissionSets.get_permissions(:admin) - - # Check if a permission set name is valid - PermissionSets.valid_permission_set?("read_only") # => true - - # Convert string to atom safely - {:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data") - - ## Performance - - All functions are pure and compile-time. Permission lookups are < 1 microsecond. - """ +- `@type scope :: :own | :linked | :all`, `@type action :: :read | :create | :update | :destroy` +- A `resource_permission` is `%{resource: String.t(), action:, scope:, granted: boolean}`; + a `permission_set` is `%{resources: [resource_permission], pages: [String.t()]}`. +- `all_permission_sets/0` → `[:own_data, :read_only, :normal_user, :admin]`. +- `get_permissions/1` — one function clause per set returning its `%{resources, pages}` map. + An unknown atom raises `ArgumentError` (callers always go through the conversion below). +- `valid_permission_set?/1` — accepts string or atom; the string clause delegates to the + converter; the atom clause checks membership in `all_permission_sets/0`. +- `permission_set_name_to_atom/1` — `String.to_existing_atom/1` guarded by validity, and + **rescues `ArgumentError`** (unknown string → never-created atom) returning + `{:error, :invalid_permission_set}`. This is the safe entry point used everywhere. - @type scope :: :own | :linked | :all - @type action :: :read | :create | :update | :destroy - - @type resource_permission :: %{ - resource: String.t(), - action: action(), - scope: scope(), - granted: boolean() - } - - @type permission_set :: %{ - resources: [resource_permission()], - pages: [String.t()] - } +**Resource permissions per set** are exactly the Permission Matrix below. Note `normal_user` +intentionally omits `Member :destroy` (safety); `own_data` has full CRUD on its linked +CustomFieldValues; all four sets grant `User read/update :own`. - @doc """ - Returns the list of all valid permission set names. - - ## Examples - - iex> PermissionSets.all_permission_sets() - [:own_data, :read_only, :normal_user, :admin] - """ - @spec all_permission_sets() :: [atom()] - def all_permission_sets do - [:own_data, :read_only, :normal_user, :admin] - end +**Pages per set:** the exact `pages` lists live in the `get_permissions/1` clauses of +`Mv.Authorization.PermissionSets` (single source of truth). Key facts that shape the lists: - @doc """ - Returns permissions for the given permission set. - - ## Examples - - iex> permissions = PermissionSets.get_permissions(:admin) - iex> Enum.any?(permissions.resources, fn p -> - ...> p.resource == "User" and p.action == :destroy - ...> end) - true - - iex> PermissionSets.get_permissions(:invalid) - ** (FunctionClauseError) no function clause matching - """ - @spec get_permissions(atom()) :: permission_set() - - def get_permissions(:own_data) do - %{ - resources: [ - # User: Can always read/update own credentials - %{resource: "User", action: :read, scope: :own, granted: true}, - %{resource: "User", action: :update, scope: :own, granted: true}, - - # Member: Can read/update linked member - %{resource: "Member", action: :read, scope: :linked, granted: true}, - %{resource: "Member", action: :update, scope: :linked, granted: true}, - - # CustomFieldValue: Can read/update/create/destroy custom field values of linked member - %{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true}, - %{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true}, - %{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true}, - %{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true}, - - # CustomField: Can read all (needed for forms) - %{resource: "CustomField", action: :read, scope: :all, granted: true} - ], - pages: [ - "/", # Home page - "/profile", # Own profile - "/members/:id" # Linked member detail (filtered by policy) - ] - } - end +- **own_data:** deliberately does **not** include `/` (Mitglied must not see the member index at + root, which has the same content as `/members`). Self-service pages are `/users/:id`, + `/users/:id/edit`, `/users/:id/show/edit`; linked-member pages are `/members/:id`, + `/members/:id/edit`, `/members/:id/show/edit` (data access filtered by policy scope `:linked`). +- **read_only / normal_user:** include `/` plus the self-service `/users/:id…` pages and their + respective member / custom-field-value / group pages; normal_user additionally has the create/edit + pages and the `/join_requests` approval pages. +- **admin:** `"*"` wildcard (all pages), with `/settings` and `/membership_fee_settings` also listed + explicitly. - def get_permissions(:read_only) do - %{ - resources: [ - # User: Can read/update own credentials only - %{resource: "User", action: :read, scope: :own, granted: true}, - %{resource: "User", action: :update, scope: :own, granted: true}, - - # Member: Can read all members, no modifications - %{resource: "Member", action: :read, scope: :all, granted: true}, - - # CustomFieldValue: Can read all custom field values - %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true}, - - # CustomField: Can read all - %{resource: "CustomField", action: :read, scope: :all, granted: true} - ], - pages: [ - "/", - "/members", # Member list - "/members/:id", # Member detail - "/custom_field_values" # Custom field values overview - "/profile" # Own profile - ] - } - end - - def get_permissions(:normal_user) do - %{ - resources: [ - # User: Can read/update own credentials only - %{resource: "User", action: :read, scope: :own, granted: true}, - %{resource: "User", action: :update, scope: :own, granted: true}, - - # Member: Full CRUD - %{resource: "Member", action: :read, scope: :all, granted: true}, - %{resource: "Member", action: :create, scope: :all, granted: true}, - %{resource: "Member", action: :update, scope: :all, granted: true}, - # Note: destroy intentionally omitted for safety - - # CustomFieldValue: Full CRUD - %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true}, - %{resource: "CustomFieldValue", action: :create, scope: :all, granted: true}, - %{resource: "CustomFieldValue", action: :update, scope: :all, granted: true}, - %{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true}, - - # CustomField: Read only (admin manages definitions) - %{resource: "CustomField", action: :read, scope: :all, granted: true} - ], - pages: [ - "/", - "/members", - "/members/new", # Create member - "/members/:id", - "/members/:id/edit", # Edit member - "/custom_field_values", - "/custom_field_values/new", - "/custom_field_values/:id/edit", - "/profile" - ] - } - end - - def get_permissions(:admin) do - %{ - resources: [ - # User: Full management including other users - %{resource: "User", action: :read, scope: :all, granted: true}, - %{resource: "User", action: :create, scope: :all, granted: true}, - %{resource: "User", action: :update, scope: :all, granted: true}, - %{resource: "User", action: :destroy, scope: :all, granted: true}, - - # Member: Full CRUD - %{resource: "Member", action: :read, scope: :all, granted: true}, - %{resource: "Member", action: :create, scope: :all, granted: true}, - %{resource: "Member", action: :update, scope: :all, granted: true}, - %{resource: "Member", action: :destroy, scope: :all, granted: true}, - - # CustomFieldValue: Full CRUD - %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true}, - %{resource: "CustomFieldValue", action: :create, scope: :all, granted: true}, - %{resource: "CustomFieldValue", action: :update, scope: :all, granted: true}, - %{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true}, - - # CustomField: Full CRUD (admin manages custom field definitions) - %{resource: "CustomField", action: :read, scope: :all, granted: true}, - %{resource: "CustomField", action: :create, scope: :all, granted: true}, - %{resource: "CustomField", action: :update, scope: :all, granted: true}, - %{resource: "CustomField", action: :destroy, scope: :all, granted: true}, - - # Role: Full CRUD (admin manages roles) - %{resource: "Role", action: :read, scope: :all, granted: true}, - %{resource: "Role", action: :create, scope: :all, granted: true}, - %{resource: "Role", action: :update, scope: :all, granted: true}, - %{resource: "Role", action: :destroy, scope: :all, granted: true} - ], - pages: [ - "*" # Wildcard: Admin can access all pages - ] - } - end - - @doc """ - Checks if a permission set name (string or atom) is valid. - - ## Examples - - iex> PermissionSets.valid_permission_set?("admin") - true - - iex> PermissionSets.valid_permission_set?(:read_only) - true - - iex> PermissionSets.valid_permission_set?("invalid") - false - """ - @spec valid_permission_set?(String.t() | atom()) :: boolean() - def valid_permission_set?(name) when is_binary(name) do - case permission_set_name_to_atom(name) do - {:ok, _atom} -> true - {:error, _} -> false - end - end - - def valid_permission_set?(name) when is_atom(name) do - name in all_permission_sets() - end - - @doc """ - Converts a permission set name string to atom safely. - - ## Examples - - iex> PermissionSets.permission_set_name_to_atom("admin") - {:ok, :admin} - - iex> PermissionSets.permission_set_name_to_atom("invalid") - {:error, :invalid_permission_set} - """ - @spec permission_set_name_to_atom(String.t()) :: {:ok, atom()} | {:error, :invalid_permission_set} - def permission_set_name_to_atom(name) when is_binary(name) do - atom = String.to_existing_atom(name) - if valid_permission_set?(atom) do - {:ok, atom} - else - {:error, :invalid_permission_set} - end - rescue - ArgumentError -> {:error, :invalid_permission_set} - end -end -``` +There is no `/profile` route; the self-service profile pages are the `/users/:id…` routes above. #### Permission Matrix @@ -697,6 +413,7 @@ Quick reference table showing what each permission set allows: | **MembershipFeeType** (all) | R | R | R | R, C, U, D | | **MembershipFeeCycle** (linked) | R | - | - | - | | **MembershipFeeCycle** (all) | - | R | R, C, U, D | R, C, U, D | +| **JoinRequest** (all) | - | - | R, U | R, C, U, D | **Legend:** R=Read, C=Create, U=Update, D=Destroy @@ -745,109 +462,27 @@ defmodule Mv.Authorization.Checks.HasPermission do """ use Ash.Policy.Check - require Ash.Query - import Ash.Expr - alias Mv.Authorization.PermissionSets - - @impl true - def describe(_opts) do - "checks if actor has permission via their role's permission set" - end - - @impl true - def match?(actor, %{resource: resource, action: %{name: action}}, _opts) do - with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom), - resource_name <- get_resource_name(resource) do - check_permission(permissions.resources, resource_name, action, actor, resource_name) - else - %{role: nil} -> - log_auth_failure(actor, resource, action, "no role assigned") - {:error, :no_role} - - %{role: %{permission_set_name: nil}} -> - log_auth_failure(actor, resource, action, "role has no permission_set_name") - {:error, :no_permission_set} - - {:error, :invalid_permission_set} = error -> - log_auth_failure(actor, resource, action, "invalid permission_set_name") - error - - _ -> - log_auth_failure(actor, resource, action, "no actor or missing data") - {:error, :no_permission} - end - end - - # Extract resource name from module (e.g., Mv.Membership.Member -> "Member") - defp get_resource_name(resource) when is_atom(resource) do - resource |> Module.split() |> List.last() - end - - # Find matching permission and apply scope - defp check_permission(resource_perms, resource_name, action, actor, resource_module_name) do - case Enum.find(resource_perms, fn perm -> - perm.resource == resource_name and - perm.action == action and - perm.granted - end) do - nil -> - {:error, :no_permission} - - perm -> - apply_scope(perm.scope, actor, resource_name) - end - end - - # Scope: all - No filtering, access to all records - defp apply_scope(:all, _actor, _resource) do - :authorized - end - - # Scope: own - Filter to records where record.id == actor.id - # Used for User resource (users can access their own user record) - defp apply_scope(:own, actor, _resource) do - {:filter, expr(id == ^actor.id)} - end - - # Scope: linked - Filter based on user_id relationship (resource-specific!) - defp apply_scope(:linked, actor, resource_name) do - case resource_name do - "Member" -> - # User.member_id → Member.id (inverse relationship) - # Filter: member.id == actor.member_id - {:filter, expr(id == ^actor.member_id)} - - "CustomFieldValue" -> - # CustomFieldValue.member_id → Member.id → User.member_id - # Filter: custom_field_value.member_id == actor.member_id - {:filter, expr(member_id == ^actor.member_id)} - - _ -> - # Fallback for other resources: try direct user_id - {:filter, expr(user_id == ^actor.id)} - end - end - - # Log authorization failures for debugging - defp log_auth_failure(actor, resource, action, reason) do - require Logger - - actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil" - resource_name = get_resource_name(resource) - - Logger.debug(""" - Authorization failed: - Actor: #{actor_id} - Resource: #{resource_name} - Action: #{action} - Reason: #{reason} - """) - end + # ... end ``` +**`match?/3` logic.** With-chain: read `actor.role.permission_set_name` (non-nil) → +`PermissionSets.permission_set_name_to_atom/1` → `get_permissions/1` → resource name via +`Module.split() |> List.last()`. Find the granted permission matching resource+action; if none, +`{:error, :no_permission}`; otherwise apply the scope filter. The `else` clauses log and return a +specific reason — `{:error, :no_role}` (role nil), `{:error, :no_permission_set}` +(permission_set_name nil), `{:error, :invalid_permission_set}`, or `{:error, :no_permission}` +(no actor/missing data). Every error results in Forbidden (fail-closed). + +**Scope filters (`apply_scope/3`):** + +- `:all` → `:authorized` (no filter) +- `:own` → `{:filter, expr(id == ^actor.id)}` (User: own record) +- `:linked` → resource-specific: + - `"Member"` → `{:filter, expr(id == ^actor.member_id)}` (User.member_id → Member.id, inverse) + - `"CustomFieldValue"` → `{:filter, expr(member_id == ^actor.member_id)}` (traverses CFV.member_id → Member → User.member_id) + - fallback → `{:filter, expr(user_id == ^actor.id)}` + **Key Design Decisions:** 1. **Resource-Specific :linked Scope:** CustomFieldValue needs to traverse `member` relationship to check `user_id` @@ -888,58 +523,18 @@ end --- -## Bypass vs. HasPermission: When to Use Which? +## Bypass vs. HasPermission -**Key Finding:** For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier approach**: +For filter-based permissions (`scope :own`, `scope :linked`) the resources use a two-tier +pattern: **bypass with `expr()` for READ** (Ash does not reliably trigger `auto_filter` +when `HasPermission`'s `strict_check` returns `{:ok, false}` on record-less list queries), +and **HasPermission for UPDATE/CREATE/DESTROY** (a changeset record is present, so scope is +evaluated correctly). The scope concept stays meaningful — bypass is only a workaround for +Ash's auto_filter limitation, not a replacement for it. -1. **Bypass with `expr()` for READ** - Handles list queries (auto_filter) -2. **HasPermission for UPDATE/CREATE/DESTROY** - Handles operations with records - -### Why This Pattern? - -**The Problem with HasPermission for List Queries:** - -When `HasPermission` returns `{:filter, expr(...)}` for `scope :own` or `scope :linked`: -- `strict_check` returns `{:ok, false}` for queries without a record -- Ash does **NOT** reliably call `auto_filter` when `strict_check` returns `false` -- Result: List queries fail ❌ - -**The Solution:** - -Use `bypass` with `expr()` directly for READ operations: -- Ash handles `expr()` natively for both `strict_check` and `auto_filter` -- List queries work correctly ✅ -- Single-record reads work correctly ✅ - -### Pattern Summary - -| Operation | Has Record? | Use | Why | -|-----------|-------------|-----|-----| -| **READ (list)** | ❌ No | `bypass` with `expr()` | Triggers auto_filter | -| **READ (single)** | ✅ Yes | `bypass` with `expr()` | expr() evaluates to true/false | -| **UPDATE** | ✅ Yes (changeset) | `HasPermission` | strict_check can evaluate record | -| **CREATE** | ✅ Yes (changeset) | `HasPermission` | strict_check can evaluate record | -| **DESTROY** | ✅ Yes | `HasPermission` | strict_check can evaluate record | - -### Is scope :own/:linked Still Useful? - -**YES! ✅** The scope concept is essential: - -1. **Documentation** - Clearly expresses intent in PermissionSets -2. **UPDATE/CREATE/DESTROY** - Works perfectly via HasPermission when record is present -3. **Consistency** - All permissions are centralized in PermissionSets -4. **Maintainability** - Easy to see what each role can do - -The bypass is a **technical workaround** for Ash's auto_filter limitation, not a replacement for the scope concept. - -### Consistency Across Resources - -Both `User` and `Member` follow this pattern: - -- **User**: Bypass for READ (`id == ^actor(:id)`), HasPermission for UPDATE (`scope :own`) -- **Member**: Bypass for READ (`id == ^actor(:member_id)`), HasPermission for UPDATE (`scope :linked`) - -This ensures consistent behavior and predictable authorization logic throughout the application. +The full rationale, the per-operation decision table, and why both `User` and `Member` +follow this pattern are documented in the canonical +[policy-bypass-vs-haspermission.md](./policy-bypass-vs-haspermission.md). --- @@ -1006,7 +601,7 @@ end ### Member Resource Policies -**Location:** `lib/mv/membership/member.ex` +**Location:** `lib/membership/member.ex` **Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :linked). @@ -1043,11 +638,10 @@ defmodule Mv.Membership.Member do # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) end - # Custom validation for email editing (see Special Cases section) + # Linked-member email editing is enforced by a dedicated Validations module + # (see Special Cases section) validations do - validate changing(:email), on: :update do - validate &validate_linked_member_email_change/2 - end + validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update] end # ... @@ -1252,144 +846,24 @@ Page permissions control which LiveView pages a user can access. This is enforce **Location:** `lib/mv_web/plugs/check_page_permission.ex` -This plug runs in the router pipeline and checks if the current user has permission to access the requested page. +This plug runs in the router pipeline and checks if the current user has permission to access +the requested page, **before** the LiveView mounts. -```elixir -defmodule MvWeb.Plugs.CheckPagePermission do - @moduledoc """ - Plug that checks if current user has permission to access the current page. - - ## How It Works - - 1. Extracts page path from conn (route template like "/members/:id") - 2. Gets current user from conn.assigns - 3. Gets user's permission_set_name from role - 4. Calls PermissionSets.get_permissions/1 to get allowed pages - 5. Matches requested path against allowed patterns - 6. If unauthorized: redirects to "/" with flash error - - ## Pattern Matching - - - Exact match: "/members" == "/members" - - Dynamic routes: "/members/:id" matches "/members/123" - - Wildcard: "*" matches everything (admin) - - ## Usage in Router - - pipeline :require_page_permission do - plug MvWeb.Plugs.CheckPagePermission - end - - scope "/members", MvWeb do - pipe_through [:browser, :require_authenticated_user, :require_page_permission] - - live "/", MemberLive.Index - live "/:id", MemberLive.Show - end - """ - - import Plug.Conn - import Phoenix.Controller - alias Mv.Authorization.PermissionSets - require Logger +**Behavior (`lib/mv_web/plugs/check_page_permission.ex`):** - def init(opts), do: opts - - def call(conn, _opts) do - user = conn.assigns[:current_user] - page_path = get_page_path(conn) - - if has_page_permission?(user, page_path) do - conn - else - log_page_access_denied(user, page_path) - - conn - |> put_flash(:error, "You don't have permission to access this page.") - |> redirect(to: "/") - |> halt() - end - end - - # Extract page path from conn (route template preferred, fallback to request_path) - defp get_page_path(conn) do - case conn.private[:phoenix_route] do - {_plug, _opts, _pipe, route_template, _meta} -> - route_template - - _ -> - conn.request_path - end - end - - # Check if user has permission for page - defp has_page_permission?(nil, _page_path) do - false - end - - defp has_page_permission?(user, page_path) do - with %{role: %{permission_set_name: ps_name}} <- user, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom) do - page_matches?(permissions.pages, page_path) - else - _ -> false - end - end - - # Check if requested path matches any allowed pattern - defp page_matches?(allowed_pages, requested_path) do - Enum.any?(allowed_pages, fn pattern -> - cond do - # Wildcard: admin can access all pages - pattern == "*" -> - true - - # Exact match - pattern == requested_path -> - true - - # Dynamic route match (e.g., "/members/:id" matches "/members/123") - String.contains?(pattern, ":") -> - match_dynamic_route?(pattern, requested_path) - - # No match - true -> - false - end - end) - end - - # Match dynamic route pattern against actual path - defp match_dynamic_route?(pattern, path) do - pattern_segments = String.split(pattern, "/", trim: true) - path_segments = String.split(path, "/", trim: true) - - # Must have same number of segments - if length(pattern_segments) == length(path_segments) do - Enum.zip(pattern_segments, path_segments) - |> Enum.all?(fn {pattern_seg, path_seg} -> - # Dynamic segment (starts with :) matches anything - String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg - end) - else - false - end - end - - defp log_page_access_denied(user, page_path) do - user_id = if is_map(user), do: Map.get(user, :id), else: "nil" - role = if is_map(user), do: get_in(user, [:role, :name]), else: "nil" - - Logger.info(""" - Page access denied: - User: #{user_id} - Role: #{role} - Page: #{page_path} - """) - end -end -``` +1. Extracts the page path as the **route template** (e.g. `/members/:id`) via + `Phoenix.Router.route_info/4`, falling back to `conn.request_path`. Using the template, + not the concrete path, is what lets the permission `pages` lists stay parameterized. + (Public paths such as `/sign-in`, `/register`, `/auth/*` are exempt and pass through.) +2. Reads `current_user` from `conn.assigns`, resolves its `permission_set_name`, and looks up + the allowed `pages` via `PermissionSets.get_permissions/1`. +3. Matches the path against allowed patterns: `*` wildcard (admin), exact match, or + segment-wise dynamic match where a `:`-prefixed pattern segment matches any path segment + (same segment count required). +4. On no match (including nil user, no role, or invalid permission set → false): logs the + denial and **redirects to `/users/:id`** (the logged-in user's own profile) or, when there is + no user, to **`/sign-in`**, then halts. The `"You don't have permission to access this page."` + flash is set only for a logged-in user; an unauthenticated visitor is redirected without a flash. ### Router Integration @@ -1435,19 +909,19 @@ end ### Page Permission Examples **Mitglied (own_data):** -- ✅ Can access: `/`, `/profile`, `/members/123` (if 123 is their linked member) -- ❌ Cannot access: `/members`, `/members/new`, `/admin/roles` +- ✅ Can access: `/users/123` (own profile), `/members/123` (if 123 is their linked member) +- ❌ Cannot access: `/` (root member index is excluded for own_data), `/members`, `/members/new`, `/settings` **Vorstand (read_only):** -- ✅ Can access: `/`, `/members`, `/members/123`, `/custom_field_values`, `/profile` -- ❌ Cannot access: `/members/new`, `/members/123/edit`, `/admin/roles` +- ✅ Can access: `/`, `/members`, `/members/123`, `/custom_field_values`, `/users/123` (own profile) +- ❌ Cannot access: `/members/new`, `/members/123/edit`, `/settings` **Kassenwart (normal_user):** -- ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/custom_field_values`, `/profile` -- ❌ Cannot access: `/admin/roles`, `/admin/custom_fields/new` +- ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/custom_field_values`, `/join_requests`, `/users/123` (own profile) +- ❌ Cannot access: `/settings`, `/membership_fee_settings` **Admin:** -- ✅ Can access: `*` (all pages, including `/admin/roles`) +- ✅ Can access: `*` (all pages, including `/settings` and `/membership_fee_settings`) --- @@ -1459,316 +933,38 @@ UI-level authorization ensures that users only see buttons, links, and form fiel **Location:** `lib/mv_web/authorization.ex` -This module provides helper functions for conditional rendering in LiveView templates. +This module provides helper functions for conditional rendering in LiveView templates, +reading from the **same** `PermissionSets` module as the backend policies so UI and backend +stay consistent (pure function calls, no DB queries). Imported into `mv_web.ex` `html_helpers` +so every LiveView has it: `import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]`. -```elixir -defmodule MvWeb.Authorization do - @moduledoc """ - UI-level authorization helpers for LiveView templates. - - These functions check if the current user has permission to perform actions - or access pages. They use the same PermissionSets module as the backend policies, - ensuring UI and backend authorization are consistent. - - ## Usage in Templates - - - <%= if can?(@current_user, :create, Mv.Membership.Member) do %> - <.link patch={~p"/members/new"}>New Member - <% end %> - - - <%= if can?(@current_user, :update, @member) do %> - <.button>Edit - <% end %> - - - <%= if can_access_page?(@current_user, "/admin/roles") do %> - <.link navigate="/admin/roles">Manage Roles - <% end %> - - ## Performance - - All checks are pure function calls using the hardcoded PermissionSets module. - No database queries, < 1 microsecond per check. - """ - - alias Mv.Authorization.PermissionSets +**Public functions:** - @doc """ - Checks if user has permission for an action on a resource (atom). - - ## Examples - - iex> admin = %{role: %{permission_set_name: "admin"}} - iex> can?(admin, :create, Mv.Membership.Member) - true - - iex> mitglied = %{role: %{permission_set_name: "own_data"}} - iex> can?(mitglied, :create, Mv.Membership.Member) - false - """ - @spec can?(map() | nil, atom(), atom()) :: boolean() - def can?(nil, _action, _resource), do: false - - def can?(user, action, resource) when is_atom(action) and is_atom(resource) do - with %{role: %{permission_set_name: ps_name}} <- user, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom) do - resource_name = get_resource_name(resource) - - Enum.any?(permissions.resources, fn perm -> - perm.resource == resource_name and - perm.action == action and - perm.granted - end) - else - _ -> false - end - end +- `can?/3` (resource atom) — `can?(user, action, Mv.Membership.Member)`: true iff the user's + permission set grants `action` on that resource (any scope). +- `can?/3` (record struct) — `can?(user, action, %Member{})`: finds the matching permission, + then applies the scope check against the record: + - `:all` → always true + - `:own` → `record.id == user.id` + - `:linked` → resource-specific: Member checks `record.user_id == user.id`; CustomFieldValue + traverses `record.member.user_id == user.id` (member must be preloaded), with a `user_id` + fallback for other resources. +- `can_access_page?/2` — matches the path against the permission set's `pages` list using the + same rules as the plug: `*` wildcard, exact match, or dynamic segment match (`:id`). - @doc """ - Checks if user has permission for an action on a specific record (struct). - - Applies scope checking: - - :own - record.id == user.id - - :linked - record.user_id == user.id (or traverses relationships) - - :all - always true - - ## Examples - - iex> user = %{id: "user-123", role: %{permission_set_name: "own_data"}} - iex> member = %Member{id: "member-456", user_id: "user-123"} - iex> can?(user, :update, member) - true - - iex> other_member = %Member{id: "member-789", user_id: "other-user"} - iex> can?(user, :update, other_member) - false - """ - @spec can?(map() | nil, atom(), struct()) :: boolean() - def can?(nil, _action, _record), do: false - - def can?(user, action, %resource{} = record) when is_atom(action) do - with %{role: %{permission_set_name: ps_name}} <- user, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom) do - resource_name = get_resource_name(resource) - - # Find matching permission - matching_perm = Enum.find(permissions.resources, fn perm -> - perm.resource == resource_name and - perm.action == action and - perm.granted - end) - - case matching_perm do - nil -> false - perm -> check_scope(perm.scope, user, record, resource_name) - end - else - _ -> false - end - end +All three return **false** for a nil user, a user without a role, or an invalid +`permission_set_name` (graceful, fail-closed — no crash). The scope/page-matching logic mirrors +`HasPermission` and `CheckPagePermission` exactly; resource names come from +`Module.split() |> List.last()`. - @doc """ - Checks if user can access a specific page. - - ## Examples - - iex> admin = %{role: %{permission_set_name: "admin"}} - iex> can_access_page?(admin, "/admin/roles") - true - - iex> mitglied = %{role: %{permission_set_name: "own_data"}} - iex> can_access_page?(mitglied, "/members") - false - """ - @spec can_access_page?(map() | nil, String.t()) :: boolean() - def can_access_page?(nil, _page_path), do: false - - def can_access_page?(user, page_path) do - with %{role: %{permission_set_name: ps_name}} <- user, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom) do - page_matches?(permissions.pages, page_path) - else - _ -> false - end - end +### UI Usage Pattern - # Check if scope allows access to record - defp check_scope(:all, _user, _record, _resource_name), do: true - - defp check_scope(:own, user, record, _resource_name) do - record.id == user.id - end - - defp check_scope(:linked, user, record, resource_name) do - case resource_name do - "Member" -> - # Direct relationship: member.user_id - Map.get(record, :user_id) == user.id - - "CustomFieldValue" -> - # Need to traverse: custom_field_value.member.user_id - # Note: In UI, custom_field_value should have member preloaded - case Map.get(record, :member) do - %{user_id: member_user_id} -> member_user_id == user.id - _ -> false - end - - _ -> - # Fallback: check user_id - Map.get(record, :user_id) == user.id - end - end - - # Check if page path matches any allowed pattern - defp page_matches?(allowed_pages, requested_path) do - Enum.any?(allowed_pages, fn pattern -> - cond do - pattern == "*" -> true - pattern == requested_path -> true - String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path) - true -> false - end - end) - end - - # Match dynamic route pattern - defp match_pattern?(pattern, path) do - pattern_segments = String.split(pattern, "/", trim: true) - path_segments = String.split(path, "/", trim: true) - - if length(pattern_segments) == length(path_segments) do - Enum.zip(pattern_segments, path_segments) - |> Enum.all?(fn {pattern_seg, path_seg} -> - String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg - end) - else - false - end - end - - # Extract resource name from module - defp get_resource_name(resource) when is_atom(resource) do - resource |> Module.split() |> List.last() - end -end -``` - -### Import in mv_web.ex - -Make helpers available to all LiveViews: - -```elixir -defmodule MvWeb do - # ... - - def html_helpers do - quote do - # ... existing helpers ... - - # Authorization helpers - import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2] - end - end - - # ... -end -``` - -### UI Examples - -**Navbar with conditional links:** - -```heex - - -``` - -**Index page with conditional "New" button:** - -```heex - - - - - - <%= for member <- @members do %> - - - - - <% end %> -
<%= member.name %> - - <%= if can?(@current_user, :update, member) do %> - <.link patch={~p"/members/#{member.id}/edit"}>Edit - <% end %> - - - <%= if can?(@current_user, :destroy, member) do %> - <.button phx-click="delete" phx-value-id={member.id}>Delete - <% end %> -
-``` - -**Show page with conditional edit button:** - -```heex - -
-

<%= @member.name %>

- -
-
Email
-
<%= @member.email %>
- -
Address
-
<%= @member.address %>
-
- - - <%= if can?(@current_user, :update, @member) do %> - <.link patch={~p"/members/#{@member.id}/edit"} class="btn-primary"> - Edit Member - - <% end %> -
-``` +LiveView templates gate elements with the helpers: page-level links use +`can_access_page?(@current_user, path)` (e.g. the `/members` link and the admin +dropdown), resource-level buttons use `can?(@current_user, :create, Resource)` +(e.g. "New Member"), and per-record buttons use `can?(@current_user, action, record)` +(e.g. Edit/Delete in a member row, or the edit button on a show page). The navbar has +since been replaced by the sidebar (`lib/mv_web/components/layouts/sidebar.ex`). --- @@ -1807,7 +1003,7 @@ end - All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) grant `User.update :own` - Even a user with `read_only` (read-only for member data) can update their own credentials -**Important:** UPDATE is NOT an unverrückbarer Spezialfall (hardcoded bypass). It is controlled by PermissionSets. If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials. See "User Credentials: Why read_only Can Still Update" below for details. +**Important:** UPDATE is NOT an immovable special case (hardcoded bypass). It is controlled by PermissionSets. If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials. See "User Credentials: Why read_only Can Still Update" below for details. ### 1a. User Credentials: Why read_only Can Still Update @@ -1832,209 +1028,33 @@ end - **Clarity:** The name "read_only" refers to member data, not user credentials - **Maintainability:** Easy to see what each role can do in PermissionSets module -**Warning:** If a permission set is changed to remove `User.update :own`, users with that set will **lose the ability to update their credentials**. This is intentional - UPDATE is controlled by PermissionSets, not hardcoded. - -**Example:** -```elixir -# In PermissionSets.get_permissions(:read_only) -resources: [ - # User: Can read/update own credentials only - # IMPORTANT: "read_only" refers to member data, NOT user credentials. - # All permission sets grant User.update :own to allow password changes. - %{resource: "User", action: :read, scope: :own, granted: true}, - %{resource: "User", action: :update, scope: :own, granted: true}, - - # Member: Can read all members, no modifications - %{resource: "Member", action: :read, scope: :all, granted: true}, - # Note: No Member.update permission - this is the "read_only" part -] -``` +**Warning:** If a permission set is changed to remove `User.update :own`, users with that set will **lose the ability to update their credentials**. This is intentional — UPDATE is controlled by PermissionSets, not hardcoded. Every set's `get_permissions/...` therefore carries both `%{resource: "User", action: :read, scope: :own}` and `%{... action: :update, scope: :own}`; the "read_only" label applies to member data (no `Member :update`), not credentials. ### 2. Linked Member Email Editing -**Requirement:** Only administrators can edit the email of a member that is linked to a user (has `user_id` set). This prevents breaking email synchronization. +**Requirement:** For a member linked to a user account (has a linked user), only administrators **or the linked user themselves** can change the email. This prevents breaking the Member↔User email synchronization while still letting a user update their own email. -**Implementation:** - -Custom validation in `Member` resource: - -```elixir -defmodule Mv.Membership.Member do - use Ash.Resource, ... - - validations do - # Only run when email is being changed - validate changing(:email), on: :update do - validate &validate_linked_member_email_change/2 - end - end - - defp validate_linked_member_email_change(changeset, _context) do - member = changeset.data - actor = changeset.context[:actor] - - # If member is not linked to user, allow change - if is_nil(member.user_id) do - :ok - else - # Member is linked - check if actor is admin - if has_admin_permission?(actor) do - :ok - else - {:error, "Only administrators can change email for members linked to user accounts"} - end - end - end - - defp has_admin_permission?(nil), do: false - - defp has_admin_permission?(actor) do - with %{role: %{permission_set_name: ps_name}} <- actor, - {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), - permissions <- PermissionSets.get_permissions(ps_atom) do - # Check if actor has User.update permission with scope :all (admin privilege) - Enum.any?(permissions.resources, fn perm -> - perm.resource == "User" and - perm.action == :update and - perm.scope == :all and - perm.granted - end) - else - _ -> false - end - end -end -``` - -**Why this is needed:** -- Member email and User email are kept in sync -- If a non-admin changes linked member email, it could create inconsistency -- Validation runs AFTER policy check, so normal_user can update member -- But validation blocks email field specifically if member is linked +**Implementation:** The `Mv.Membership.Member.Validations.EmailChangePermission` module (registered as `validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]`) runs **after** the policy check (so a `normal_user` may update the member but is still blocked on the email field). It only acts when the email is changing: if the member has no linked user it allows the change; otherwise it allows the change when the actor is admin (`Mv.Authorization.Actor.admin?/1`, which also treats the system actor as admin) **or** owns the linked member (`actor.member_id == member.id`), and otherwise returns `{:error, "Only administrators or the linked user can change the email for members linked to users"}`. A missing actor is not allowed. ### 3. System Role Protection -**Requirement:** The "Mitglied" role cannot be deleted because it's the default role for all users. +**Requirement:** The "Mitglied" role cannot be deleted (it's the default role for all users). -**Implementation:** - -Flag + validation in `Role` resource: - -```elixir -defmodule Mv.Authorization.Role do - use Ash.Resource, ... - - attributes do - # ... - attribute :is_system_role, :boolean, default: false - end - - validations do - validate action(:destroy) do - validate fn _changeset, %{data: role} -> - if role.is_system_role do - {:error, "Cannot delete system role. System roles are required for the application to function."} - else - :ok - end - end - end - end -end -``` - -**Seeds set the flag:** - -```elixir -%{ - name: "Mitglied", - permission_set_name: "own_data", - is_system_role: true # <-- Protected! -} -``` - -**UI hides delete button:** - -```heex -<%= if can?(@current_user, :destroy, role) and not role.is_system_role do %> - <.button phx-click="delete">Delete -<% end %> -``` +**Implementation:** The `Role` resource has an `is_system_role` boolean (default false); a destroy validation returns `{:error, "Cannot delete system role. ..."}` when `role.is_system_role` is true. Seeds set `is_system_role: true` only on "Mitglied". The UI also hides the delete button: `can?(@current_user, :destroy, role) and not role.is_system_role`. ### 4. User Without Role (Edge Case) -**Requirement:** Users without a role should be denied all access (except logout). +**Requirement:** Users without a role are denied all access (except logout). -**Implementation:** +**Implementation:** Seeds assign "Mitglied" to all users where `role_id` is nil. At runtime every check handles a missing role gracefully — `HasPermission` returns `{:error, :no_role}` (and the UI helpers/plug return false) rather than crashing. -**Default Assignment:** Seeds assign "Mitglied" role to all existing users - -```elixir -# In authorization_seeds.exs -mitglied_role = Ash.get!(Role, name: "Mitglied") -users_without_role = Ash.read!(User, filter: expr(is_nil(role_id))) - -Enum.each(users_without_role, fn user -> - Ash.update!(user, %{role_id: mitglied_role.id}) -end) -``` - -**Runtime Handling:** All authorization checks handle missing role gracefully - -```elixir -# In HasPermission check -def match?(actor, %{resource: resource, action: action}, _opts) do - with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, - # ... - else - %{role: nil} -> - {:error, :no_role} # User has no role -> forbidden - - _ -> - {:error, :no_permission} - end -end -``` - -**Result:** User with no role sees empty UI, cannot access pages, gets forbidden on all actions. +**Result:** A user with no role sees an empty UI, cannot access pages, and is forbidden on all actions. ### 5. Invalid permission_set_name (Edge Case) **Requirement:** If a role has an invalid `permission_set_name`, fail gracefully without crashing. -**Implementation:** - -**Prevention:** Validation on Role resource - -```elixir -validations do - validate attribute(:permission_set_name) do - validate fn _changeset, value -> - if PermissionSets.valid_permission_set?(value) do - :ok - else - {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"} - end - end - end -end -``` - -**Runtime Handling:** All lookups check validity - -```elixir -# In PermissionSets module -def permission_set_name_to_atom(name) when is_binary(name) do - atom = String.to_existing_atom(name) - if valid_permission_set?(atom) do - {:ok, atom} - else - {:error, :invalid_permission_set} - end -rescue - ArgumentError -> {:error, :invalid_permission_set} -end -``` +**Implementation:** Prevented up front by a `Role` attribute validation that rejects any value not in `PermissionSets.all_permission_sets/0` (`"Invalid permission set name. Must be one of: ..."`). At runtime, every lookup goes through `permission_set_name_to_atom/1`, which rescues the `ArgumentError` from `String.to_existing_atom/1` (see PermissionSets above), so an invalid name yields `{:error, :invalid_permission_set}`. **Result:** Invalid `permission_set_name` → authorization fails → forbidden (safe default). @@ -2054,158 +1074,41 @@ Users and Members are separate entities that can be linked. Special rules: - **User side:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit. - **Member side:** Only admins may set or change the user–member link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins can still create and update members as long as they do **not** pass the `:user` argument. The Member resource uses **`on_missing: :ignore`** for the `:user` relationship on update_member, so **omitting** `:user` from params does **not** change the link (no "unlink by omission"); unlink is only possible by explicitly passing `:user` (e.g. `user: nil`), which is admin-only. -### Approach: Separate Ash Actions +### Approach: One Pair of Actions Plus an Admin-Only `:user` Argument -We use **different Ash actions** to enforce different policies: - -1. **`create_member_for_self`** - User creates member and links to themselves -2. **`create_member`** - Admin creates member for any user (or unlinked) -3. **`link_member_to_user`** - Admin links existing member to user -4. **`unlink_member_from_user`** - Admin removes user link -5. **`update`** - Standard update (cannot change `user_id`) +Linking is **not** modelled as separate per-operation actions. The `Mv.Membership.Member` +resource (`lib/membership/member.ex`) exposes the actions `create_member`, `update_member`, +`set_vereinfacht_contact_id`, `search`, and `available_for_linking` (plus the default +`:read`/`:destroy`). Linking and unlinking happen through the optional **`:user` argument** on +`create_member` / `update_member`, not through dedicated `link_*`/`unlink_*` actions. (`user_id` +is deliberately **not** in the accept list, so the foreign key cannot be set directly.) ### Implementation -```elixir -defmodule Mv.Membership.Member do - use Ash.Resource, ... - - actions do - # SELF-SERVICE: User creates member and links to self - create :create_member_for_self do - description "User creates a new member and links it to their own account" - - accept [:name, :email, :address, ...] # All fields except user_id - - # Automatically set user_id to actor - change set_attribute(:user_id, actor(:id)) - - # Prevent creating multiple members for same user (optional business rule) - validate fn changeset, _context -> - actor_id = get_change(changeset, :user_id) - - case Ash.read(Member, filter: expr(user_id == ^actor_id)) do - {:ok, []} -> :ok # No existing member, allow - {:ok, [_member | _]} -> {:error, "You already have a member profile"} - {:error, _} -> :ok - end - end - end +The user–member link is governed by two facts about `create_member` / `update_member`: - # ADMIN: Create member with optional user link - create :create_member do - description "Admin creates a new member, optionally linked to a user" - - accept [:name, :email, :address, ..., :user_id] # Admin can set user_id - end +- The `:user` argument drives the relationship via `manage_relationship(:user, ...)` with + `on_lookup: :relate`, `on_no_match: :error`, `on_match: :error`, and **`on_missing: :ignore`**. + Because of `on_missing: :ignore`, **omitting** `:user` leaves the link unchanged (no "unlink by + omission"); unlink is explicit (`user: nil`/`user: %{}`), handled on update via the + `UnrelateUserWhenArgumentNil` change. +- Whether the `:user` argument may be used at all is gated by the policy check + `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` (`forbid_if` before + `authorize_if HasPermission` on `action_type([:create, :update])`). It forbids the action for a + non-admin whenever the `:user` argument **key is present** (any value), so only admins may set or + change the link. Non-admins can still create/update members as long as they do not pass `:user`. - # ADMIN: Link existing member to user - update :link_member_to_user do - description "Admin links an existing member to a user account" - - accept [:user_id] - - validate fn changeset, _context -> - member = changeset.data - - # Cannot link if already linked - if is_nil(member.user_id) do - :ok - else - {:error, "Member is already linked to a user"} - end - end - end +Self-service ("a user creates a member and is linked to it") is handled on the **User** side, not +by a special Member action: the admin-only `update_user` action takes a `:member` argument for +link/unlink (see Enforcement above), and the UI gates the linking controls on admin status. - # ADMIN: Remove user link from member - update :unlink_member_from_user do - description "Admin removes user link from member" - - change set_attribute(:user_id, nil) - end +### Why This Design? - # STANDARD UPDATE: Cannot change user_id - update :update do - description "Update member data (cannot change user link)" - - accept [:name, :email, :address, ...] # user_id NOT in accept list - end - end - - policies do - # Self-service member creation - policy action(:create_member_for_self) do - description "Any authenticated user can create member for themselves" - authorize_if actor_present() - end - - # Admin-only actions - policy action([:create_member, :link_member_to_user, :unlink_member_from_user]) do - description "Only admin can manage user-member links" - authorize_if Mv.Authorization.Checks.HasPermission - end - - # Standard actions (regular permission check) - policy action([:read, :update, :destroy]) do - authorize_if Mv.Authorization.Checks.HasPermission - end - end -end -``` - -### UI Examples - -**User Self-Service:** - -```heex - -<%= if is_nil(@current_user.member_id) do %> - <.link navigate="/members/new_for_self"> - Create My Member Profile - -<% end %> - - -<.simple_form for={@form} phx-submit="create_for_self"> - <.input field={@form[:name]} label="Name" /> - <.input field={@form[:email]} label="Email" /> - <.input field={@form[:address]} label="Address" /> - - - - <:actions> - <.button>Create My Profile - - -``` - -**Admin Interface:** - -```heex - -<%= if can?(@current_user, :link_member_to_user, @member) do %> - <%= if is_nil(@member.user_id) do %> - - <.form for={@link_form} phx-submit="link_to_user"> - <.input field={@link_form[:user_id]} type="select" label="Link to User" options={@users} /> - <.button>Link to User - - <% else %> - - <.button phx-click="unlink_from_user" phx-value-id={@member.id}> - Unlink from User (<%= @member.user.email %>) - - <% end %> -<% end %> -``` - -### Why Separate Actions? - -✅ **Clear Intent:** Action name communicates what's happening -✅ **Precise Policies:** Different policies for different operations -✅ **Better UX:** Separate UI flows for self-service vs. admin -✅ **Testable:** Each action can be tested independently -✅ **Idiomatic Ash:** Uses Ash's action system as designed +Keeping the link on a single `:user` argument (rather than a fan-out of `link_*`/`unlink_*` +actions) means there is exactly one create and one update path to reason about, the +admin-only rule lives in one reusable policy check (`ForbidMemberUserLinkUnlessAdmin`) instead of +being duplicated per action, and `user_id` can never be mass-assigned because it is not accepted — +only the argument-driven relationship management can change it. --- @@ -2244,58 +1147,12 @@ def get_permissions(:read_only) do end ``` -**Read Filtering via Ash Calculations:** +**Read filtering** via an Ash calculation that takes `allowed_fields` from PermissionSets and +`Map.take/2`s each record to those fields. **Write protection** via an update validation that +diffs `Map.keys(changeset.attributes)` against the allowed write fields and returns +`{:error, "You do not have permission to modify: ..."}` for any forbidden field. -```elixir -defmodule Mv.Membership.Member do - calculations do - calculate :filtered_fields, :map do - calculate fn members, context -> - actor = context[:actor] - - # Get allowed fields from PermissionSets - allowed_fields = get_allowed_read_fields(actor, "Member") - - # Filter fields - Enum.map(members, fn member -> - Map.take(member, allowed_fields) - end) - end - end - end -end -``` - -**Write Protection via Custom Validations:** - -```elixir -validations do - validate on: :update do - validate fn changeset, context -> - actor = context[:actor] - changed_fields = Map.keys(changeset.attributes) - - # Get allowed fields from PermissionSets - allowed_fields = get_allowed_write_fields(actor, "Member") - - # Check if any forbidden field is being changed - forbidden = Enum.reject(changed_fields, &(&1 in allowed_fields)) - - if Enum.empty?(forbidden) do - :ok - else - {:error, "You do not have permission to modify: #{Enum.join(forbidden, ", ")}"} - end - end - end -end -``` - -**Benefits:** -- ✅ No database schema changes -- ✅ Still uses hardcoded PermissionSets -- ✅ Granular control over sensitive fields -- ✅ Clear error messages +**Benefits:** No database schema changes, still uses hardcoded PermissionSets, granular control over sensitive fields, clear error messages. **Estimated Effort:** 2-3 weeks @@ -2368,15 +1225,9 @@ defmodule Mv.Authorization.PermissionCache do end ``` -**Benefits:** -- ✅ Runtime permission configuration -- ✅ More flexible than hardcoded -- ✅ Can add new permission sets without code changes +**Benefits:** Runtime permission configuration, more flexible than hardcoded, can add new permission sets without code changes. -**Trade-offs:** -- ⚠️ More complex (DB queries, cache, invalidation) -- ⚠️ Slightly slower (mitigated by cache) -- ⚠️ More testing needed +**Trade-offs:** More complex (DB queries, cache, invalidation), slightly slower (mitigated by cache), more testing needed. **Estimated Effort:** 3-4 weeks @@ -2393,12 +1244,24 @@ See [Migration Strategy](#migration-strategy) for detailed migration plan. ### Three-Phase Approach -**Phase 1: MVP (2-3 weeks) - CURRENT** +**Phase 1: MVP (2-3 weeks) - CURRENT** (shipped 2026-01-08, PR #346, closes #345) - Hardcoded PermissionSets module - `HasPermission` check reads from module - Role table with `permission_set_name` string - Zero DB queries for permission checks +**What's NOT in MVP (deferred to Phase 3):** +- `PermissionSetResource` database table +- `PermissionSetPage` database table +- ETS Permission Cache +- Database-backed dynamic permissions / runtime permission editing + +**MVP DB migration & rollback.** Issue #1 adds a single migration: create the `roles` table (`name` unique, `permission_set_name`, `is_system_role`, timestamps; indexes on `name` and `permission_set_name`) and add nullable `users.role_id` FK (`ON DELETE RESTRICT`) with its index. The migration is additive only — no existing table is modified destructively. The 5 roles are created by `priv/repo/seeds_bootstrap.exs`, and the `assign_mitglied_role_to_existing_users` migration assigns "Mitglied" to users without a role. + +Rollback options, in order of escalation: +1. **DB rollback:** the `down` migration drops the `users.role_id` index, removes the `role_id` column, and drops the `roles` table — `mix ecto.rollback --step 1`. Existing tables are untouched. +2. **Code rollback:** revert the commit and redeploy the previous version. + **Phase 2: Field-Level (2-3 weeks) - FUTURE** - Extend PermissionSets with `:fields` key - Ash Calculations for read filtering @@ -2432,60 +1295,13 @@ See [Migration Strategy](#migration-strategy) for detailed migration plan. ### Migration from MVP to Phase 3 -**Step-by-step:** - -1. **Create DB Tables** (1 day) - - Run migrations for `permission_sets`, `permission_set_resources`, `permission_set_pages` - - Add indexes - -2. **Seed from PermissionSets Module** (1 day) - - Script that reads from `PermissionSets.get_permissions/1` - - Inserts into new tables - - Verify data integrity - -3. **Create HasResourcePermission Check** (2 days) - - New check that queries DB - - Same logic as `HasPermission` but different data source - - Comprehensive tests - -4. **Implement ETS Cache** (2 days) - - Cache module - - Cache invalidation on updates - - Performance tests - -5. **Update Policies** (3 days) - - Replace `HasPermission` with `HasResourcePermission` in all resources - - Test each resource thoroughly - -6. **Update UI Helpers** (1 day) - - Modify `MvWeb.Authorization` to query DB - - Use cache for performance - -7. **Update Page Plug** (1 day) - - Modify `CheckPagePermission` to query DB - - Use cache - -8. **Integration Testing** (3 days) - - Full user journey tests - - Performance testing - - Load testing - -9. **Deploy to Staging** (1 day) - - Feature flag approach - - Run both systems in parallel - - Compare results - -10. **Deploy to Production** (1 day) - - Gradual rollout - - Monitor performance - - Rollback plan ready - -11. **Cleanup** (1 day) - - Remove old `HasPermission` check - - Remove `PermissionSets` module - - Update documentation - -**Total:** ~3-4 weeks +Sequence (~3-4 weeks): create the three permission tables + indexes; seed them from +`PermissionSets.get_permissions/1`; add a `HasResourcePermission` check that queries the DB +(same logic as `HasPermission`, different data source) backed by the ETS cache with +invalidation on update; swap `HasPermission` → `HasResourcePermission` in all resources and +point the UI helper + page plug at the DB/cache; integration + performance/load test; deploy +behind the feature flag (run both systems in parallel to compare) then gradually to production; +finally remove the old `HasPermission` check and `PermissionSets` module. --- @@ -2547,28 +1363,9 @@ See [Migration Strategy](#migration-strategy) for detailed migration plan. ### Audit Logging (Future) -**Not in MVP, but planned:** - -```elixir -defmodule Mv.Authorization.AuditLog do - def log_authorization_failure(actor, resource, action, reason) do - Ash.create!(AuditLog, %{ - user_id: actor.id, - resource: inspect(resource), - action: action, - outcome: "forbidden", - reason: reason, - ip_address: get_ip_address(), - timestamp: DateTime.utc_now() - }) - end -end -``` - -**Benefits:** -- Track suspicious authorization attempts -- Compliance (GDPR requires access logs) -- Debugging production issues +Not in MVP, but planned: persist authorization failures (user id, resource, action, outcome, +reason, IP, timestamp) to an `AuditLog` resource — for tracking suspicious attempts, GDPR access +logs, and production debugging. Currently failures are only `Logger`-logged. --- @@ -2577,11 +1374,9 @@ end ### Glossary - **Permission Set:** Named collection of permissions (e.g., "admin", "read_only") -- **Role:** Database entity linking users to permission sets -- **Scope:** Range of records permission applies to (:own, :linked, :all) +- **Role:** Database entity linking users to a permission set; **system role** cannot be deleted (`is_system_role=true`) +- **Scope:** Range of records a permission applies to (`:own`, `:linked`, `:all`) - **Actor:** Currently authenticated user in Ash context -- **Policy:** Ash authorization rule on a resource -- **System Role:** Role that cannot be deleted (is_system_role=true) - **Special Case:** Authorization rule that takes precedence over general permissions ### Resource Name Mapping @@ -2614,54 +1409,9 @@ These strings must match exactly in `PermissionSets` module. | User without role | Access denied everywhere | Seeds assign default role, runtime checks handle gracefully | | Invalid permission_set_name | Access denied | Validation on Role, runtime safety checks | | System role deletion | Forbidden | Validation prevents deletion if `is_system_role=true` | -| Linked member email | Admin-only edit | Custom validation in Member resource | +| Linked member email | Admin or linked user may edit | `Member.Validations.EmailChangePermission` | | Own credentials | Always accessible | Special policy before general check | -### Testing Checklist - -**For Each Resource:** -- [ ] All 5 roles tested (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin) -- [ ] All actions tested (read, create, update, destroy) -- [ ] All scopes tested (own, linked, all) -- [ ] Special cases tested -- [ ] Edge cases tested (nil role, invalid permission_set_name) - -**For UI:** -- [ ] Buttons/links show/hide correctly per role -- [ ] Page access controlled per role -- [ ] No broken links (all visible links are accessible) - -**Integration:** -- [ ] One complete user journey per role -- [ ] Cross-resource scenarios (e.g., Member -> CustomFieldValue) -- [ ] Special cases in context (e.g., linked member email during full edit flow) - -### Useful Commands - -```bash -# Run all authorization tests -mix test test/mv/authorization - -# Run integration tests -mix test test/integration - -# Run with coverage -mix test --cover - -# Generate migrations -mix ash.codegen - -# Run seeds -mix run priv/repo/seeds/authorization_seeds.exs - -# Check permission for user in IEx -iex> user = Mv.Accounts.get_user!("user-id") -iex> MvWeb.Authorization.can?(user, :create, Mv.Membership.Member) - -# Check page access in IEx -iex> MvWeb.Authorization.can_access_page?(user, "/members/new") -``` - --- ## Authorization Bootstrap Patterns diff --git a/docs/roles-and-permissions-implementation-plan.md b/docs/roles-and-permissions-implementation-plan.md index 95db031..74b8705 100644 --- a/docs/roles-and-permissions-implementation-plan.md +++ b/docs/roles-and-permissions-implementation-plan.md @@ -1,1663 +1,39 @@ -# Roles and Permissions - Implementation Plan (MVP) +# Roles and Permissions - Implementation Record (MVP) -**Version:** 2.0 (Clean Rewrite) -**Date:** 2025-01-13 -**Last Updated:** 2026-01-13 -**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345) -**Related Documents:** -- [Overview](./roles-and-permissions-overview.md) - High-level concepts -- [Architecture](./roles-and-permissions-architecture.md) - Technical specification +**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345) +**Related:** [Overview](./roles-and-permissions-overview.md) · [Architecture](./roles-and-permissions-architecture.md) ---- +> Historical record of how the MVP (Phase 1) of the hardcoded Roles & Permissions +> system was built. The architecture document is the canonical design reference; +> the DB migration/rollback steps and the "What's NOT in MVP" boundary now live in +> its [Migration Strategy](./roles-and-permissions-architecture.md#migration-strategy) +> section. -## Table of Contents +## How the MVP was built -- [Executive Summary](#executive-summary) -- [MVP Scope](#mvp-scope) -- [Implementation Strategy](#implementation-strategy) -- [Issue Breakdown](#issue-breakdown) - - [Sprint 1: Foundation](#sprint-1-foundation-week-1) - - [Sprint 2: Policies](#sprint-2-policies-week-2) - - [Sprint 3: Special Cases & Seeds](#sprint-3-special-cases--seeds-week-3) - - [Sprint 4: UI & Integration](#sprint-4-ui--integration-week-4) -- [Dependencies & Parallelization](#dependencies--parallelization) -- [Testing Strategy](#testing-strategy) -- [Migration & Rollback](#migration--rollback) -- [Risk Management](#risk-management) +The MVP shipped as **PR #346 (closes #345)** across four week-sized sprints, built +test-first (TDD) with the work split into 15 issues: ---- +- **Sprint 1 — Foundation:** `Role` Ash resource + `users.role_id` FK (#1); hardcoded + `PermissionSets` module with the 4 sets `own_data`/`read_only`/`normal_user`/`admin` (#2); + Role CRUD admin LiveViews (#3). +- **Sprint 2 — Policies:** `HasPermission` custom Ash policy check (#6); resource policies + for Member (#7), User (#8), CustomFieldValue (#9), CustomField (#10); page-permission + router plug (#11). Issues #7–#11 ran in parallel after #6. +- **Sprint 3 — Special cases & seeds:** linked-member email validation (#12); role seed + data + default-role assignment (#13). +- **Sprint 4 — UI & integration:** `MvWeb.Authorization` UI helper (`can?/3`, + `can_access_page?/2`) (#14); admin role-management UI (#15); applying UI authorization + to existing LiveViews + navbar (#16); per-role integration journey tests (#17). -## Executive Summary +The 5 seeded roles map to permission sets as: Mitglied → own_data (system role), +Vorstand → read_only, Kassenwart → normal_user, Buchhaltung → read_only, Admin → admin. -### Overview +Issues #4, #5, #18 (DB-backed permission tables and ETS cache) were intentionally +**not** built — see "What's NOT in MVP" in the architecture document. -This document defines the implementation plan for the **MVP (Phase 1)** of the Roles and Permissions system using **hardcoded Permission Sets** in an Elixir module. - -**Key Characteristics:** -- **15 issues total** (Issues #1-3, #6-17) -- **2-3 weeks duration** -- **180+ tests** -- **Test-Driven Development (TDD)** throughout -- **No database tables for permissions** - only `roles` table -- **Zero performance concerns** - all permission checks are in-memory function calls - -### What's NOT in MVP - -**Deferred to Phase 3 (Future):** -- Issue #4: `PermissionSetResource` database table -- Issue #5: `PermissionSetPage` database table -- Issue #18: ETS Permission Cache -- Database-backed dynamic permissions - -### The Four Permission Sets - -Hardcoded in `Mv.Authorization.PermissionSets` module: - -1. **own_data** - User can only access their own data (default for "Mitglied") -2. **read_only** - Read access to all members/custom field values (for "Vorstand", "Buchhaltung") -3. **normal_user** - Create/Read/Update members (no delete), full CRUD on properties (for "Kassenwart") -4. **admin** - Unrestricted access including user/role management (for "Admin") - -### The Five Roles - -Stored in database `roles` table, each referencing a `permission_set_name`: - -1. **Mitglied** → "own_data" (is_system_role=true, default) -2. **Vorstand** → "read_only" -3. **Kassenwart** → "normal_user" -4. **Buchhaltung** → "read_only" -5. **Admin** → "admin" - ---- - -## MVP Scope - -### What We're Building - -**Core Authorization System:** -- ✅ Hardcoded PermissionSets module with 4 permission sets -- ✅ Role database table and CRUD interface -- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets -- ✅ Policies on all resources (Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle) -- ✅ Page-level permissions via Phoenix Plug (including admin-only `/settings` and `/membership_fee_settings`) -- ✅ UI authorization helpers for conditional rendering -- ✅ Special case: Member email validation for linked users -- ✅ User role assignment: admin-only `role_id` in update_user; Last-Admin validation; role dropdown in User form when `can?(actor, :update, Role)` -- ✅ Seed data for 5 roles - -**Benefits of Hardcoded Approach:** -- **Speed:** 2-3 weeks vs. 4-5 weeks for DB-backed -- **Performance:** < 1 microsecond per check (pure function call) -- **Simplicity:** No cache, no DB queries, easy to reason about -- **Version Control:** All permission changes tracked in Git -- **Testing:** Deterministic, no DB setup needed - -**Clear Migration Path to Phase 3:** -- Architecture document defines exact DB schema for future -- HasPermission check can be swapped for DB-querying version -- Role->PermissionSet link remains unchanged - ---- - -## Implementation Strategy - -### Test-Driven Development - -**Every issue follows TDD:** -1. Write failing tests first -2. Implement minimum code to pass tests -3. Refactor if needed -4. All tests must pass before moving on - -**Test Types:** -- **Unit Tests:** Individual modules (PermissionSets, Policy checks, Helpers) -- **Integration Tests:** Cross-resource authorization, special cases -- **LiveView Tests:** UI rendering, page permissions -- **E2E Tests:** Complete user journeys (one per role) - -### Incremental Rollout - -**Feature Flag Approach:** -- Implement behind environment variable `ENABLE_RBAC` -- Default: `false` (existing auth remains active) -- Test thoroughly in staging -- Flip flag in production after validation -- Allows instant rollback if needed - -### Definition of Done (All Issues) - -- [ ] All acceptance criteria met -- [ ] All tests written and passing -- [ ] Code reviewed and approved -- [ ] Documentation updated -- [ ] No linter errors -- [ ] Manual testing completed -- [ ] Feature flag tested (on/off states) - ---- - -## Issue Breakdown - -### Sprint 1: Foundation (Week 1) - -#### Issue #1: Create Authorization Domain and Role Resource - -**Size:** M (2 days) -**Dependencies:** None -**Assignable to:** Backend Developer - -**Description:** - -Create the authorization domain in Ash with the `Role` resource. This establishes the foundation for all authorization logic. - -**Tasks:** - -1. Create `lib/mv/authorization/` directory -2. Create `lib/mv/authorization/role.ex` Ash resource with: - - `id` (UUIDv7, primary key) - - `name` (String, unique, required) - e.g., "Vorstand", "Admin" - - `description` (String, optional) - - `permission_set_name` (String, required) - must be one of: "own_data", "read_only", "normal_user", "admin" - - `is_system_role` (Boolean, default false) - prevents deletion - - timestamps -3. Add validation: `permission_set_name` must exist in `PermissionSets.all_permission_sets/0` -4. Add `role_id` (UUID, nullable, foreign key) to `users` table -5. Add `belongs_to :role` relationship in User resource -6. Run `mix ash.codegen` to generate migrations -7. Review and apply migrations - -**Acceptance Criteria:** - -- [ ] Role resource created with all fields -- [ ] Migration applied successfully -- [ ] User.role relationship works -- [ ] Validation prevents invalid `permission_set_name` -- [ ] `is_system_role` flag present - -**Test Strategy:** - -**Smoke Tests Only** (detailed behavior tests in later issues): - -- Role resource can be loaded via `Code.ensure_loaded?(Mv.Authorization.Role)` -- Migration created valid table (manually verify with `psql`) -- User resource can be loaded and has `:role` in `relationships()` - -**No extensive behavior tests** - those come in Issue #3 (Role CRUD). - -**Test File:** `test/mv/authorization/role_test.exs` (minimal smoke tests) - ---- - -#### Issue #2: PermissionSets Elixir Module (Hardcoded Permissions) - -**Size:** M (2 days) -**Dependencies:** None -**Can work in parallel:** Yes (parallel with #1) -**Assignable to:** Backend Developer - -**Description:** - -Create the core `PermissionSets` module that defines all four permission sets with their resource and page permissions. This is the heart of the MVP's authorization logic. - -**Tasks:** - -1. Create `lib/mv/authorization/permission_sets.ex` -2. Define module with `@moduledoc` explaining the 4 permission sets -3. Define types: - ```elixir - @type scope :: :own | :linked | :all - @type action :: :read | :create | :update | :destroy - @type resource_permission :: %{ - resource: String.t(), - action: action(), - scope: scope(), - granted: boolean() - } - @type permission_set :: %{ - resources: [resource_permission()], - pages: [String.t()] - } - ``` -4. Implement `get_permissions/1` for each of the 4 permission sets -5. Implement `all_permission_sets/0` returning `[:own_data, :read_only, :normal_user, :admin]` -6. Implement `valid_permission_set?/1` checking if name is in the list -7. Implement `permission_set_name_to_atom/1` with error handling -8. Add comprehensive `@doc` examples for each function - -**Permission Set Details:** - -**1. own_data (Mitglied):** -- Resources: - - User: read/update :own - - Member: read/update :linked - - CustomFieldValue: read/update :linked - - CustomField: read :all -- Pages: `["/", "/profile", "/members/:id"]` - -**2. read_only (Vorstand, Buchhaltung):** -- Resources: - - User: read :own, update :own - - Member: read :all - - CustomFieldValue: read :all - - CustomField: read :all -- Pages: `["/", "/members", "/members/:id", "/custom_field_values"]` - -**3. normal_user (Kassenwart):** -- Resources: - - User: read/update :own - - Member: read/create/update :all (no destroy for safety) - - CustomFieldValue: read/create/update/destroy :all - - CustomField: read :all -- Pages: `["/", "/members", "/members/new", "/members/:id", "/members/:id/edit", "/custom_field_values", "/custom_field_values/new", "/custom_field_values/:id/edit"]` - -**4. admin:** -- Resources: - - User: read/update/destroy :all - - Member: read/create/update/destroy :all - - CustomFieldValue: read/create/update/destroy :all - - CustomField: read/create/update/destroy :all - - Role: read/create/update/destroy :all -- Pages: `["*"]` (wildcard = all pages) - -**Acceptance Criteria:** - -- [ ] Module created with all 4 permission sets -- [ ] `get_permissions/1` returns correct structure for each set -- [ ] `valid_permission_set?/1` works for atoms and strings -- [ ] `permission_set_name_to_atom/1` handles errors gracefully -- [ ] All functions have `@doc` and `@spec` -- [ ] Code is readable and well-commented - -**Test Strategy (TDD):** - -**Structure Tests:** -- `get_permissions(:own_data)` returns map with `:resources` and `:pages` keys -- Each permission set returns list of resource permissions -- Each resource permission has required keys: `:resource`, `:action`, `:scope`, `:granted` -- Pages lists are non-empty (except potentially for restricted roles) - -**Permission Content Tests:** -- `:own_data` allows User read/update with scope :own -- `:own_data` allows Member/CustomFieldValue read/update with scope :linked -- `:read_only` allows Member/CustomFieldValue read with scope :all -- `:read_only` does NOT allow Member/CustomFieldValue create/update/destroy -- `:normal_user` allows Member/CustomFieldValue full CRUD with scope :all -- `:admin` allows everything with scope :all -- `:admin` has wildcard page permission "*" - -**Validation Tests:** -- `valid_permission_set?("own_data")` returns true -- `valid_permission_set?(:admin)` returns true -- `valid_permission_set?("invalid")` returns false -- `permission_set_name_to_atom("own_data")` returns `{:ok, :own_data}` -- `permission_set_name_to_atom("invalid")` returns `{:error, :invalid_permission_set}` - -**Edge Cases:** -- All 4 sets defined in `all_permission_sets/0` -- Function doesn't crash on nil input (returns false/error tuple) - -**Test File:** `test/mv/authorization/permission_sets_test.exs` - ---- - -#### Issue #3: Role CRUD LiveViews - -**Size:** M (3 days) -**Dependencies:** #1 (Role resource) -**Assignable to:** Backend Developer + Frontend Developer - -**Description:** - -Create LiveView interface for administrators to manage roles. Only admins should be able to access this. - -**Tasks:** - -1. Create `lib/mv_web/live/role_live/` directory -2. Implement `index.ex` - List all roles -3. Implement `show.ex` - View role details -4. Implement `form.ex` - Create/Edit role form component -5. Add routes in `router.ex` under `/admin` scope -6. Create table component showing: name, description, permission_set_name, is_system_role -7. Add form validation for `permission_set_name` (dropdown with 4 options) -8. Prevent deletion of system roles (UI + backend) -9. Add flash messages for success/error -10. Style with existing DaisyUI theme - -**Acceptance Criteria:** - -- [ ] Index page lists all roles -- [ ] Show page displays role details -- [ ] Form allows creating new roles -- [ ] Form allows editing non-system roles -- [ ] `permission_set_name` is dropdown (not free text) -- [ ] Cannot delete system roles (grayed out button + backend check) -- [ ] All CRUD operations work -- [ ] Routes are under `/admin/roles` - -**Test Strategy (TDD):** - -**LiveView Mount Tests:** -- Index page mounts successfully -- Index page loads all roles from database -- Show page mounts with valid role ID -- Show page returns 404 for invalid role ID - -**CRUD Operation Tests:** -- Create new role with valid data succeeds -- Create new role with invalid `permission_set_name` shows error -- Update role name succeeds -- Update system role's `permission_set_name` succeeds -- Delete non-system role succeeds -- Delete system role fails with error message - -**UI Rendering Tests:** -- Index page shows table with role names -- System roles have badge/indicator -- Delete button disabled for system roles -- Form dropdown shows all 4 permission sets -- Flash messages appear after actions - -**Test File:** `test/mv_web/live/role_live_test.exs` - ---- - -### Sprint 2: Policies (Week 2) - -#### Issue #6: Custom Policy Check - HasPermission - -**Size:** L (3-4 days) -**Dependencies:** #2 (PermissionSets), #3 (Role resource exists) -**Assignable to:** Senior Backend Developer - -**Description:** - -Create the core custom Ash Policy Check that reads permissions from the `PermissionSets` module and applies them to Ash queries. This is the bridge between hardcoded permissions and Ash's authorization system. - -**Tasks:** - -1. Create `lib/mv/authorization/checks/has_permission.ex` -2. Implement `use Ash.Policy.Check` -3. Implement `describe/1` - returns human-readable description -4. Implement `match?/3` - the core authorization logic: - - Extract `actor.role.permission_set_name` - - Convert to atom via `PermissionSets.permission_set_name_to_atom/1` - - Call `PermissionSets.get_permissions/1` - - Find matching permission for current resource + action - - Apply scope filter -5. Implement `apply_scope/3` helper: - - `:all` → `:authorized` (no filter) - - `:own` → `{:filter, expr(id == ^actor.id)}` - - `:linked` → resource-specific logic: - - Member: `{:filter, expr(user_id == ^actor.id)}` - - CustomFieldValue: `{:filter, expr(member.user_id == ^actor.id)}` (traverse relationship!) -6. Handle errors gracefully: - - No actor → `{:error, :no_actor}` - - No role → `{:error, :no_role}` - - Invalid permission_set_name → `{:error, :invalid_permission_set}` - - No matching permission → `{:error, :no_permission}` -7. Add logging for authorization failures (debug level) -8. Add comprehensive `@doc` with examples - -**Acceptance Criteria:** - -- [ ] Check module implements `Ash.Policy.Check` behavior -- [ ] `match?/3` correctly evaluates permissions from PermissionSets -- [ ] Scope filters work correctly (:all, :own, :linked) -- [ ] `:linked` scope handles Member and CustomFieldValue differently -- [ ] Errors are handled gracefully (no crashes) -- [ ] Authorization failures are logged -- [ ] Module is well-documented - -**Test Strategy (TDD):** - -**Permission Lookup Tests:** -- Actor with :admin permission_set has permission for all resources/actions -- Actor with :read_only permission_set has read permission for Member -- Actor with :read_only permission_set does NOT have create permission for Member -- Actor with :own_data permission_set has update permission for User with scope :own - -**Scope Application Tests - :all:** -- Actor with scope :all can access any record -- Query returns all records in database - -**Scope Application Tests - :own:** -- Actor with scope :own can access record where record.id == actor.id -- Actor with scope :own cannot access record where record.id != actor.id -- Query filters to only actor's own record - -**Scope Application Tests - :linked:** -- Actor with scope :linked can access Member where member.user_id == actor.id -- Actor with scope :linked can access CustomFieldValue where custom_field_value.member.user_id == actor.id (relationship traversal!) -- Actor with scope :linked cannot access unlinked member -- Query correctly filters based on user_id relationship - -**Error Handling Tests:** -- `match?` with nil actor returns `{:error, :no_actor}` -- `match?` with actor missing role returns `{:error, :no_role}` -- `match?` with invalid permission_set_name returns `{:error, :invalid_permission_set}` -- `match?` with no matching permission returns `{:error, :no_permission}` -- No crashes on edge cases - -**Logging Tests:** -- Authorization failure logs at debug level -- Log includes actor ID, resource, action, reason - -**Test Files:** -- `test/mv/authorization/checks/has_permission_test.exs` - ---- - -#### Issue #7: Member Resource Policies - -**Size:** M (2 days) -**Dependencies:** #6 (HasPermission check) -**Can work in parallel:** Yes (parallel with #8, #9, #10) -**Assignable to:** Backend Developer - -**Description:** - -Add authorization policies to the Member resource using the new `HasPermission` check. - -**Tasks:** - -1. Open `lib/mv/membership/member.ex` -2. Add `policies` block at top of resource (before actions) -3. Configure policy to `Mv.Authorization.Checks.HasPermission` -4. Add policy for each action: - - `:read` → check HasPermission for :read - - `:create` → check HasPermission for :create - - `:update` → check HasPermission for :update - - `:destroy` → check HasPermission for :destroy -5. Add special policy: Allow user to read/update their linked member (before general policy) - ```elixir - policy action_type(:read) do - authorize_if expr(user_id == ^actor(:id)) - end - ``` -6. Ensure policies load actor with `:role` relationship preloaded -7. Test policies with different actors - -**Policy Order (Critical!):** -1. Allow user to access their own linked member (most specific) -2. Check HasPermission (general authorization) -3. Default: Forbid - -**Acceptance Criteria:** - -- [ ] Policies block added to Member resource -- [ ] All CRUD actions protected by HasPermission -- [ ] Special case: User can always access linked member -- [ ] Policy order is correct (specific before general) -- [ ] Actor preloads :role relationship -- [ ] All policies tested - -**Test Strategy (TDD):** - -**Policy Tests for :own_data (Mitglied):** -- User can read their linked member (user_id matches) -- User can update their linked member -- User cannot read unlinked member (returns empty list or forbidden) -- User cannot create member -- Verify scope :linked works - -**Policy Tests for :read_only (Vorstand):** -- User can read all members (returns all records) -- User cannot create member (returns Forbidden) -- User cannot update any member (returns Forbidden) -- User cannot destroy any member (returns Forbidden) - -**Policy Tests for :normal_user (Kassenwart):** -- User can read all members -- User can create new member -- User can update any member -- User cannot destroy member (not in permission set) - -**Policy Tests for :admin:** -- User can perform all CRUD operations on any member -- No restrictions - -**Test File:** `test/mv/membership/member_policies_test.exs` - ---- - -#### Issue #8: User Resource Policies - -**Size:** M (2 days) -**Dependencies:** #6 (HasPermission check) -**Can work in parallel:** Yes (parallel with #7, #9, #10) -**Assignable to:** Backend Developer -**Status:** ✅ **COMPLETED** - -**Description:** - -Add authorization policies to the User resource. Users can always read their own credentials (via bypass), and update their own credentials (via HasPermission with scope :own). - -**Implementation Pattern:** - -Following the same pattern as Member resource: -- **Bypass for READ** - Handles list queries (auto_filter) -- **HasPermission for UPDATE** - Handles updates with scope :own - -**Tasks:** - -1. ✅ Open `lib/accounts/user.ex` -2. ✅ Add `policies` block -3. ✅ Add AshAuthentication bypass (registration/login without actor) -4. ✅ ~~Add NoActor bypass (test environment only)~~ **REMOVED** - NoActor bypass was removed to prevent masking authorization bugs. All tests now use `system_actor`. -5. ✅ Add bypass for READ: Allow user to always read their own account - ```elixir - bypass action_type(:read) do - description "Users can always read their own account" - authorize_if expr(id == ^actor(:id)) - end - ``` -6. ✅ Add general policy: Check HasPermission for all actions (including UPDATE with scope :own) -7. ✅ Ensure :destroy is admin-only (via HasPermission) -8. ✅ Preload :role relationship for actor in tests - -**Policy Order:** -1. ✅ AshAuthentication bypass (registration/login) -2. ✅ Bypass: User can READ own account (id == actor.id) -3. ✅ HasPermission: General permission check (UPDATE uses scope :own, admin uses scope :all) -4. ✅ Default: Ash implicitly forbids (fail-closed) - -**Note:** NoActor bypass was removed. All tests now use `system_actor` for authorization. - -**Why Bypass for READ but not UPDATE?** - -- **READ list queries**: No record at strict_check time → bypass with `expr()` needed for auto_filter ✅ -- **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :own` correctly ✅ - -This ensures `scope :own` in PermissionSets is actually used (not redundant). - -**Acceptance Criteria:** - -- ✅ User can always read own credentials (via bypass) -- ✅ User can always update own credentials (via HasPermission with scope :own) -- ✅ Only admin can read/update other users (scope :all) -- ✅ Only admin can destroy users (scope :all) -- ✅ Policy order is correct (AshAuth → Bypass READ → HasPermission) -- ✅ Actor preloads :role relationship -- ✅ All tests pass (30/31 pass, 1 skipped) - -**Test Results:** - -**Test File:** `test/mv/accounts/user_policies_test.exs` -- ✅ 31 tests total: 30 passing, 1 skipped (AshAuthentication edge case) -- ✅ Tests for all 4 permission sets: own_data, read_only, normal_user, admin -- ✅ Tests for AshAuthentication bypass (registration/login) -- ✅ Tests use system_actor for authorization (NoActor bypass removed) -- ✅ Tests verify scope :own is used for UPDATE (not redundant) - ---- - -#### Issue #9: CustomFieldValue Resource Policies - -**Size:** M (2 days) -**Dependencies:** #6 (HasPermission check) -**Can work in parallel:** Yes (parallel with #7, #8, #10) -**Assignable to:** Backend Developer - -**Description:** - -Add authorization policies to the CustomFieldValue resource. CustomFieldValues are linked to members, which are linked to users. - -**Tasks:** - -1. Open `lib/mv/membership/custom_field_value.ex` -2. Add `policies` block -3. Add special policy: Allow user to read/update custom field values of their linked member - ```elixir - policy action_type([:read, :update]) do - authorize_if expr(member.user_id == ^actor(:id)) - end - ``` -4. Add general policy: Check HasPermission -5. Ensure CustomFieldValue preloads :member relationship for scope checks -6. Preload :role relationship for actor - -**Policy Order:** -1. Allow user to read/update properties of linked member -2. Check HasPermission -3. Default: Forbid - -**Acceptance Criteria:** - -- [ ] User can access properties of their linked member -- [ ] Policy traverses Member -> User relationship correctly -- [ ] HasPermission check works for other scopes -- [ ] Actor preloads :role relationship - -**Test Strategy (TDD):** - -**Linked CustomFieldValues Tests (:own_data):** -- User can read custom field values of their linked member -- User can update custom field values of their linked member -- User cannot read custom field values of unlinked members -- Verify relationship traversal works (custom_field_value.member.user_id) - -**Read-Only Tests:** -- User with :read_only can read all custom field values -- User with :read_only cannot create/update custom field values - -**Normal User Tests:** -- User with :normal_user can CRUD custom field values - -**Admin Tests:** -- Admin can perform all operations - -**Test File:** `test/mv/membership/custom_field_value_policies_test.exs` - ---- - -#### Issue #10: CustomField Resource Policies - -**Size:** S (1 day) -**Dependencies:** #6 (HasPermission check) -**Can work in parallel:** Yes (parallel with #7, #8, #9) -**Assignable to:** Backend Developer - -**Description:** - -Add authorization policies to the CustomField resource. CustomFields are admin-managed, but readable by all. - -**Tasks:** - -1. Open `lib/mv/membership/custom_field.ex` -2. Add `policies` block -3. Add read policy: All authenticated users can read (scope :all) -4. Add write policies: Only admin can create/update/destroy -5. Use HasPermission check - -**Acceptance Criteria:** - -- [ ] All users can read custom fields -- [ ] Only admin can create/update/destroy custom fields -- [ ] Policies tested - -**Test Strategy (TDD):** - -**Read Access (All Roles):** -- User with :own_data can read all custom fields -- User with :read_only can read all custom fields -- User with :normal_user can read all custom fields -- User with :admin can read all custom fields - -**Write Access (Admin Only):** -- Non-admin cannot create custom field (Forbidden) -- Non-admin cannot update custom field (Forbidden) -- Non-admin cannot destroy custom field (Forbidden) -- Admin can create custom field -- Admin can update custom field -- Admin can destroy custom field - -**Test File:** `test/mv/membership/custom_field_policies_test.exs` - ---- - -#### Issue #11: Page Permission Router Plug - -**Size:** S (1 day) -**Dependencies:** #2 (PermissionSets), #6 (HasPermission) -**Can work in parallel:** Yes (after #2 and #6) -**Assignable to:** Backend Developer - -**Description:** - -Create a Phoenix plug that checks if the current user has permission to access the requested page/route. This runs before LiveView mounts. - -**Tasks:** - -1. Create `lib/mv_web/plugs/check_page_permission.ex` -2. Implement `init/1` and `call/2` -3. Extract page path from `conn.private[:phoenix_route]` (route template like "/members/:id") -4. Get user from `conn.assigns[:current_user]` -5. Get user's role and permission_set_name -6. Call `PermissionSets.get_permissions/1` to get allowed pages list -7. Match requested path against allowed patterns: - - Exact match: "/members" == "/members" - - Dynamic match: "/members/:id" matches "/members/123" - - Wildcard: "*" matches everything (admin) -8. If unauthorized: redirect to "/" with flash error "You don't have permission to access this page." -9. If authorized: continue (conn not halted) -10. Add plug to router pipelines (`:browser`, `:require_authenticated_user`) - -**Acceptance Criteria:** - -- [ ] Plug checks page permissions from PermissionSets -- [ ] Static routes work ("/members") -- [ ] Dynamic routes work ("/members/:id" matches "/members/123") -- [ ] Wildcard works for admin ("*") -- [ ] Unauthorized users redirected with flash message -- [ ] Plug added to appropriate router pipelines - -**Test Strategy (TDD):** - -**Static Route Tests:** -- User with permission for "/members" can access (conn not halted) -- User without permission for "/members" is denied (conn halted, redirected to "/") -- Flash error message present after denial - -**Dynamic Route Tests:** -- User with "/members/:id" permission can access "/members/123" -- User with "/members/:id/edit" permission can access "/members/456/edit" -- User with only "/members/:id" cannot access "/members/123/edit" -- Pattern matching works correctly - -**Wildcard Tests:** -- Admin with "*" permission can access any page -- Wildcard overrides all other checks - -**Unauthenticated User Tests:** -- Nil current_user is redirected to login -- Login redirect preserves attempted path (optional feature) - -**Error Handling Tests:** -- User with invalid permission_set_name is denied -- User with no role is denied -- Error is logged but user sees generic message - -**Test File:** `test/mv_web/plugs/check_page_permission_test.exs` - ---- - -### Sprint 3: Special Cases & Seeds (Week 3) - -#### Issue #12: Member Email Validation for Linked Members - -**Size:** M (2 days) -**Dependencies:** #7 (Member policies), #8 (User policies) -**Assignable to:** Backend Developer - -**Description:** - -Implement special validation: Only admins can edit a member's email if that member is linked to a user. This prevents breaking email synchronization. - -**Tasks:** - -1. Open `lib/mv/membership/member.ex` -2. Add custom validation in `validations` block: - ```elixir - validate changing(:email), on: :update do - validate &validate_email_change_permission/2 - end - ``` -3. Implement `validate_email_change_permission/2`: - - Check if member has `user_id` (is linked) - - If linked: Check if actor has User.update permission with scope :all (admin) - - If not admin: Return error "Only administrators can change email for members linked to users" - - If not linked: Allow change -4. Use `PermissionSets.get_permissions/1` to check admin status -5. Add tests for all cases - -**Acceptance Criteria:** - -- [ ] Non-admin can edit email of unlinked member -- [ ] Non-admin cannot edit email of linked member -- [ ] Admin can edit email of linked member -- [ ] Validation only runs when email changes -- [ ] Error message is clear and helpful - -**Test Strategy (TDD):** - -**Unlinked Member Tests:** -- User with :normal_user can update email of unlinked member -- User with :read_only cannot update email (caught by policy, not validation) -- Validation doesn't block if member.user_id is nil - -**Linked Member Tests:** -- User with :normal_user cannot update email of linked member (validation error) -- Error message mentions "administrators" and "linked to users" -- User with :admin can update email of linked member (validation passes) - -**No-Op Tests:** -- Validation doesn't run if email didn't change -- Updating other fields (name, address) works normally - -**Test File:** `test/mv/membership/member_email_validation_test.exs` - ---- - -#### Issue #13: Seed Data - Roles and Default Assignment - -**Size:** S (1 day) -**Dependencies:** #2 (PermissionSets), #3 (Role resource) -**Can work in parallel:** Yes (parallel with #12 after #2 and #3 complete) -**Assignable to:** Backend Developer - -**Description:** - -Create seed data for 5 roles and assign default "Mitglied" role to existing users. Optionally designate one admin via environment variable. - -**Tasks:** - -1. Create `priv/repo/seeds/authorization_seeds.exs` -2. Seed 5 roles using `Ash.Seed.seed!/2` or create actions: - - **Mitglied:** name="Mitglied", description="Default member role", permission_set_name="own_data", is_system_role=true - - **Vorstand:** name="Vorstand", description="Board member with read access", permission_set_name="read_only", is_system_role=false - - **Kassenwart:** name="Kassenwart", description="Treasurer with full member management", permission_set_name="normal_user", is_system_role=false - - **Buchhaltung:** name="Buchhaltung", description="Accounting with read access", permission_set_name="read_only", is_system_role=false - - **Admin:** name="Admin", description="Administrator with full access", permission_set_name="admin", is_system_role=false -3. Make idempotent: Use upsert logic (get by name, update if exists, create if not) -4. Assign "Mitglied" role to all users without role_id: - ```elixir - mitglied_role = Ash.get!(Role, name: "Mitglied") - users_without_role = Ash.read!(User, filter: expr(is_nil(role_id))) - Enum.each(users_without_role, fn user -> - Ash.update!(user, %{role_id: mitglied_role.id}) - end) - ``` -5. (Optional) Check for `ADMIN_EMAIL` env var, assign Admin role to that user -6. Add error handling with clear error messages -7. Add `IO.puts` statements to show progress - -**Acceptance Criteria:** - -- [ ] All 5 roles created with correct permission_set_name -- [ ] "Mitglied" has is_system_role=true -- [ ] Existing users without role get "Mitglied" role -- [ ] Optional: ADMIN_EMAIL user gets Admin role -- [ ] Seeds are idempotent (can run multiple times) -- [ ] Error messages are clear -- [ ] Progress is logged to console - -**Test Strategy (TDD):** - -**Role Creation Tests:** -- After running seeds, 5 roles exist -- Each role has correct permission_set_name: - - Mitglied → "own_data" - - Vorstand → "read_only" - - Kassenwart → "normal_user" - - Buchhaltung → "read_only" - - Admin → "admin" -- "Mitglied" role has is_system_role=true -- Other roles have is_system_role=false -- All permission_set_names are valid (exist in PermissionSets.all_permission_sets/0) - -**User Assignment Tests:** -- Users without role_id are assigned "Mitglied" role -- Users who already have role_id are not changed -- Count of users with "Mitglied" role increases by number of previously unassigned users - -**Idempotency Tests:** -- Running seeds twice doesn't create duplicate roles -- Each role name appears exactly once -- Running seeds twice doesn't reassign users who already have roles - -**Optional Admin Tests:** -- If ADMIN_EMAIL set, user with that email gets Admin role -- If ADMIN_EMAIL not set, no error occurs -- If email doesn't exist, error is logged but seeds continue - -**Error Handling Tests:** -- Seeds fail gracefully if invalid permission_set_name provided -- Error message indicates which permission_set_name is invalid - -**Test File:** `test/seeds/authorization_seeds_test.exs` - ---- - -### Sprint 4: UI & Integration (Week 4) - -#### Issue #14: UI Authorization Helper Module - -**Size:** M (2-3 days) -**Dependencies:** #2 (PermissionSets), #6 (HasPermission), #13 (Seeds - for testing) -**Assignable to:** Backend Developer + Frontend Developer - -**Description:** - -Create helper functions for UI-level authorization checks. These will be used in LiveView templates to conditionally render buttons, links, and sections based on user permissions. - -**Tasks:** - -1. Create `lib/mv_web/authorization.ex` -2. Implement `can?/3` for resource-level checks: - ```elixir - def can?(user, action, resource) when is_atom(resource) - # Returns true if user has permission for action on resource - # e.g., can?(current_user, :create, Mv.Membership.Member) - ``` -3. Implement `can?/3` for record-level checks: - ```elixir - def can?(user, action, %resource{} = record) - # Returns true if user has permission for action on specific record - # Applies scope checking (own, linked, all) - # e.g., can?(current_user, :update, member) - ``` -4. Implement `can_access_page?/2`: - ```elixir - def can_access_page?(user, page_path) - # Returns true if user's permission set includes page - # e.g., can_access_page?(current_user, "/members/new") - ``` -5. All functions use `PermissionSets.get_permissions/1` (same logic as HasPermission) -6. All functions handle nil user gracefully (return false) -7. Implement resource-specific scope checking (Member vs CustomFieldValue for :linked) -8. Add comprehensive `@doc` with template examples -9. Import helper in `mv_web.ex` `html_helpers` section - -**Acceptance Criteria:** - -- [ ] `can?/3` works for resource atoms -- [ ] `can?/3` works for record structs with scope checking -- [ ] `can_access_page?/2` matches page patterns correctly -- [ ] Nil user always returns false -- [ ] Invalid permission_set_name returns false (not crash) -- [ ] Helper imported in `mv_web.ex` -- [ ] Comprehensive documentation with examples - -**Test Strategy (TDD):** - -**can?/3 with Resource Atom:** -- Returns true when user has permission for resource+action -- Admin can create Member (returns true) -- Read-only cannot create Member (returns false) -- Nil user returns false - -**can?/3 with Record Struct - Scope :all:** -- Admin can update any member (returns true for any record) -- Normal user can update any member (scope :all) - -**can?/3 with Record Struct - Scope :own:** -- User can update own User record (record.id == user.id) -- User cannot update other User record (record.id != user.id) - -**can?/3 with Record Struct - Scope :linked:** -- User can update linked Member (member.user_id == user.id) -- User cannot update unlinked Member -- User can update CustomFieldValue of linked Member (custom_field_value.member.user_id == user.id) -- User cannot update CustomFieldValue of unlinked Member -- Scope checking is resource-specific (Member vs CustomFieldValue) - -**can_access_page?/2:** -- User with page in list can access (returns true) -- User without page in list cannot access (returns false) -- Dynamic routes match correctly ("/members/:id" matches "/members/123") -- Admin wildcard "*" matches any page -- Nil user returns false - -**Error Handling:** -- User without role returns false -- User with invalid permission_set_name returns false (no crash) -- Handles missing fields gracefully - -**Test File:** `test/mv_web/authorization_test.exs` - ---- - -#### Issue #15: Admin UI for Role Management - -**Size:** M (2 days) -**Dependencies:** #14 (UI Authorization Helper) -**Assignable to:** Frontend Developer - -**Description:** - -Update Role management LiveViews to use authorization helpers for conditional rendering. Add UI polish. - -**Tasks:** - -1. Open `lib/mv_web/live/role_live/index.ex` -2. Add authorization checks for "New Role" button: - ```heex - <%= if can?(@current_user, :create, Mv.Authorization.Role) do %> - <.link patch={~p"/admin/roles/new"}>New Role - <% end %> - ``` -3. Add authorization checks for "Edit" and "Delete" buttons in table -4. Gray out/hide "Delete" for system roles -5. Update `show.ex` to hide edit button if user can't update -6. Add role badge/pill for system roles -7. Add permission_set_name badge with color coding: - - own_data → gray - - read_only → blue - - normal_user → green - - admin → red -8. Test UI with different user roles - -**Acceptance Criteria:** - -- [ ] Only admin sees "New Role" button -- [ ] Only admin sees "Edit" and "Delete" buttons -- [ ] System roles have visual indicator -- [ ] Delete button hidden/disabled for system roles -- [ ] Permission set badges are color-coded -- [ ] UI tested with all role types - -**Test Strategy (TDD):** - -**Admin View:** -- Admin sees "New Role" button -- Admin sees "Edit" buttons for all roles -- Admin sees "Delete" buttons for non-system roles -- Admin does not see "Delete" button for system roles - -**Non-Admin View:** -- Non-admin does not see "New Role" button (redirected by page permission plug anyway) -- Non-admin cannot access /admin/roles (caught by plug) - -**Visual Tests:** -- System roles have badge -- Permission set names are color-coded -- UI renders correctly - -**Test File:** `test/mv_web/live/role_live_authorization_test.exs` - ---- - -#### Issue #16: Apply UI Authorization to Existing LiveViews - -**Size:** L (3 days) -**Dependencies:** #14 (UI Authorization Helper) -**Can work in parallel:** Yes (parallel with #15) -**Assignable to:** Frontend Developer - -**Description:** - -Update all existing LiveViews (Member, User, CustomFieldValue, CustomField) to use authorization helpers for conditional rendering. - -**Tasks:** - -1. **Member LiveViews:** - - Index: Hide "New Member" if can't create - - Index: Hide "Edit" and "Delete" buttons per record if can't update/destroy - - Show: Hide "Edit" button if can't update record - - Form: Should not be accessible (caught by page permission plug) - -2. **User LiveViews:** - - Index: Only show if user is admin - - Show: Only show other users if admin, always show own profile - - Edit: Only allow editing own profile or admin editing anyone - -3. **CustomFieldValue LiveViews:** - - Similar to Member (hide create/edit/delete based on permissions) - -4. **CustomField LiveViews:** - - All users can view - - Only admin can create/edit/delete - -5. **Navbar:** - - Only show "Admin" dropdown if user has admin permission set - - Only show "Roles" link if can access /admin/roles - - Only show "Members" link if can access /members - - Always show "Profile" link - -6. Test all views with all 5 role types - -**Acceptance Criteria:** - -- [ ] All LiveViews use `can?/3` for conditional rendering -- [ ] Buttons/links hidden when user lacks permission -- [ ] Navbar shows appropriate links per role -- [ ] Tested with all 5 roles (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin) -- [ ] UI is clean (no awkward empty spaces from hidden buttons) - -**Test Strategy (TDD):** - -**Member Index - Mitglied (own_data):** -- Does not see "New Member" button -- Does not see list of members (empty or filtered) -- Can only see own linked member if navigated directly - -**Member Index - Vorstand (read_only):** -- Sees full member list -- Does not see "New Member" button -- Does not see "Edit" or "Delete" buttons - -**Member Index - Kassenwart (normal_user):** -- Sees full member list -- Sees "New Member" button -- Sees "Edit" button for all members -- Does not see "Delete" button (not in permission set) - -**Member Index - Admin:** -- Sees everything (New, Edit, Delete) - -**Navbar Tests (all roles):** -- Mitglied: Sees only "Home" and "Profile" -- Vorstand: Sees "Home", "Members" (read-only), "Profile" -- Kassenwart: Sees "Home", "Members", "Properties", "Profile" -- Buchhaltung: Sees "Home", "Members" (read-only), "Profile" -- Admin: Sees "Home", "Members", "Custom Field Values", "Custom Fields", "Admin", "Profile" - -**Test Files:** -- `test/mv_web/live/member_live_authorization_test.exs` -- `test/mv_web/live/user_live_authorization_test.exs` -- `test/mv_web/live/custom_field_value_live_authorization_test.exs` -- `test/mv_web/live/custom_field_live_authorization_test.exs` -- `test/mv_web/components/navbar_authorization_test.exs` - ---- - -#### Issue #17: Integration Tests - Complete User Journeys - -**Size:** L (3 days) -**Dependencies:** All above (full system must be functional) -**Assignable to:** Backend Developer - -**Description:** - -Write comprehensive integration tests that follow complete user journeys for each role. These tests verify that policies, UI helpers, and page permissions all work together correctly. - -**Tasks:** - -1. Create test file for each role: - - `test/integration/mitglied_journey_test.exs` - - `test/integration/vorstand_journey_test.exs` - - `test/integration/kassenwart_journey_test.exs` - - `test/integration/buchhaltung_journey_test.exs` - - `test/integration/admin_journey_test.exs` - -2. Each test follows a complete user flow: - - Login as user with role - - Navigate to allowed pages - - Attempt to access forbidden pages - - Perform allowed actions - - Attempt forbidden actions - - Verify UI shows/hides appropriate elements - -3. Test cross-cutting concerns: - - Email synchronization (Member <-> User) - - User-Member linking (admin only) - - System role protection - -**Acceptance Criteria:** - -- [ ] One integration test per role (5 total) -- [ ] Tests cover complete user journeys -- [ ] Tests verify both backend (policies) and frontend (UI helpers) -- [ ] Tests verify page permissions -- [ ] Tests verify special cases (email, linking, system roles) -- [ ] All tests pass - -**Test Strategy:** - -**Mitglied Journey:** -1. Login as Mitglied user -2. Can access home page and profile -3. Cannot access /members (redirected) -4. Cannot access /admin/roles (redirected) -5. Can view own linked member via direct URL -6. Can update own member data -7. Cannot update unlinked member -8. Can update own user credentials -9. Cannot view other users - -**Vorstand Journey:** -1. Login as Vorstand user -2. Can access /members (reads all members) -3. Cannot create member (no button in UI, backend forbids) -4. Cannot edit member (no button in UI, backend forbids) -5. Can access /members/:id (read-only view) -6. Cannot access /members/:id/edit (page permission denies) -7. Can update own credentials -8. Cannot access /admin/roles - -**Kassenwart Journey:** -1. Login as Kassenwart user -2. Can access /members -3. Can create new member -4. Can edit any member (except email if linked - see special case) -5. Cannot delete member -6. Can manage properties -7. Cannot manage custom fields (read-only) -8. Cannot access /admin/roles - -**Buchhaltung Journey:** -1. Login as Buchhaltung user -2. Can access /members (read-only) -3. Cannot create/edit members -4. Can view properties (read-only) -5. Same restrictions as Vorstand - -**Admin Journey:** -1. Login as Admin user -2. Can access all pages (wildcard permission) -3. Can CRUD all resources -4. Can edit member email even if linked -5. Can manage roles -6. Cannot delete system roles (backend prevents) -7. Can link/unlink users and members -8. Can edit any user's credentials - -**Special Cases Tests:** -- Member email editing (admin vs non-admin for linked member) -- System role deletion (always fails) -- User without role (access denied everywhere) -- User with invalid permission_set_name (access denied) - -**Test Files:** -- `test/integration/mitglied_journey_test.exs` -- `test/integration/vorstand_journey_test.exs` -- `test/integration/kassenwart_journey_test.exs` -- `test/integration/buchhaltung_journey_test.exs` -- `test/integration/admin_journey_test.exs` -- `test/integration/special_cases_test.exs` - ---- - -## Dependencies & Parallelization - -### Dependency Graph - -``` - ┌──────────────────┐ - │ Issue #1 │ - │ Auth Domain │ - │ + Role Res │ - └────────┬─────────┘ - │ - ┌────────────┴────────────┐ - │ │ - ┌───────▼────────┐ ┌───────▼────────┐ - │ Issue #2 │ │ Issue #3 │ - │ PermissionSets│ │ Role CRUD │ - │ Module │ │ LiveViews │ - └───────┬────────┘ └────────────────┘ - │ - │ - └────────────┬────────────┘ - │ - ┌────────▼─────────┐ - │ Issue #6 │ - │ HasPermission │ - │ Policy Check │ - └────────┬─────────┘ - │ - ┌────────────────────┼─────────────────────┐ - │ │ │ - ┌────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐ - │ Issue #7 │ │ Issue #8 │ │ Issue #11 │ - │ Member │ │ User │ │ Page Plug │ - │ Policies │ │ Policies │ └──────┬──────┘ - └────┬─────┘ └──────┬──────┘ │ - │ │ │ - ┌────▼─────┐ ┌──────▼──────┐ │ - │ Issue #9 │ │ Issue #10 │ │ - │ CustomFieldValue │ │ CustomField │ │ - │ Policies │ │ Policies │ │ - └────┬─────┘ └──────┬──────┘ │ - │ │ │ - └────────────────────┴─────────────────────┘ - │ - ┌────────────┴────────────┐ - │ │ - ┌───────▼────────┐ ┌───────▼────────┐ - │ Issue #12 │ │ Issue #13 │ - │ Email Valid │ │ Seeds │ - └───────┬────────┘ └───────┬────────┘ - │ │ - └────────────┬────────────┘ - │ - ┌────────▼─────────┐ - │ Issue #14 │ - │ UI Helper │ - └────────┬─────────┘ - │ - ┌────────────┴────────────┐ - │ │ - ┌───────▼────────┐ ┌───────▼────────┐ - │ Issue #15 │ │ Issue #16 │ - │ Admin UI │ │ Apply UI Auth│ - └───────┬────────┘ └───────┬────────┘ - │ │ - └────────────┬────────────┘ - │ - ┌────────▼─────────┐ - │ Issue #17 │ - │ Integration │ - │ Tests │ - └──────────────────┘ -``` - -### Parallelization Opportunities - -**After Issue #1:** -- Issues #2 and #3 can run in parallel - -**After Issue #6:** -- Issues #7, #8, #9, #10, #11 can ALL run in parallel (5 issues!) -- This is the main parallelization opportunity - -**After Issues #7-#11:** -- Issues #12 and #13 can run in parallel - -**After Issue #14:** -- Issues #15 and #16 can run in parallel - -### Sprint Breakdown - -| Sprint | Issues | Duration | Can Parallelize | -|--------|--------|----------|-----------------| -| Sprint 1 | #1, #2, #3 | Week 1 | #2 and #3 after #1 | -| Sprint 2 | #6, #7, #8, #9, #10, #11 | Week 2 | #7-#11 after #6 (5 parallel!) | -| Sprint 3 | #12, #13 | Week 3 | Yes (2 parallel) | -| Sprint 4 | #14, #15, #16, #17 | Week 4 | #15 & #16 after #14 | - ---- - -## Testing Strategy - -### Test-Driven Development Process - -**For Every Issue:** -1. Read acceptance criteria -2. Write failing tests covering all criteria -3. Verify tests fail (red) -4. Implement minimum code to pass -5. Verify tests pass (green) -6. Refactor if needed -7. All tests still pass - -### Test Coverage Goals - -**Total Estimated Tests: 180+** - -| Test Type | Count | Coverage | -|-----------|-------|----------| -| Unit Tests | ~80 | PermissionSets module, Policy checks, Scope logic, UI helpers | -| Integration Tests | ~70 | Cross-resource authorization, Special cases, Email validation | -| LiveView Tests | ~25 | UI rendering, Page permissions, Conditional elements | -| E2E Journey Tests | ~5 | Complete user flows (one per role) | - -### What to Test (Focus on Behavior) - -**DO Test:** -- Permission lookups return correct results -- Policies allow/deny actions correctly -- Scope filters work (own, linked, all) -- UI elements show/hide based on permissions -- Page access is controlled -- Special cases work (email, system roles) -- Error handling (no crashes) - -**DON'T Test:** -- Database schema existence -- Table columns (Ash generates these) -- Implementation details -- Private functions (test through public API) - -### Test Files Structure - -``` -test/ -├── mv/ -│ └── authorization/ -│ ├── permission_sets_test.exs # Issue #2 -│ ├── role_test.exs # Issue #1 (smoke) -│ └── checks/ -│ └── has_permission_test.exs # Issue #6 -├── mv/accounts/ -│ └── user_policies_test.exs # Issue #8 -├── mv/membership/ -│ ├── member_policies_test.exs # Issue #7 -│ ├── member_email_validation_test.exs # Issue #12 -│ ├── custom_field_value_policies_test.exs # Issue #9 -│ └── custom_field_policies_test.exs # Issue #10 -├── mv_web/ -│ ├── authorization_test.exs # Issue #14 -│ ├── plugs/ -│ │ └── check_page_permission_test.exs # Issue #11 -│ └── live/ -│ ├── role_live_test.exs # Issue #3 -│ ├── role_live_authorization_test.exs # Issue #15 -│ ├── member_live_authorization_test.exs # Issue #16 -│ ├── user_live_authorization_test.exs # Issue #16 -│ ├── custom_field_value_live_authorization_test.exs # Issue #16 -│ └── custom_field_live_authorization_test.exs # Issue #16 -├── integration/ -│ ├── mitglied_journey_test.exs # Issue #17 -│ ├── vorstand_journey_test.exs # Issue #17 -│ ├── kassenwart_journey_test.exs # Issue #17 -│ ├── buchhaltung_journey_test.exs # Issue #17 -│ ├── admin_journey_test.exs # Issue #17 -│ └── special_cases_test.exs # Issue #17 -└── seeds/ - └── authorization_seeds_test.exs # Issue #13 -``` - ---- - -## Migration & Rollback - -### Database Migrations - -**Issue #1 creates one migration:** - -```elixir -# priv/repo/migrations/TIMESTAMP_add_authorization.exs -defmodule Mv.Repo.Migrations.AddAuthorization do - use Ecto.Migration - - def up do - # Create roles table - create table(:roles, primary_key: false) do - add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()") - add :name, :string, null: false - add :description, :text - add :permission_set_name, :string, null: false - add :is_system_role, :boolean, default: false, null: false - - timestamps() - end - - create unique_index(:roles, [:name]) - create index(:roles, [:permission_set_name]) - - # Add role_id to users table - alter table(:users) do - add :role_id, references(:roles, type: :binary_id, on_delete: :restrict) - end - - create index(:users, [:role_id]) - end - - def down do - drop index(:users, [:role_id]) - - alter table(:users) do - remove :role_id - end - - drop table(:roles) - end -end -``` - -### Data Migration (Seeds) - -**After migration applied:** - -Run seeds to create roles and assign defaults: - -```bash -mix run priv/repo/seeds/authorization_seeds.exs -``` - -### Rollback Plan - -**If issues discovered in production:** - -1. **Immediate Rollback:** - - Set `ENABLE_RBAC=false` environment variable - - Restart application - - Old authorization system takes over instantly - -2. **Database Rollback (if needed):** - ```bash - mix ecto.rollback --step 1 - ``` - - Removes `role_id` from users - - Removes `roles` table - - Existing auth untouched - -3. **Code Rollback:** - - Revert Git commit - - Redeploy previous version - -**Rollback Safety:** -- No existing tables modified (only additions) -- Feature flag allows instant disable -- Old auth code remains in place until RBAC proven stable - ---- - -## Risk Management - -### Identified Risks - -| Risk | Probability | Impact | Mitigation | -|------|-------------|--------|------------| -| **Policy order issues** | Medium | High | Clear documentation, strict order enforcement, integration tests verify policies work together | -| **Scope filter errors** | Medium | High | TDD approach, extensive scope tests (own/linked/all), test with all resource types | -| **UI/Policy divergence** | Low | Medium | UI helpers use same PermissionSets module as policies, shared logic, integration tests verify consistency | -| **Breaking existing auth** | Low | High | Feature flag allows instant rollback, parallel systems until proven, gradual rollout | -| **User without role edge case** | Low | Medium | Default "Mitglied" role assigned in seeds, validation on User.create, tests cover nil role | -| **Invalid permission_set_name** | Low | Low | Validation on Role resource, tests cover invalid names, error handling throughout | -| **Performance (not a concern)** | Very Low | Low | Hardcoded permissions are < 1 microsecond, no DB queries, no cache needed | - -### Edge Cases Handled - -**User without role:** -- Default: Access denied (no permissions) -- Seeds assign "Mitglied" to all existing users -- New users must be assigned role on creation - -**Invalid permission_set_name:** -- Role validation prevents creation -- Runtime checks handle gracefully (return false/error, no crash) -- Error logged for debugging - -**System role protection:** -- Cannot delete role with `is_system_role=true` -- UI hides delete button -- Backend validation prevents deletion -- "Mitglied" is system role by default - -**Linked member email:** -- Custom validation on Member resource -- Only admins can edit if member.user_id present -- Prevents breaking email synchronization - -**Missing actor context:** -- All policies check for actor presence -- Missing actor = access denied -- No crashes, graceful error handling - -### Performance Considerations - -**No concerns for MVP:** -- Hardcoded permissions are pure function calls -- No database queries for permission checks -- Pattern matching on small lists (< 50 items total) -- Typical check: < 1 microsecond -- Can handle 10,000+ requests/second easily - -**Future considerations (Phase 3):** -- If migrating to database-backed: add ETS cache -- Cache invalidation on role/permission changes -- Database indexes on permission tables - ---- - -## Success Criteria - -**MVP is successful when:** - -- [ ] All 15 issues completed -- [ ] All 180+ tests passing -- [ ] Zero linter errors -- [ ] Manual testing completed for all 5 roles -- [ ] Integration tests verify complete user journeys -- [ ] Feature flag tested (on/off states) -- [ ] Documentation complete -- [ ] Code review approved -- [ ] Deployed to staging and verified -- [ ] Performance verified (< 100ms per page load) -- [ ] No authorization bypasses found in security review - -**Ready for Production when:** - -- [ ] 1 week in staging with no critical issues -- [ ] All stakeholders have tested their role types -- [ ] Rollback plan tested -- [ ] Monitoring/alerting configured -- [ ] Runbook created for common issues - ---- - -## Next Steps After MVP - -**Phase 2: Field-Level Permissions (Future - 2-3 weeks)** - -- Extend PermissionSets with `:fields` key -- Implement Ash Calculations to filter readable fields -- Implement Custom Validations for writable fields -- No database changes needed -- See [Architecture Document](./roles-and-permissions-architecture.md) for details - -**Phase 3: Database-Backed Permissions (Future - 3-4 weeks)** - -- Create `permission_sets`, `permission_set_resources`, `permission_set_pages` tables -- Replace hardcoded PermissionSets module with DB queries -- Implement ETS cache for performance -- Allow runtime permission configuration -- See [Architecture Document](./roles-and-permissions-architecture.md) for migration strategy - ---- - -## Document History - -| Version | Date | Author | Changes | -|---------|------|--------|---------| -| 1.0 | 2025-01-12 | AI Assistant | Initial version with DB-backed permissions | -| 2.0 | 2025-01-13 | AI Assistant | Complete rewrite for hardcoded MVP, removed all V1 references, fixed Buchhaltung inconsistency | - ---- - -## Appendix - -### Glossary - -- **Permission Set:** A named collection of resource and page permissions (e.g., "admin", "read_only") -- **Role:** A database entity that links users to a permission set -- **Scope:** The range of records a permission applies to (:own, :linked, :all) -- **Actor:** The currently authenticated user in Ash authorization context -- **System Role:** A role that cannot be deleted (is_system_role=true) - -### Key Files - -- `lib/mv/authorization/permission_sets.ex` - Core permissions logic -- `lib/mv/authorization/checks/has_permission.ex` - Ash policy check -- `lib/mv_web/authorization.ex` - UI helper functions -- `lib/mv_web/plugs/check_page_permission.ex` - Page access control -- `priv/repo/seeds/authorization_seeds.exs` - Role seed data - -### Useful Commands - -```bash -# Run all authorization tests -mix test test/mv/authorization - -# Run integration tests only -mix test test/integration - -# Run with coverage -mix test --cover - -# Generate migrations after Ash resource changes -mix ash.codegen - -# Run seeds -mix run priv/repo/seeds/authorization_seeds.exs - -# Check for linter errors -mix credo --strict -``` - ---- - -**End of Implementation Plan** +## Scope, migration & rollback +For the MVP scope boundary, the DB migration (create `roles`, add `users.role_id`), +the seed step, and the two-tier rollback plan (`mix ecto.rollback` → code revert), see +[Architecture › Migration Strategy](./roles-and-permissions-architecture.md#migration-strategy). diff --git a/docs/roles-and-permissions-overview.md b/docs/roles-and-permissions-overview.md index 13bf7cf..6331682 100644 --- a/docs/roles-and-permissions-overview.md +++ b/docs/roles-and-permissions-overview.md @@ -63,20 +63,7 @@ During the design phase, we evaluated multiple implementation approaches to find ### Approach 1: JSONB in Roles Table -Store all permissions as a single JSONB column directly in the roles table. - -**Advantages:** -- Simplest database schema (single table) -- Very flexible structure -- No additional tables needed -- Fast to implement - -**Disadvantages:** -- Poor queryability (can't efficiently filter by specific permissions) -- No referential integrity -- Difficult to validate structure -- Hard to audit permission changes -- Can't leverage database indexes effectively +Store all permissions as a single JSONB column directly in the roles table. Simplest schema (single table), flexible, fast to implement — but poor queryability (can't filter by specific permissions), no referential integrity, hard to validate/audit, can't use indexes. **Verdict:** Rejected - Poor queryability makes it unsuitable for complex permission logic. @@ -84,22 +71,7 @@ Store all permissions as a single JSONB column directly in the roles table. ### Approach 2: Normalized Database Tables -Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization. - -**Advantages:** -- Fully queryable with SQL -- Runtime configurable permissions -- Strong referential integrity -- Easy to audit changes -- Can index for performance - -**Disadvantages:** -- Complex database schema (4+ tables) -- DB queries required for every permission check -- Requires ETS cache for performance -- Needs admin UI for permission management -- Longer implementation time (4-5 weeks) -- Overkill for fixed set of 4 permission sets +Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization. Fully queryable, runtime-configurable, strong referential integrity, auditable, indexable — but complex schema (4+ tables), a DB query per check, needs ETS cache + admin UI, 4-5 weeks, overkill for 4 fixed sets. **Verdict:** Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP. @@ -107,20 +79,7 @@ Separate tables for `permission_sets`, `permission_set_resources`, `permission_s ### Approach 3: Custom Authorizer -Implement a custom Ash Authorizer from scratch instead of using Ash Policies. - -**Advantages:** -- Complete control over authorization logic -- Can implement any custom behavior -- Not constrained by Ash Policy DSL - -**Disadvantages:** -- Significantly more code to write and maintain -- Loses benefits of Ash's declarative policies -- Harder to test than built-in policy system -- Mixes declarative and imperative approaches -- Must reimplement filter generation for queries -- Higher bug risk +Implement a custom Ash Authorizer from scratch instead of using Ash Policies. Full control over logic — but significantly more code, loses Ash's declarative policies (must reimplement query filter generation), harder to test, mixes declarative/imperative, higher bug risk. **Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits. @@ -128,21 +87,7 @@ Implement a custom Ash Authorizer from scratch instead of using Ash Policies. ### Approach 4: Simple Role Enum -Add a simple `:role` enum field directly on User resource with hardcoded checks in each policy. - -**Advantages:** -- Very simple to implement (< 1 week) -- No extra tables needed -- Fast performance -- Easy to understand - -**Disadvantages:** -- No separation between roles and permissions -- Can't add new roles without code changes -- No dynamic permission configuration -- Not extensible to field-level permissions -- Violates separation of concerns (role = job function, not permission set) -- Difficult to maintain as requirements grow +Add a `:role` enum field directly on User with hardcoded checks in each policy. Very simple (< 1 week), no extra tables, fast — but no separation of role (job function) from permission set, can't add roles without code changes, no dynamic config, not extensible to field-level, hard to maintain as requirements grow. **Verdict:** Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation. @@ -150,33 +95,11 @@ Add a simple `:role` enum field directly on User resource with hardcoded checks ### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP) -Permission Sets hardcoded in Elixir module, only Roles table in database. +Permission Sets hardcoded in Elixir module, only Roles table in database. Fast (2-3 weeks vs 4-5), maximum performance (zero DB queries, < 1μs), pure-function testing, Git-reviewable permissions, no data migration, keeps role/permission-set separation, clear Phase 3 upgrade path. Trade-offs: permissions not editable at runtime (only role assignment), new permissions need a code deploy, unsuitable if permissions change > 1x/week, limited to the 4 predefined sets. -**Advantages:** -- Fast implementation (2-3 weeks vs 4-5 weeks) -- Maximum performance (zero DB queries, < 1 microsecond) -- Simple to test (pure functions) -- Code-reviewable permissions (visible in Git) -- No migration needed for existing data -- Clearly defined 4 permission sets as required -- Clear migration path to database-backed solution (Phase 3) -- Maintains separation of roles and permission sets +**Why Selected:** MVP requires 4 fixed sets (not custom ones), no stated need for runtime permission editing, performance is critical, fast time-to-market, and a clear upgrade path exists when runtime config becomes necessary. -**Disadvantages:** -- Permissions not editable at runtime (only role assignment possible) -- New permissions require code deployment -- Not suitable if permissions change frequently (> 1x/week) -- Limited to the 4 predefined permission sets - -**Why Selected:** -- MVP requirement is for 4 fixed permission sets (not custom ones) -- No stated requirement for runtime permission editing -- Performance is critical for authorization checks -- Fast time-to-market (2-3 weeks) -- Clear upgrade path when runtime configuration becomes necessary - -**Migration Path:** -When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module. +**Migration Path:** When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module. --- @@ -201,7 +124,7 @@ When runtime permission editing becomes a business requirement, migrate to Appro **Resource Level (MVP):** - Controls create, read, update, destroy actions on resources -- Resources: Member, User, CustomFieldValue, CustomField, Role +- Resources: Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle, JoinRequest **Page Level (MVP):** - Controls access to LiveView pages @@ -214,7 +137,7 @@ When runtime permission editing becomes a business requirement, migrate to Appro ### Special Cases 1. **Own Credentials:** Users can always edit their own email and password -2. **Linked Member Email:** Only admins can edit email of members linked to users +2. **Linked Member Email:** Only administrators or the linked user themselves can change the email of a member linked to a user 3. **User-Member Linking:** Only admins can link/unlink users to members (except self-service creation) --- @@ -331,46 +254,39 @@ Users need to create member profiles for themselves (self-service), but only adm - Unlink members from users - Create members pre-linked to arbitrary users -### Selected Approach: Separate Ash Actions +### Selected Approach: Admin-Only `:user` Argument -Instead of complex field-level validation, we use action-based authorization. +Linking is **not** modelled as separate per-operation actions. The Member resource has a single +`create_member` and a single `update_member` action; linking and unlinking happen through an +optional **`:user` argument** on those actions. `user_id` is deliberately not accepted, so the +foreign key cannot be set directly. -### Actions on Member Resource +### How Linking Works on the Member Resource -**1. create_member_for_self** (All authenticated users) -- Automatically sets user_id = actor.id -- User cannot specify different user_id -- UI: "Create My Profile" button +**`create_member` / `update_member`** (the only Member write actions) +- The optional `:user` argument drives the relationship via `manage_relationship`. +- On update, `on_missing: :ignore` means omitting `:user` leaves the link unchanged + (no "unlink by omission"); unlink is explicit (`user: nil`). +- The policy check `ForbidMemberUserLinkUnlessAdmin` forbids the action for non-admins whenever the + `:user` argument is present (any value), so only admins may set or change the link. +- Non-admins can still create/update members as long as they do not pass `:user`. -**2. create_member** (Admin only) -- Can set user_id to any user or leave unlinked -- Full flexibility for admin -- UI: Admin member management form +**Self-service** ("a user creates a member linked to themselves") is handled on the **User** side: +the admin-only `update_user` action takes a `:member` argument for link/unlink, and the UI exposes +the linking controls only to admins. -**3. link_member_to_user** (Admin only) -- Updates existing member to set user_id -- Connects unlinked member to user account +### Why This Design? -**4. unlink_member_from_user** (Admin only) -- Sets user_id to nil -- Disconnects member from user account +**Single write path:** one create and one update action to reason about, instead of a fan-out of +`link_*`/`unlink_*` actions. -**5. update** (Permission-based) -- Normal updates (name, address, etc.) -- user_id NOT in accept list (prevents manipulation) -- Available to users with Member.update permission +**Centralized rule:** the admin-only constraint lives in one reusable policy check +(`ForbidMemberUserLinkUnlessAdmin`). -### Why Separate Actions? +**Server-Side Security:** `user_id` is never accepted directly, so it cannot be mass-assigned — +only argument-driven relationship management can change it. -**Explicit Semantics:** Each action has clear, single purpose - -**Server-Side Security:** user_id set by server, not client input - -**Better UX:** Different UI flows for different use cases - -**Simple Policies:** Authorization at action level, not field level - -**Easy Testing:** Each action independently testable +**Better UX:** distinct UI flows for self-service vs. admin linking. --- @@ -486,23 +402,7 @@ Use Custom Validations **[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples -**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Detailed implementation plan with TDD approach +**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Historical record of how the MVP was built (PR #346/#345) **[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards ---- - -## Summary - -The selected architecture uses **hardcoded Permission Sets in Elixir** for the MVP, providing: -- **Speed:** 2-3 weeks implementation vs 4-5 weeks -- **Performance:** Zero database queries for authorization -- **Clarity:** Permissions in Git, reviewable and testable -- **Flexibility:** Clear migration path to database-backed system - -**User-Member linking** uses **separate Ash Actions** for clarity and security. - -**Field-level permissions** have a **defined strategy** (Calculations + Validations) for Phase 2 implementation. - -The approach balances pragmatism for MVP delivery with extensibility for future requirements. - diff --git a/docs/user-resource-policies-implementation-summary.md b/docs/user-resource-policies-implementation-summary.md deleted file mode 100644 index c939c6b..0000000 --- a/docs/user-resource-policies-implementation-summary.md +++ /dev/null @@ -1,269 +0,0 @@ -# User Resource Authorization Policies - Implementation Summary - -**Date:** 2026-01-22 -**Status:** ✅ COMPLETED - ---- - -## Overview - -Successfully implemented authorization policies for the User resource following the Bypass + HasPermission pattern, ensuring consistency with Member resource policies and proper use of the scope concept from PermissionSets. - ---- - -## What Was Implemented - -### 1. Policy Structure in `lib/accounts/user.ex` - -```elixir -policies do - # 1. AshAuthentication Bypass - bypass AshAuthentication.Checks.AshAuthenticationInteraction do - authorize_if always() - end - - # 2. Bypass for READ (list queries via auto_filter) - bypass action_type(:read) do - description "Users can always read their own account" - authorize_if expr(id == ^actor(:id)) - end - - # 3. HasPermission for all operations (uses scope from PermissionSets) - policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions from user's role and permission set" - authorize_if Mv.Authorization.Checks.HasPermission - end -end -``` - -### 2. Test Suite in `test/mv/accounts/user_policies_test.exs` - -**Coverage:** -- ✅ 31 tests total: 30 passing, 1 skipped -- ✅ All 4 permission sets tested: `own_data`, `read_only`, `normal_user`, `admin` -- ✅ READ operations (list and single record) -- ✅ UPDATE operations (own and other users) -- ✅ CREATE operations (admin only) -- ✅ DESTROY operations (admin only) -- ✅ AshAuthentication bypass (registration/login) -- ✅ Tests use system_actor for authorization - ---- - -## Key Design Decisions - -### Decision 1: Bypass for READ, HasPermission for UPDATE - -**Rationale:** -- READ list queries have no record at `strict_check` time -- `HasPermission` returns `{:ok, false}` for queries without record -- Ash doesn't call `auto_filter` when `strict_check` returns `false` -- `expr()` in bypass is handled natively by Ash for `auto_filter` - -**Result:** -- Bypass handles READ list queries ✅ -- HasPermission handles UPDATE with `scope :own` ✅ -- No redundancy - both are necessary ✅ - -### Decision 2: No Explicit `forbid_if always()` - -**Rationale:** -- Ash implicitly forbids if no policy authorizes (fail-closed by default) -- Explicit `forbid_if always()` at the end breaks tests -- It would forbid valid operations that should be authorized by previous policies - -**Result:** -- Policies rely on Ash's implicit forbid ✅ -- Tests pass with this approach ✅ - -### Decision 3: Consistency with Member Resource - -**Rationale:** -- Member resource uses same pattern: Bypass for READ, HasPermission for UPDATE -- Consistent patterns improve maintainability and predictability -- Developers can understand authorization logic across resources - -**Result:** -- User and Member follow identical pattern ✅ -- Authorization logic is consistent throughout the app ✅ - ---- - -## The Scope Concept Is NOT Redundant - -### Initial Concern - -> "If we use a bypass with `expr(id == ^actor(:id))` for READ, isn't `scope :own` in PermissionSets redundant?" - -### Resolution - -**NO! The scope concept is essential:** - -1. **Documentation** - `scope :own` clearly expresses intent in PermissionSets -2. **UPDATE operations** - `scope :own` is USED by HasPermission when changeset contains record -3. **Admin operations** - `scope :all` allows admins full access -4. **Maintainability** - All permissions centralized in one place - -**Test Proof:** - -```elixir -test "can update own email", %{user: user} do - # This works via HasPermission with scope :own (NOT bypass) - {:ok, updated_user} = - user - |> Ash.Changeset.for_update(:update_user, %{email: "new@example.com"}) - |> Ash.update(actor: user) - - assert updated_user.email # ✅ Proves scope :own is used -end -``` - ---- - -## Documentation Updates - -### 1. Created `docs/policy-bypass-vs-haspermission.md` - -Comprehensive documentation explaining: -- Why bypass is needed for READ -- Why HasPermission works for UPDATE -- Technical deep dive into Ash policy evaluation -- Test coverage proving the pattern -- Lessons learned - -### 2. Updated `docs/roles-and-permissions-architecture.md` - -- Added "Bypass vs. HasPermission: When to Use Which?" section -- Updated User Resource Policies section with correct implementation -- Updated Member Resource Policies section for consistency -- Added pattern comparison table - -### 3. Updated `docs/roles-and-permissions-implementation-plan.md` - -- Marked Issue #8 as COMPLETED ✅ -- Added implementation details -- Documented why bypass is needed -- Added test results - ---- - -## Test Results - -### All Relevant Tests Pass - -```bash -mix test test/mv/accounts/user_policies_test.exs \ - test/mv/authorization/checks/has_permission_test.exs \ - test/mv/membership/member_policies_test.exs - -# Results: -# 75 tests: 74 passing, 1 skipped -# ✅ User policies: 30/31 (1 skipped) -# ✅ HasPermission check: 21/21 -# ✅ Member policies: 23/23 -``` - -### Specific Test Coverage - -**Own Data Access (All Roles):** -- ✅ Can read own user record (via bypass) -- ✅ Can update own email (via HasPermission with scope :own) -- ✅ Cannot read other users (filtered by bypass) -- ✅ Cannot update other users (forbidden by HasPermission) -- ✅ List returns only own user (auto_filter via bypass) - -**Admin Access:** -- ✅ Can read all users (HasPermission with scope :all) -- ✅ Can update other users (HasPermission with scope :all) -- ✅ Can create users (HasPermission with scope :all) -- ✅ Can destroy users (HasPermission with scope :all) - -**AshAuthentication:** -- ✅ Registration works without actor -- ✅ OIDC registration works -- ✅ OIDC sign-in works - -**Test Environment:** -- ✅ Operations without actor work in test environment -- ✅ All tests explicitly use system_actor for authorization - ---- - -## Files Changed - -### Implementation -1. ✅ `lib/accounts/user.ex` - Added policies block (lines 271-315) -2. ✅ `lib/mv/authorization/checks/has_permission.ex` - Added User resource support in `evaluate_filter_for_strict_check` - -### Tests -3. ✅ `test/mv/accounts/user_policies_test.exs` - Created comprehensive test suite (435 lines) -4. ✅ `test/mv/authorization/checks/has_permission_test.exs` - Updated to expect `false` instead of `:unknown` - -### Documentation -5. ✅ `docs/policy-bypass-vs-haspermission.md` - New comprehensive guide (created) -6. ✅ `docs/roles-and-permissions-architecture.md` - Updated User and Member sections -7. ✅ `docs/roles-and-permissions-implementation-plan.md` - Marked Issue #8 as completed -8. ✅ `docs/user-resource-policies-implementation-summary.md` - This file (created) - ---- - -## Lessons Learned - -### 1. Test Before Assuming - -The initial plan assumed HasPermission with `scope :own` would be sufficient. Testing revealed that Ash's policy evaluation doesn't reliably call `auto_filter` when `strict_check` returns `false` or `:unknown`. - -### 2. Bypass Is Not a Workaround, It's a Pattern - -The bypass with `expr()` is not a hack or workaround - it's the **correct pattern** for filter-based authorization in Ash when dealing with list queries. - -### 3. Scope Concept Remains Essential - -Even with bypass for READ, the scope concept in PermissionSets is essential for: -- UPDATE/CREATE/DESTROY operations -- Documentation and maintainability -- Centralized permission management - -### 4. Consistency Across Resources - -Following the same pattern (Bypass for READ, HasPermission for UPDATE) across User and Member resources makes the codebase more maintainable and predictable. - -### 5. Documentation Is Key - -Thorough documentation explaining **WHY** the pattern exists prevents future confusion and ensures the pattern is applied correctly in future resources. - ---- - -## Future Considerations - -### If Adding New Resources with Filter-Based Permissions - -Follow the same pattern: -1. Bypass with `expr()` for READ (list queries) -2. HasPermission for UPDATE/CREATE/DESTROY (uses scope from PermissionSets) -3. Define appropriate scopes in PermissionSets (`:own`, `:linked`, `:all`) - -### If Ash Framework Changes - -If a future version of Ash reliably calls `auto_filter` when `strict_check` returns `:unknown`: -1. Consider removing bypass for READ -2. Keep only HasPermission policy -3. Update tests to verify new behavior -4. Update documentation - -**For now (Ash 3.13.1), the current pattern is correct and necessary.** - ---- - -## Conclusion - -✅ **User Resource Authorization Policies are fully implemented, tested, and documented.** - -The implementation: -- Follows best practices for Ash policies -- Is consistent with Member resource pattern -- Uses the scope concept from PermissionSets effectively -- Has comprehensive test coverage -- Is thoroughly documented for future developers - -**Status: PRODUCTION READY** 🎉 From 5d8f1735291df7bc9cf8414aa67131a7e239d9a3 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 21:53:36 +0200 Subject: [PATCH 17/50] docs(membership): condense membership, onboarding and import docs and align with the code --- docs/csv-member-import-v1.md | 918 +++++----------------------- docs/membership-fee-architecture.md | 714 +++------------------- docs/membership-fee-overview.md | 403 +++--------- docs/onboarding-join-concept.md | 305 ++++----- 4 files changed, 436 insertions(+), 1904 deletions(-) diff --git a/docs/csv-member-import-v1.md b/docs/csv-member-import-v1.md index 1a717c6..9f4fe8c 100644 --- a/docs/csv-member-import-v1.md +++ b/docs/csv-member-import-v1.md @@ -1,796 +1,172 @@ -# CSV Member Import v1 - Implementation Plan +# CSV Member Import -**Version:** 1.0 -**Last Updated:** 2026-01-13 -**Status:** In Progress (Backend Complete, UI Complete, Tests Pending) -**Related Documents:** -- [Feature Roadmap](./feature-roadmap.md) - Overall feature planning +Reference for how the CSV member import actually behaves. The end-to-end +LiveView test (`test/mv_web/live/import_live_test.exs`) and future maintenance +depend on the rules documented here. -## Implementation Status +**Status:** implemented (backend + LiveView UI). -**Completed Issues:** -- ✅ Issue #1: CSV Specification & Static Template Files -- ✅ Issue #2: Import Service Module Skeleton -- ✅ Issue #3: CSV Parsing + Delimiter Auto-Detection + BOM Handling -- ✅ Issue #4: Header Normalization + Per-Header Mapping -- ✅ Issue #5: Validation (Required Fields) + Error Formatting -- ✅ Issue #6: Persistence via Ash Create + Per-Row Error Capture (with Error-Capping) -- ✅ Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results + Template Links) -- ✅ Issue #8: Authorization + Limits -- ✅ Issue #11: Custom Field Import (Backend + UI) +Implementation: -**In Progress / Pending:** -- ⏳ Issue #9: End-to-End LiveView Tests + Fixtures -- ⏳ Issue #10: Documentation Polish +- `lib/mv/membership/import/csv_parser.ex` — BOM stripping, delimiter detection, physical line numbering +- `lib/mv/membership/import/header_mapper.ex` — header normalization + column mapping +- `lib/mv/membership/import/column_resolver.ex` — read-only resolution of groups + fee-type columns (preview) +- `lib/mv/membership/import/member_csv.ex` — `prepare/2`, `process_chunk/4`, validation, member creation +- `lib/mv/membership/import/import_runner.ex` — orchestration glue +- `lib/mv_web/live/import_live.ex` (+ `import_live/components.ex`) — UI, state machine, chunk driving +- `lib/mv_web/controllers/import_template_controller.ex` — on-the-fly template generation -**Latest Update:** CSV Import UI fully implemented in GlobalSettingsLive with chunk processing, progress tracking, error display, and custom field support (2026-01-13) +## Scope ---- +Admin-only bulk creation of members from an uploaded CSV. -## Table of Contents +- **Create only** — no upsert/update of existing members. +- **No deduplication** — a duplicate email fails its row (unique constraint) and is reported as an error. +- **Best-effort, row-by-row** — no transactional rollback; a failed row does not abort the import. +- **No background jobs** — progress is driven via LiveView `handle_info` chunk messages. +- **Errors shown in UI only** — no error-CSV export. -- [Overview & Scope](#overview--scope) -- [UX Flow](#ux-flow) -- [CSV Specification](#csv-specification) -- [Technical Design Notes](#technical-design-notes) -- [Implementation Issues](#implementation-issues) -- [Rollout & Risks](#rollout--risks) +Out of scope: upsert, mapping wizard, transactional all-or-nothing, error export, import history/audit. ---- +## UI Flow -## Overview & Scope +- **Route:** `/admin/import` (LiveView `MvWeb.ImportLive`). Template downloads: + `/admin/import/template/en` and `/admin/import/template/de` (dynamic controller, not static files). +- **Authorization:** requires `can?(:create, Mv.Membership.Member)`. Non-admins are + redirected with a "don't have permission" flash. The import section, the template + controller, and the `start_import` event all enforce this. +- **Upload:** `allow_upload(:csv_file, accept: .csv, max_entries: 1, auto_upload: true)`. + File size limit enforced by `max_file_size`. +- **State machine** (`@import_status`): `idle → preview → running → done|error`. + - **start_import** parses + resolves the file and transitions to **preview**. This step + is **read-only**: no members are created yet. The preview shows the column mapping, + sample rows, groups that exist vs. would be created, and fee-type/unknown-column warnings. + - **confirm_import** begins processing and creates members chunk by chunk. +- **Results:** success count, failure count, error list (each with CSV line number, message, + optional field), warnings, and a truncation notice when errors exceed the cap. -### What We're Building +## Limits -A **basic CSV member import feature** that allows administrators to upload a CSV file and import new members into the system. This is a **v1 minimal implementation** focused on establishing the import structure without advanced features. +- **Max file size:** configurable via `config :mv, csv_import: [max_file_size_mb: ...]` (enforced by `allow_upload`). +- **Max rows:** configurable via `config :mv, csv_import: [max_rows: ...]`, default **1000**, excluding header. Enforced in `MemberCSV.prepare/2`; exceeding it yields an error containing `"exceeds"`. +- **Chunk size:** 200 rows per chunk. +- **Error cap:** 50 errors collected per import overall (`failed` count stays accurate; `errors_truncated?` flag set when exceeded). -**Core Functionality (v1 Minimal):** -- Upload CSV file via LiveView file upload -- Parse CSV with bilingual header support for core member fields (English/German) -- Auto-detect delimiter (`;` or `,`) using header recognition -- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`, `country`) -- **Import custom field values** - Map CSV columns to existing custom fields by name (unknown custom field columns will be ignored with a warning) -- Validate each row (required field: `email`) -- Create members via Ash resource (one-by-one, **no background jobs**, processed in chunks of 200 rows via LiveView messages) -- Display import results: success count, error count, and error details -- Provide static CSV templates (EN/DE) +## Parsing (`CsvParser.parse/1`) -**Key Constraints (v1):** -- ✅ **Admin-only feature** -- ✅ **No upsert** (create only) -- ✅ **No deduplication** (duplicate emails fail and show as errors) -- ✅ **No mapping wizard** (fixed header mapping via bilingual variants) -- ✅ **No background jobs** (progress via LiveView `handle_info`) -- ✅ **Best-effort import** (row-by-row, no rollback) -- ✅ **UI-only error display** (no error CSV export) -- ✅ **Safety limits** (10 MB, 1,000 rows, chunks of 200) +- Content must be **valid UTF-8** (else error). Empty content / empty header row are errors. +- **UTF-8 BOM is stripped first**, before any header handling. +- Line endings normalized: `\r\n`, `\r`, `\n` all handled. +- **Delimiter auto-detection:** parse the header with both `;` and `,` parsers (NimbleCSV, + quote-aware), count non-empty fields each yields, pick the higher; **`;` wins ties**; + default `;`. +- **Quoting:** double-quote quoting; `""` inside a quoted field is a literal `"`. Newlines + inside quoted fields are supported — the record keeps its **start** line number. +- **Physical line numbers:** rows are returned as `{csv_line_number, values}` where the line + number is the physical 1-based line in the file (header is line 1, first data row is line 2). + **Empty lines are skipped but do not shift numbering** — downstream code must use the + parser's line numbers, never recompute from row index. (Test asserts an invalid row after a + skipped empty line still reports its true physical line, e.g. `Line 4`.) +- Completely empty rows are skipped. An unparsable row produces an error naming its line number. -### Out of Scope (v1) +## Header Mapping & Normalization (`HeaderMapper`) -**Deferred to Future Versions:** -- ❌ Upsert/update existing members -- ❌ Advanced deduplication strategies -- ❌ Column mapping wizard UI -- ❌ Background job processing (Oban/GenStage) -- ❌ Transactional all-or-nothing import -- ❌ Error CSV export/download -- ❌ Batch validation preview before import -- ❌ Dynamic template generation -- ❌ Import history/audit log -- ❌ Import templates for other entities +**`normalize_header/1`** (applied identically to incoming headers, mapping variants, custom +field names, group names, and fee-type names): ---- +1. trim, lowercase +2. transliterate German chars: `ß → ss`, `ä → ae`, `ö → oe`, `ü → ue` (and uppercase forms) +3. unify hyphen variants (en dash U+2013, em dash U+2014, minus U+2212 → `-`) +4. punctuation to spaces: `_`, `()[]{}`, `/`, `\` → space +5. **remove all whitespace** (so `first name` == `firstname`) +6. final trim -## UX Flow +Matching is on the fully normalized string. -### Access & Location +**Required field:** `email`. Missing it aborts `prepare` with a "Missing required header" error. -**Entry Point:** -- **Location:** Global Settings page (`/settings`) -- **UI Element:** New section "Import Members (CSV)" below "Custom Fields" section -- **Access Control:** Admin-only (enforced at LiveView event level, not entire `/settings` route) +**Unknown member-field columns:** ignored (no error). If an unknown column looks like it +could be a custom field that does not exist, a **warning** is emitted (import continues). -### User Journey +**Duplicate headers** mapping to the same canonical field (or same custom field) are an error. -1. **Navigate to Global Settings** -2. **Access Import Section** - - **Important notice:** Custom fields should be created in Mila before importing CSV files with custom field columns (unknown columns will be ignored with a warning) - - Upload area (drag & drop or file picker) - - Template download links (English / German) - - Help text explaining CSV format and custom field requirements -3. **Ensure Custom Fields Exist (if importing custom fields)** - - Navigate to Custom Fields section and create required custom fields - - Note the name/identifier for each custom field (used as CSV header) -4. **Download Template (Optional)** -5. **Prepare CSV File** - - Include custom field columns using the custom field name as header (e.g., `membership_number`, `birth_date`) -6. **Upload CSV** -7. **Start Import** - - Runs server-side via LiveView messages (may take up to ~30 seconds for large files) - - Warning messages if custom field columns reference non-existent custom fields (columns will be ignored) -8. **View Results** - - Success count - - Error count - - First 50 errors, each with: - - **CSV line number** (header is line 1, first data record begins at line 2) - - Error message - - Field name (if applicable) +### Supported member fields and header variants -### Error Handling +Source of truth is `@member_field_variants_raw` in `header_mapper.ex`. Variants below are +illustrative; matching is via normalization, so casing/hyphen/whitespace differences all collapse. -- **File too large:** Flash error before upload starts -- **Too many rows:** Flash error before import starts -- **Invalid CSV format:** Error shown in results -- **Partial success:** Results show both success and error counts - ---- - -## CSV Specification - -### Delimiter - -**Recommended:** Semicolon (`;`) -**Supported:** `;` and `,` - -**Auto-Detection (Header Recognition):** -- Remove UTF-8 BOM *first* -- Extract header record and try parsing with both delimiters -- For each delimiter, count how many recognized headers are present (via normalized variants) -- Choose delimiter with higher recognition; prefer `;` if tied -- If neither yields recognized headers, default to `;` - -### Quoting Rules - -- Fields may be quoted with double quotes (`"`) -- Escaped quotes: `""` inside quoted field represents a single `"` -- **v1 assumption:** CSV records do **not** contain embedded newlines inside quoted fields. (If they do, parsing may fail or line numbers may be inaccurate.) - -### Column Headers - -**v1 Supported Fields:** - -**Core Member Fields (all importable):** -- `email` / `E-Mail` (required) -- `first_name` / `Vorname` (optional) -- `last_name` / `Nachname` (optional) -- `join_date` / `Beitrittsdatum` (optional, ISO-8601 date) -- `exit_date` / `Austrittsdatum` (optional, ISO-8601 date) -- `notes` / `Notizen` (optional) -- `country` / `Land` / `Staat` (optional) -- `city` / `Stadt` (optional) -- `street` / `Straße` (optional) -- `house_number` / `Hausnummer` / `Nr.` (optional) -- `postal_code` / `PLZ` / `Postleitzahl` (optional) -- `membership_fee_start_date` / `Beitragsbeginn` (optional, ISO-8601 date) - -Address column order in import/export matches the members overview: country, city, street, house number, postal code. - -**Not supported for import (by design):** -- **membership_fee_status** – Computed field (from fee cycles). Not stored; export-only. -- **groups** – Many-to-many relationship. Would require resolving group names to IDs; not in current scope. -- **membership_fee_type_id** – Foreign key; could be added later (e.g. resolve type name to ID). - -**Custom Fields:** -- Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`) -- **Important:** Custom fields must be created in Mila before importing. The CSV header must match the custom field name exactly (same normalization as member fields). -- **Behavior:** If the CSV contains custom field columns that don't exist in Mila, a warning message will be shown and those columns will be ignored during import. -- **Value Validation:** Custom field values are validated according to the custom field type: - - **string**: Any text value (trimmed) - - **integer**: Must be a valid integer (e.g., `42`, `-10`). Invalid values will cause a row error with the custom field name and reason. - - **boolean**: Accepts `true`, `false`, `1`, `0`, `yes`, `no`, `ja`, `nein` (case-insensitive). Invalid values will cause a row error. - - **date**: Must be in ISO-8601 format (YYYY-MM-DD, e.g., `2024-01-15`). Invalid values will cause a row error. - - **email**: Must be a valid email format (contains `@`, 5-254 characters, valid format). Invalid values will cause a row error. -- **Error Messages:** Custom field validation errors are included in the import error list with format: `custom_field: ` (e.g., `custom_field: Alter – expected integer, got: abc`) - -**Member Field Header Mapping:** - -| Canonical Field | English Variants | German Variants | +| Canonical | Example accepted headers (EN / DE) | Notes | |---|---|---| -| `first_name` | `first_name`, `firstname` | `Vorname`, `vorname` | -| `last_name` | `last_name`, `lastname`, `surname` | `Nachname`, `nachname`, `Familienname` | -| `email` | `email`, `e-mail`, `e_mail` | `E-Mail`, `e-mail`, `e_mail` | -| `join_date` | `join date`, `join_date` | `Beitrittsdatum`, `beitritts-datum` | -| `exit_date` | `exit date`, `exit_date` | `Austrittsdatum`, `austritts-datum` | -| `notes` | `notes` | `Notizen`, `bemerkungen` | -| `street` | `street`, `address` | `Straße`, `strasse`, `Strasse` | -| `house_number` | `house number`, `house_number`, `house no` | `Hausnummer`, `Nr`, `Nr.`, `Nummer` | -| `postal_code` | `postal_code`, `zip`, `postcode` | `PLZ`, `plz`, `Postleitzahl`, `postleitzahl` | -| `city` | `city`, `town` | `Stadt`, `stadt`, `Ort` | -| `country` | `country` | `Land`, `land`, `Staat`, `staat` | -| `membership_fee_start_date` | `membership fee start date`, `membership_fee_start_date`, `fee start` | `Beitragsbeginn`, `beitrags-beginn` | - -**Header Normalization (used consistently for both input headers AND mapping variants):** -- Trim whitespace -- Convert to lowercase -- Normalize Unicode: `ß` → `ss` (e.g., `Straße` → `strasse`) -- Replace hyphens/whitespace with underscores: `E-Mail` → `e_mail`, `phone number` → `phone_number` -- Collapse multiple underscores: `e__mail` → `e_mail` -- Case-insensitive matching - -**Unknown columns:** ignored (no error) - -**Required fields:** `email` - -**Custom Field Columns:** -- Custom field columns are identified by matching the normalized CSV header to the custom field `name` (not slug) -- Same normalization rules apply as for member fields (trim, lowercase, Unicode normalization, underscore replacement) -- Unknown custom field columns (non-existent names) will be ignored with a warning message - -### CSV Template Files - -**Location:** -- `priv/static/templates/member_import_en.csv` -- `priv/static/templates/member_import_de.csv` - -**Content:** -- Header row with required + common optional fields -- **Note:** Custom field columns are not included in templates by default (users add them based on their custom field configuration) -- One example row -- Uses semicolon delimiter (`;`) -- UTF-8 encoding **with BOM** (Excel compatibility) - -**Template Access:** -- Templates are static files in `priv/static/templates/` -- Served at: - - `/templates/member_import_en.csv` - - `/templates/member_import_de.csv` -- In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version). - -**Example Usage in LiveView Templates:** - -```heex - -<.link href={~p"/templates/member_import_en.csv"} download> - <%= gettext("Download English Template") %> - - -<.link href={~p"/templates/member_import_de.csv"} download> - <%= gettext("Download German Template") %> - - - -<.link href={Routes.static_path(MvWeb.Endpoint, "/templates/member_import_en.csv")} download> - <%= gettext("Download English Template") %> - -``` - -**Note:** The `templates` directory must be included in `MvWeb.static_paths()` (configured in `lib/mv_web.ex`) for the files to be served. - -### File Limits - -- **Max file size:** 10 MB -- **Max rows:** 1,000 rows (excluding header) -- **Processing:** chunks of 200 (via LiveView messages) -- **Encoding:** UTF-8 (BOM handled) - ---- - -## Technical Design Notes - -### Architecture Overview - -``` -┌─────────────────┐ -│ LiveView UI │ (GlobalSettingsLive or component) -│ - Upload area │ -│ - Progress │ -│ - Results │ -└────────┬────────┘ - │ prepare - ▼ -┌─────────────────────────────┐ -│ Import Service │ (Mv.Membership.Import.MemberCSV) -│ - parse + map + limit checks│ -> returns import_state -│ - process_chunk(chunk) │ -> returns chunk results -└────────┬────────────────────┘ - │ create - ▼ -┌─────────────────┐ -│ Ash Resource │ (Mv.Membership.Member) -│ - Create │ -└─────────────────┘ -``` - -### Technology Stack - -- **Phoenix LiveView:** file upload via `allow_upload/3` -- **NimbleCSV:** CSV parsing (add explicit dependency if missing) -- **Ash Resource:** member creation via `Membership.create_member/1` -- **Gettext:** bilingual UI/error messages - -### Module Structure - -**New Modules:** -- `lib/mv/membership/import/member_csv.ex` - import orchestration + chunk processing + custom field handling -- `lib/mv/membership/import/csv_parser.ex` - delimiter detection + parsing + BOM handling -- `lib/mv/membership/import/header_mapper.ex` - normalization + header mapping (core fields + custom fields) - -**Modified Modules:** -- `lib/mv_web/live/global_settings_live.ex` - render import section, handle upload/events/messages - -### Data Flow - -1. **Upload:** LiveView receives file via `allow_upload` -2. **Consume:** `consume_uploaded_entries/3` reads file content -3. **Prepare:** `MemberCSV.prepare/2` - - Strip BOM - - Detect delimiter (header recognition) - - Parse header + rows - - Map headers to canonical fields (core member fields) - - **Query existing custom fields and map custom field columns by name** (using same normalization as member fields) - - **Warn about unknown custom field columns** (non-existent names will be ignored with warning) - - Early abort if required headers missing - - Row count check - - Return `import_state` containing chunks, column_map, and custom_field_map -4. **Process:** LiveView drives chunk processing via `handle_info` - - For each chunk: validate + create member + create custom field values + collect errors -5. **Results:** LiveView shows progress + final summary - -### Types & Key Consistency - -- **Raw CSV parsing:** returns headers as list of strings, and rows **with csv line numbers** -- **Header mapping:** operates on normalized strings; mapping table variants are normalized once -- **Ash attrs:** built as atom-keyed map (`%{first_name: ..., ...}`) - -### Error Model - -```elixir -%{ - csv_line_number: 5, # physical line number in the CSV file - field: :email, # optional - message: "is not a valid email" -} -``` - -### CSV Line Numbers (Important) - -To keep error reporting user-friendly and accurate, **row errors must reference the physical line number in the original file**, even if empty lines are skipped. - -**Design decision:** the parser returns rows as: - -```elixir -rows :: [{csv_line_number :: pos_integer(), row_map :: map()}] -``` - -Downstream logic must **not** recompute line numbers from row indexes. - -### Authorization - -**Enforcement points:** -1. **LiveView event level:** check admin permission in `handle_event("start_import", ...)` -2. **UI level:** render import section only for admin users -3. **Static templates:** public assets (no authorization needed) - -Use `Mv.Authorization.PermissionSets` (preferred) instead of hard-coded string checks where possible. - -### Safety Limits - -- File size enforced by `allow_upload` (`max_file_size`) -- Row count enforced in `MemberCSV.prepare/2` before processing starts -- Chunking is done via **LiveView `handle_info` loop** (sequential, cooperative scheduling) - ---- - -## Implementation Issues - -### Issue #1: CSV Specification & Static Template Files - -**Dependencies:** None - -**Status:** ✅ **COMPLETED** - -**Goal:** Define CSV contract and add static templates. - -**Tasks:** -- [x] Finalize header mapping variants -- [x] Document normalization rules -- [x] Document delimiter detection strategy -- [x] Create templates in `priv/static/templates/` (UTF-8 with BOM) - - `member_import_en.csv` with English headers - - `member_import_de.csv` with German headers -- [x] Document template URLs and how to link them from LiveView -- [x] Document line number semantics (physical CSV line numbers) -- [x] Templates included in `MvWeb.static_paths()` configuration - -**Definition of Done:** -- [x] Templates open cleanly in Excel/LibreOffice -- [x] CSV spec section complete - ---- - -### Issue #2: Import Service Module Skeleton - -**Dependencies:** None - -**Status:** ✅ **COMPLETED** - -**Goal:** Create service API and error types. - -**API (recommended):** -- `prepare/2` — parse + map + limit checks, returns import_state -- `process_chunk/4` — process one chunk (pure-ish), returns per-chunk results - -**Tasks:** -- [x] Create `lib/mv/membership/import/member_csv.ex` -- [x] Define public function: `prepare/2 (file_content, opts \\ [])` -- [x] Define public function: `process_chunk/4 (chunk_rows_with_lines, column_map, custom_field_map, opts \\ [])` -- [x] Define error struct: `%MemberCSV.Error{csv_line_number: integer, field: atom | nil, message: String.t}` -- [x] Document module + API - ---- - -### Issue #3: CSV Parsing + Delimiter Auto-Detection + BOM Handling - -**Dependencies:** Issue #2 - -**Status:** ✅ **COMPLETED** - -**Goal:** Parse CSV robustly with correct delimiter detection and BOM handling. - -**Tasks:** -- [x] Verify/add NimbleCSV dependency (`{:nimble_csv, "~> 1.0"}`) -- [x] Create `lib/mv/membership/import/csv_parser.ex` -- [x] Implement `strip_bom/1` and apply it **before** any header handling -- [x] Handle `\r\n` and `\n` line endings (trim `\r` on header record) -- [x] Detect delimiter via header recognition (try `;` and `,`) -- [x] Parse CSV and return: - - `headers :: [String.t()]` - - `rows :: [{csv_line_number, [String.t()]}]` with correct physical line numbers -- [x] Skip completely empty records (but preserve correct physical line numbers) -- [x] Return `{:ok, headers, rows}` or `{:error, reason}` - -**Definition of Done:** -- [x] BOM handling works (Excel exports) -- [x] Delimiter detection works reliably -- [x] Rows carry correct `csv_line_number` - ---- - -### Issue #4: Header Normalization + Per-Header Mapping (No Language Detection) - -**Dependencies:** Issue #3 - -**Status:** ✅ **COMPLETED** - -**Goal:** Map each header individually to canonical fields (normalized comparison). - -**Tasks:** -- [x] Create `lib/mv/membership/import/header_mapper.ex` -- [x] Implement `normalize_header/1` -- [x] Normalize mapping variants once and compare normalized strings -- [x] Build `column_map` (canonical field -> column index) -- [x] **Early abort if required headers missing** (`email`) -- [x] Ignore unknown columns (member fields only) -- [x] **Separate custom field column detection** (by name, with normalization) - -**Definition of Done:** -- [x] English/German headers map correctly -- [x] Missing required columns fails fast - ---- - -### Issue #5: Validation (Required Fields) + Error Formatting - -**Dependencies:** Issue #4 - -**Status:** ✅ **COMPLETED** - -**Goal:** Validate each row and return structured, translatable errors. - -**Tasks:** -- [x] Implement `validate_row/3 (row_map, csv_line_number, opts)` -- [x] Required field presence (`email`) -- [x] Email format validation (EctoCommons.EmailValidator) -- [x] Trim values before validation -- [x] Gettext-backed error messages - ---- - -### Issue #6: Persistence via Ash Create + Per-Row Error Capture (Chunked Processing) - -**Dependencies:** Issue #5 - -**Status:** ✅ **COMPLETED** - -**Goal:** Create members and capture errors per row with correct CSV line numbers. - -**Tasks:** -- [x] Implement `process_chunk/4` in service: - - Input: `[{csv_line_number, row_map}]` - - Validate + create sequentially - - Collect counts + first 50 errors (per import overall; LiveView enforces cap across chunks) - - **Error-Capping:** Supports `existing_error_count` and `max_errors` in opts (default: 50) - - **Error-Capping:** Only collects errors if under limit, but continues processing all rows - - **Error-Capping:** `failed` count is always accurate, even when errors are capped -- [x] Implement Ash error formatter helper: - - Convert `Ash.Error.Invalid` into `%MemberCSV.Error{}` - - Prefer field-level errors where possible (attach `field` atom) - - Handle unique email constraint error as user-friendly message -- [x] Map row_map to Ash attrs (`%{first_name: ..., ...}`) -- [x] Custom field value processing and creation - -**Important:** **Do not recompute line numbers** in this layer—use the ones provided by the parser. - -**Implementation Notes:** -- `process_chunk/4` accepts `opts` with `existing_error_count` and `max_errors` for error capping across chunks -- Error capping respects the limit per import overall (not per chunk) -- Processing continues even after error limit is reached (for accurate counts) - ---- - -### Issue #7: Admin Global Settings LiveView UI (Upload + Start Import + Results + Template Links) - -**Dependencies:** Issue #6 - -**Status:** ✅ **COMPLETED** - -**Goal:** UI section with upload, progress, results, and template links. - -**Tasks:** -- [x] Render import section only for admins -- [x] **Add prominent UI notice about custom fields:** - - Display alert/info box: "Custom fields must be created in Mila before importing CSV files with custom field columns" - - Explain: "Use the custom field name as the CSV column header (same normalization as member fields applies)" - - Add link to custom fields management section -- [x] Configure `allow_upload/3`: - - `.csv` only, `max_entries: 1`, `max_file_size: 10MB`, `auto_upload: true` (auto-upload enabled for better UX) -- [x] `handle_event("start_import", ...)`: - - Admin permission check - - Consume upload -> read file content - - Call `MemberCSV.prepare/2` - - Store `import_state` in assigns (chunks + column_map + metadata) - - Initialize progress assigns - - `send(self(), {:process_chunk, 0})` -- [x] `handle_info({:process_chunk, idx}, socket)`: - - Fetch chunk from `import_state` - - Call `MemberCSV.process_chunk/4` with error capping support - - Merge counts/errors into progress assigns (cap errors at 50 overall) - - Schedule next chunk (or finish and show results) - - Async task processing with SQL sandbox support for tests -- [x] Results UI: - - Success count - - Failure count - - Error list (line number + message + field) - - **Warning messages for unknown custom field columns** (non-existent names) shown in results - - Progress indicator during import - - Error truncation notice when errors exceed limit - -**Template links:** -- [x] Link `/templates/member_import_en.csv` and `/templates/member_import_de.csv` via Phoenix static path helpers. - -**Definition of Done:** -- [x] Upload area with drag & drop support -- [x] Template download links (EN/DE) -- [x] Progress tracking during import -- [x] Results display with success/error counts -- [x] Error list with line numbers and field information -- [x] Warning display for unknown custom field columns -- [x] Admin-only access control -- [x] Async chunk processing with proper error handling - ---- - -### Issue #8: Authorization + Limits - -**Dependencies:** None (can be parallelized) - -**Status:** ✅ **COMPLETED** - -**Goal:** Ensure admin-only access and enforce limits. - -**Tasks:** -- [x] Admin check in start import event handler (via `Authorization.can?/3`) -- [x] File size enforced in upload config (`max_file_size: 10MB`) -- [x] Row limit enforced in `MemberCSV.prepare/2` (max_rows: 1000, configurable via opts) -- [x] Chunk size limit (200 rows per chunk) -- [x] Error limit (50 errors per import) -- [x] UI-level authorization check (import section only visible to admins) -- [x] Event-level authorization check (prevents unauthorized import attempts) - -**Implementation Notes:** -- File size limit: 10 MB (10,485,760 bytes) enforced via `allow_upload/3` -- Row limit: 1,000 rows (excluding header) enforced in `MemberCSV.prepare/2` -- Chunk size: 200 rows per chunk (configurable via opts) -- Error limit: 50 errors per import (configurable via `@max_errors`) -- Authorization uses `MvWeb.Authorization.can?/3` with `:create` permission on `Mv.Membership.Member` - -**Definition of Done:** -- [x] Admin-only access enforced at UI and event level -- [x] File size limit enforced -- [x] Row count limit enforced -- [x] Chunk processing with size limits -- [x] Error capping implemented - ---- - -### Issue #9: End-to-End LiveView Tests + Fixtures - -**Dependencies:** Issue #7 and #8 - -**Tasks:** -- [ ] Fixtures: - - valid EN/DE (core fields only) - - valid with custom fields - - invalid - - unknown custom field name (non-existent, should show warning) - - too many rows (1,001) - - BOM + `;` delimiter fixture - - fixture with empty line(s) to validate correct line numbers -- [ ] LiveView tests: - - admin sees section, non-admin does not - - upload + start import - - success + error rendering - - row limit + file size errors - - custom field import success - - custom field import warning (non-existent name, column ignored) - ---- - -### Issue #10: Documentation Polish (Inline Help Text + Docs) - -**Dependencies:** Issue #9 - -**Tasks:** -- [ ] UI help text + translations -- [ ] CHANGELOG entry -- [ ] Ensure moduledocs/docs - ---- - -### Issue #11: Custom Field Import - -**Dependencies:** Issue #6 (Persistence) - -**Priority:** High (Core v1 Feature) - -**Status:** ✅ **COMPLETED** (Backend + UI Implementation) - -**Goal:** Support importing custom field values from CSV columns. Custom fields should exist in Mila before import for best results. - -**Important Requirements:** -- **Custom fields should be created in Mila first** - Unknown custom field columns will be ignored with a warning message -- CSV headers for custom fields must match the custom field **name** exactly (same normalization as member fields applies) -- Custom field values are validated according to the custom field type (string, integer, boolean, date, email) -- Unknown custom field columns (non-existent names) will be ignored with a warning - import continues - -**Tasks:** -- [x] Extend `header_mapper.ex` to detect custom field columns by name (using same normalization as member fields) -- [x] Query existing custom fields during `prepare/2` to map custom field columns -- [x] Collect unknown custom field columns and add warning messages (don't fail import) -- [x] Map custom field CSV values to `CustomFieldValue` creation in `process_chunk/4` -- [x] Handle custom field type validation (string, integer, boolean, date, email) with proper error messages -- [x] Create `CustomFieldValue` records linked to members during import -- [x] Validate custom field values and return structured errors with custom field name and reason -- [x] UI help text and link to custom field management (implemented in Issue #7) -- [x] Update error messages to include custom field validation errors (format: `custom_field: – expected , got: `) -- [x] Add UI help text explaining custom field requirements (completed in Issue #7): - - "Custom fields must be created in Mila before importing" - - "Use the custom field name as the CSV column header (same normalization as member fields)" - - Link to custom fields management section -- [x] Update CSV templates documentation to explain custom field columns (documented in Issue #1) -- [x] Add tests for custom field import (valid, invalid name, type validation, warning for unknown) - -**Definition of Done:** -- [x] Custom field columns are recognized by name (with normalization) -- [x] Warning messages shown for unknown custom field columns (import continues) -- [x] Custom field values are created and linked to members -- [x] Type validation works for all custom field types (string, integer, boolean, date, email) -- [x] UI clearly explains custom field requirements (completed in Issue #7) -- [x] Tests cover custom field import scenarios (including warning for unknown names) -- [x] Error messages include custom field validation errors with proper formatting - -**Implementation Notes:** -- Custom field lookup is built in `prepare/2` and passed via `custom_field_lookup` in opts -- Custom field values are formatted according to type in `format_custom_field_value/2` -- Unknown custom field columns generate warnings in `import_state.warnings` - ---- - -## Rollout & Risks - -### Rollout Strategy -- Dev → Staging → Production (with anonymized real-world CSV tests) - -### Risks & Mitigations - -| Risk | Impact | Likelihood | Mitigation | -|---|---:|---:|---| -| Large import timeout | High | Medium | 10 MB + 1,000 rows, chunking via `handle_info` | -| Encoding issues | Medium | Medium | BOM stripping, templates with BOM | -| Invalid CSV format | Medium | High | Clear errors + templates | -| Duplicate emails | Low | High | Ash constraint error -> user-friendly message | -| Performance (no background jobs) | Medium | Low | Small limits, sequential chunk processing | -| Admin access bypass | High | Low | Event-level auth + UI hiding | -| Data corruption | High | Low | Per-row validation + best-effort | - ---- - -## Appendix - -### Module File Structure - -``` -lib/ -├── mv/ -│ └── membership/ -│ └── import/ -│ ├── member_csv.ex # prepare + process_chunk -│ ├── import_runner.ex # orchestration: file read, progress merge, chunk process, error format -│ ├── csv_parser.ex # delimiter detection + parsing + BOM handling -│ └── header_mapper.ex # normalization + header mapping -└── mv_web/ - └── live/ - ├── import_export_live.ex # mount / handle_event / handle_info + glue only - └── import_export_live/ - └── components.ex # UI: custom_fields_notice, template_links, import_form, import_progress, import_results - -priv/ -└── static/ - └── templates/ - ├── member_import_en.csv - └── member_import_de.csv - -test/ -├── mv/ -│ └── membership/ -│ └── import/ -│ ├── member_csv_test.exs -│ ├── csv_parser_test.exs -│ └── header_mapper_test.exs -└── fixtures/ - ├── member_import_en.csv - ├── member_import_de.csv - ├── member_import_invalid.csv - ├── member_import_large.csv - └── member_import_empty_lines.csv -``` - -### Example Usage (LiveView) - -```elixir -def handle_event("start_import", _params, socket) do - assert_admin!(socket.assigns.current_user) - - [{_name, content}] = - consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry -> - {:ok, File.read!(path)} - end) - - case Mv.Membership.Import.MemberCSV.prepare(content) do - {:ok, import_state} -> - socket = - socket - |> assign(:import_state, import_state) - |> assign(:import_progress, %{processed: 0, inserted: 0, failed: 0, errors: []}) - |> assign(:importing?, true) - - send(self(), {:process_chunk, 0}) - {:noreply, socket} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, reason)} - end -end - -def handle_info({:process_chunk, idx}, socket) do - %{chunks: chunks, column_map: column_map} = socket.assigns.import_state - - case Enum.at(chunks, idx) do - nil -> - {:noreply, assign(socket, importing?: false)} - - chunk_rows_with_lines -> - {:ok, chunk_result} = - Mv.Membership.Import.MemberCSV.process_chunk(chunk_rows_with_lines, column_map) - - socket = merge_progress(socket, chunk_result) # caps errors at 50 overall - - send(self(), {:process_chunk, idx + 1}) - {:noreply, socket} - end -end -``` - ---- - -**End of Implementation Plan** +| `email` (required) | email, e-mail, e_mail, mail, e-mail-adresse / E-Mail | | +| `first_name` | first name, firstname / Vorname | | +| `last_name` | last name, lastname, surname / Nachname, Familienname | | +| `join_date` | join date / Beitrittsdatum | ISO-8601 date | +| `exit_date` | exit date / Austrittsdatum | ISO-8601 date | +| `notes` | notes / Notizen, Bemerkungen | | +| `street` | street, address / Straße, Strasse | | +| `house_number` | house number, house no / Hausnummer, Nr, Nr., Nummer | | +| `postal_code` | postal code, zip, postcode / PLZ, Postleitzahl | | +| `city` | city, town / Stadt, Ort | | +| `country` | country / Land, Staat | | +| `membership_fee_start_date` | membership fee start date, fee start / Beitragsbeginn | ISO-8601 date | + +### Special relationship columns + +- **groups** (headers `Groups` / `Gruppen` / `Gruppe`) — comma-separated group names. Names + matched case-insensitively against existing groups; **missing groups are auto-created** during + processing. A group-assignment failure fails that row (the member was already created). +- **membership_fee_type** (headers `Fee Type`, `fee_type`, `membership_fee_type` / `Beitragsart`) + — name matched to an existing `MembershipFeeType`. **Empty cell → default fee type** (no warning). + **Matched name → that fee type.** **Unmatched name → default fee type + warning** naming the value. + +These columns are resolved against the DB read-only in `prepare` (`ColumnResolver`) for the +preview; the actual writes happen in `process_chunk`. + +### Fields not importable (explicitly ignored) + +- **membership_fee_status** — computed from fee cycles; not stored. Fee-status header variants + (`Membership Fee Status`, `Bezahlstatus`, `Mitgliedsbeitragsstatus`) and the DE export label + `Startdatum Mitgliedsbeitrag` are placed in the `ignored` list and never mapped. (The UI notice + names `Groups`/`Gruppen`, `Fee Type`/`Beitragsart`, and the always-ignored `Bezahlstatus`.) + +## Custom Fields + +- Custom field columns are matched by the custom field **name** (not slug), using the same + normalization. Member fields take priority on a name collision. +- **Custom fields must exist in Mila before import.** Unknown custom-field columns are ignored + with a warning; the import still runs. +- Empty custom-field cells create no value. Values are trimmed; type-validated per the custom + field's `value_type`: + - **string** — any text (trimmed). + - **integer** — must parse fully (`Integer.parse` with no remainder); e.g. `42`, `-10`. + - **boolean** — case-insensitive `true/false`, `1/0`, `yes/no`, `ja/nein`. + - **date** — ISO-8601 `YYYY-MM-DD`. + - **email** — validated with `EctoCommons.EmailValidator` (same checks as member email). +- A value failing type validation fails the row. Error message format: + `custom_field: – expected , got: ` (type label is the human-readable + `FieldTypes.label/1`, with format hints for boolean/date). + +## Validation & Member Creation (`process_chunk/4` → `process_row`) + +Per row: validate → create member → create custom-field values → assign groups. Sequential. + +- **Email** is required and format-validated (`EctoCommons.EmailValidator`, `Mv.Constants.email_validator_checks()`) on a trimmed value. All string member values are trimmed. +- **Date fields** (`join_date`, `exit_date`, `membership_fee_start_date`): empty/blank strings are converted to `nil` so Ash accepts them. +- Member created via `Mv.Membership.create_member/2`. Custom field values are passed as + `custom_field_values` (Ash union `_union_type`/`_union_value` format), omitted when none. +- **Errors** are `%MemberCSV.Error{csv_line_number, field, message}`: + - `csv_line_number` is the physical line (1-based); never recomputed in this layer. + - Validation errors get `field: :email`; Ash errors prefer the field-level error. + - **Duplicate email** (unique constraint) is surfaced as a friendly + `"email has already been taken"` message. +- **Error capping** (`max_errors`, default 50, tracked across chunks via `existing_error_count`): + once the cap is hit, no further errors are collected but **all rows are still processed** and + the `failed` count stays accurate; `errors_truncated?` is set and the UI shows a truncation notice. + +## Templates (`ImportTemplateController`) + +- Generated on the fly (not static files), gated by `can?(:create, Member)`. +- One header row: standard member columns (localized EN/DE) + `Groups`/`Gruppen` + + `Fee Type`/`Beitragsart` + **every existing custom field name** appended, then one example row. +- Semicolon-delimited, RFC-4180 quoting; fields run through `MembersCSV.safe_cell/1` to + neutralize spreadsheet formula injection (e.g. a custom-field name like `=HYPERLINK(...)`). diff --git a/docs/membership-fee-architecture.md b/docs/membership-fee-architecture.md index 6c81169..d9af604 100644 --- a/docs/membership-fee-architecture.md +++ b/docs/membership-fee-architecture.md @@ -1,67 +1,23 @@ # Membership Fees - Technical Architecture -**Project:** Mila - Membership Management System -**Feature:** Membership Fee Management -**Version:** 1.0 -**Last Updated:** 2026-01-13 -**Status:** ✅ Implemented +**Feature:** Membership Fee Management — **Status:** Implemented + +Architectural decisions, patterns, module structure, and integration points (no concrete implementation details). + +**Related:** [membership-fee-overview.md](./membership-fee-overview.md) (business logic, worked examples, UI mockups), [database-schema-readme.md](./database-schema-readme.md), [database_schema.dbml](./database_schema.dbml). --- -## Purpose +## Core Design Decisions -This document defines the technical architecture for the Membership Fees system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details. - -**Related Documents:** - -- [membership-fee-overview.md](./membership-fee-overview.md) - Business logic and requirements -- [database-schema-readme.md](./database-schema-readme.md) - Database documentation -- [database_schema.dbml](./database_schema.dbml) - Database schema definition - ---- - -## Table of Contents - -1. [Architecture Principles](#architecture-principles) -2. [Domain Structure](#domain-structure) -3. [Data Architecture](#data-architecture) -4. [Business Logic Architecture](#business-logic-architecture) -5. [Integration Points](#integration-points) -6. [Acceptance Criteria](#acceptance-criteria) -7. [Testing Strategy](#testing-strategy) -8. [Security Considerations](#security-considerations) -9. [Performance Considerations](#performance-considerations) - ---- - -## Architecture Principles - -### Core Design Decisions - -1. **Single Responsibility:** - - Each module has one clear responsibility - - Cycle generation separated from status management - - Calendar logic isolated in dedicated module - -2. **No Redundancy:** - - No `cycle_end` field (calculated from `cycle_start` + `interval`) - - No `interval_type` field (read from `membership_fee_type.interval`) - - Eliminates data inconsistencies - -3. **Immutability Where Important:** - - `membership_fee_type.interval` cannot be changed after creation - - Prevents complex migration scenarios - - Enforced via Ash change validation - -4. **Historical Accuracy:** - - `amount` stored per cycle for audit trail - - Enables tracking of membership fee changes over time - - Old cycles retain original amounts - -5. **Calendar-Based Cycles:** - - All cycles aligned to calendar boundaries - - Simplifies date calculations - - Predictable cycle generation +1. **No redundant fields:** + - No `cycle_end` field — calculated from `cycle_start` + `interval`. + - No `interval_type` field — read from `membership_fee_type.interval`. + - Eliminates data inconsistencies. +2. **Interval immutability:** `membership_fee_type.interval` cannot be changed after creation (enforced via an Ash validation in `Mv.MembershipFees.MembershipFeeType`, and the attribute is omitted from the update action's `accept` list). Prevents complex migration scenarios. +3. **Historical accuracy:** `amount` stored per cycle for audit trail — old cycles retain their original amount, so membership-fee changes over time stay traceable. +4. **Calendar-based cycles:** all cycles aligned to calendar boundaries; simplifies date math and makes generation predictable. +5. **Single responsibility:** cycle generation, status management, and calendar logic live in separate modules. --- @@ -69,25 +25,20 @@ This document defines the technical architecture for the Membership Fees system. ### Ash Domain: `Mv.MembershipFees` -**Purpose:** Encapsulates all membership fee-related resources and logic +Encapsulates all membership-fee resources and logic. **Resources:** -- `MembershipFeeType` - Membership fee type definitions (admin-managed) -- `MembershipFeeCycle` - Individual membership fee cycles per member +- `MembershipFeeType` — membership fee type definitions (admin-managed). +- `MembershipFeeCycle` — individual membership fee cycles per member. -**Public API:** -The domain exposes code interface functions: -- `create_membership_fee_type/1`, `list_membership_fee_types/0`, `update_membership_fee_type/2`, `destroy_membership_fee_type/1` -- `create_membership_fee_cycle/1`, `list_membership_fee_cycles/0`, `update_membership_fee_cycle/2`, `destroy_membership_fee_cycle/1` +**Public API** (code interface): `create/list/update/destroy_membership_fee_type`, `create/list/update/destroy_membership_fee_cycle`. -**Note:** In LiveViews, direct `Ash.read`, `Ash.create`, `Ash.update`, `Ash.destroy` calls are used with `domain: Mv.MembershipFees` instead of code interface functions. This is acceptable for LiveView forms that use `AshPhoenix.Form`. +**Note:** LiveViews use direct `Ash.read/create/update/destroy` with `domain: Mv.MembershipFees` instead of the code interface — acceptable for LiveView forms using `AshPhoenix.Form`. -**Extensions:** +The Member resource is extended with membership fee fields. -- Member resource extended with membership fee fields - -### Module Organization +### Module Map ``` lib/ @@ -96,636 +47,159 @@ lib/ │ ├── membership_fee_type.ex # MembershipFeeType resource │ ├── membership_fee_cycle.ex # MembershipFeeCycle resource │ └── changes/ -│ ├── prevent_interval_change.ex # Validates interval immutability -│ ├── set_membership_fee_start_date.ex # Auto-sets start date -│ └── validate_same_interval.ex # Validates interval match on type change +│ ├── set_membership_fee_start_date.ex # Auto-sets start date +│ └── validate_same_interval.ex # Validates interval match on type change ├── mv/ │ └── membership_fees/ -│ ├── cycle_generator.ex # Cycle generation algorithm -│ └── calendar_cycles.ex # Calendar cycle calculations +│ ├── cycle_generator.ex # Cycle generation algorithm +│ ├── cycle_generation_job.ex # Scheduled cycle generation job +│ └── calendar_cycles.ex # Calendar cycle calculations └── membership/ - └── member.ex # Extended with membership fee relationships + └── member.ex # Extended with membership fee relationships ``` ### Separation of Concerns -**Domain Layer (Ash Resources):** - -- Data validation -- Relationship management -- Policy enforcement -- Action definitions - -**Business Logic Layer (`Mv.MembershipFees`):** - -- Cycle generation algorithm -- Calendar calculations -- Date boundary handling -- Status transitions - -**UI Layer (LiveView):** - -- User interaction -- Display logic -- Authorization checks -- Form handling +- **Domain layer (Ash resources):** data validation, relationships, policy enforcement, action definitions. +- **Business logic (`Mv.MembershipFees`):** cycle generation, calendar calculations, date boundaries, status transitions. +- **UI layer (LiveView):** interaction, display, authorization checks, form handling. --- ## Data Architecture -### Database Schema Extensions - -**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation. +See [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema. ### New Tables -1. **`membership_fee_types`** - - Purpose: Define membership fee types with fixed intervals - - Key Constraint: `interval` field immutable after creation - - Relationships: has_many members, has_many membership_fee_cycles - -2. **`membership_fee_cycles`** - - Purpose: Individual membership fee cycles for members - - Key Design: NO `cycle_end` or `interval_type` fields (calculated) - - Relationships: belongs_to member, belongs_to membership_fee_type - - Composite uniqueness: One cycle per member per cycle_start +1. **`membership_fee_types`** — fee types with fixed `interval` (immutable after creation). has_many members, has_many membership_fee_cycles. +2. **`membership_fee_cycles`** — per-member cycles. NO `cycle_end`/`interval_type` (calculated). belongs_to member, belongs_to membership_fee_type. Composite uniqueness: one cycle per member per `cycle_start`. ### Member Table Extensions -**Fields Added:** - -- `membership_fee_type_id` (FK, NOT NULL with default from settings) +- `membership_fee_type_id` (FK, nullable — default applied from settings at the app level) - `membership_fee_start_date` (Date, nullable) -**Existing Fields Used:** - -- `join_date` - For calculating membership fee start -- `exit_date` - For limiting cycle generation -- These fields must remain member fields and should not be replaced by custom fields in the future +**Existing fields used:** `join_date` (computes membership fee start), `exit_date` (limits cycle generation). These must remain Member fields and should **not** be replaced by custom fields in the future. ### Settings Integration -**Global Settings:** - -- `membership_fees.include_joining_cycle` (Boolean) -- `membership_fees.default_membership_fee_type_id` (UUID) - -**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource) +Global settings: `membership_fees.include_joining_cycle` (Boolean), `membership_fees.default_membership_fee_type_id` (UUID). Read during cycle generation and member creation; written only via admin UI. Validation: default fee type must exist. ### Foreign Key Behaviors | Relationship | On Delete | Rationale | |--------------|-----------|-----------| -| `membership_fee_cycles.member_id → members.id` | CASCADE | Remove membership fee cycles when member deleted | -| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if cycles exist | -| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if assigned to members | +| `membership_fee_cycles.member_id → members.id` | CASCADE | Remove cycles when member deleted | +| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent fee type deletion if cycles exist | +| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent fee type deletion if assigned to members | --- ## Business Logic Architecture -### Cycle Generation System +### Cycle Generation — `Mv.MembershipFees.CycleGenerator` -**Component:** `Mv.MembershipFees.CycleGenerator` +Calculates which cycles should exist for a member, generates the missing ones (idempotent — skips existing), respects `membership_fee_start_date` and `exit_date` boundaries, and uses **PostgreSQL advisory locks per member** to prevent race conditions. -**Responsibilities:** +**Triggers:** fee type assigned (Ash change); member created with fee type (Ash change); scheduled job (daily/weekly cron); admin manual regeneration (UI). -- Calculate which cycles should exist for a member -- Generate missing cycles -- Respect membership_fee_start_date and exit_date boundaries -- Skip existing cycles (idempotent) -- Use PostgreSQL advisory locks per member to prevent race conditions +**Algorithm:** -**Triggers:** - -1. Member membership fee type assigned (via Ash change) -2. Member created with membership fee type (via Ash change) -3. Scheduled job runs (daily/weekly cron) -4. Admin manual regeneration (UI action) - -**Algorithm Steps:** - -1. Retrieve member with membership fee type and dates +1. Retrieve member with fee type and dates. 2. Determine generation start point: - - If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`) - - If cycles exist: Start from the cycle AFTER the last existing one -3. Generate all cycle starts from the determined start point to today (or `exit_date`) -4. Create new cycles with current membership fee type's amount -5. Use PostgreSQL advisory locks per member to prevent race conditions + - No cycles exist → start from `membership_fee_start_date` (or calculated from `join_date`). + - Cycles exist → start from the cycle AFTER the last existing one. +3. Generate all cycle starts from that point to today (or `exit_date`). +4. Create new cycles with the current fee type's amount. -**Edge Case Handling:** +**Edge cases:** -- If membership_fee_start_date is NULL: Calculate from join_date + global setting -- If exit_date is set: Stop generation at exit_date -- If membership fee type changes: Handled separately by regeneration logic -- **Gap Handling:** If cycles were explicitly deleted (gaps exist), they are NOT recreated. - The generator always continues from the cycle AFTER the last existing cycle, regardless of gaps. +- `membership_fee_start_date` NULL → calculate from `join_date` + global setting. +- `exit_date` set → stop generation at `exit_date`. +- Fee type changes → handled separately by regeneration logic. +- **Gap handling:** if cycles were explicitly deleted (gaps exist), they are **NOT** recreated. The generator always continues from the cycle AFTER the last existing cycle, regardless of gaps. -### Calendar Cycle Calculations +### Calendar Cycles — `Mv.MembershipFees.CalendarCycles` -**Component:** `Mv.MembershipFees.CalendarCycles` +Calculates cycle boundaries by interval, the current cycle, the last completed cycle, and `cycle_end` from `cycle_start` + interval. -**Responsibilities:** +**Functions (high-level):** `calculate_cycle_start/2,3`, `calculate_cycle_end/2`, `next_cycle_start/2`, `current_cycle?/2,3`, `last_completed_cycle?/2,3`. -- Calculate cycle boundaries based on interval type -- Determine current cycle -- Determine last completed cycle -- Calculate cycle_end from cycle_start + interval +**Interval logic:** -**Functions (high-level):** +- **Monthly:** 1st of month → last day of month. +- **Quarterly:** 1st of quarter (Jan/Apr/Jul/Oct) → last day of quarter. +- **Half-yearly:** 1st of half (Jan/Jul) → last day of half. +- **Yearly:** Jan 1 → Dec 31. -- `calculate_cycle_start/3` - Given date and interval, find cycle start -- `calculate_cycle_end/2` - Given cycle_start and interval, calculate end -- `next_cycle_start/2` - Given cycle_start and interval, find next -- `is_current_cycle?/2` - Check if cycle contains today -- `is_last_completed_cycle?/2` - Check if cycle just ended +### Status Management — Ash actions on `MembershipFeeCycle` -**Interval Logic:** +Simple state machine unpaid ↔ paid ↔ suspended; all transitions allowed; permissions checked via Ash policies. Actions: `mark_as_paid`, `mark_as_suspended`, `mark_as_unpaid` (error correction). `bulk_mark_as_paid` is low priority / future. -- **Monthly:** Start = 1st of month, End = last day of month -- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter -- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half -- **Yearly:** Start = Jan 1st, End = Dec 31st +### Membership Fee Type Change — Ash change on `Member.membership_fee_type_id` -### Status Management +**Validation:** new type must have the same interval as the old type; different interval is rejected (MVP constraint). -**Component:** Ash actions on `MembershipFeeCycle` +**Side effects on allowed change:** keep all existing cycles; find future unpaid cycles, delete them, regenerate with the new `membership_fee_type_id` and amount. -**Status Transitions:** +**Implementation pattern:** -- Simple state machine: unpaid ↔ paid ↔ suspended -- No complex validation (all transitions allowed) -- Permissions checked via Ash policies +- Ash change module validates; `after_action` hook triggers regeneration synchronously. +- **Regeneration runs in the same transaction as the member update** to ensure atomicity. CycleGenerator uses advisory locks and transactions internally to prevent races. -**Actions Required:** +**Validation behavior:** -- `mark_as_paid` - Set status to :paid -- `mark_as_suspended` - Set status to :suspended -- `mark_as_unpaid` - Set status to :unpaid (error correction) - -**Bulk Operations:** - -- `bulk_mark_as_paid` - Mark multiple cycles as paid (efficiency) - - low priority, can be a future issue - -### Membership Fee Type Change Handling - -**Component:** Ash change on `Member.membership_fee_type_id` - -**Validation:** - -- Check if new type has same interval as old type -- If different: Reject change (MVP constraint) -- If same: Allow change - -**Side Effects on Allowed Change:** - -1. Keep all existing cycles unchanged -2. Find future unpaid cycles -3. Delete future unpaid cycles -4. Regenerate cycles with new membership_fee_type_id and amount - -**Implementation Pattern:** - -- Use Ash change module to validate -- Use after_action hook to trigger regeneration synchronously -- Regeneration runs in the same transaction as the member update to ensure atomicity -- CycleGenerator uses advisory locks and transactions internally to prevent race conditions - -**Validation Behavior:** - -- Fail-closed: If membership fee types cannot be loaded during validation, the change is rejected with a validation error -- Nil assignment prevention: Attempts to set membership_fee_type_id to nil are rejected when a current type exists +- **Fail-closed:** if fee types cannot be loaded during validation, the change is rejected with a validation error. +- **Nil prevention:** setting `membership_fee_type_id` to nil is rejected when a current type exists. --- ## Integration Points -### Member Resource Integration +### Member Resource -**Extension Points:** +Extension points: fields via migration; relationships (belongs_to, has_many); calculations (current_cycle_status, overdue_count); changes (auto-set `membership_fee_start_date`, validate interval). Backward compatible — new fields nullable/defaulted; existing members get the default fee type from settings. -1. Add fields via migration -2. Add relationships (belongs_to, has_many) -3. Add calculations (current_cycle_status, overdue_count) -4. Add changes (auto-set membership_fee_start_date, validate interval) +### Settings System -**Backward Compatibility:** +Store two global settings, admin UI to modify, default values if unset, validation (default fee type must exist). -- New fields nullable or with defaults -- Existing members get default membership fee type from settings -- No breaking changes to existing member functionality +### Permission System — Implemented -### Settings System Integration +See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full matrix and policy patterns. -**Requirements:** +**PermissionSets (`lib/mv/authorization/permission_sets.ex`):** -- Store two global settings -- Provide UI for admin to modify -- Default values if not set -- Validation (e.g., default membership fee type must exist) +- **MembershipFeeType:** all sets read (:all); only admin has create/update/destroy (:all). +- **MembershipFeeCycle:** all read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all). +- **Manual "Regenerate Cycles" (UI + server):** the "Regenerate Cycles" button in the member detail view is shown to users with MembershipFeeCycle create permission (normal_user and admin). UI access is gated by `can_create_cycle`. The LiveView handler **also enforces `can?(:create, MembershipFeeCycle)` server-side** before running regeneration (so e.g. a read_only user cannot trigger it via DevTools). Regeneration runs with the system actor. -**Access Pattern:** +**Resource policies:** -- Read settings during cycle generation -- Read settings during member creation -- Write settings only via admin UI - -### Permission System Integration - -**Status:** ✅ Implemented. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns. - -**PermissionSets (lib/mv/authorization/permission_sets.ex):** - -- **MembershipFeeType:** All permission sets can read (:all); only admin has create/update/destroy (:all). -- **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all). -- **Manual "Regenerate Cycles" (UI + server):** The "Regenerate Cycles" button in the member detail view is shown to users who have MembershipFeeCycle create permission (normal_user and admin). UI access is gated by `can_create_cycle`. The LiveView handler also enforces `can?(:create, MembershipFeeCycle)` server-side before running regeneration (so e.g. a read_only user cannot trigger it via DevTools). Regeneration runs with system actor. - -**Resource Policies:** - -- **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy. -- **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid. +- `MembershipFeeType` (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy. +- `MembershipFeeCycle` (`lib/membership_fees/membership_fee_cycle.ex`): same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid. ### LiveView Integration -**New LiveViews Required:** +**New:** MembershipFeeType index/form (admin); MembershipFeeCycle table component in the member detail view — implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent` (displays all cycles with status management, amount editing, and manual regeneration for normal_user and admin); Settings form section (admin); member-list status column. -1. MembershipFeeType index/form (admin) -2. MembershipFeeCycle table component (member detail view) - - Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent` - - Displays all cycles in a table with status management - - Allows changing cycle status, editing amounts, and manually regenerating cycles (normal_user and admin) -3. Settings form section (admin) -4. Member list column (membership fee status) - -**Existing LiveViews to Extend:** - -- Member detail view: Add membership fees section -- Member list view: Add status column -- Settings page: Add membership fees section - -**Authorization Helpers:** - -- Use existing `can?/3` helper for UI conditionals -- Check permissions before showing actions +**Extended:** member detail view (membership fees section), member list view (status column), settings page (membership fees section). Use the existing `can?/3` helper for UI conditionals. --- -## Acceptance Criteria +## Performance Notes -### MembershipFeeType Resource +**Indexes:** `membership_fee_cycles` on `member_id`, `membership_fee_type_id`, `status`, `cycle_start`, composite unique `(member_id, cycle_start)`; `members(membership_fee_type_id)`. -**AC-MFT-1:** Admin can create membership fee type with name, amount, interval, description -**AC-MFT-2:** Interval field is immutable after creation (validation error on change attempt) -**AC-MFT-3:** Admin can update name, amount, description (but not interval) -**AC-MFT-4:** Cannot delete membership fee type if assigned to members -**AC-MFT-5:** Cannot delete membership fee type if cycles exist referencing it -**AC-MFT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly +**Query:** preload fee type with cycles to avoid N+1; `cycle_end` and `current_cycle_status` are Ash calculations (lazy, not stored); paginate cycle lists > 50. -### MembershipFeeCycle Resource - -**AC-MFC-1:** Cycle has cycle_start, status, amount, notes, member_id, membership_fee_type_id -**AC-MFC-2:** cycle_end is calculated, not stored -**AC-MFC-3:** Status defaults to :unpaid -**AC-MFC-4:** One cycle per member per cycle_start (uniqueness constraint) -**AC-MFC-5:** Amount is set at generation time from membership_fee_type.amount -**AC-MFC-6:** Cycles cascade delete when member deleted -**AC-MFC-7:** Admin/Treasurer can change status -**AC-MFC-8:** Member can read own cycles - -### Member Extensions - -**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default) -**AC-M-2:** Member has membership_fee_start_date field (nullable) -**AC-M-3:** New members get default membership fee type from global setting -**AC-M-4:** membership_fee_start_date auto-set based on join_date and global setting -**AC-M-5:** Admin can manually override membership_fee_start_date -**AC-M-6:** Cannot change to membership fee type with different interval (MVP) - -### Cycle Generation - -**AC-CG-1:** Cycles generated when member gets membership fee type -**AC-CG-2:** Cycles generated when member created (via change hook) -**AC-CG-3:** Scheduled job generates missing cycles daily -**AC-CG-4:** Generation respects membership_fee_start_date -**AC-CG-5:** Generation stops at exit_date if member exited -**AC-CG-6:** Generation is idempotent (skips existing cycles) -**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year) -**AC-CG-8:** Amount comes from membership_fee_type at generation time - -### Calendar Logic - -**AC-CL-1:** Monthly cycles: 1st to last day of month -**AC-CL-2:** Quarterly cycles: 1st of Jan/Apr/Jul/Oct to last day of quarter -**AC-CL-3:** Half-yearly cycles: 1st of Jan/Jul to last day of half -**AC-CL-4:** Yearly cycles: Jan 1 to Dec 31 -**AC-CL-5:** cycle_end calculated correctly for all interval types -**AC-CL-6:** Current cycle determined correctly based on today's date -**AC-CL-7:** Last completed cycle determined correctly - -### Membership Fee Type Change - -**AC-TC-1:** Can change to type with same interval -**AC-TC-2:** Cannot change to type with different interval (error message) -**AC-TC-3:** On allowed change: future unpaid cycles regenerated -**AC-TC-4:** On allowed change: paid/suspended cycles unchanged -**AC-TC-5:** On allowed change: amount updated to new type's amount -**AC-TC-6:** Change is atomic (transaction) - ✅ Implemented: Regeneration runs synchronously in the same transaction as the member update - -### Settings - -**AC-S-1:** Global setting: include_joining_cycle (boolean, default true) -**AC-S-2:** Global setting: default_membership_fee_type_id (UUID, required) -**AC-S-3:** Admin can modify settings via UI -**AC-S-4:** Settings validated (e.g., default membership fee type must exist) -**AC-S-5:** Settings applied to new members immediately - -### UI - Member List - -**AC-UI-ML-1:** New column shows membership fee status -**AC-UI-ML-2:** Default: Shows last completed cycle status -**AC-UI-ML-3:** Optional: Toggle to show current cycle status -**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended) -**AC-UI-ML-5:** Filter: Unpaid in last cycle -**AC-UI-ML-6:** Filter: Unpaid in current cycle - -### UI - Member Detail - -**AC-UI-MD-1:** Membership fees section shows all cycles -**AC-UI-MD-2:** Table columns: Cycle, Interval, Amount, Status, Actions -**AC-UI-MD-3:** Checkbox per cycle for bulk marking (low prio) -**AC-UI-MD-4:** "Mark selected as paid" button -**AC-UI-MD-5:** Dropdown to change membership fee type (same interval only) -**AC-UI-MD-6:** Warning if different interval selected -**AC-UI-MD-7:** Only show actions if user has permission - -### UI - Membership Fee Types Admin - -**AC-UI-CTA-1:** List all membership fee types -**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count -**AC-UI-CTA-3:** Create new membership fee type form -**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable -**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable) -**AC-UI-CTA-6:** Warning on amount change (explain impact) -**AC-UI-CTA-7:** Cannot delete if members assigned -**AC-UI-CTA-8:** Only admin can access - -### UI - Settings Admin - -**AC-UI-SA-1:** Membership fees section in settings -**AC-UI-SA-2:** Dropdown to select default membership fee type -**AC-UI-SA-3:** Checkbox: Include joining cycle -**AC-UI-SA-4:** Explanatory text with examples -**AC-UI-SA-5:** Save button with validation - ---- - -## Testing Strategy - -### Unit Testing - -**Cycle Generator Tests:** - -- Correct cycle_start calculation for all interval types -- Correct cycle count from start to end date -- Respects membership_fee_start_date boundary -- Respects exit_date boundary -- Skips existing cycles (idempotent) -- Does not fill gaps when cycles were deleted -- Handles edge dates (year boundaries, leap years) - -**Calendar Cycles Tests:** - -- Cycle boundaries correct for all intervals -- cycle_end calculation correct -- Current cycle detection -- Last completed cycle detection -- Next cycle calculation - -**Validation Tests:** - -- Interval immutability enforced -- Same interval validation on type change -- Status transitions allowed -- Uniqueness constraints enforced - -### Integration Testing - -**Cycle Generation Flow:** - -- Member creation triggers generation -- Type assignment triggers generation -- Type change regenerates future cycles -- Scheduled job generates missing cycles -- Left member stops generation - -**Status Management Flow:** - -- Mark single cycle as paid -- Bulk mark multiple cycles (low prio) -- Status transitions work -- Permissions enforced - -**Membership Fee Type Management:** - -- Create type -- Update amount (regeneration triggered) -- Cannot update interval -- Cannot delete if in use - -### LiveView Testing - -**Member List:** - -- Status column displays correctly -- Toggle between last/current works -- Filters work correctly -- Color coding applied - -**Member Detail:** - -- Cycles table displays all cycles -- Checkboxes work -- Bulk marking works (low prio) -- Membership fee type change validation works -- Actions only shown with permission - -**Admin UI:** - -- Type CRUD works -- Settings save correctly -- Validations display errors -- Only authorized users can access - -### Edge Case Testing - -**Interval Change Attempt:** - -- Error message displayed -- No data modified -- User can cancel/choose different type - -**Exit with Unpaid:** - -- Warning shown -- Option to suspend offered -- Exit completes correctly - -**Amount Change:** - -- Warning displayed -- Only future unpaid regenerated -- Historical cycles unchanged - -**Date Boundaries:** - -- Today = cycle start handled -- Today = cycle end handled -- Leap year handled - -### Performance Testing - -**Cycle Generation:** - -- Generate 10 years of monthly cycles: < 100ms -- Generate for 1000 members: < 5 seconds -- Idempotent check efficient (no full scan) - -**Member List Query:** - -- With status column: < 200ms for 1000 members -- Filters applied efficiently -- No N+1 queries - ---- - -## Security Considerations - -### Authorization - -**Permissions Required:** - -- Membership fee type management: Admin only -- Membership fee cycle status changes: Admin + Treasurer -- View all cycles: Admin + Treasurer + Board -- View own cycles: All authenticated users - -**Policy Enforcement:** - -- All actions protected by Ash policies -- UI shows/hides based on permissions -- Backend validates permissions (never trust UI alone) - -### Data Integrity - -**Validation Layers:** - -1. Database constraints (NOT NULL, UNIQUE, CHECK) -2. Ash validations (business rules) -3. UI validations (user experience) - -**Immutability Protection:** - -- Interval change prevented at multiple layers -- Cycle amounts immutable (audit trail) -- Settings changes logged (future) - -### Audit Trail - -**Tracked Information:** - -- Cycle status changes (who, when) - future enhancement -- Membership fee type amount changes (implicit via cycle amounts) - ---- - -## Performance Considerations - -### Database Indexes - -**Required Indexes:** - -- `membership_fee_cycles(member_id)` - For member cycle lookups -- `membership_fee_cycles(membership_fee_type_id)` - For type queries -- `membership_fee_cycles(status)` - For unpaid filters -- `membership_fee_cycles(cycle_start)` - For date range queries -- `membership_fee_cycles(member_id, cycle_start)` - Composite unique index -- `members(membership_fee_type_id)` - For type membership count - -### Query Optimization - -**Preloading:** - -- Load membership_fee_type with cycles (avoid N+1) -- Load cycles when displaying member detail -- Use Ash's load for efficient preloading - -**Calculated Fields:** - -- cycle_end calculated on-demand (not stored) -- current_cycle_status calculated when needed -- Use Ash calculations for lazy evaluation - -**Pagination:** - -- Cycle list paginated if > 50 cycles -- Member list already paginated - -### Caching Strategy - -**No caching needed in MVP:** - -- Membership fee types rarely change -- Cycle queries are fast -- Settings read infrequently - -**Future caching if needed:** - -- Cache settings in application memory -- Cache membership fee types list -- Invalidate on change - -### Scheduled Job Performance - -**Cycle Generation Job:** - -- Run daily or weekly (not hourly) -- Batch members (process 100 at a time) -- Skip members with no changes -- Log failures for retry +**No caching in MVP** (fee types rarely change, queries fast). Scheduled generation job: run daily/weekly, batch members, skip unchanged, log failures for retry. --- ## Future Enhancements -### Phase 2: Interval Change Support - -**Architecture Changes:** - -- Add logic to handle cycle overlaps -- Calculate prorata amounts if needed -- More complex validation -- Migration path for existing cycles - -### Phase 3: Payment Details - -**Architecture Changes:** - -- Add PaymentTransaction resource -- Link transactions to cycles -- Support multiple payments per cycle -- Reconciliation logic - -### Phase 4: vereinfacht.digital Integration - -**Architecture Changes:** - -- External API client module -- Webhook handling for transactions -- Automatic matching logic -- Manual review interface - ---- - -**End of Architecture Document** +- **Phase 2 — Interval change support:** cycle-overlap logic, prorata, more validation, migration path for existing cycles. +- **Phase 3 — Payment details:** `PaymentTransaction` resource linked to cycles, multiple payments per cycle, reconciliation. +- **Phase 4 — vereinfacht.digital integration:** external API client, webhook handling, automatic matching, manual review. diff --git a/docs/membership-fee-overview.md b/docs/membership-fee-overview.md index 8eb48b0..b00178d 100644 --- a/docs/membership-fee-overview.md +++ b/docs/membership-fee-overview.md @@ -1,50 +1,20 @@ # Membership Fees - Overview -**Project:** Mila - Membership Management System -**Feature:** Membership Fee Management -**Version:** 1.0 -**Last Updated:** 2026-01-13 -**Status:** ✅ Implemented +**Feature:** Membership Fee Management — **Status:** Implemented ---- - -## Purpose - -This document provides a comprehensive overview of the Membership Fees system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format. - -**For detailed implementation:** See [membership-fee-implementation-plan.md](./membership-fee-implementation-plan.md) (created after concept iterations) - ---- - -## Table of Contents - -1. [Core Principle](#core-principle) -2. [Terminology](#terminology) -3. [Data Model](#data-model) -4. [Business Logic](#business-logic) -5. [UI/UX Design](#uiux-design) -6. [Edge Cases](#edge-cases) -7. [Technical Integration](#technical-integration) -8. [Implementation Scope](#implementation-scope) +Coarse, business-oriented entry point for the Membership Fees system: terminology, worked examples, and UI/UX. For architecture (data model, FK behaviors, module map, generation algorithm, policies) see [membership-fee-architecture.md](./membership-fee-architecture.md). --- ## Core Principle -**Maximum Simplicity:** - -- Minimal complexity -- Clear data model without redundancies -- Intuitive operation -- Calendar cycle-based (Month/Quarter/Half-Year/Year) +Maximum simplicity: minimal complexity, clear data model without redundancies, intuitive operation, calendar-cycle-based (Month / Quarter / Half-Year / Year). --- -## Terminology +## Terminology (German ↔ English) -### German ↔ English - -**Core Entities:** +**Core entities:** - Beitragsart ↔ Membership Fee Type - Beitragszyklus ↔ Membership Fee Cycle @@ -56,14 +26,14 @@ This document provides a comprehensive overview of the Membership Fees system. I - unbezahlt ↔ unpaid - ausgesetzt ↔ suspended / waived -**Intervals (Frequenz / Payment Frequency):** +**Intervals (Frequenz / payment frequency):** - monatlich ↔ monthly - quartalsweise ↔ quarterly - halbjährlich ↔ half-yearly / semi-annually - jährlich ↔ yearly / annually -**UI Elements:** +**UI elements:** - "Letzter Zyklus" ↔ "Last Cycle" (e.g., 2023 when in 2024) - "Aktueller Zyklus" ↔ "Current Cycle" (e.g., 2024) @@ -72,112 +42,39 @@ This document provides a comprehensive overview of the Membership Fees system. I --- -## Data Model +## Data Model (summary) -### Membership Fee Type (MembershipFeeType) +Three entities — full schema, FK on-delete behaviors, and design rationale are in [membership-fee-architecture.md](./membership-fee-architecture.md). -``` -- id (UUID) -- name (String) - e.g., "Regular", "Reduced", "Student" -- amount (Decimal) - Membership fee amount in Euro -- interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly -- description (Text, optional) -``` +- **MembershipFeeType:** name, amount (€), `interval` (:monthly/:quarterly/:half_yearly/:yearly), optional description. `interval` is **IMMUTABLE** after creation; admin can change only name/amount/description; on amount change, future unpaid cycles regenerate with the new amount. +- **MembershipFeeCycle:** member_id, membership_fee_type_id, `cycle_start` (calendar start: 01.01., 01.04., 01.07., 01.10., …), status (:unpaid default / :paid / :suspended), `amount` (captured at generation time → history when type changes), optional notes. NO `cycle_end` (derived from `cycle_start` + interval), NO `interval_type` (read from the fee type). +- **Member extensions:** `membership_fee_type_id` (FK, nullable — default applied from settings at the app level), `membership_fee_start_date` (Date, nullable), plus the existing `exit_date`. -**Important:** +**Calendar cycle logic:** Monthly 01.01.–31.01., etc. · Quarterly 01.01.–31.03., 01.04.–30.06., 01.07.–30.09., 01.10.–31.12. · Half-yearly 01.01.–30.06., 01.07.–31.12. · Yearly 01.01.–31.12. -- `interval` is **IMMUTABLE** after creation! -- Admin can only change `name`, `amount`, `description` -- On change: Future unpaid cycles regenerated with new amount +### `membership_fee_start_date` derivation -### Membership Fee Cycle (MembershipFeeCycle) +Auto-set from global setting `include_joining_cycle`: -``` -- id (UUID) -- member_id (FK → members.id) -- membership_fee_type_id (FK → membership_fee_types.id) -- cycle_start (Date) - Calendar cycle start (01.01., 01.04., 01.07., 01.10., etc.) -- status (Enum) - :unpaid (default), :paid, :suspended -- amount (Decimal) - Membership fee amount at generation time (history when type changes) -- notes (Text, optional) - Admin notes -``` +- `include_joining_cycle = true` → first day of the joining month/quarter/year (member pays from the joining cycle). +- `include_joining_cycle = false` → first day of the NEXT cycle after joining. -**Important:** +Can be manually overridden by admin. There is intentionally **no** `include_joining_cycle` field on Member — `membership_fee_start_date` makes it unnecessary. -- **NO** `cycle_end` - calculated from `cycle_start` + `interval` -- **NO** `interval_type` - read from `membership_fee_type.interval` -- Avoids redundancy and inconsistencies! +### Global settings -**Calendar Cycle Logic:** - -- Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc. -- Quarterly: 01.01. - 31.03., 01.04. - 30.06., 01.07. - 30.09., 01.10. - 31.12. -- Half-yearly: 01.01. - 30.06., 01.07. - 31.12. -- Yearly: 01.01. - 31.12. - -### Member (Extensions) - -``` -- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings) -- membership_fee_start_date (Date, nullable) - When to start generating membership fees -- exit_date (Date, nullable) - Exit date (existing) -``` - -**Logic for membership_fee_start_date:** - -- Auto-set based on global setting `include_joining_cycle` -- If `include_joining_cycle = true`: First day of joining month/quarter/year -- If `include_joining_cycle = false`: First day of NEXT cycle after joining -- Can be manually overridden by admin - -**NO** `include_joining_cycle` field on Member - unnecessary due to `membership_fee_start_date`! - -### Global Settings - -``` -key: "membership_fees.include_joining_cycle" -value: Boolean (Default: true) - -key: "membership_fees.default_membership_fee_type_id" -value: UUID (Required) - Default membership fee type for new members -``` - -**Meaning include_joining_cycle:** - -- `true`: Joining cycle is included (member pays from joining cycle) -- `false`: Only from next full cycle after joining - -**Meaning of default membership fee type setting:** - -- Every new member automatically gets this membership fee type -- Must be configured in admin settings -- Prevents: Members without membership fee type +- `membership_fees.include_joining_cycle` — Boolean (default `true`): whether the joining cycle is billed. +- `membership_fees.default_membership_fee_type_id` — UUID (required): fee type auto-assigned to every new member; must be configured in admin settings (prevents members without a fee type). --- ## Business Logic -### Cycle Generation +### Cycle generation -**Triggers:** +**Triggers:** fee type assigned (incl. at member creation), new cycle begins (cron daily/weekly), admin manual regeneration. Uses PostgreSQL advisory locks per member. -- Member gets membership fee type assigned (also during member creation) -- New cycle begins (Cron job daily/weekly) -- Admin requests manual regeneration - -**Algorithm:** - -Use PostgreSQL advisory locks per member to prevent race conditions - -1. Get `member.membership_fee_start_date` and member's membership fee type -2. Determine generation start point: - - If NO cycles exist: Start from `membership_fee_start_date` - - If cycles exist: Start from the cycle AFTER the last existing one -3. Generate cycles until today (or `exit_date` if present): - - Use the interval to generate the cycles - - **Note:** If cycles were explicitly deleted (gaps exist), they are NOT recreated. - The generator always continues from the cycle AFTER the last existing cycle. -4. Set `amount` to current membership fee type's amount +**Algorithm:** start from `membership_fee_start_date` if no cycles exist, else from the cycle AFTER the last existing one; generate to today (or `exit_date`); set each cycle's `amount` from the current fee type. **Deleted cycles (gaps) are NOT recreated** — generation always continues after the last existing cycle. (Full algorithm in architecture doc.) **Example (Yearly):** @@ -207,93 +104,31 @@ Generated cycles: - ... ``` -### Status Transitions +### Status transitions -``` -unpaid → paid -unpaid → suspended -paid → unpaid -suspended → paid -suspended → unpaid -``` +unpaid → paid · unpaid → suspended · paid → unpaid · suspended → paid · suspended → unpaid. Admin + Treasurer (Kassenwart) can change status, via the existing permission system. -**Permissions:** +### Membership fee type change -- Admin + Treasurer (Kassenwart) can change status -- Uses existing permission system +MVP allows changing only to a fee type with the **same interval** (e.g. "Regular (yearly)" → "Reduced (yearly)" ✓; → "Reduced (monthly)" ✗). On change: set `member.membership_fee_type_id`; future **unpaid** cycles deleted and regenerated with the new amount; paid/suspended cycles unchanged (historical amount). Future: enable interval switching (overlap handling, extra validation). -### Membership Fee Type Change +### Member exit -**MVP - Same Cycle Only:** - -- Member can only choose membership fee type with **same cycle** -- Example: From "Regular (yearly)" to "Reduced (yearly)" ✓ -- Example: From "Regular (yearly)" to "Reduced (monthly)" ✗ - -**Logic on Change:** - -1. Check: New membership fee type has same interval -2. If yes: Set `member.membership_fee_type_id` -3. Future **unpaid** cycles: Delete and regenerate with new amount -4. Paid/suspended cycles: Remain unchanged (historical amount) - -**Future - Different Intervals:** - -- Enable interval switching (e.g., yearly → monthly) -- More complex logic for cycle overlaps -- Needs additional validation - -### Member Exit - -**Logic:** - -- Cycles only generated until `member.exit_date` -- Existing cycles remain visible -- Unpaid exit cycle can be marked as "suspended" - -**Example:** - -``` -Exit: 15.08.2024 -Yearly cycle: 01.01.2024 - 31.12.2024 - -→ Cycle 2024 is shown (Status: unpaid) -→ Admin can set to "suspended" -→ No cycles for 2025+ generated -``` +Cycles generated only up to `member.exit_date`; existing cycles remain visible; an unpaid exit cycle can be marked "suspended". E.g. exit 15.08.2024 with a yearly cycle 01.01.–31.12.2024 → 2024 cycle shown (unpaid, admin may suspend); no 2025+ cycles generated. --- ## UI/UX Design -### Member List View +### Member List View — column "Membership Fee Status" -**New Column: "Membership Fee Status"** +- **Default (last completed cycle):** in 2024, shows the 2023 status. Color: green = paid ✓, red = unpaid ✗, gray = suspended ⊘. +- **Optional toggle:** "Show current cycle" (2024). +- **Filters:** "Unpaid membership fees in last cycle", "Unpaid membership fees in current cycle". -**Default Display (Last Cycle):** +### Member Detail View — section "Membership Fees" -- Shows status of **last completed** cycle -- Example in 2024: Shows membership fee for 2023 -- Color coding: - - Green: paid ✓ - - Red: unpaid ✗ - - Gray: suspended ⊘ - -**Optional: Show Current Cycle** - -- Toggle: "Show current cycle" (2024) -- Admin decides what to display - -**Filters:** - -- "Unpaid membership fees in last cycle" -- "Unpaid membership fees in current cycle" - -### Member Detail View - -**Section: "Membership Fees"** - -**Membership Fee Type Assignment:** +**Fee type assignment:** ``` ┌─────────────────────────────────────┐ @@ -303,7 +138,7 @@ Yearly cycle: 01.01.2024 - 31.12.2024 └─────────────────────────────────────┘ ``` -**Cycle Table:** +**Cycle table:** ``` ┌───────────────┬──────────┬────────┬──────────┬─────────┐ @@ -322,11 +157,7 @@ Yearly cycle: 01.01.2024 - 31.12.2024 Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended ``` -**Quick Marking:** - -- Checkbox in each row for fast marking -- Button: "Mark selected as paid/unpaid/suspended" -- Bulk action for multiple cycles +**Quick marking:** checkbox per row; "Mark selected as paid/unpaid/suspended" button; bulk action for multiple cycles. ### Admin: Membership Fee Types Management @@ -342,18 +173,13 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended └────────────┴──────────┴──────────┴────────────┴─────────┘ ``` -**Edit:** +**Edit:** Name ✓, Amount ✓, Description ✓ editable; Interval ✗ **NOT** editable (grayed out). -- Name: ✓ editable -- Amount: ✓ editable -- Description: ✓ editable -- Interval: ✗ **NOT** editable (grayed out) - -**Warning on Amount Change:** +**Warning on amount change:** ``` ⚠ Change amount to 65 €? - + Impact: - 45 members affected - Future unpaid cycles will be generated with 65 € @@ -362,9 +188,7 @@ Impact: [Cancel] [Confirm] ``` -### Admin: Settings - -**Membership Fee Configuration:** +### Admin: Settings — Membership Fee Configuration ``` Default Membership Fee Type: [Dropdown: Membership Fee Types] @@ -397,135 +221,58 @@ Joining: 15.03.2023 ## Edge Cases -### 1. Membership Fee Type Change with Different Interval +1. **Type change with different interval:** MVP blocks it. UI message: -**MVP:** Blocked (only same interval allowed) + ``` + Error: Interval change not possible -**UI:** + Current membership fee type: "Regular (Yearly)" + Selected membership fee type: "Student (Monthly)" -``` -Error: Interval change not possible + Changing the interval is currently not possible. + Please select a membership fee type with interval "Yearly". -Current membership fee type: "Regular (Yearly)" -Selected membership fee type: "Student (Monthly)" + [OK] + ``` -Changing the interval is currently not possible. -Please select a membership fee type with interval "Yearly". + Future: allow interval switching with overlap calculation and no duplicate cycles. -[OK] -``` +2. **Exit with unpaid fees (low prio):** on exit, offer to mark unpaid cycles "suspended". -**Future:** + ``` + ⚠ Unpaid membership fees present -- Allow interval switching -- Calculate overlaps -- Generate new cycles without duplicates + This member has 1 unpaid cycle(s): + - 2024: 60 € (unpaid) -### 2. Exit with Unpaid Membership Fees + Do you want to continue? -**Scenario:** + [ ] Mark membership fee as "suspended" + [Cancel] [Confirm Exit] + ``` -``` -Member exits: 15.08.2024 -Yearly cycle 2024: unpaid -``` +3. **Multiple unpaid cycles:** all shown; select several and bulk-mark. -**UI Notice on Exit: (Low Prio)** + ``` + ┌───────────────┬──────────┬────────┬──────────┬─────────┐ + │ 2023 │ Yearly │ 50 € │ ☐ Open │ [✓] │ + │ 2024 │ Yearly │ 60 € │ ☐ Open │ [✓] │ + │ 2025 │ Yearly │ 60 € │ ☐ Open │ [ ] │ + └───────────────┴──────────┴────────┴──────────┴─────────┘ -``` -⚠ Unpaid membership fees present + [Mark selected as paid/unpaid/suspended] (2 selected) + ``` -This member has 1 unpaid cycle(s): -- 2024: 60 € (unpaid) +4. **Amount changes:** 2023 Regular = 50 €, 2024 = 60 € → 2023 cycle keeps 50 € (history), 2024 generated with 60 €; each cycle shows its historical amount. -Do you want to continue? - -[ ] Mark membership fee as "suspended" -[Cancel] [Confirm Exit] -``` - -### 3. Multiple Unpaid Cycles - -**Scenario:** Member hasn't paid for 2 years - -**Display:** - -``` -┌───────────────┬──────────┬────────┬──────────┬─────────┐ -│ 2023 │ Yearly │ 50 € │ ☐ Open │ [✓] │ -│ 2024 │ Yearly │ 60 € │ ☐ Open │ [✓] │ -│ 2025 │ Yearly │ 60 € │ ☐ Open │ [ ] │ -└───────────────┴──────────┴────────┴──────────┴─────────┘ - -[Mark selected as paid/unpaid/suspended] (2 selected) -``` - -### 4. Amount Changes - -**Scenario:** - -``` -2023: Regular = 50 € -2024: Regular = 60 € (increase) -``` - -**Result:** - -- Cycle 2023: Saved with 50 € (history) -- Cycle 2024: Generated with 60 € (current) -- Both cycles show correct historical amount - -### 5. Date Boundaries - -**Problem:** What if today = 01.01.2025? - -**Solution:** - -- Current cycle (2025) is generated -- Status: unpaid (open) -- Shown in overview +5. **Date boundaries:** today = 01.01.2025 → current 2025 cycle generated, status unpaid, shown in overview. --- ## Implementation Scope -### MVP (Phase 1) +**MVP (Phase 1) — included:** fee types CRUD; automatic cycle generation; status management (paid/unpaid/suspended); member overview with status; per-member cycle view; quick checkbox marking; bulk actions; amount history; same-interval type change; default fee type; joining-cycle configuration. -**Included:** +**NOT included:** interval change; payment details (date, method); automatic vereinfacht.digital integration; prorata; reports/statistics; reminders/dunning (manual via filters). -- ✓ Membership fee types (CRUD) -- ✓ Automatic cycle generation -- ✓ Status management (paid/unpaid/suspended) -- ✓ Member overview with membership fee status -- ✓ Cycle view per member -- ✓ Quick checkbox marking -- ✓ Bulk actions -- ✓ Amount history -- ✓ Same-interval type change -- ✓ Default membership fee type -- ✓ Joining cycle configuration - -**NOT Included:** - -- ✗ Interval change (only same interval) -- ✗ Payment details (date, method) -- ✗ Automatic integration (vereinfacht.digital) -- ✗ Prorata calculation -- ✗ Reports/statistics -- ✗ Reminders/dunning (manual via filters) - -### Future Enhancements - -**Phase 2:** - -- Payment details (date, amount, method) -- Interval change for future unpaid cycles -- Manual vereinfacht.digital links per member -- Extended filter options - -**Phase 3:** - -- Automated vereinfacht.digital integration -- Automatic payment matching -- SEPA integration -- Advanced reports +**Future:** Phase 2 — payment details, interval change for future unpaid cycles, manual vereinfacht.digital links per member, extended filters. Phase 3 — automated vereinfacht.digital integration, automatic payment matching, SEPA, advanced reports. diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 8e6c615..0dbf7c8 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -1,20 +1,16 @@ # Onboarding & Join – High-Level Concept -**Status:** Draft for design decisions and implementation specs. **Prio 1 (Subtasks 1–4) implemented.** -**Scope:** Prio 1 = public Join form; Step 2 = Vorstand approval. Invite-Link and OIDC JIT are out of scope and documented only as future entry paths. +**Status:** Prio 1 (Subtasks 1–4) and Step 2 (Vorstand approval, Subtask 5) implemented. The Invite-Link / OIDC-JIT join entry paths (§4) are designed here but **not yet implemented**. **Related:** Issue #308, roles-and-permissions-architecture, page-permission-route-coverage. --- ## 1. Focus and Goals -- **Focus:** Onboarding and **initial data capture**, not self-service editing of existing members. -- **Entry paths (vision):** - - **Public Join form** (Prio 1) – unauthenticated submission. - - **Invite link** (tokenized) – later. - - **OIDC first-login** (Just-in-Time Provisioning) – later. -- **Admin control:** All entry paths and their behaviour (e.g. which fields, approval required) shall be configurable by admins; MVP can start with sensible defaults. -- **Approval:** A Vorstand (board) approval step is a direct follow-up (Step 2) after the public Join; the data model and flow must support it. +- **Focus:** onboarding and **initial data capture**, not self-service editing of existing members. +- **Entry paths (vision):** public Join form (Prio 1, unauthenticated submission); invite link (tokenized, later); OIDC first-login / Just-in-Time Provisioning (later). +- **Admin control:** all entry paths and their behaviour (which fields, approval required) shall be admin-configurable; MVP may start with sensible defaults. +- **Approval:** a Vorstand (board) approval step is the direct follow-up (Step 2) after the public Join; the data model and flow support it. --- @@ -22,168 +18,137 @@ ### 2.1 Intent -- **Public** page (e.g. `/join`): no login; anyone can open and submit. -- Result is **not** a User or Member. Result is an **onboarding / join request**: the JoinRequest record is **created in the database on form submit** in status `pending_confirmation`, then **updated to** `submitted` after the user clicks the confirmation link. -- This keeps: - - **Public intake** (abuse-prone) separate from **identity and account creation** (after approval / invite / OIDC). - - Existing policies (e.g. User–Member linking, admin-only link) untouched until a defined "promotion" flow (e.g. after approval) creates User/Member. -- **Elixir/Phoenix/Ash standard:** Data is persisted in the database from the start (one Ash resource, status-driven flow). No ETS or stateless token for pre-confirmation storage; confirm flow only updates the existing record. +- **Public** page `/join`: no login; anyone can open and submit. +- The result is **not** a User or Member but a **JoinRequest** record, created in the DB on form submit in status `pending_confirmation`, then updated to `submitted` after the user clicks the confirmation link. +- This keeps public intake (abuse-prone) separate from identity/account creation, and leaves existing policies (User–Member linking, admin-only link) untouched until a defined promotion flow (after approval) creates User/Member. +- **Standard:** data is persisted in the DB from the start (one Ash resource, status-driven). No ETS or stateless token for pre-confirmation storage; the confirm flow only updates the existing record. -### 2.2 User Flow (Prio 1) +### 2.2 User Flow 1. Unauthenticated user opens `/join`. -2. Short explanation + form (what happens next: "We will review … you will hear from us"). -3. **Submit** → A **JoinRequest is created** in the database with status `pending_confirmation`; confirmation email is sent; user sees: "We have saved your details. To complete your request, please click the link we sent to your email." -4. **User clicks confirmation link** → The existing JoinRequest is **updated** to status `submitted` (`submitted_at` set, confirmation token invalidated); user sees: "Thank you, we have received your request." +2. Short explanation + form ("We will review … you will hear from us"). +3. **Submit** → JoinRequest created with status `pending_confirmation`; confirmation email sent; user sees "We have saved your details. To complete your request, please click the link we sent to your email." +4. **User clicks confirmation link** → existing JoinRequest updated to `submitted` (`submitted_at` set, confirmation token invalidated); user sees "Thank you, we have received your request." -**Rationale (double opt-in with DB-first):** Email confirmation remains best practice (we only treat the request as "submitted" after the link is clicked). The record exists in the DB from submit time so we use standard Phoenix/Ash persistence, multi-node safety, and a simple status transition (`pending_confirmation` → `submitted`) on confirm. This aligns with patterns like AshAuthentication (resource exists before confirm; confirm updates state). - -**Out of scope for Prio 1:** Approval UI, account creation, OIDC, invite links. +**Rationale (double opt-in, DB-first):** email confirmation stays best practice (treated as "submitted" only after the click); the record exists in the DB from submit time, so we get standard Phoenix/Ash persistence, multi-node safety, and a simple `pending_confirmation → submitted` transition. Aligns with AshAuthentication (resource exists before confirm; confirm updates state). ### 2.3 Data Flow -- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields is enforced in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`** so that even direct API/submit_join_request calls only persist allowlisted form_data keys. -- **On form submit:** **Create** a JoinRequest with status `pending_confirmation`, store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data (see §2.3.2), then send confirmation email. -- **On confirmation link click:** **Update** the JoinRequest (find by token hash): set status to `submitted`, set `submitted_at`, clear/invalidate token fields. If the record is already `submitted`, return success without changing it (idempotent). -- **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval). +- **Input:** only data explicitly allowed for the public form; field set is admin-configured (§2.6). No internal/sensitive fields. **Server-side allowlist:** accepted fields are enforced both in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`**, so even direct API / `submit_join_request` calls persist only allowlisted `form_data` keys. +- **On submit:** create a JoinRequest (status `pending_confirmation`), store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data, then send the confirmation email. +- **On confirm link click:** find by token hash, set status `submitted`, set `submitted_at`, clear/invalidate token fields. If already `submitted`, return success without changing it (idempotent). +- **No Member/User creation** in Prio 1; promotion happens later (after approval). #### 2.3.1 Pre-Confirmation Store (Decided) -**Decision:** Store in the **database** only. Use the **same** JoinRequest resource and table from the start. +**Decision:** store in the **database** only, using the **same** JoinRequest resource and table throughout. On submit, create one row (`pending_confirmation`, token hash, expiry); on confirm, update that row to `submitted` — no second table, no ETS, no stateless token. -- On submit: **create** one JoinRequest row with status `pending_confirmation`, confirmation token **hash**, and expiry. -- On confirm: **update** that row to status `submitted` (no second table, no ETS, no stateless token). -- **Retention and cleanup:** JoinRequests that remain in `pending_confirmation` past the token expiry (e.g. 24 hours) are **hard-deleted** by a scheduled job (e.g. Oban cron). Retention period: **24 hours**; document in DSGVO/retention as needed. -- **Rationale:** Elixir/Phoenix/Ash standard is persistence in DB, one resource, status machine. Multi-node safe, restart safe, and cleanup is a standard cron task. +**Retention and cleanup:** JoinRequests still in `pending_confirmation` past the token expiry are **hard-deleted** by a scheduled job (Oban cron). Retention period: **24 hours**; document in DSGVO/retention as needed. Multi-node and restart safe; cleanup is a standard cron task. #### 2.3.2 JoinRequest: Data Model and Schema -- **Status:** `pending_confirmation` (initial, after form submit) → `submitted` (after link click) → later `approved` / `rejected`. Include **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit. -- **Confirmation:** Store **confirmation_token_hash** (not the raw token); **confirmation_token_expires_at**; optional **confirmation_sent_at**. Raw token appears only in the email link; on confirm, hash the incoming token and find the record by hash. -- **Payload vs typed columns (recommendation):** - - **Typed columns** for **email** (required, dedicated field for index, search, dedup, audit) and for **first_name** and **last_name** (optional). These align with `Mv.Constants.member_fields()` and with the existing Member resource; they support approval-list display and straightforward promotion to Member without parsing JSON. - - **Remaining form data** (other member fields + custom field values) in a **jsonb** attribute (e.g. `form_data`) plus a **schema_version** (e.g. tied to join-form or member_fields evolution) so future changes do not break existing records. - - **What it depends on:** (1) Whether the join form field set is fixed or often extended – if fixed, more typed columns are feasible; if very dynamic, keeping the rest in jsonb avoids migrations. (2) Whether the approval UI or reporting needs to filter/sort by other fields (e.g. city) – if yes, consider adding those as typed columns later. For MVP, email + first_name + last_name typed and rest in jsonb is a good balance with the current codebase (Member has typed attributes; export/import use allowlists of field names). -- **Logger hygiene:** Do not log the full payload/form_data; follow CODE_GUIDELINES on log sanitization. -- **Idempotency:** Confirm action finds the JoinRequest by token hash; if status is already `submitted`, return success without updating. Optionally enforce **unique_index on confirmation_token_hash** so the same token cannot apply to more than one record. -- **Abuse metadata:** If stored (e.g. IP hash), classify as **security telemetry** or **personally identifiable** (DSGVO). Prefer hashed/aggregated values only (e.g. /24 prefix hash or keyed-hash), not raw IP; document classification and retention. Out of scope for Prio 1 unless explicitly added. +- **Status:** `pending_confirmation` (initial) → `submitted` (after link click) → later `approved` / `rejected`. Audit: **approved_at**, **rejected_at**, **reviewed_by_user_id**. +- **Confirmation:** store **confirmation_token_hash** (not the raw token); **confirmation_token_expires_at**; optional **confirmation_sent_at**. The raw token appears only in the email link; on confirm, hash the incoming token and find the record by hash. +- **Payload vs typed columns:** **typed columns** for **email** (required — dedicated field for index, search, dedup, audit) and **first_name** / **last_name** (optional); these align with `Mv.Constants.member_fields()` and the Member resource, supporting approval-list display and straightforward promotion without parsing JSON. **Remaining form data** (other member fields + custom field values) goes in a **jsonb** attribute (`form_data`) plus a **schema_version** so future changes don't break existing records. + - *Depends on:* (1) whether the join-form field set is fixed (more typed columns feasible) or dynamic (keep rest in jsonb to avoid migrations); (2) whether approval UI/reporting needs to filter/sort by other fields (e.g. city) — if so, add typed columns later. For MVP, email + first_name + last_name typed and the rest in jsonb balances well with the current codebase. +- **Logger hygiene:** do not log the full payload/`form_data`; follow CODE_GUIDELINES on log sanitization. +- **Idempotency:** confirm finds the JoinRequest by token hash; if already `submitted`, return success without updating. Optionally enforce a **unique_index on confirmation_token_hash**. +- **Abuse metadata:** if stored (e.g. IP hash), classify as security telemetry or PII (DSGVO). Prefer hashed/aggregated values only (e.g. /24 prefix hash or keyed-hash), not raw IP; document classification and retention. Out of scope for Prio 1 unless explicitly added. ### 2.4 Security - **Public paths:** `/join` and the confirmation route must be public (unauthenticated access returns 200). - - **Explicit public path for `/join`:** Add **`/join`** (and if needed `/join/*`) to the page-permission plug’s **`public_path?/1`** so that the join page is reachable without login. Do not rely on the confirm path alone. - - **Confirmation route:** Use **`/confirm_join/:token`** so that the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it; no extra plug change for confirm. -- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Use Phoenix/Elixir standard options (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP for rate limiting: prefer **X-Forwarded-For** / **X-Real-IP** when behind a reverse proxy (see Endpoint `connect_info: [:x_headers]` and `JoinLive.client_ip_from_socket/1`); fallback to peer_data with correct IPv4/IPv6 string via `:inet.ntoa/1`. Verify library version and multi-node behaviour before or during implementation. -- **Data:** Minimal PII; no sensitive data on the public form; consider DSGVO when extending. If abuse signals are stored: only hashed or aggregated values; document classification and retention. -- **Approval-only:** No automatic User/Member creation from the join form; approval (Step 2) or other trusted path creates identity. -- **Ash policies and actor:** JoinRequest has **explicit public actions** allowed with `actor: nil` (e.g. `submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path. -- **No system-actor fallback:** Join and confirmation run without an authenticated user. Do **not** use the system actor as a fallback for "missing actor". Use explicit unauthenticated context; see CODE_GUIDELINES §5.0. + - **Explicit public path for `/join`:** add **`/join`** (and if needed `/join/*`) to the page-permission plug's **`public_path?/1`**; do not rely on the confirm path alone. + - **Confirmation route:** use **`/confirm_join/:token`** so the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it — no extra plug change for confirm. +- **Abuse:** **honeypot** + **rate limiting** in MVP (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP: prefer **X-Forwarded-For** / **X-Real-IP** behind a reverse proxy (Endpoint `connect_info: [:x_headers]`, `JoinLive.client_ip_from_socket/1`); fallback to peer_data with correct IPv4/IPv6 string via `:inet.ntoa/1`. Verify library version and multi-node behaviour. +- **Data:** minimal PII; no sensitive data on the public form; consider DSGVO when extending. Stored abuse signals: only hashed/aggregated, documented. +- **Approval-only:** no automatic User/Member creation from the join form; approval (Step 2) or another trusted path creates identity. +- **Ash policies and actor:** JoinRequest has **explicit public actions** allowed with `actor: nil` (`submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path. +- **No system-actor fallback:** join and confirmation run without an authenticated user. Do **not** use the system actor as a fallback for a "missing actor"; use an explicit unauthenticated context. See CODE_GUIDELINES §5.0 and `lib/mv/authorization/checks/actor_is_nil.ex`. ### 2.5 Usability and UX -- **After submit:** Communicate clearly: e.g. "We have saved your details. To complete your request, please click the link we sent to your email." (Exact copy in implementation spec.) -- Clear heading and short copy (e.g. "Become a member / Submit request" and "What happens next"). +- **After submit:** "We have saved your details. To complete your request, please click the link we sent to your email." +- Clear heading + short copy ("Become a member / Submit request", "What happens next"). - Form only as simple as needed (conversion vs. data hunger). -- Success message after confirm: neutral, no promise of an account ("We will get in touch"). -- **Expired confirmation link:** If the user clicks after the token has expired, show a clear message (e.g. "This link has expired") and instruct them to submit the form again. Specify exact copy and behaviour in the implementation spec. -- **Re-send confirmation link:** Out of scope for Prio 1. If not implemented in Prio 1, **create a separate ticket immediately**. Example UX: "Request new confirmation email" on the "Please confirm your email" or expired-link page. -- Accessibility and i18n: same standards as rest of the app (labels, errors, Gettext). +- Confirm success message: neutral, no promise of an account ("We will get in touch"). +- **Expired confirmation link:** clear message ("This link has expired") + instruction to submit the form again. Exact copy in the implementation spec. +- **Re-send confirmation link:** out of scope for Prio 1; if not implemented, **create a separate ticket immediately**. Example UX: "Request new confirmation email" on the confirm/expired page. +- Accessibility and i18n: same standards as the rest of the app (labels, errors, Gettext). ### 2.6 Admin Configurability: Join Form Settings -- **Placement:** Own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten" (club data). -- **Join form enabled:** Checkbox (e.g. `join_form_enabled`). When set, the public `/join` page is active and the following config applies. -- **Copyable join link:** When the join form is enabled, a copyable full URL to the `/join` page is shown below the checkbox (above the field list), with a short hint so admins can share it with applicants. -- **Field selection:** From **all existing** member fields (from `Mv.Constants.member_fields()`) and **custom fields**, the admin selects which fields appear on the join form. Stored as a list/set of field identifiers (no separate table); display in settings as a simple list, e.g. **badges with X to remove** (similar to the groups overview). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a **separate subtask**. -- **Technically required fields:** The only field that must always be required for the join flow is **email**. All other fields can be optional or marked as required per admin choice; implementation should support a "required" flag per selected join-form field. -- **Other:** Which entry paths are enabled, approval workflow (who can approve) – to be detailed in Step 2 and later specs. +- **Placement:** own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten". +- **Join form enabled:** checkbox (`join_form_enabled`); when set, the public `/join` page is active and the config below applies. +- **Copyable join link:** when enabled, a copyable full URL to `/join` is shown below the checkbox (above the field list), with a short hint for sharing with applicants. +- **Field selection:** from **all existing** member fields (`Mv.Constants.member_fields()`) and **custom fields**, the admin picks which appear on the join form. Stored as a list/set of field identifiers (no separate table); displayed as **badges with X to remove** (like the groups overview), added via dropdown/modal. Detailed UX to be specified in a separate subtask. +- **Technically required fields:** only **email** must always be required. All others can be optional or marked required per admin choice; support a "required" flag per selected field. +- **Other:** which entry paths are enabled, approval workflow (who can approve) — detailed in Step 2 and later specs. --- -## 3. Step 2: Vorstand Approval +## 3. Step 2: Vorstand Approval (implemented) -- **Goal:** Board (Vorstand) can review join requests (e.g. list status "submitted") and approve or reject. -- **Route:** **`/join_requests`** for the approval UI (list and detail). See §3.1 for full specification. -- **Outcome of approval (admin-configurable):** - - **Default:** Approval creates **Member only**; no User is created. An admin can link a User later if needed. - - **Optional (configurable):** If an option is set, approval may also create a **User** (e.g. invite-to-set-password). This is **open for later**; implementation concepts will be detailed when that option is implemented. -- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and **`/join_requests`** (and **`/join_requests/:id`** for detail) are added to normal_user’s allowed pages. +- **Goal:** the board can review join requests (e.g. list status "submitted") and approve or reject. +- **Route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail), defined in `MvWeb.Router` → `MvWeb.JoinRequestLive.Index` / `.Show`. Full spec in §3.1. +- **Outcome of approval:** approval creates a **Member only** (no User; an admin can link a User later). The optional "also create a User on approval" variant is **not yet implemented**. +- **Permissions:** approval uses the existing **normal_user** permission set (e.g. role "Kassenwart"). In `Mv.Authorization.PermissionSets`, normal_user has JoinRequest read + update for scope :all, and `/join_requests` and `/join_requests/:id` are in its allowed pages. -### 3.1 Step 2 – Approval (detail) +### 3.1 Step 2 – Approval (detail) — implemented in Subtask 5 -Implementation spec for Subtask 5. +**Route and pages:** -#### Route and pages +- **List `/join_requests`:** filter by status (default/primary view: `submitted`); optional view for "all" or "approved/rejected" for audit. +- **Detail `/join_requests/:id`:** two blocks — (1) **Applicant data**: all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review**: submitted_at, status, and when decided approved_at/rejected_at + reviewed by. Approve / Reject actions when status is `submitted`. -- **List:** **`/join_requests`** – list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit. -- **Detail:** **`/join_requests/:id`** – single join request. **Two blocks:** (1) **Applicant data** – all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review** – submitted_at, status, and when decided: approved_at/rejected_at, reviewed by. Actions Approve / Reject when status is `submitted`. +**Backend (`Mv.Membership.JoinRequest`) — actions (authenticated only):** -#### Backend (JoinRequest) +- **`approve`** (update, change `JoinRequest.Changes.ApproveRequest`): allowed only when status is `submitted`. Sets `approved`, `approved_at`, `reviewed_by_user_id` / `reviewed_by_display` (actor). Promotion to Member is driven by the domain function (see below), not the change. +- **`reject`** (update, change `JoinRequest.Changes.RejectRequest`): allowed only when status is `submitted`. Sets `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP. +- **Policies:** `approve` and `reject` are each permitted via **`HasPermission`**; the read policy uses **`HasJoinRequestAccess`** (a SimpleCheck) so list/detail can load data. Not allowed for `actor: nil`. +- **Domain (`Mv.Membership`):** `list_join_requests/1` (filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor). -- **New actions (authenticated only):** - - **`approve`** (update): allowed only when status is `submitted`. Sets status `approved`, `approved_at`, `reviewed_by_user_id` (actor). Triggers promotion to Member (see Promotion below). - - **`reject`** (update): allowed only when status is `submitted`. Sets status `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP. -- **Policies:** `approve` and `reject` permitted via **HasPermission** for permission set **normal_user** (read/update or explicit approve/reject on JoinRequest, scope :all). Not allowed for `actor: nil`. -- **Domain:** Expose `list_join_requests/1` (e.g. filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor). Read action for JoinRequest for normal_user scope :all so list/detail can load data. +**Promotion: JoinRequest → Member:** -#### Promotion: JoinRequest → Member +- **When:** on successful `approve` only (status was `submitted`). +- **Mapping:** typed fields **email**, **first_name**, **last_name** → Member attributes. **form_data** keys matching `Mv.Constants.member_fields()` (string form) → Member attributes; keys that are custom field IDs (UUID) → **CustomFieldValue** records linked to the new Member. +- **Defaults:** `join_date` = today. `membership_fee_type_id` is not set here; the Member `create_member` action applies the default fee type from settings (see `Mv.Membership.Member.Changes.SetDefaultMembershipFeeType`). +- **Implementation:** the domain function `Mv.Membership.approve_join_request/2` calls the private `promote_to_member/2`, which builds member attributes + custom_field_values and calls Member `create_member` with the reviewer as actor. No User created in MVP. +- **Atomicity:** the approve flow (get → update to approved → promote to Member) runs inside **`Ash.transact(JoinRequest, fn -> ... end)`**, so if Member creation fails (validation, unique constraint) the JoinRequest status rolls back. +- **Idempotency:** `ApproveRequest` only transitions from `submitted`; a repeated approve on an already-`approved` request is rejected with a status error, so no duplicate Member is created. -- **When:** On successful `approve` only (status was `submitted`). -- **Mapping:** - - JoinRequest typed fields → Member: **email**, **first_name**, **last_name** copied to Member attributes. - - **form_data** (jsonb): keys that match `Mv.Constants.member_fields()` (atom names or string keys) → corresponding Member attributes. Keys that are custom field IDs (UUID format) → create **CustomFieldValue** records linked to the new Member. - - **Defaults:** e.g. `join_date` = today if not in form_data; `membership_fee_type_id` = default from settings (or first available) if not provided. Handle required Member validations (e.g. email already present from JoinRequest). -- **Implementation:** Prefer a single Ash change (e.g. `JoinRequest.Changes.PromoteToMember`) or a domain function that builds member attributes + custom_field_values from the approved JoinRequest and calls Member `create_member` (actor: reviewer or system actor as per CODE_GUIDELINES; document choice). No User created in MVP. -- **Atomicity:** The approve flow (get JoinRequest → update to approved → promote to Member) runs inside **`Ash.transact(JoinRequest, fn -> ... end)`** so that if Member creation fails (e.g. validation, unique constraint), the JoinRequest status is rolled back and remains consistent. -- **Idempotency:** If approve is called again by mistake (e.g. race), either reject transition when status is already `approved` or ensure Member creation is idempotent (e.g. do not create duplicate Member for same JoinRequest). +**Permission sets and routing:** -#### Permission sets and routing +- **PermissionSets (`Mv.Authorization.PermissionSets`, normal_user):** JoinRequest **read** :all and **update** :all; pages `/join_requests` and `/join_requests/:id`. +- **Router (`MvWeb.Router`):** live routes `/join_requests` → `JoinRequestLive.Index` and `/join_requests/:id` → `JoinRequestLive.Show`; entries recorded in **page-permission-route-coverage.md**; plug coverage so normal_user is allowed, read_only/own_data denied. -- **PermissionSets (normal_user):** Add JoinRequest **read** :all and **update** :all (or **approve** / **reject** if using dedicated actions). Add pages **`/join_requests`** and **`/join_requests/:id`** to the normal_user pages list. -- **Router:** Register live routes for list and detail; add entries to **page-permission-route-coverage.md** and extend plug tests so normal_user is allowed, read_only/own_data denied. +**UI/UX (approval):** -#### UI/UX (approval) +- **List:** table/card list with columns e.g. submitted_at, first_name, last_name, email, status; primary/default filter status = `submitted`; links to detail. Follow existing list patterns (Members/Groups): header, back link, CoreComponents table. +- **Detail:** all request data (typed + form_data rendered by field); buttons **Approve** (primary), **Reject** (secondary); reject in MVP has no reason field. Same accessibility/i18n standards. -- **List:** Table or card list with columns e.g. submitted_at, first_name, last_name, email, status. Primary filter or default filter: status = `submitted`. Buttons or links to open detail. Follow existing list patterns (e.g. Members/Groups): header, back link, CoreComponents table. -- **Detail:** Show all request data (typed + form_data rendered by field). Buttons: **Approve** (primary), **Reject** (secondary). Reject in MVP: no reason field; just set status to rejected and audit fields. -- **Accessibility and i18n:** Same standards as rest of app (labels, Gettext, keyboard, ARIA where needed). - -#### Tests - -- JoinRequest: policy tests – approve/reject allowed for normal_user (and admin), forbidden for nil/own_data/read_only. -- Domain: approve creates one Member with correct mapped data; reject only updates status and audit fields; approve when already approved is no-op or error. -- Page permission: normal_user can GET `/join_requests` and `/join_requests/:id`; read_only/own_data cannot. -- Optional: LiveView smoke test – list loads, approve/reject from detail updates state. +**Tests:** policy tests (approve/reject allowed for normal_user and admin, forbidden for nil/own_data/read_only); domain (approve creates one Member with correct mapped data; reject only updates status + audit; approve-when-already-approved is no-op or error); page permission (normal_user can GET both routes; read_only/own_data cannot); optional LiveView smoke test. --- -## 4. Future Entry Paths (Out of Scope Here) +## 4. Future Entry Paths (Out of Scope Here, not yet implemented) -- **Invite link (tokenized):** Unique link per invitee; submission or account creation tied to token. -- **OIDC first-login (JIT):** First login via OIDC creates/links User and optionally Member from IdP data. -- Both must be design-ready so they can attach to the same approval or creation pipeline later. +- **Invite link (tokenized):** unique link per invitee; submission or account creation tied to the token. +- **OIDC first-login (JIT):** first OIDC login creates/links a User and optionally a Member from IdP data. +- Both must be design-ready so they can attach to the same approval/creation pipeline later. --- -## 5. Evaluation of the Proposed Concept Draft +## 5. Concept Evaluation — adopted decisions -**Adopted and reflected above:** +- **Naming:** resource **JoinRequest** (one resource, status + audit timestamps). +- **No User/Member from `/join`:** only a JoinRequest, created on submit (`pending_confirmation`), updated to `submitted` on confirmation. Member/User domain unchanged. +- **Public actions:** `submit` (create with `pending_confirmation` + send email) and `confirm` (update to `submitted`). +- **Public paths:** `/join` explicitly added to the plug's public path list; `/confirm_join/:token` covered by the existing `/confirm*` rule. +- **Minimal data:** email technically required; other fields from the admin-configured set, with optional "required" per field. +- **Security:** honeypot + rate limiting in MVP; email confirmation before "submitted"; token stored as hash; 24h retention + hard-delete for expired pending. -- **Naming:** Resource name **JoinRequest** (one resource, status + audit timestamps). -- **No User/Member from `/join`:** Only a JoinRequest; record is **created on form submit** (status `pending_confirmation`) and **updated to** `submitted` on confirmation. Abuse surface and policy complexity stay low. -- **Dedicated resource and actions:** New resource `JoinRequest` with public actions: **submit** (create with `pending_confirmation` + send email) and **confirm** (update to `submitted`). Member/User domain unchanged. -- **Public paths:** `/join` is **explicitly** added to the page-permission plug’s public path list; confirmation route `/confirm_join/:token` is covered by existing `/confirm*` rule. -- **Minimal data:** Email is technically required; other fields from admin-configured join-form field set, with optional "required" per field. -- **Security:** Honeypot + rate limiting in MVP; email confirmation before treating request as submitted; token stored as hash; 24h retention and hard-delete for expired pending. -- **Tests:** Unauthenticated GET `/join` → 200; submit creates one JoinRequest (`pending_confirmation`); confirm updates it to `submitted`; idempotent confirm; honeypot and rate limiting covered; public-path tests updated. - -**Refinements in this document:** - -- Approval as Step 2; User creation after approval left open for later. -- Admin configurability: join form settings as own section; detailed UX in a subtask. -- Three entry paths (public, invite, OIDC) and their place in the roadmap made explicit. -- Pre-confirmation store: DB only, one resource, 24h retention, hard-delete. -- Payload: typed email (required), first_name, last_name; rest in jsonb with schema_version; rationale and what it depends on documented. +Refinements layered in this document: approval as Step 2 (User creation after approval left open); join-form settings as their own section (detailed UX in a subtask); three entry paths placed in the roadmap; pre-confirmation store DB-only with 24h hard-delete; payload split typed (email/first_name/last_name) + jsonb with schema_version. --- @@ -191,90 +156,60 @@ Implementation spec for Subtask 5. **Decided:** -- **Email confirmation (double opt-in):** JoinRequest is **created on form submit** with status `pending_confirmation` and **updated to** `submitted` when the user clicks the confirmation link. Double opt-in is preserved (we only treat as "submitted" after the link is clicked). Existing confirmation pattern (AshAuthentication) is reused for token + email sender + route. +- **Email confirmation (double opt-in):** JoinRequest created on submit (`pending_confirmation`), updated to `submitted` on link click; treated as submitted only after the click. Reuses the existing AshAuthentication pattern (token + email sender + route). - **Naming:** **JoinRequest**. -- **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron). -- **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it. -- **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plug’s `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page. -- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User) for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`). -- **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is **left for later**. -- **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug). -- **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**. -- **Approval permission:** normal_user; JoinRequest read/update and approval page added to normal_user; no new permission set. -- **Approval route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail). -- **Resend confirmation:** If not in Prio 1, create a separate ticket immediately. +- **Pre-confirmation store:** DB only, same resource; no ETS, no stateless token. Token stored as **hash**; raw token only in the email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (Oban cron) — see `lib/mix/tasks/join_requests.cleanup_expired.ex`. +- **Confirmation route:** **`/confirm_join/:token`** so `starts_with?(path, "/confirm")` covers it. +- **Public path for `/join`:** explicitly add `/join` to the plug's `public_path?/1` (e.g. in `CheckPagePermission`). +- **JoinRequest schema:** status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for the rest. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User). Idempotent confirm (unique constraint on token hash, or update only when status is `pending_confirmation`). +- **Approval outcome:** admin-configurable; default Member only (no User); optional "create User on approval" left for later. +- **Rate limiting:** honeypot + rate limiting from the start (e.g. Hammer.Plug). +- **Settings:** own section "Onboarding / Join"; `join_form_enabled` + field selection; display as list/badges; detailed UX in a separate subtask. +- **Approval permission:** normal_user; JoinRequest read/update and the approval page added to normal_user; no new permission set. +- **Approval route:** `/join_requests` (list), `/join_requests/:id` (detail). +- **Resend confirmation:** if not in Prio 1, create a separate ticket immediately. -**Open for later:** - -- Abuse metadata (IP hash etc.): classification and whether to store in Prio 1. -- "Create User on approval" option: to be specified when implemented. -- Invite link and OIDC JIT entry paths. +**Open for later:** abuse metadata (IP hash etc.) classification and whether to store in Prio 1; "create User on approval" option (specify when implemented); invite link and OIDC JIT entry paths. --- ## 7. Definition of Done (Prio 1) -- Public `/join` page and confirmation route reachable without login; **`/join` explicitly** in public paths (plug and tests). -- Flow: form submit → **JoinRequest created** in status `pending_confirmation` → confirmation email sent → user clicks link → **JoinRequest updated** to status `submitted`; no User or Member created by this flow. +- Public `/join` page and confirmation route reachable without login; `/join` explicitly in public paths (plug + tests). +- Flow: submit → JoinRequest `pending_confirmation` → email sent → click link → JoinRequest `submitted`; no User/Member created. - Anti-abuse: honeypot and rate limiting implemented and tested. -- Cleanup: scheduled job hard-deletes JoinRequests in `pending_confirmation` older than 24h (or configured retention). -- Page-permission and routing tests updated (including public-path coverage for `/join` and `/confirm_join/:token`). -- Concept and decisions (§6) documented for use in implementation specs. +- Cleanup: scheduled job hard-deletes `pending_confirmation` JoinRequests older than 24h. +- Page-permission and routing tests updated (public-path coverage for `/join` and `/confirm_join/:token`). +- Concept and decisions (§6) documented for implementation specs. --- ## 8. Implementation Plan (Subtasks) -**Resend confirmation** remains a separate ticket (see §2.5, §6). +Resend confirmation remains a separate ticket (§2.5, §6). -### Prio 1 – Public Join (4 subtasks) +**Prio 1 – Public Join (4 subtasks, all shipped):** -#### 1. JoinRequest resource and public policies **(done)** +1. **JoinRequest resource and public policies** *(shipped)* — Ash resource per §2.3.2 (status, email required, first_name/last_name, form_data jsonb, schema_version, confirmation_token_hash + expiry, audit timestamps, source); migration; unique_index on confirmation_token_hash for idempotency. Public actions `submit` (create) and `confirm` (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`. +2. **Submit and confirm flow** *(shipped)* — submit creates JoinRequest + sends confirmation email (reuse AshAuthentication sender); `/confirm_join/:token` verifies token (hash + lookup), updates to `submitted`, sets submitted_at, invalidates token (idempotent if already submitted); Oban hard-delete job for expired `pending_confirmation`. +3. **Admin: Join form settings** *(shipped)* — "Onboarding / Join" settings section (§2.6): `join_form_enabled`, field selection (member_fields + custom fields), "required" per field; persisted; **server-side allowlist** available to subtask 4. +4. **Public join page and anti-abuse** *(shipped)* — public `/join` route added to the plug's public path list; LiveView with fields from the allowlist; copy per §2.5; honeypot + rate limiting (Hammer.Plug); after-submit and expired-link copy; public-path tests updated to include `/join`. -- **Scope:** Ash resource `JoinRequest` per §2.3.2: status (`pending_confirmation`, `submitted`, `approved`, `rejected`), email (required), first_name, last_name (optional), form_data (jsonb), schema_version; confirmation_token_hash, confirmation_token_expires_at; submitted_at, approved_at, rejected_at, reviewed_by_user_id, source. Migration; unique_index on confirmation_token_hash (or equivalent for idempotency). -- **Policies:** Public actions **submit** (create) and **confirm** (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`. -- **Boundary:** No UI, no emails – only resource, persistence, and actions callable with nil actor. -- **Done:** Resource and migration in place; tests for create/update with `actor: nil` and for idempotent confirm (same token twice → no second update). +**Order and dependencies:** 1 → 2 (flow uses the resource); 3 before/parallel with 4 (form reads the allowlist from settings; MVP subtask 4 can use a default allowlist with 3 following shortly). Recommended: 1 → 2 → 3 → 4. -#### 2. Submit and confirm flow **(done)** +**Step 2 – Approval (1 subtask, shipped):** -- **Scope:** Form submit → **create** JoinRequest (status `pending_confirmation`, token hash + expiry, form data) → send confirmation email (reuse AshAuthentication sender pattern). Route **`/confirm_join/:token`** → verify token (hash and lookup) → **update** JoinRequest to status `submitted`, set submitted_at, invalidate token (idempotent if already submitted). Optional: Oban (or similar) job to **hard-delete** JoinRequests in `pending_confirmation` with confirmation_token_expires_at older than 24h. -- **Boundary:** No join-form UI, no admin settings – only backend create/update and email/route. -- **Done:** Submit creates one JoinRequest; confirm updates it to submitted; double-click idempotent; expired token shows clear message; cleanup job implemented and documented. Tests for these cases. - -#### 3. Admin: Join form settings **(done)** - -- **Scope:** Section "Onboarding / Join" in global settings (§2.6): `join_form_enabled`, selection of join-form fields (from member_fields + custom fields), "required" per field. Persist (e.g. Setting or existing config). UI e.g. badges with remove + dropdown/modal to add (details in sub-subtask if needed). -- **Boundary:** No public form – only save/load of config and **server-side allowlist** for use in subtask 4. -- **Done:** Settings save/load; allowlist available in backend for join form; tests. - -#### 4. Public join page and anti-abuse **(done)** - -- **Scope:** Route **`/join`** (public). **Add `/join` to the page-permission plug’s public path list** so unauthenticated access is allowed. LiveView (or controller + form). Form fields from allowlist (subtask 3); copy per §2.5. **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show "We have saved your details … click the link …". Expired-link page: clear message + "submit form again". Public-path tests updated to include `/join`. -- **Boundary:** No approval UI, no User/Member creation – only public page, form, anti-abuse, and wiring to submit/confirm flow (subtask 2). -- **Done:** Unauthenticated GET `/join` → 200; submit creates JoinRequest (pending_confirmation) and sends email; confirm updates to submitted; honeypot and rate limit tested; public-path tests updated. - -### Order and dependencies - -- **1 → 2:** Submit/confirm flow uses JoinRequest resource. -- **3 before or in parallel with 4:** Form reads allowlist from settings; for MVP, subtask 4 can use a default allowlist and 3 can follow shortly after. -- **Recommended order:** **1** → **2** → **3** → **4** (or 3 in parallel with 2 if two people work on it). - -### Step 2 – Approval (1 subtask, later) - -#### 5. Approval UI (Vorstand) - -- **Route:** **`/join_requests`** (list), **`/join_requests/:id`** (detail). Full specification: §3.1. -- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add JoinRequest read/update (or approve/reject) and pages `/join_requests`, `/join_requests/:id` to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id). Promotion: JoinRequest → Member per §3.1 (mapping, defaults, idempotency). -- **Boundary:** Separate ticket; builds on JoinRequest and existing Member creation. +5. **Approval UI (Vorstand)** *(shipped)* — routes `/join_requests` (list) → `JoinRequestLive.Index`, `/join_requests/:id` (detail) → `JoinRequestLive.Show`; full spec in §3.1. Lists submitted JoinRequests, approve/reject; on approve creates a Member (no User in MVP). Permission: normal_user has JoinRequest read/update and the two pages in PermissionSets; audit fields populated; promotion JoinRequest → Member via `Mv.Membership.approve_join_request/2` per §3.1. --- ## 9. References -- `docs/roles-and-permissions-architecture.md` – Permission sets, roles, page permissions. -- `docs/page-permission-route-coverage.md` – Public paths, plug behaviour, tests; add `/join_requests` and `/join_requests/:id` for Step 2 (normal_user). -- `lib/mv_web/plugs/check_page_permission.ex` – Public path list; **add `/join`** in `public_path?/1`. -- `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` – Existing confirmation-email pattern (token, link, Mailer). -- Hammer / Hammer.Plug (e.g. hexdocs.pm/hammer) – Rate limiting for Phoenix/Plug. -- Issue #308 – Original feature/planning context. +- `docs/roles-and-permissions-architecture.md` — permission sets, roles, page permissions. +- `docs/page-permission-route-coverage.md` — public paths, plug behaviour, tests; covers `/join_requests` and `/join_requests/:id` for Step 2 (normal_user). +- `lib/mv_web/plugs/check_page_permission.ex` — public path list; add `/join` in `public_path?/1`. +- `lib/mv/authorization/checks/actor_is_nil.ex` — the actor:nil public-action check. +- `lib/mix/tasks/join_requests.cleanup_expired.ex` — hard-delete of expired `pending_confirmation` JoinRequests (24h retention). +- `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` — existing confirmation-email pattern (token, link, Mailer). +- Hammer / Hammer.Plug (hexdocs.pm/hammer) — rate limiting for Phoenix/Plug. +- Issue #308 — original feature/planning context. From 0b36a43edc7a0568f8dc4a1467de680d6276f8b0 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 21:53:36 +0200 Subject: [PATCH 18/50] docs(db): refresh, condense and align database and groups docs --- docs/custom-fields-search-performance.md | 244 +---- docs/database-schema-readme.md | 523 ++------- docs/database_schema.dbml | 214 +++- docs/groups-architecture.md | 1254 ++-------------------- 4 files changed, 360 insertions(+), 1875 deletions(-) diff --git a/docs/custom-fields-search-performance.md b/docs/custom-fields-search-performance.md index 3987c85..47de308 100644 --- a/docs/custom-fields-search-performance.md +++ b/docs/custom-fields-search-performance.md @@ -2,242 +2,88 @@ ## Current Implementation -The search vector includes custom field values via database triggers that: -1. Aggregate all custom field values for a member -2. Extract values from JSONB format -3. Add them to the search_vector with weight 'C' +The member `search_vector` includes custom field values via database triggers that aggregate all of a member's custom field values, extract the value from each JSONB record (`value->>'_union_value'`), and add them at weight `C`. -## Performance Considerations +Two triggers maintain the vector: -### 1. Trigger Performance on Member Updates +- `members_search_vector_trigger()` — fires on `members` INSERT/UPDATE; runs a subquery `SELECT string_agg(...) FROM custom_field_values WHERE member_id = NEW.id`. +- `update_member_search_vector_from_custom_field_value()` — fires on `custom_field_values` INSERT/UPDATE/DELETE; re-aggregates and updates the member's `search_vector`. -**Current Implementation:** -- `members_search_vector_trigger()` executes a subquery on every INSERT/UPDATE: - ```sql - SELECT string_agg(...) FROM custom_field_values WHERE member_id = NEW.id - ``` +Both rely on `custom_field_values_member_id_idx`, so the per-member aggregation is an indexed lookup. -**Performance Impact:** -- ✅ **Good:** Index on `member_id` exists (`custom_field_values_member_id_idx`) -- ✅ **Good:** Subquery only runs for the affected member -- ⚠️ **Potential Issue:** With many custom fields per member (e.g., 50+), aggregation could be slower -- ⚠️ **Potential Issue:** JSONB extraction (`value->>'_union_value'`) is relatively fast but adds overhead +## Applied Trigger Optimizations -**Expected Performance:** -- **Small scale (< 10 custom fields per member):** Negligible impact (< 5ms per operation) -- **Medium scale (10-30 custom fields):** Minor impact (5-20ms per operation) -- **Large scale (30+ custom fields):** Noticeable impact (20-50ms+ per operation) +`update_member_search_vector_from_custom_field_value()` was optimized: -### 2. Trigger Performance on Custom Field Value Changes +- **Fetch only required member fields** (first_name, last_name, email, etc.) instead of the full record — reduces per-call overhead by roughly 30–50%. +- **Early return on UPDATE when the value is unchanged** — skips the expensive re-aggregation entirely. -**Current Implementation:** -- `update_member_search_vector_from_custom_field_value()` executes on every INSERT/UPDATE/DELETE on `custom_field_values` -- **Optimized:** Only fetches required member fields (not full record) to reduce overhead -- **Optimized:** Skips re-aggregation on UPDATE if value hasn't actually changed -- Aggregates all custom field values, then updates member search_vector +Measured effect per custom-field-value change: -**Performance Impact:** -- ✅ **Good:** Index on `member_id` ensures fast lookup -- ✅ **Optimized:** Only required fields are fetched (first_name, last_name, email, etc.) instead of full record -- ✅ **Optimized:** UPDATE operations that don't change the value skip expensive re-aggregation (early return) -- ⚠️ **Note:** Re-aggregation is still necessary when values change (required for search_vector consistency) -- ⚠️ **Critical:** Bulk operations (e.g., importing 1000 members with custom fields) will trigger this for each row +| Case | Before | After | +|------|--------|-------| +| Value changed | 5–15 ms | 3–10 ms | +| Value unchanged (UPDATE) | 5–15 ms | < 1 ms (early return) | -**Expected Performance:** -- **Single operation (value changed):** 3-10ms per custom field value change (improved from 5-15ms) -- **Single operation (value unchanged):** <1ms (early return, no aggregation) -- **Bulk operations:** Could be slow (consider disabling trigger temporarily) +Re-aggregation is still required whenever a value actually changes — that is necessary for `search_vector` consistency. -### 3. Search Vector Size +## Search Vector Size -**Current Constraints:** -- String values: max 10,000 characters per custom field -- No limit on number of custom fields per member -- tsvector has no explicit size limit, but very large vectors can cause issues +- String custom field values are capped at **10,000 characters each**; there is no cap on the number of custom fields per member. +- `tsvector` has no hard size limit, but very large vectors (> ~100 KB) degrade GIN index maintenance, tsvector operations, and trigger time. Worst case: 100 fields × 10,000 chars ≈ 1 MB of aggregated text for one member. +- **Recommendation:** monitor `search_vector` size in production; consider capping total custom-field content per member if large vectors appear. -**Potential Issues:** -- **Theoretical maximum:** If a member has 100 custom fields with 10,000 char strings each, the aggregated text could be ~1MB -- **Practical concern:** Very large search vectors (> 100KB) can slow down: - - Index updates (GIN index maintenance) - - Search queries (tsvector operations) - - Trigger execution time +## Bulk Imports -**Recommendation:** -- Monitor search_vector size in production -- Consider limiting total custom field content per member if needed -- PostgreSQL can handle large tsvectors, but performance degrades gradually +The custom-field-value trigger fires once per row, so importing many members with custom fields is expensive. For bulk imports, **temporarily disable the `custom_field_values` trigger**, then re-aggregate `search_vector` in a batch after the import. The initial backfill migration also updates all members in a single transaction (table lock); for > 10,000 members, batch the backfill and run during a maintenance window. -### 4. Initial Migration Performance +## Search Query Structure -**Current Implementation:** -- Updates ALL members in a single transaction: - ```sql - UPDATE members m SET search_vector = ... (subquery for each member) - ``` +Full-text search uses the GIN index on `search_vector` (fast). Substring/custom-field matching adds `EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND ... LIKE ...)` subqueries, which are **not indexed** on the JSONB value (sequential scan) and run even when the FTS branch already matches. This is the main known weakness; it is acceptable at the current scale (< 30 custom fields/member, < 10,000 members) but is the first thing to revisit if search slows. -**Performance Impact:** -- ⚠️ **Potential Issue:** With 10,000+ members, this could take minutes -- ⚠️ **Potential Issue:** Single transaction locks the members table -- ⚠️ **Potential Issue:** If migration fails, entire rollback required +## Search Filter Functions -**Recommendation:** -- For large datasets (> 10,000 members), consider: - - Batch updates (e.g., 1000 members at a time) - - Run during maintenance window - - Monitor progress +The search query in `lib/membership/member.ex` is split into modular filter builders, combined as a single OR-chain in priority order: -### 5. Search Query Performance +1. `build_fts_filter/1` — full-text search (highest priority, GIN-indexed, fastest). +2. `build_substring_filter/2` — `ILIKE` substring matching on structured fields (postal_code, house_number, email, city, country). +3. `build_custom_field_filter/1` — JSONB custom-field value matching via `EXISTS` subquery. +4. `build_fuzzy_filter/2` — trigram fuzzy matching on first_name, last_name, street (pg_trgm). -**Current Implementation:** -- Full-text search uses GIN index on `search_vector` (fast) -- Additional LIKE queries on `custom_field_values` for substring matching: - ```sql - EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND ... LIKE ...) - ``` - -**Performance Impact:** -- ✅ **Good:** GIN index on `search_vector` is very fast -- ⚠️ **Potential Issue:** LIKE queries on JSONB are not indexed (sequential scan) -- ⚠️ **Potential Issue:** EXISTS subquery runs for every search, even if search_vector match is found -- ⚠️ **Potential Issue:** With many custom fields, the LIKE queries could be slow - -**Expected Performance:** -- **With GIN index match:** Very fast (< 10ms for typical queries) -- **Without GIN index match (fallback to LIKE):** Slower (10-100ms depending on data size) -- **Worst case:** Sequential scan of all custom_field_values for all members - -## Recommendations - -### Short-term (Current Implementation) - -1. **Monitor Performance:** - - Add logging for trigger execution time - - Monitor search_vector size distribution - - Track search query performance - -2. **Index Verification:** - - Ensure `custom_field_values_member_id_idx` exists and is used - - Verify GIN index on `search_vector` is maintained - -3. **Bulk Operations:** - - For bulk imports, consider temporarily disabling the custom_field_values trigger - - Re-enable and update search_vectors in batch after import - -### Medium-term Optimizations - -1. **✅ Optimize Trigger Function (FULLY IMPLEMENTED):** - - ✅ Only fetch required member fields instead of full record (reduces overhead) - - ✅ Skip re-aggregation on UPDATE if value hasn't actually changed (early return optimization) - -2. **Limit Search Vector Size:** - - Truncate very long custom field values (e.g., first 1000 chars) - - Add warning if aggregated text exceeds threshold - -3. **Optimize LIKE Queries:** - - Consider adding a generated column for searchable text - - Or use a materialized view for custom field search - -### Long-term Considerations - -1. **Alternative Approaches:** - - Separate search index table for custom fields - - Use Elasticsearch or similar for advanced search - - Materialized view for search optimization - -2. **Scaling Strategy:** - - If performance becomes an issue with 100+ custom fields per member: - - Consider limiting which custom fields are searchable - - Use a separate search service - - Implement search result caching - -## Performance Benchmarks (Estimated) - -Based on typical PostgreSQL performance: - -| Scenario | Members | Custom Fields/Member | Expected Impact | -|----------|---------|---------------------|-----------------| -| Small | < 1,000 | < 10 | Negligible (< 5ms per operation) | -| Medium | 1,000-10,000 | 10-30 | Minor (5-20ms per operation) | -| Large | 10,000-100,000 | 30-50 | Noticeable (20-50ms per operation) | -| Very Large | > 100,000 | 50+ | Significant (50-200ms+ per operation) | +Priority: **FTS > Substring > Custom Fields > Fuzzy**. ## Monitoring Queries ```sql --- Check search_vector size distribution -SELECT - pg_size_pretty(octet_length(search_vector::text)) as size, - COUNT(*) as member_count +-- search_vector size distribution +SELECT + pg_size_pretty(octet_length(search_vector::text)) AS size, + COUNT(*) AS member_count FROM members WHERE search_vector IS NOT NULL GROUP BY octet_length(search_vector::text) ORDER BY octet_length(search_vector::text) DESC LIMIT 20; --- Check average custom fields per member -SELECT - AVG(custom_field_count) as avg_custom_fields, - MAX(custom_field_count) as max_custom_fields +-- average / max custom fields per member +SELECT + AVG(custom_field_count) AS avg_custom_fields, + MAX(custom_field_count) AS max_custom_fields FROM ( - SELECT member_id, COUNT(*) as custom_field_count + SELECT member_id, COUNT(*) AS custom_field_count FROM custom_field_values GROUP BY member_id ) subq; --- Check trigger execution time (requires pg_stat_statements) -SELECT - mean_exec_time, - calls, - query +-- trigger execution time (requires pg_stat_statements) +SELECT mean_exec_time, calls, query FROM pg_stat_statements WHERE query LIKE '%members_search_vector_trigger%' ORDER BY mean_exec_time DESC; ``` -## Code Quality Improvements (Post-Review) - -### Refactored Search Implementation - -The search query has been refactored for better maintainability and clarity: - -**Before:** Single large OR-chain with mixed search types (hard to maintain) - -**After:** Modular functions grouped by search type: -- `build_fts_filter/1` - Full-text search (highest priority, fastest) -- `build_substring_filter/2` - Substring matching on structured fields -- `build_custom_field_filter/1` - Custom field value search (JSONB LIKE) -- `build_fuzzy_filter/2` - Trigram/fuzzy matching for names and streets - -**Benefits:** -- ✅ Clear separation of concerns -- ✅ Easier to maintain and test -- ✅ Better documentation of search priority -- ✅ Easier to optimize individual search types - -**Search Priority Order:** -1. **FTS (Full-Text Search)** - Fastest, uses GIN index on search_vector -2. **Substring** - For structured fields (postal_code, phone_number, etc.) -3. **Custom Fields** - JSONB LIKE queries (fallback for substring matching) -4. **Fuzzy Matching** - Trigram similarity for names and streets - -## Conclusion - -The current implementation is **well-optimized for typical use cases** (< 30 custom fields per member, < 10,000 members). For larger scales, monitoring and potential optimizations may be needed. - -**Key Strengths:** -- Indexed lookups (member_id index) -- Efficient GIN index for search -- Trigger-based automatic updates -- Modular, maintainable search code structure - -**Key Weaknesses:** -- LIKE queries on JSONB (not indexed) -- Re-aggregation on every custom field change (necessary for consistency) -- Potential size issues with many/large custom fields -- Substring searches (contains/ILIKE) not index-optimized - -**Recent Optimizations:** -- ✅ Trigger function optimized to fetch only required fields (reduces overhead by ~30-50%) -- ✅ Early return on UPDATE when value hasn't changed (skips expensive re-aggregation, <1ms vs 3-10ms) -- ✅ Improved performance for custom field value updates (3-10ms vs 5-15ms when value changes) +## Future Options (if scale demands) +- Generated/searchable text column or materialized view for custom-field substring search (to escape the unindexed JSONB `LIKE`). +- Limit which custom fields are searchable, or truncate long values. +- External search service (e.g., Elasticsearch) for advanced search. diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index fa6ea55..a7bfb1a 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -4,105 +4,54 @@ This document provides a comprehensive overview of the Mila Membership Management System database schema. -## Quick Links +- **DBML file:** [`database_schema.dbml`](./database_schema.dbml) — full per-column intent notes and relationship edges. +- **Search-vector performance:** see [`custom-fields-search-performance.md`](./custom-fields-search-performance.md) for trigger cost analysis and tuning. -- **DBML File:** [`database_schema.dbml`](./database_schema.dbml) -- **Visualize Online:** - - [dbdiagram.io](https://dbdiagram.io) - Upload the DBML file - - [dbdocs.io](https://dbdocs.io) - Generate interactive documentation +The DBML is **hand-maintained** (not auto-generated); keep it in sync with `priv/repo/migrations/`. ## Schema Statistics | Metric | Count | |--------|-------| -| **Tables** | 11 | +| **Tables** | 12 | | **Domains** | 4 (Accounts, Membership, MembershipFees, Authorization) | -| **Relationships** | 9 | -| **Indexes** | 25+ | -| **Triggers** | 1 (Full-text search) | +| **Triggers** | 3 (member, custom_field_values, member_groups → member search-vector) | ## Tables Overview ### Accounts Domain +- **`users`** — authentication accounts. Dual auth (Password + OIDC), optional 1:1 link to a member; email is the source of truth when linked. +- **`tokens`** — JWT storage for AshAuthentication; multiple purposes, revocation by deletion. -#### `users` -- **Purpose:** User authentication and session management -- **Rows (Estimated):** Low to Medium (typically 10-50% of members) -- **Key Features:** - - Dual authentication (Password + OIDC) - - Optional 1:1 link to members - - Email as source of truth when linked - -#### `tokens` -- **Purpose:** JWT token storage for AshAuthentication -- **Rows (Estimated):** Medium to High (multiple tokens per user) -- **Key Features:** - - Token lifecycle management - - Revocation support - - Multiple token purposes +OIDC account linking is recorded on the `users` table via the `oidc_id` column; there is no separate `user_identities` table. ### Membership Domain - -#### `members` -- **Purpose:** Club member master data -- **Rows (Estimated):** High (core entity) -- **Key Features:** - - Complete member profile - - Full-text search via tsvector - - Bidirectional email sync with users - - Flexible address and contact data - -#### `custom_field_values` -- **Purpose:** Dynamic custom member attributes -- **Rows (Estimated):** Variable (N per member) -- **Key Features:** - - Union type value storage (JSONB) - - Multiple data types supported - - One custom field value per custom field per member - -#### `custom_fields` -- **Purpose:** Schema definitions for custom_field_values -- **Rows (Estimated):** Low (admin-defined) -- **Key Features:** - - Type definitions - - Immutable and required flags - - Centralized custom field management - -#### `settings` -- **Purpose:** Global application settings (singleton resource) -- **Rows (Estimated):** 1 (singleton pattern) -- **Key Features:** - - Club name configuration - - Member field visibility settings - - Membership fee default settings - - Environment variable support for club name - -#### `groups` -- **Purpose:** Group definitions for organizing members -- **Rows (Estimated):** Low (typically 5-20 groups per club) -- **Key Features:** - - Unique group names (case-insensitive) - - URL-friendly slugs (auto-generated, immutable) - - Optional descriptions - - Many-to-many relationship with members - -#### `member_groups` -- **Purpose:** Join table for many-to-many relationship between members and groups -- **Rows (Estimated):** Medium to High (multiple groups per member) -- **Key Features:** - - Unique constraint on (member_id, group_id) - - CASCADE delete on both sides - - Efficient indexes for queries +- **`members`** — club member master data. Full-text + fuzzy search, bidirectional email sync with users, flexible address/contact data, `country`, optional `vereinfacht_contact_id` (external vereinfacht.de contact). +- **`custom_field_values`** — dynamic per-member attributes. Union-type value in JSONB; one value per custom field per member. +- **`custom_fields`** — schema definitions for custom field values (type, `required`/`show_in_overview` flags, optional `join_description`, auto-generated slug). +- **`settings`** — global application settings (singleton). Club name (also via `ASSOCIATION_NAME` env), member-field visibility/required maps, fee defaults, plus OIDC, SMTP/mail-from, vereinfacht.de, public join-form, `registration_enabled`, and `oidc_only` configuration. See [Settings configuration columns](#settings-configuration-columns). +- **`groups`** — member groupings. Case-insensitive-unique names, auto-generated immutable slugs, optional descriptions; many-to-many with members. +- **`member_groups`** — join table for members ↔ groups. Unique `(member_id, group_id)`, CASCADE delete on both sides (join table only). +- **`join_requests`** — public join flow (onboarding, double opt-in). Status machine `pending_confirmation → submitted → approved/rejected`; confirmation token stored as hash only, ~24h retention for unconfirmed records. ### Authorization Domain +- **`roles`** — RBAC. Links users to one of four hardcoded permission sets (`own_data`, `read_only`, `normal_user`, `admin`); system roles are deletion-protected. -#### `roles` -- **Purpose:** Role-based access control (RBAC) -- **Rows (Estimated):** Low (typically 3-10 roles) -- **Key Features:** - - Links users to permission sets - - System role protection - - Four hardcoded permission sets: own_data, read_only, normal_user, admin +### MembershipFees Domain +- **`membership_fee_types`** — fee types with immutable billing interval. +- **`membership_fee_cycles`** — per-member billing cycles with payment status. + +## Settings configuration columns + +The singleton `settings` row carries runtime configuration (all nullable unless noted). Grouped by area: + +- **Member overview:** `member_field_visibility` (JSONB; absent key = visible), `member_field_required` (JSONB). +- **Membership fees:** `include_joining_cycle` (bool, NOT NULL, default true), `default_membership_fee_type_id` (FK → membership_fee_types, ON DELETE SET NULL). +- **Registration / login:** `registration_enabled` (bool, NOT NULL, default true), `oidc_only` (bool, NOT NULL, default false). +- **OIDC:** `oidc_client_id`, `oidc_client_secret`, `oidc_base_url`, `oidc_redirect_uri`, `oidc_admin_group_name`, `oidc_groups_claim`. +- **SMTP / mail-from:** `smtp_host`, `smtp_port` (bigint), `smtp_username`, `smtp_password`, `smtp_ssl`, `smtp_from_name`, `smtp_from_email`. +- **vereinfacht.de:** `vereinfacht_api_url`, `vereinfacht_api_key`, `vereinfacht_club_id`, `vereinfacht_app_url`. +- **Public join form:** `join_form_enabled` (bool, NOT NULL, default false), `join_form_field_ids` (text[]), `join_form_field_required` (JSONB). ## Key Relationships @@ -124,123 +73,54 @@ Member (N) ←→ (N) Group Settings (1) → MembershipFeeType (0..1) ``` -### Relationship Details +## Foreign Key On-Delete Behavior -1. **User ↔ Member (Optional 1:1, both sides optional)** - - A User can have 0 or 1 Member (`user.member_id` can be NULL) - - A Member can have 0 or 1 User (optional `has_one` relationship) - - Both entities can exist independently - - Email synchronization when linked (User.email is source of truth) - - `ON DELETE SET NULL` on user side (User preserved when Member deleted) +| Relationship | On Delete | Rationale | +|--------------|-----------|-----------| +| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted | +| `users.role_id → roles.id` | RESTRICT | Cannot delete a role that still has users | +| `custom_field_values.member_id → members.id` | CASCADE | Delete values with member | +| `custom_field_values.custom_field_id → custom_fields.id` | CASCADE | Delete values when the custom field is deleted | +| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Cannot delete a fee type assigned to members | +| `membership_fee_cycles.member_id → members.id` | CASCADE | Cycles deleted with member | +| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Cannot delete a fee type with cycles | +| `settings.default_membership_fee_type_id → membership_fee_types.id` | SET NULL | Clear default if fee type deleted | +| `member_groups.member_id → members.id` | CASCADE | Association removed; member preserved | +| `member_groups.group_id → groups.id` | CASCADE | Association removed; group preserved | -2. **User → Role (N:1)** - - Many users can be assigned to one role - - `ON DELETE RESTRICT` - cannot delete role if users are assigned - - Role links user to permission set for authorization +`join_requests.reviewed_by_user_id` is intentionally **unconstrained** (no FK); `reviewed_by_display` is denormalized so the UI need not load the reviewer User. -3. **Member → CustomFieldValues (1:N)** - - One member, many custom_field_values - - `ON DELETE CASCADE` - custom_field_values deleted with member - - Composite unique constraint (member_id, custom_field_id) - -4. **CustomFieldValue → CustomField (N:1)** - - Custom field values reference type definition - - `ON DELETE RESTRICT` - cannot delete type if in use - - Type defines data structure - -5. **Member → MembershipFeeType (N:1, optional)** - - Many members can be assigned to one fee type - - `ON DELETE RESTRICT` - cannot delete fee type if members are assigned - - Optional relationship (member can have no fee type) - -6. **Member → MembershipFeeCycles (1:N)** - - One member, many billing cycles - - `ON DELETE CASCADE` - cycles deleted when member deleted - - Unique constraint (member_id, cycle_start) - -7. **MembershipFeeCycle → MembershipFeeType (N:1)** - - Many cycles reference one fee type - - `ON DELETE RESTRICT` - cannot delete fee type if cycles exist - -8. **Settings → MembershipFeeType (N:1, optional)** - - Settings can reference a default fee type - - `ON DELETE SET NULL` - if fee type is deleted, setting is cleared - -9. **Member ↔ Group (N:N via MemberGroup)** - - Many-to-many relationship through `member_groups` join table - - `ON DELETE CASCADE` on both sides - removing member/group removes associations - - Unique constraint on (member_id, group_id) prevents duplicates - - Groups searchable via member search vector +**User ↔ Member** is an optional 1:1 (both sides may be NULL; entities exist independently). **Member ↔ Group** is many-to-many through `member_groups` (CASCADE lives only on the join table). ## Important Business Rules ### Email Synchronization -- **User.email** is the source of truth when linked -- On linking: Member.email ← User.email (overwrite) -- After linking: Changes sync bidirectionally -- Validation prevents email conflicts +- **User.email is the source of truth when linked.** On linking, `Member.email ← User.email` (overwrite). Afterwards changes sync bidirectionally. Validation prevents email conflicts with other unlinked users. ### Authentication Strategies -- **Password:** Email + hashed_password -- **OIDC:** Email + oidc_id (Rauthy provider) -- At least one method required per user +- **Password:** email + hashed_password. **OIDC:** email + oidc_id (Rauthy provider), the external identity recorded via the `oidc_id` column on `users`. At least one method required per user. ### Member Constraints -- First name and last name required (min 1 char) -- Email unique, validated format (5-254 chars) -- Exit date must be after join date -- Phone: `+?[0-9\- ]{6,20}` -- Postal code: optional (no format validation) -- Country: optional +- `first_name` / `last_name`: optional, but if present min 1 char. +- `email`: unique, validated format (5–254 chars). +- `exit_date` must be after `join_date`. +- `postal_code`, `country`: optional, no format validation. ### CustomFieldValue System -- Maximum one custom field value per custom field per member -- Value stored as union type in JSONB -- Supported types: string, integer, boolean, date, email -- Types can be marked as immutable or required - -## Indexes - -### Performance Indexes - -**members:** -- `search_vector` (GIN) - Full-text search (tsvector) -- `first_name` (GIN trgm) - Fuzzy search on first name -- `last_name` (GIN trgm) - Fuzzy search on last name -- `email` (GIN trgm) - Fuzzy search on email -- `city` (GIN trgm) - Fuzzy search on city -- `street` (GIN trgm) - Fuzzy search on street -- `notes` (GIN trgm) - Fuzzy search on notes -- `email` (B-tree) - Exact email lookups -- `last_name` (B-tree) - Name sorting -- `join_date` (B-tree) - Date filtering - -**custom_field_values:** -- `member_id` - Member custom field value lookups -- `custom_field_id` - Type-based queries -- Composite `(member_id, custom_field_id)` - Uniqueness - -**tokens:** -- `subject` - User token lookups -- `expires_at` - Token cleanup -- `purpose` - Purpose-based queries - -**users:** -- `email` (unique) - Login lookups -- `oidc_id` (unique) - OIDC authentication -- `member_id` (unique) - Member linkage +- One value per custom field per member. Value stored as a union type in JSONB: `{type: "string|integer|boolean|date|email", value: }`. Custom fields can be marked `required` and toggled `show_in_overview`. ## Full-Text Search ### Implementation -- **Trigger** on `members` (INSERT/UPDATE): runs function `members_search_vector_trigger()` +- **Trigger** on `members` (INSERT/UPDATE): `update_search_vector` runs function `members_search_vector_trigger()` +- **Trigger** on `custom_field_values` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_custom_field_value_change` runs function `update_member_search_vector_from_custom_field_value()` - **Trigger** on `member_groups` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_member_groups_change` runs function `update_member_search_vector_from_member_groups()` - **Index Type:** GIN (Generalized Inverted Index) ### Weighted Fields - **Weight A (highest):** first_name, last_name - **Weight B:** email, notes, group names (from member_groups → groups) -- **Weight C:** city, street, house_number, postal_code, country, custom_field_values +- **Weight C:** city, street, house_number, postal_code, custom_field_values - **Weight D (lowest):** join_date, exit_date ### Group Names in Search @@ -258,291 +138,46 @@ Custom field values are automatically included in the search vector: ### Usage Example ```sql -SELECT * FROM members +SELECT * FROM members WHERE search_vector @@ to_tsquery('simple', 'john & doe'); ``` ## Fuzzy Search (Trigram-based) -### Implementation -- **Extension:** `pg_trgm` (PostgreSQL Trigram) -- **Index Type:** GIN with `gin_trgm_ops` operator class -- **Similarity Threshold:** 0.2 (default, configurable) -- **Added:** November 2025 (PR #187, closes #162) +- **Extension:** `pg_trgm`; GIN indexes with `gin_trgm_ops` on `first_name`, `last_name`, `email`, `city`, `street`, `notes`. +- **Similarity threshold:** 0.2 (default, configurable) — balances precision/recall. +- **Added:** November 2025 (PR #187, closes #162). -### How It Works -Fuzzy search combines multiple search strategies: -1. **Full-text search** - Primary filter using tsvector -2. **Trigram similarity** - `similarity(field, query) > threshold` -3. **Word similarity** - `word_similarity(query, field) > threshold` -4. **Substring matching** - `LIKE` and `ILIKE` for exact substrings -5. **Modulo operator** - `query % field` for quick similarity check +Fuzzy search combines several strategies (applied as an OR-chain alongside full-text and substring matching): -### Indexed Fields for Fuzzy Search -- `first_name` - GIN trigram index -- `last_name` - GIN trigram index -- `email` - GIN trigram index -- `city` - GIN trigram index -- `street` - GIN trigram index -- `notes` - GIN trigram index +1. Full-text search — primary filter via tsvector. +2. Trigram similarity — `similarity(field, query) > threshold`. +3. Word similarity — `word_similarity(query, field) > threshold`. +4. Substring matching — `LIKE` / `ILIKE`. +5. `%` operator — quick trigram-similarity check. -### Usage Example (Ash Action) -```elixir -# In LiveView or context -Member.fuzzy_search(Member, query: "john", similarity_threshold: 0.2) - -# Or using Ash Query directly -Member -|> Ash.Query.for_read(:search, %{query: "john", similarity_threshold: 0.2}) -|> Mv.Membership.read!() -``` - -### Usage Example (SQL) -```sql --- Trigram similarity search -SELECT * FROM members -WHERE similarity(first_name, 'john') > 0.2 - OR similarity(last_name, 'doe') > 0.2 -ORDER BY similarity(first_name, 'john') DESC; - --- Word similarity (better for partial matches) -SELECT * FROM members -WHERE word_similarity('john', first_name) > 0.2; - --- Quick similarity check with % operator -SELECT * FROM members -WHERE 'john' % first_name; -``` - -### Performance Considerations -- **GIN indexes** speed up trigram operations significantly -- **Similarity threshold** of 0.2 balances precision and recall -- **Combined approach** (FTS + trigram) provides best results -- Lower threshold = more results but less specific +For the Elixir search action and per-strategy filter functions, see `lib/membership/member.ex` and [`custom-fields-search-performance.md`](./custom-fields-search-performance.md). ## Database Extensions -### Required PostgreSQL Extensions +Installed extensions are defined in `Mv.Repo.installed_extensions/0`: -1. **uuid-ossp** - - Purpose: UUID generation functions - - Used for: `gen_random_uuid()`, `uuid_generate_v7()` +| Extension | Purpose | Notes | +|-----------|---------|-------| +| `ash-functions` | Ash helper SQL functions | installed by Ash | +| `citext` | Case-insensitive text | `users.email` | +| `pg_trgm` | Trigram fuzzy search | added in `20251001141005_add_trigram_to_members.exs`; operators `%`, `similarity()`, `word_similarity()` | -2. **citext** - - Purpose: Case-insensitive text type - - Used for: `users.email` (case-insensitive email matching) +`gen_random_uuid()` is built into PostgreSQL; `uuid_generate_v7()` is a custom SQL function defined in a migration (not provided by an extension). -3. **pg_trgm** - - Purpose: Trigram-based fuzzy text search and similarity matching - - Used for: Fuzzy member search with similarity scoring - - Operators: `%` (similarity), `word_similarity()`, `similarity()` - - Added in: Migration `20251001141005_add_trigram_to_members.exs` +## Sensitive Data (GDPR / logging) -### Installation -```sql -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "citext"; -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -``` - -## Migration Strategy - -### Ash Migrations -This project uses Ash Framework's migration system: - -```bash -# Generate new migration -mix ash.codegen --name add_new_feature - -# Apply migrations -mix ash.setup - -# Rollback migrations -mix ash_postgres.rollback -n 1 -``` - -### Migration Files Location -``` -priv/repo/migrations/ -├── 20250421101957_initialize_extensions_1.exs -├── 20250528163901_initial_migration.exs -├── 20250617090641_member_fields.exs -├── 20250620110850_add_accounts_domain.exs -├── 20250912085235_AddSearchVectorToMembers.exs -├── 20250926180341_add_unique_email_to_members.exs -├── 20251001141005_add_trigram_to_members.exs -└── 20251016130855_add_constraints_for_user_member_and_property.exs -``` - -## Data Integrity - -### Foreign Key Behaviors - -| Relationship | On Delete | Rationale | -|--------------|-----------|-----------| -| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted | -| `custom_field_values.member_id → members.id` | CASCADE | Delete custom_field_values with member | -| `custom_field_values.custom_field_id → custom_fields.id` | RESTRICT | Prevent deletion of types in use | - -### Validation Layers - -1. **Database Level:** - - CHECK constraints - - NOT NULL constraints - - UNIQUE indexes - - Foreign key constraints - -2. **Application Level (Ash):** - - Custom validators - - Email format validation (EctoCommons.EmailValidator) - - Business rule validation - - Cross-entity validation - -3. **UI Level:** - - Client-side form validation - - Real-time feedback - - Error messages - -## Performance Considerations - -### Query Patterns - -**High Frequency:** -- Member search (uses GIN index on search_vector) -- Member list with filters (uses indexes on join_date, membership_fee_type_id) -- User authentication (uses unique index on email/oidc_id) -- CustomFieldValue lookups by member (uses index on member_id) - -**Medium Frequency:** -- Member CRUD operations -- CustomFieldValue updates -- Token validation - -**Low Frequency:** -- CustomField management -- User-Member linking -- Bulk operations - -### Optimization Tips - -1. **Use indexes:** All critical query paths have indexes -2. **Preload relationships:** Use Ash's `load` to avoid N+1 -3. **Pagination:** Use keyset pagination (configured by default) -4. **GIN indexes:** Full-text search and fuzzy search on multiple fields -5. **Search optimization:** Full-text search via tsvector, not LIKE - -## Visualization - -### Using dbdiagram.io - -1. Visit [https://dbdiagram.io](https://dbdiagram.io) -2. Click "Import" → "From file" -3. Upload `database_schema.dbml` -4. View interactive diagram with relationships - -### Using dbdocs.io - -1. Install dbdocs CLI: `npm install -g dbdocs` -2. Generate docs: `dbdocs build database_schema.dbml` -3. View generated documentation - -### VS Code Extension - -Install "DBML Language" extension to view/edit DBML files with: -- Syntax highlighting -- Inline documentation -- Error checking - -## Security Considerations - -### Sensitive Data - -**Encrypted:** -- `users.hashed_password` (bcrypt) - -**Should Not Log:** -- hashed_password -- tokens (jti, purpose, extra_data) - -**Personal Data (GDPR):** -- All member fields (name, email, address) -- User email -- Token subject - -### Access Control - -- Implement through Ash policies -- Row-level security considerations for future -- Audit logging for sensitive operations - -## Backup Recommendations - -### Critical Tables (Priority 1) -- `members` - Core business data -- `users` - Authentication data -- `custom_fields` - Schema definitions - -### Important Tables (Priority 2) -- `custom_field_values` - Member custom data -- `tokens` - Can be regenerated but good to backup - -### Backup Strategy -```bash -# Full database backup -pg_dump -Fc mv_prod > backup_$(date +%Y%m%d).dump - -# Restore -pg_restore -d mv_prod backup_20251110.dump -``` - -## Testing - -### Test Database -- Separate test database: `mv_test` -- Sandbox mode via Ecto.Adapters.SQL.Sandbox -- Reset between tests - -### Seed Data -```bash -# Load seed data -mix run priv/repo/seeds.exs -``` - -## Future Considerations - -### Potential Additions - -1. **Audit Log Table** - - Track changes to members - - Compliance and history tracking - -2. **Payment Tracking** - - Payment history table - - Transaction records - - Fee calculation - -3. **Document Storage** - - Member documents/attachments - - File metadata table - -4. **Email Queue** - - Outbound email tracking - - Delivery status - -5. **Roles & Permissions** - - User roles (admin, treasurer, member) - - Permission management - -## Resources - -- **Ash Framework:** [https://hexdocs.pm/ash](https://hexdocs.pm/ash) -- **AshPostgres:** [https://hexdocs.pm/ash_postgres](https://hexdocs.pm/ash_postgres) -- **DBML Specification:** [https://dbml.dbdiagram.io](https://dbml.dbdiagram.io) -- **PostgreSQL Docs:** [https://www.postgresql.org/docs/](https://www.postgresql.org/docs/) +- **Never log:** `users.hashed_password` (bcrypt), token fields (`jti`, `purpose`, `extra_data`), OIDC/SMTP/vereinfacht secrets in `settings`. +- **Personal data:** all member fields, user email, join-request applicant data. --- -**Last Updated:** 2026-01-27 -**Schema Version:** 1.5 +**Last Updated:** 2026-06-15 +**Schema Version:** 1.6 (12 tables) **Database:** PostgreSQL 17.6 (dev) / 16 (prod) diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 16c9723..f763726 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -6,8 +6,9 @@ // - https://dbdocs.io // - VS Code Extensions: "DBML Language" or "dbdiagram.io" // -// Version: 1.4 -// Last Updated: 2026-01-13 +// Version: 1.6 +// Last Updated: 2026-06-15 +// Hand-maintained (NOT auto-generated). 12 tables. Project mila_membership_management { database_type: 'PostgreSQL' @@ -25,15 +26,16 @@ Project mila_membership_management { - GDPR-compliant data management ## Domains: - - **Accounts**: User authentication and session management - - **Membership**: Club member data and custom fields + - **Accounts**: User authentication, sessions, OIDC strategy identities + - **Membership**: Club member data, custom fields, groups, settings, public join requests - **MembershipFees**: Membership fee types and billing cycles - **Authorization**: Role-based access control (RBAC) - ## Required PostgreSQL Extensions: - - uuid-ossp (UUID generation) + ## Required PostgreSQL Extensions (see Mv.Repo.installed_extensions/0): + - ash-functions (Ash helper SQL functions) - citext (case-insensitive text) - pg_trgm (trigram-based fuzzy search) + UUIDv7 ids use uuid_generate_v7(), a custom SQL function defined in a migration (not an extension). ''' } @@ -135,7 +137,8 @@ Table members { search_vector tsvector [null, note: 'Full-text search index (auto-generated)'] membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type'] membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated'] - + vereinfacht_contact_id text [null, note: 'External contact id from the vereinfacht.de API (no FK; null if unlinked)'] + indexes { email [unique, name: 'members_unique_email_index'] search_vector [type: gin, name: 'members_search_vector_idx', note: 'GIN index for full-text search (tsvector)'] @@ -169,7 +172,8 @@ Table members { **Search Capabilities:** 1. Full-Text Search (tsvector): - `search_vector` is auto-updated via trigger - - Weighted fields: first_name (A), last_name (A), email (B), notes (B) + - Weighted fields (A/B/C/D map): see the "Weighted Fields" section of + database-schema-readme.md (single source of truth, matches the search trigger) - GIN index for fast text search 2. Fuzzy Search (pg_trgm): @@ -225,7 +229,7 @@ Table custom_field_values { **Constraints:** - Each member can have only ONE custom field value per custom field - Custom field values are deleted when member is deleted (CASCADE) - - Custom field cannot be deleted if custom field values exist (RESTRICT) + - Custom field values are deleted when the custom field is deleted (CASCADE) **Use Cases:** - Custom membership numbers @@ -241,8 +245,9 @@ Table custom_fields { slug text [not null, unique, note: 'URL-friendly, immutable identifier (e.g., "membership-number"). Auto-generated from name.'] value_type text [not null, note: 'Data type: string | integer | boolean | date | email'] description text [null, note: 'Human-readable description'] - immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation'] + join_description text [null, note: 'Optional label shown for this field on the public join form (e.g., a GDPR confirmation text); supports inline external links. Falls back to name when null.'] required boolean [not null, default: false, note: 'If true, all members must have this custom field'] + show_in_overview boolean [not null, default: true, note: 'If true, this custom field is displayed in the member overview table and can be sorted'] indexes { name [unique, name: 'custom_fields_unique_name_index'] @@ -259,8 +264,9 @@ Table custom_fields { - `slug`: URL-friendly, human-readable identifier (auto-generated, immutable) - `value_type`: Enforces data type consistency - `description`: Documentation for users/admins - - `immutable`: Prevents changes after initial creation (e.g., membership numbers) + - `join_description`: Optional label shown for this field on the public join form (falls back to `name` when null) - `required`: Enforces that all members must have this custom field + - `show_in_overview`: When true, the field is shown in the member overview table and can be sorted **Slug Generation:** - Automatically generated from `name` on creation @@ -275,13 +281,13 @@ Table custom_fields { - `name` must be unique across all custom fields - `slug` must be unique across all custom fields - `slug` cannot be empty (validated on creation) - - Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT) - + - Deleting a custom field cascades: its custom_field_values are deleted too (ON DELETE CASCADE) + **Examples:** - - Membership Number (string, immutable, required) → slug: "membership-number" - - Emergency Contact (string, mutable, optional) → slug: "emergency-contact" - - Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer" - - Certification Date (date, immutable, optional) → slug: "certification-date" + - Membership Number (string, required) → slug: "membership-number" + - Emergency Contact (string, optional) → slug: "emergency-contact" + - Certified Trainer (boolean, optional) → slug: "certified-trainer" + - Certification Date (date, optional) → slug: "certification-date" ''' } @@ -399,8 +405,8 @@ Ref: custom_field_values.member_id > members.id [delete: cascade] // CustomFieldValue → CustomField (N:1) // - Many custom_field_values can reference one custom field // - CustomFieldValue type defines the schema/behavior -// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist -Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict] +// - ON DELETE CASCADE: deleting the custom field deletes its custom_field_values +Ref: custom_field_values.custom_field_id > custom_fields.id [delete: cascade] // Member → MembershipFeeType (N:1) // - Many members can be assigned to one fee type @@ -462,25 +468,14 @@ Enum membership_fee_status { TableGroup accounts_domain { users tokens - + Note: ''' **Accounts Domain** - - Handles user authentication and session management using AshAuthentication. - Supports multiple authentication strategies (Password, OIDC). - ''' -} -TableGroup membership_domain { - members - custom_field_values - custom_fields - - Note: ''' - **Membership Domain** - - Core business logic for club membership management. - Supports flexible, extensible member data model. + Handles user authentication and session management using AshAuthentication. + Supports multiple authentication strategies (Password, OIDC). OIDC linking + is recorded on the users table via the oidc_id column (there is no separate + user_identities table). ''' } @@ -550,9 +545,32 @@ Table roles { Table settings { id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier'] club_name text [not null, note: 'The name of the association/club (min length: 1)'] - member_field_visibility jsonb [null, note: 'Visibility configuration for member fields in overview (JSONB map)'] + member_field_visibility jsonb [null, note: 'Visibility config for member fields in overview (JSONB map; absent key = visible)'] + member_field_required jsonb [null, note: 'Required-field config for member fields (JSONB map)'] include_joining_cycle boolean [not null, default: true, note: 'Whether to include the joining cycle in membership fee generation'] - default_membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - default fee type for new members'] + default_membership_fee_type_id uuid [null, note: 'Logical reference to membership_fee_types (default fee type for new members) - app-enforced, NO DB foreign key'] + registration_enabled boolean [not null, default: true, note: 'Whether self-service user registration is enabled'] + oidc_only boolean [not null, default: false, note: 'If true, only OIDC login is offered (password login hidden)'] + oidc_client_id text [null, note: 'OIDC client id'] + oidc_client_secret text [null, note: 'OIDC client secret'] + oidc_base_url text [null, note: 'OIDC provider base URL (e.g., Rauthy)'] + oidc_redirect_uri text [null, note: 'OIDC redirect URI'] + oidc_admin_group_name text [null, note: 'Provider group name mapped to admin role on login'] + oidc_groups_claim text [null, note: 'JWT claim carrying the user groups for role sync'] + smtp_host text [null, note: 'Outbound SMTP host'] + smtp_port bigint [null, note: 'Outbound SMTP port'] + smtp_username text [null, note: 'SMTP auth username'] + smtp_password text [null, note: 'SMTP auth password (secret)'] + smtp_ssl text [null, note: 'SMTP TLS/SSL mode'] + smtp_from_name text [null, note: 'Display name for the From header (mail_from)'] + smtp_from_email text [null, note: 'Email address for the From header (mail_from)'] + vereinfacht_api_url text [null, note: 'vereinfacht.de API base URL'] + vereinfacht_api_key text [null, note: 'vereinfacht.de API key (secret)'] + vereinfacht_club_id text [null, note: 'vereinfacht.de club identifier'] + vereinfacht_app_url text [null, note: 'vereinfacht.de app URL (for links)'] + join_form_enabled boolean [not null, default: false, note: 'Whether the public join form is enabled'] + join_form_field_ids text[] [null, note: 'Ordered custom_field ids shown on the public join form'] + join_form_field_required jsonb [null, note: 'Per-field required config for the join form (JSONB map)'] inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)'] updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)'] @@ -590,19 +608,123 @@ Table settings { ''' } +// ============================================ +// MEMBERSHIP DOMAIN — Groups +// ============================================ + +Table groups { + id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key'] + name text [not null, note: 'Group name (unique case-insensitively via LOWER(name))'] + slug text [not null, unique, note: 'URL-friendly, immutable identifier auto-generated from name (shared GenerateSlug change)'] + description text [null, note: 'Optional description'] + inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)'] + updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)'] + + indexes { + slug [unique, name: 'groups_unique_slug_index', note: 'Case-sensitive unique slug'] + name [unique, name: 'groups_unique_name_lower_index', note: 'UNIQUE on LOWER(name) - case-insensitive name uniqueness'] + } + + Note: ''' + **Member Groups** + + Flat groupings of members (no hierarchy in current schema). Many-to-many + with members via member_groups. `slug` is generated by the shared + Mv.Membership.Changes.GenerateSlug change (same as custom_fields) and is + used for URL routing (/groups/:slug). Group names feed the member + search_vector at weight B (see member_groups note). + + **Future extension path (not yet in schema):** + - parent_group_id (self-referential, nullable) + circular-ref guard + path calc for hierarchy + - member_group_roles table linking MemberGroup to a Role (position within a group) + ''' +} + +Table member_groups { + id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key'] + member_id uuid [not null, note: 'FK to members'] + group_id uuid [not null, note: 'FK to groups'] + inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)'] + updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)'] + + indexes { + (member_id, group_id) [unique, name: 'member_groups_unique_member_group_index', note: 'One association per member per group'] + member_id [name: 'member_groups_member_id_index'] + group_id [name: 'member_groups_group_id_index'] + } + + Note: ''' + **Member ↔ Group Join Table** + + CASCADE delete on BOTH foreign keys (the cascade lives only on the join + table; members and groups themselves are never deleted by association + removal). INSERT/UPDATE/DELETE here fires the trigger that refreshes the + affected member's search_vector so group names (weight B) stay current. + ''' +} + +// ============================================ +// MEMBERSHIP DOMAIN — Public Join Requests +// ============================================ + +Table join_requests { + id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key'] + status text [not null, default: 'pending_confirmation', note: 'pending_confirmation → submitted (after email confirm) → approved/rejected'] + email text [not null, note: 'Applicant email'] + first_name text [null, note: 'Applicant first name'] + last_name text [null, note: 'Applicant last name'] + form_data jsonb [null, note: 'Submitted join-form field values (custom fields)'] + schema_version integer [null, note: 'Version of the join-form schema used at submission time'] + confirmation_token_hash text [null, note: 'Hash of the double-opt-in token (raw token never stored)'] + confirmation_token_expires_at timestamp [null, note: 'Token expiry (UTC)'] + confirmation_sent_at timestamp [null, note: 'When the confirmation email was sent (UTC)'] + submitted_at timestamp [null, note: 'When email was confirmed and request submitted (UTC)'] + approved_at timestamp [null, note: 'When an admin approved (UTC)'] + rejected_at timestamp [null, note: 'When an admin rejected (UTC)'] + reviewed_by_user_id uuid [null, note: 'User who reviewed (no FK constraint)'] + reviewed_by_display text [null, note: 'Reviewer display string, denormalized so the UI need not load the User'] + source text [null, note: 'Origin of the request (e.g., public form)'] + inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)'] + updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)'] + + indexes { + confirmation_token_hash [unique, name: 'join_requests_confirmation_token_hash_unique', note: 'Partial unique WHERE confirmation_token_hash IS NOT NULL'] + email [name: 'join_requests_email_index'] + status [name: 'join_requests_status_index'] + } + + Note: ''' + **Public Join Flow (Onboarding, Double Opt-In)** + + Stores public join-form submissions. Double opt-in: the confirmation token + is stored as a hash only; unconfirmed records have a ~24h retention and are + removed by a scheduled cleanup job. `reviewed_by_user_id` is intentionally + unconstrained (no FK); `reviewed_by_display` is denormalized so showing the + reviewer does not require loading the User. + ''' +} + // ============================================ // RELATIONSHIPS (Additional) // ============================================ +// MemberGroup → Member (N:1) +// - ON DELETE CASCADE (join table only): association removed, member preserved +Ref: member_groups.member_id > members.id [delete: cascade] + +// MemberGroup → Group (N:1) +// - ON DELETE CASCADE (join table only): association removed, group preserved +Ref: member_groups.group_id > groups.id [delete: cascade] + // User → Role (N:1) // - Many users can be assigned to one role // - ON DELETE RESTRICT: Cannot delete role if users are assigned Ref: users.role_id > roles.id [delete: restrict] -// Settings → MembershipFeeType (N:1, optional) -// - Settings can reference a default membership fee type -// - ON DELETE SET NULL: If fee type is deleted, setting is cleared -Ref: settings.default_membership_fee_type_id > membership_fee_types.id [delete: set null] +// Settings → MembershipFeeType (N:1, optional) — LOGICAL relationship only +// - No DB foreign key (cross-domain dependency is deliberately avoided); +// referential integrity is enforced in the app (Mv.Membership.Setting) +Ref: settings.default_membership_fee_type_id > membership_fee_types.id // ============================================ // TABLE GROUPS (Updated) @@ -624,12 +746,16 @@ TableGroup membership_domain { custom_field_values custom_fields settings - + groups + member_groups + join_requests + Note: ''' **Membership Domain** - + Core business logic for club membership management. Supports flexible, extensible member data model. - Includes global application settings (singleton). + Includes member groups (many-to-many), global application settings + (singleton), and the public join-request flow. ''' } diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index ca1f07b..0959488 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -1,1223 +1,101 @@ # Groups - Technical Architecture -**Project:** Mila - Membership Management System **Feature:** Groups Management -**Version:** 1.0 -**Last Updated:** 2025-01-XX -**Status:** ✅ Implemented (authorization: see [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)) +**Status:** Implemented (authorization: see [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)) + +This document records the durable design of the Groups feature: data model, key decisions, integration points, accessibility rules, and the planned extension paths. The original implementation plan (estimations, vertical slices, per-issue acceptance criteria, testing/migration strategy) has been removed now that the feature has shipped. + +**Related:** [database-schema-readme.md](./database-schema-readme.md), [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md). --- -## Purpose +## Core Design Decisions -This document defines the technical architecture for the Groups feature. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details. +1. **Many-to-many:** members can belong to multiple groups and vice versa, via the `member_groups` join table (a separate Ash resource). +2. **Flat structure:** no hierarchy in the current schema; the design leaves a clear path to add it later (see [Future Extensibility](#future-extensibility)). +3. **Minimal attributes:** `name`, `description`, `slug`. The `slug` is auto-generated from `name`, immutable, URL-friendly. +4. **Cascade on the join table only:** deleting a group (or member) removes the `member_groups` associations but never deletes members/groups themselves. Group deletion requires explicit confirmation (typing the group name). +5. **Search integration:** group names are included in the member `search_vector` (not a separate search index). -**Related Documents:** +## Domain & Resources -- [database-schema-readme.md](./database-schema-readme.md) - Database documentation -- [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) - Authorization system +Groups live in the **`Mv.Membership`** domain alongside Members and CustomFields. ---- +- `Mv.Membership.Group` (`lib/membership/group.ex`) — attributes `name`, `slug`, `description`; `has_many :member_groups`, `many_to_many :members`; `member_count` aggregate (`count :member_count, :member_groups`); `unique_slug` identity for slug lookups. Slug is generated by the shared **`Mv.Membership.Changes.GenerateSlug`** change (the same change CustomFields uses), generated on create and immutable on update. +- `Mv.Membership.MemberGroup` (`lib/membership/member_group.ex`) — join table; `belongs_to :member`, `belongs_to :group`; unique on `(member_id, group_id)`. Has `create`/`destroy` actions only (no `update`); group membership is managed by creating and destroying these join rows. +- `Mv.Membership.Member` (extended) — `has_many :member_groups`, `many_to_many :groups`. Group membership is managed through the `MemberGroup` join resource, not via dedicated Member actions. -## Table of Contents +## Data Model -1. [Architecture Principles](#architecture-principles) -2. [Domain Structure](#domain-structure) -3. [Data Architecture](#data-architecture) -4. [Business Logic Architecture](#business-logic-architecture) -5. [UI/UX Architecture](#uiux-architecture) -6. [Integration Points](#integration-points) -7. [Authorization](#authorization) -8. [Performance Considerations](#performance-considerations) -9. [Future Extensibility](#future-extensibility) -10. [Implementation Phases](#implementation-phases) +### `groups` +- `id` (UUIDv7), `name` (required), `slug` (required, immutable, auto-generated), `description` (optional), timestamps. +- Uniqueness: `name` unique case-insensitively (`UNIQUE` on `LOWER(name)`, index `groups_unique_name_lower_index`); `slug` unique case-sensitively (`groups_unique_slug_index`). ---- +### `member_groups` (join table) +- `id` (UUIDv7), `member_id`, `group_id`, timestamps. +- Unique `(member_id, group_id)` prevents duplicates; indexes on `member_id` and `group_id`. +- **CASCADE delete on both foreign keys** — the cascade is intentionally on the join table only. -## Architecture Principles +For exact columns/indexes see `database_schema.dbml`. -### Core Design Decisions +## Search Integration -1. **Many-to-Many Relationship:** - - Members can belong to multiple groups - - Groups can contain multiple members - - Implemented via join table (`member_groups`) as separate Ash resource +Group names are part of the member full-text search: -2. **Flat Structure (MVP):** - - Groups are initially flat (no hierarchy) - - Architecture designed to allow hierarchical extension later - - No parent/child relationships in MVP +- They are aggregated from `member_groups` joined to `groups` and added to `members.search_vector` at **weight B**. +- The trigger `update_member_search_vector_on_member_groups_change` runs `update_member_search_vector_from_member_groups()` on **INSERT/UPDATE/DELETE on `member_groups`** and refreshes the affected member's `search_vector`. +- Migration `20260217120000_add_group_names_to_member_search_vector.exs` (Issue #375). No Elixir search change is needed — searching a group name finds its members automatically. -3. **Minimal Attributes (MVP):** - - `name`, `description`, and `slug` in initial version - - `slug` is automatically generated from `name` (immutable, URL-friendly) - - Extensible for future attributes (dates, status, etc.) +## UI Surface (implemented) -4. **Cascade Deletion:** - - Deleting a group removes all member-group associations - - Members themselves are not deleted (CASCADE on join table only) - - Requires explicit confirmation with group name input +- **`/groups`** — index table (name, description, member count, actions), sorted by name at the DB level. Create button → `/groups/new`. +- **`/groups/:slug`** — detail: group info, member list, inline add-member combobox (search/autocomplete, excludes members already in the group), per-row remove (no confirmation), edit/delete. Add/remove are guarded by `:update` permission both in the UI and server-side in the event handlers. +- **`/groups/:slug/edit`** and **`/groups/new`** — separate form pages; slug not editable. Edit does auth in `mount/3` and loads the group once in `handle_params/3`. +- **Delete confirmation modal** — warns with member count (pluralized), requires typing the group name to enable delete (`phx-debounce="200"`), stays open on mismatch, authorizes server-side. +- **Member overview** — "Groups" column with badges; filter dropdown (persisted in URL query params); sort by group; group names searchable. +- **Member detail** — Groups shown as a data field in Personal Data (below Linked User), button-style links to `/groups/:slug`. -5. **Search Integration:** - - Groups searchable within member search (not separate search) - - Group names included in member search vector for full-text search +## Accessibility ---- - -## Domain Structure - -### Ash Domain: `Mv.Membership` - -**Purpose:** Groups are part of the Membership domain, alongside Members and CustomFields - -**New Resources:** - -- `Group` - Group definitions (name, description, slug) -- `MemberGroup` - Join table for many-to-many relationship between Members and Groups - -**Extended Resources:** - -- `Member` - Extended with `has_many :groups` relationship (through MemberGroup) - -### Module Organization - -``` -lib/ -├── membership/ -│ ├── membership.ex # Domain definition (extended) -│ ├── group.ex # Group resource -│ ├── member_group.ex # MemberGroup join table resource -│ └── member.ex # Extended with groups relationship -├── mv_web/ -│ └── live/ -│ ├── group_live/ -│ │ ├── index.ex # Groups management page -│ │ ├── form.ex # Create/edit group form -│ │ └── show.ex # Group detail view -│ └── member_live/ -│ ├── index.ex # Extended with group filtering/sorting -│ └── show.ex # Extended with group display -└── mv/ - └── membership/ - └── group/ # Future: Group-specific business logic - └── helpers.ex # Group-related helper functions -``` - ---- - -## Data Architecture - -### Database Schema - -#### `groups` Table - -**Attributes:** -- `id` - UUID v7 primary key -- `name` - Unique group name (required, max 100 chars) -- `slug` - URL-friendly identifier (required, max 100 chars, auto-generated from name) -- `description` - Optional description (max 500 chars) -- `inserted_at` / `updated_at` - Timestamps - -**Constraints:** -- `name` must be unique (case-insensitive, using LOWER(name)) -- `slug` must be unique (case-sensitive, exact match) -- `name` cannot be null -- `slug` cannot be null -- `name` max length: 100 characters -- `slug` max length: 100 characters -- `description` max length: 500 characters - -#### `member_groups` Table (Join Table) - -**Attributes:** -- `id` - UUID v7 primary key -- `member_id` - Foreign key to members (CASCADE delete) -- `group_id` - Foreign key to groups (CASCADE delete) -- `inserted_at` / `updated_at` - Timestamps - -**Constraints:** -- Unique constraint on `(member_id, group_id)` - prevents duplicate memberships -- CASCADE delete: Removing member removes all group associations -- CASCADE delete: Removing group removes all member associations - -**Indexes:** -- Index on `member_id` for efficient member → groups queries -- Index on `group_id` for efficient group → members queries - -### Ash Resources - -#### `Mv.Membership.Group` - -**Relationships:** -- `has_many :member_groups` - Relationship to MemberGroup join table -- `many_to_many :members` - Relationship to Members through MemberGroup - -**Calculations:** -- `member_count` - Integer calculation counting associated members - -**Actions:** -- `create` - Create new group (auto-generates slug from name) -- `read` - List/search groups (can query by slug via identity) -- `update` - Update group name/description (slug remains unchanged) -- `destroy` - Delete group (with confirmation) - -**Validations:** -- `name` required, unique (case-insensitive), max 100 chars -- `slug` required, unique (case-sensitive), max 100 chars, auto-generated, immutable -- `description` optional, max 500 chars - -**Identities:** -- `unique_slug` - Unique identity on `slug` for efficient lookups - -#### `Mv.Membership.MemberGroup` - -**Relationships:** -- `belongs_to :member` - Relationship to Member -- `belongs_to :group` - Relationship to Group - -**Actions:** -- `create` - Add member to group -- `read` - Query member-group associations -- `destroy` - Remove member from group - -**Validations:** -- Unique constraint on `(member_id, group_id)` - -#### `Mv.Membership.Member` (Extended) - -**New Relationships:** -- `has_many :member_groups` - Relationship to MemberGroup join table -- `many_to_many :groups` - Relationship to Groups through MemberGroup - -**New Actions:** -- `add_to_groups` - Add member to one or more groups -- `remove_from_groups` - Remove member from one or more groups - ---- - -## Business Logic Architecture - -### Group Management - -**Create Group:** -- Validate name uniqueness -- Automatically generate slug from name (using `GenerateSlug` change, same pattern as CustomFields) -- Validate slug uniqueness -- Return created group - -**Update Group:** -- Validate name uniqueness (if name changed) -- Update description -- Slug remains unchanged (immutable after creation) -- Return updated group - -**Delete Group:** -- Check if group has members (for warning display) -- Require explicit confirmation (group name input) -- Cascade delete all `member_groups` associations -- Group itself deleted - -### Member-Group Association - -**Add Member to Group:** -- Validate member exists -- Validate group exists -- Check for duplicate association -- Create `MemberGroup` record - -**Remove Member from Group:** -- Find `MemberGroup` record -- Delete association -- Member and group remain intact - -**Bulk Operations:** -- Add member to multiple groups in single transaction -- Remove member from multiple groups in single transaction - -### Search Integration - -**Member Search Enhancement:** -- Include group names in member search vector -- When searching for member, also search in associated group names -- Example: Searching for a group name finds all members in groups with that name - -**Implementation:** -- Extend `member.search_vector` trigger to include group names -- Update trigger on `member_groups` changes -- Use PostgreSQL `tsvector` for full-text search - ---- - -## UI/UX Architecture - -### Groups Management Page (`/groups`) - -**Route:** `/groups` - Groups management index page - -**Features:** -- List all groups in table (sorted by name via database query) -- Create new group button (navigates to `/groups/new`) -- Edit group via separate form page (`/groups/:slug/edit`) -- Delete group with confirmation modal -- Show member count per group - -**Table Columns:** -- Name (sortable, searchable) -- Description -- Member Count -- Actions (Edit, Delete) - -**Delete Confirmation Modal:** -- Warning: "X members are in this group" (with proper pluralization) -- Confirmation: "All member-group associations will be permanently deleted" -- Input field: Enter group name to confirm (with `phx-debounce="200"` for better UX) -- Delete button disabled until name matches -- Modal remains open on name mismatch (allows user to correct input) -- Cancel button -- Server-side authorization check in delete event handler (security best practice) - -### Member Overview Integration - -**New Column: "Groups"** -- Display group badges for each member -- Badge shows group name -- Multiple badges if member in multiple groups -- *(Optional)* Click badge to filter by that group (enhanced UX, can be added later) - -**Filtering:** -- Dropdown/select to filter by group -- "All groups" option (default) -- Filter persists in URL query params -- Works with existing search/sort - -**Sorting:** -- Sort by group name (members with groups first, then alphabetically) -- Sort by number of groups (members with most groups first) - -**Search:** -- Group names included in member search -- Searching group name shows all members in that group - -### Member Detail View Integration - -**New Section: "Groups"** -- List all groups member belongs to -- Display as badges or list -- Add/remove groups inline -- Link to group detail page - -### Group Detail View (`/groups/:slug`) - -**Route:** `/groups/:slug` - Group detail page (uses slug for URL-friendly routing) - -**Features:** -- Display group name and description -- List all members in group -- Link to member detail pages -- Add members to group (via inline combobox with search/autocomplete) -- Remove members from group (via remove button per member) -- Edit group button (navigates to `/groups/:slug/edit`) -- Delete group button (with confirmation modal) - -**Add Member Functionality:** -- "Add Member" button displayed above member table (only for users with `:update` permission) -- Opens inline add member area with member search/autocomplete (combobox) -- Search filters out members already in the group -- Selecting a member adds them to the group immediately -- Success/error flash messages provide feedback -- "Cancel" button closes the inline add member area without adding - -**Remove Member Functionality:** -- "Remove" button (icon button) for each member in table (only for users with `:update` permission) -- Clicking remove immediately removes member from group (no confirmation dialog) -- Success/error flash messages provide feedback - -**Note:** Uses slug for routing to provide URL-friendly, readable group URLs (e.g., `/groups/board-members`). - -### Group Form Pages - -**Create Form:** `/groups/new` -- Separate LiveView page for creating new groups -- Form with name and description fields -- Slug is auto-generated and not editable -- Redirects to `/groups` on success - -**Edit Form:** `/groups/:slug/edit` -- Separate LiveView page for editing existing groups -- Form pre-populated with current group data -- Slug is immutable (not displayed in form) -- Redirects to `/groups/:slug` on success -- `mount/3` performs authorization check, `handle_params/3` loads group once - -### Accessibility (A11y) Considerations - -**Requirements:** -- All UI elements must be keyboard accessible -- Screen readers must be able to navigate and understand the interface -- ARIA labels and roles must be properly set - -**Group Badges and Links in Member Overview / Detail:** -- Use `aria-label` to indicate group membership (e.g. "Member of group X"). Do not use `role="status"` on badges or links—that role is for live regions (screen reader announcements), not for navigation or static labels. -- Badge/link text or title should indicate group membership for screen readers. - -**Clickable Group Badge (for filtering) - Optional:** - -**Note:** This is an optional enhancement. The dropdown filter provides the same functionality. The clickable badge improves UX by showing the active filter visually and allowing quick removal. - -**Estimated effort:** 1.5-2.5 hours - -- Clickable badges must be proper button elements with `type="button"` -- Must include `aria-label` describing the filter action -- Icon for removal should have `aria-hidden="true"` - -**Group Filter Dropdown:** -- Select element must have appropriate `id`, `name`, and `aria-label` attributes -- Options should clearly indicate selected state - -**Screen Reader Announcements:** -- Use `role="status"` with `aria-live="polite"` for dynamic content -- Announce filter changes and member count updates - -**Delete Confirmation Modal:** -- Modal must use proper `role="dialog"` with `aria-labelledby` and `aria-describedby` -- Warning messages must be clearly associated with the modal description -- Form inputs must be properly labeled - -**Keyboard Navigation:** -- All interactive elements (buttons, links, form inputs) must be focusable via Tab key -- Modal dialogs must trap focus (Tab key cycles within modal) -- Escape key closes modals -- Enter/Space activates buttons when focused - ---- - -## Integration Points - -### Member Search Vector - -**Trigger Update:** -- When `member_groups` record created/deleted -- Update `members.search_vector` to include group names -- Use PostgreSQL trigger for automatic updates - -**Search Query:** -- Extend existing `fuzzy_search` to include group names -- Group names added with weight 'B' (same as city, etc.) - -### Member Form - -**Future Enhancement:** -- Add groups selection in member form -- Multi-select dropdown for groups -- Add/remove groups during member creation/edit - -### Authorization Integration - -**Current (MVP):** -- Only admins can manage groups -- Uses existing `Mv.Authorization.Checks.HasPermission` -- Permission: `groups` resource with `:all` scope - -**Future:** -- Group-specific permissions -- Role-based group management -- Member-level group assignment permissions - ---- +- **Do not use `role="status"` on group badges or navigation links.** That role is for live regions (screen-reader announcements), not for static labels or navigation. Use `aria-label` (e.g. "Member of group X") instead. +- `role="status"` with `aria-live="polite"` is appropriate only for dynamic announcements (filter changes, member-count updates). +- Clickable filter badges (optional enhancement) must be real `
-``` - -## How drawer-toggle Works - -### Mechanism - -The `drawer-toggle` is a **hidden checkbox** that serves as the state controller: - -```html - -``` - -### Toggle Behavior - -1. **Label Connection**: Any `