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