From a93dd9d5350be73960eeeb8be593c8dbb26512d4 Mon Sep 17 00:00:00 2001
From: Moritz
Date: Wed, 3 Jun 2026 02:21:36 +0200
Subject: [PATCH] feat(import): serve dynamic CSV import templates reflecting
current custom fields
---
.../controllers/import_template_controller.ex | 120 ++++++++++++++++++
lib/mv_web/live/import_live/components.ex | 12 +-
lib/mv_web/router.ex | 4 +
.../import_template_controller_test.exs | 104 +++++++++++++++
test/mv_web/live/import_live_test.exs | 11 +-
5 files changed, 238 insertions(+), 13 deletions(-)
create mode 100644 lib/mv_web/controllers/import_template_controller.ex
create mode 100644 test/mv_web/controllers/import_template_controller_test.exs
diff --git a/lib/mv_web/controllers/import_template_controller.ex b/lib/mv_web/controllers/import_template_controller.ex
new file mode 100644
index 0000000..f040c7a
--- /dev/null
+++ b/lib/mv_web/controllers/import_template_controller.ex
@@ -0,0 +1,120 @@
+defmodule MvWeb.ImportTemplateController do
+ @moduledoc """
+ Serves CSV import templates generated on the fly from the current custom fields.
+
+ Two actions provide an English (`en/2`) and a German (`de/2`) template. Each
+ template has a single header row listing the standard member columns followed
+ by every existing custom field name (exact match, as the import expects), plus
+ the importable groups and fee-type columns. A single placeholder example row is
+ included to illustrate the format.
+
+ Both actions require the same authorization as the import page
+ (`can?(:create, Member)`); unauthorized requests are rejected.
+ """
+ use MvWeb, :controller
+
+ alias Mv.Authorization.Actor
+ alias Mv.Membership.Member
+ alias Mv.Membership.MembersCSV
+ alias MvWeb.Authorization
+
+ # Standard member columns in template order, with their English and German headers
+ # and a placeholder example value. Groups and fee type are importable extras.
+ @columns [
+ {"first name", "Vorname", "John", "Max"},
+ {"last name", "Nachname", "Doe", "Mustermann"},
+ {"email", "E-Mail", "john.doe@example.com", "max.mustermann@example.com"},
+ {"country", "Land", "Germany", "Deutschland"},
+ {"city", "Stadt", "Berlin", "Berlin"},
+ {"street", "Straße", "Main Street", "Hauptstraße"},
+ {"house number", "Hausnummer", "1a", "12"},
+ {"postal_code", "PLZ", "12345", "10115"},
+ {"join_date", "Beitrittsdatum", "2020-01-15", "2020-01-15"},
+ {"exit_date", "Austrittsdatum", "", ""},
+ {"notes", "Notizen", "", ""},
+ {"membership_fee_start_date", "Beitragsbeginn", "", ""},
+ {"Groups", "Gruppen", "", ""},
+ {"Fee Type", "Beitragsart", "", ""}
+ ]
+
+ @spec en(Plug.Conn.t(), map()) :: Plug.Conn.t()
+ def en(conn, _params) do
+ serve_template(conn, :en, "member_import_en.csv")
+ end
+
+ @spec de(Plug.Conn.t(), map()) :: Plug.Conn.t()
+ def de(conn, _params) do
+ serve_template(conn, :de, "member_import_de.csv")
+ end
+
+ defp serve_template(conn, locale, filename) do
+ actor = current_actor(conn)
+
+ if Authorization.can?(actor, :create, Member) do
+ csv = build_csv(locale, actor)
+
+ send_download(conn, {:binary, csv},
+ filename: filename,
+ content_type: "text/csv; charset=utf-8"
+ )
+ else
+ return_forbidden(conn)
+ end
+ end
+
+ defp build_csv(locale, actor) do
+ custom_field_names = custom_field_names(actor)
+
+ header =
+ Enum.map(@columns, &header_for(&1, locale)) ++ custom_field_names
+
+ example =
+ Enum.map(@columns, &example_for(&1, locale)) ++ Enum.map(custom_field_names, fn _ -> "" end)
+
+ [csv_row(header), csv_row(example)]
+ |> Enum.join("\n")
+ end
+
+ defp header_for({en, _de, _ex_en, _ex_de}, :en), do: en
+ defp header_for({_en, de, _ex_en, _ex_de}, :de), do: de
+
+ defp example_for({_en, _de, ex_en, _ex_de}, :en), do: ex_en
+ defp example_for({_en, _de, _ex_en, ex_de}, :de), do: ex_de
+
+ defp custom_field_names(actor) do
+ Mv.Membership.list_custom_fields!(actor: actor)
+ |> Enum.map(& &1.name)
+ end
+
+ # Serializes a row using the semicolon delimiter (the import auto-detects it),
+ # quoting any field that contains a delimiter, quote, or newline.
+ defp csv_row(fields) do
+ Enum.map_join(fields, ";", &escape_field/1)
+ end
+
+ # Neutralizes spreadsheet formula triggers (the same guard the export writer
+ # applies) before RFC 4180 quoting, so a custom-field name like
+ # `=HYPERLINK(...)` is not evaluated when the template is opened.
+ defp escape_field(field) do
+ field = field |> to_string() |> MembersCSV.safe_cell()
+
+ if String.contains?(field, [";", "\"", "\n", "\r"]) do
+ "\"" <> String.replace(field, "\"", "\"\"") <> "\""
+ else
+ field
+ end
+ end
+
+ defp current_actor(conn) do
+ conn.assigns[:current_user]
+ |> Actor.ensure_loaded()
+ end
+
+ defp return_forbidden(conn) do
+ conn
+ |> put_status(403)
+ |> put_resp_content_type("application/json")
+ |> json(%{error: "Forbidden"})
+ |> halt()
+ end
+end
diff --git a/lib/mv_web/live/import_live/components.ex b/lib/mv_web/live/import_live/components.ex
index 3bf10cb..eacc263 100644
--- a/lib/mv_web/live/import_live/components.ex
+++ b/lib/mv_web/live/import_live/components.ex
@@ -44,20 +44,12 @@ defmodule MvWeb.ImportLive.Components do
-
- <.link
- href={~p"/templates/member_import_en.csv"}
- download="member_import_en.csv"
- class="link link-primary"
- >
+ <.link href={~p"/admin/import/template/en"} class="link link-primary">
{gettext("English Template")}
-
- <.link
- href={~p"/templates/member_import_de.csv"}
- download="member_import_de.csv"
- class="link link-primary"
- >
+ <.link href={~p"/admin/import/template/de"} class="link link-primary">
{gettext("German Template")}
diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex
index 64036c9..bc2ab30 100644
--- a/lib/mv_web/router.ex
+++ b/lib/mv_web/router.ex
@@ -102,6 +102,10 @@ defmodule MvWeb.Router do
# Import (Admin only)
live "/admin/import", ImportLive
+ # Dynamic CSV import templates (admin only; generated from current custom fields)
+ get "/admin/import/template/en", ImportTemplateController, :en
+ get "/admin/import/template/de", ImportTemplateController, :de
+
post "/members/export.csv", MemberExportController, :export
post "/members/export.pdf", MemberPdfExportController, :export
post "/set_locale", LocaleController, :set_locale
diff --git a/test/mv_web/controllers/import_template_controller_test.exs b/test/mv_web/controllers/import_template_controller_test.exs
new file mode 100644
index 0000000..5799938
--- /dev/null
+++ b/test/mv_web/controllers/import_template_controller_test.exs
@@ -0,0 +1,104 @@
+defmodule MvWeb.ImportTemplateControllerTest do
+ use MvWeb.ConnCase, async: true
+
+ setup %{conn: conn} do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ {:ok, custom_field} =
+ Mv.Membership.CustomField
+ |> Ash.Changeset.for_create(:create, %{name: "Lieblingsfarbe", value_type: :string})
+ |> Ash.create(actor: actor)
+
+ %{conn: conn, custom_field: custom_field}
+ end
+
+ describe "authenticated EN template" do
+ setup %{conn: conn} do
+ admin = Mv.Fixtures.user_with_role_fixture("admin")
+ %{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)}
+ end
+
+ test "returns CSV with English headers and current custom fields", %{conn: conn} do
+ conn = get(conn, ~p"/admin/import/template/en")
+
+ assert response_content_type(conn, :csv) =~ "text/csv"
+ body = response(conn, 200)
+
+ header = body |> String.split("\n") |> List.first()
+ assert header =~ "email"
+ # EN headers use the canonical English variant from HeaderMapper, not the
+ # underscore form, so the template stays faithful to the documented variant list.
+ assert header =~ "first name"
+ assert header =~ "last name"
+ refute header =~ "first_name"
+ assert header =~ "house number"
+ refute header =~ "house_number"
+ assert header =~ "Lieblingsfarbe"
+
+ assert get_resp_header(conn, "content-disposition")
+ |> Enum.any?(&(&1 =~ "member_import_en.csv"))
+ end
+
+ test "neutralizes formula-injection in a custom field header", %{conn: conn} do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ {:ok, _} =
+ Mv.Membership.CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "=cmd|'/c calc'!A1",
+ value_type: :string
+ })
+ |> Ash.create(actor: actor)
+
+ conn = get(conn, ~p"/admin/import/template/en")
+ body = response(conn, 200)
+ header = body |> String.split("\n") |> List.first()
+
+ # The dangerous cell must be prefixed with a single quote so spreadsheet
+ # software does not evaluate it as a formula, matching the export writer.
+ refute header =~ ~r/(^|;)=cmd/
+ assert header =~ "'=cmd|'/c calc'!A1"
+ end
+ end
+
+ describe "authenticated DE template" do
+ setup %{conn: conn} do
+ admin = Mv.Fixtures.user_with_role_fixture("admin")
+ %{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)}
+ end
+
+ test "returns CSV with German headers and current custom fields", %{conn: conn} do
+ conn = get(conn, ~p"/admin/import/template/de")
+
+ body = response(conn, 200)
+ header = body |> String.split("\n") |> List.first()
+
+ assert header =~ "E-Mail"
+ assert header =~ "Vorname"
+ assert header =~ "Lieblingsfarbe"
+
+ assert get_resp_header(conn, "content-disposition")
+ |> Enum.any?(&(&1 =~ "member_import_de.csv"))
+ end
+ end
+
+ describe "authorization" do
+ @tag role: :unauthenticated
+ test "unauthenticated request does not receive a CSV", %{conn: conn} do
+ conn = get(conn, ~p"/admin/import/template/en")
+
+ refute conn.status == 200
+ refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv"))
+ refute to_string(conn.resp_body) =~ "email"
+ end
+
+ @tag role: :member
+ test "user without import permission is forbidden", %{conn: conn} do
+ conn = get(conn, ~p"/admin/import/template/en")
+
+ refute conn.status == 200
+ refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv"))
+ refute to_string(conn.resp_body) =~ "email"
+ end
+ end
+end
diff --git a/test/mv_web/live/import_live_test.exs b/test/mv_web/live/import_live_test.exs
index 09ec02c..7b4dd40 100644
--- a/test/mv_web/live/import_live_test.exs
+++ b/test/mv_web/live/import_live_test.exs
@@ -240,10 +240,15 @@ defmodule MvWeb.ImportLiveTest do
assert has_element?(view, "[data-testid='start-import-button']")
end
- test "template links and file input are present", %{conn: conn} do
+ test "template links point to the dynamic import template routes", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/admin/import")
+ assert has_element?(view, "a[href='/admin/import/template/en']")
+ assert has_element?(view, "a[href='/admin/import/template/de']")
+ refute has_element?(view, "a[href*='/templates/member_import_en.csv']")
+ end
+
+ test "file input is present", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import")
- assert has_element?(view, "a[href*='/templates/member_import_en.csv']")
- assert has_element?(view, "a[href*='/templates/member_import_de.csv']")
assert has_element?(view, "label[for='csv_file']")
assert has_element?(view, "#csv_file_help")
assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']")