Merge branch 'main' into issue/mitgliederverwaltung-420
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing

Integrate current main (CSV import, GDPR join-form description, dependency and
tooling bumps) into the bulk-actions-dropdown feature. Gettext catalogs were
reconciled with mix gettext.extract --merge; the CHANGELOG Unreleased entries
of both sides were combined.
This commit is contained in:
Simon 2026-06-04 16:56:27 +02:00
commit 6a6099659b
48 changed files with 3541 additions and 148 deletions

View file

@ -12,6 +12,8 @@ defmodule Mv.Membership.CustomField do
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation.
- `description` - Optional human-readable description
- `join_description` - 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 nil.
- `required` - If true, all members must have this custom field (future feature)
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
@ -61,7 +63,14 @@ defmodule Mv.Membership.CustomField do
end
actions do
default_accept [:name, :value_type, :description, :required, :show_in_overview]
default_accept [
:name,
:value_type,
:description,
:join_description,
:required,
:show_in_overview
]
read :read do
primary? true
@ -69,13 +78,13 @@ defmodule Mv.Membership.CustomField do
end
create :create do
accept [:name, :value_type, :description, :required, :show_in_overview]
accept [:name, :value_type, :description, :join_description, :required, :show_in_overview]
change Mv.Membership.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
update :update do
accept [:name, :description, :required, :show_in_overview]
accept [:name, :description, :join_description, :required, :show_in_overview]
require_atomic? false
validate fn changeset, _context ->
@ -139,6 +148,15 @@ defmodule Mv.Membership.CustomField do
trim?: true
]
attribute :join_description, :string,
allow_nil?: true,
public?: true,
description: "Label shown for this field on the public join form; supports external links",
constraints: [
max_length: 1000,
trim?: true
]
attribute :required, :boolean,
default: false,
allow_nil?: false

View file

@ -4,13 +4,13 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
Same logic as the member overview Formatter but without Gettext or web helpers,
so it can be used from the Membership context. For boolean: "Yes"/"No";
for date: European format (dd.mm.yyyy).
for date: ISO-8601 (YYYY-MM-DD) so exported values can be re-imported.
"""
@doc """
Formats a custom field value for plain text (e.g. CSV).
Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type
for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy.
for typing. Boolean -> "Yes"/"No", Date -> ISO-8601 (YYYY-MM-DD).
"""
def format_custom_field_value(nil, _custom_field), do: ""
@ -18,6 +18,10 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
format_value_by_type(value, type, custom_field)
end
def format_custom_field_value(%Date{} = value, custom_field) do
format_value_by_type(value, :date, custom_field)
end
def format_custom_field_value(value, custom_field) when is_map(value) do
type = Map.get(value, "type") || Map.get(value, "_union_type")
val = Map.get(value, "value") || Map.get(value, "_union_value")
@ -41,12 +45,12 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
defp format_value_by_type(value, :boolean, _), do: to_string(value)
defp format_value_by_type(%Date{} = date, :date, _) do
Calendar.strftime(date, "%d.%m.%Y")
Date.to_iso8601(date)
end
defp format_value_by_type(value, :date, _) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> Calendar.strftime(date, "%d.%m.%Y")
{:ok, date} -> Date.to_iso8601(date)
_ -> value
end
end

View file

@ -0,0 +1,258 @@
defmodule Mv.Membership.Import.ColumnResolver do
@moduledoc """
Read-only resolution of CSV import columns against the database.
Given the `HeaderMapper.build_maps/2` result, the raw numbered rows, and an
actor, `resolve/3` determines:
- which group names in the groups column already exist (`groups_found`) and
which would have to be created (`groups_to_create`);
- a small set of preview rows for the mapping preview UI.
No database writes happen here; the resolver only reads. Group creation and
member-group assignment happen during processing via `create_or_find_group/3`.
This module has no Phoenix or web dependencies.
"""
require Logger
alias Mv.Membership.Import.HeaderMapper
@preview_row_limit 3
@type numbered_row :: {pos_integer(), [String.t()]}
@type resolution :: %{
groups_found: [%{id: String.t(), name: String.t()}],
groups_to_create: [String.t()],
fee_type_map: %{String.t() => String.t()},
fee_type_warnings: [String.t()],
has_empty_fee_type_cells?: boolean(),
preview_rows: [[String.t()]]
}
@doc """
Resolves the group and fee-type columns of an import against the database and
extracts preview rows.
Returns a map with `:groups_found`, `:groups_to_create`, `:fee_type_map`,
`:fee_type_warnings`, `:has_empty_fee_type_cells?`, and `:preview_rows`.
"""
@spec resolve(map(), [numbered_row()], term()) :: resolution()
def resolve(header_maps, rows, actor) do
%{
groups_found: groups_found,
groups_to_create: groups_to_create
} = resolve_groups(header_maps, rows, actor)
%{
fee_type_map: fee_type_map,
fee_type_warnings: fee_type_warnings,
has_empty_fee_type_cells?: has_empty_fee_type_cells?
} = resolve_fee_types(header_maps, rows, actor)
%{
groups_found: groups_found,
groups_to_create: groups_to_create,
fee_type_map: fee_type_map,
fee_type_warnings: fee_type_warnings,
has_empty_fee_type_cells?: has_empty_fee_type_cells?,
preview_rows: preview_rows(rows)
}
end
defp resolve_groups(%{groups_column_index: nil}, _rows, _actor) do
%{groups_found: [], groups_to_create: []}
end
defp resolve_groups(%{groups_column_index: index}, rows, actor) do
existing_groups = list_groups(actor)
lookup = build_group_lookup(existing_groups)
names = unique_group_names(rows, index)
{found, to_create} =
Enum.reduce(names, {[], []}, fn name, {found, to_create} ->
case Map.get(lookup, normalize_name(name)) do
nil -> {found, [name | to_create]}
group -> {[%{id: group.id, name: group.name} | found], to_create}
end
end)
%{groups_found: Enum.reverse(found), groups_to_create: Enum.reverse(to_create)}
end
defp resolve_fee_types(%{fee_type_column_index: nil}, _rows, _actor) do
%{fee_type_map: %{}, fee_type_warnings: [], has_empty_fee_type_cells?: false}
end
defp resolve_fee_types(%{fee_type_column_index: index}, rows, actor) do
lookup = build_fee_type_lookup(actor)
cells = Enum.map(rows, fn {_line, values} -> Enum.at(values, index) end)
has_empty? = Enum.any?(cells, &blank?/1)
{fee_type_map, warnings} =
cells
|> Enum.reject(&blank?/1)
|> Enum.uniq_by(&normalize_fee_type_name/1)
|> Enum.reduce({%{}, []}, fn name, {map, warnings} ->
case Map.get(lookup, normalize_fee_type_name(name)) do
nil -> {map, [String.trim(name) | warnings]}
id -> {Map.put(map, normalize_fee_type_name(name), id), warnings}
end
end)
%{
fee_type_map: fee_type_map,
fee_type_warnings: Enum.reverse(warnings),
has_empty_fee_type_cells?: has_empty?
}
end
@doc """
Normalizes a fee-type name using the same rules as CSV header normalization
(trim, lowercase, transliterate, drop hyphens and whitespace).
"""
@spec normalize_fee_type_name(String.t() | nil) :: String.t()
def normalize_fee_type_name(name) when is_binary(name), do: HeaderMapper.normalize_header(name)
def normalize_fee_type_name(_), do: ""
defp build_fee_type_lookup(actor) do
actor
|> list_fee_types()
|> Enum.reduce(%{}, fn fee_type, acc ->
normalized = normalize_fee_type_name(fee_type.name)
if Map.has_key?(acc, normalized) do
Logger.warning(
"Multiple membership fee types normalize to #{inspect(normalized)}; using the first match for CSV import."
)
acc
else
Map.put(acc, normalized, fee_type.id)
end
end)
end
defp list_fee_types(actor) do
Mv.MembershipFees.list_membership_fee_types!(actor: actor)
end
defp blank?(nil), do: true
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
defp blank?(_), do: false
@doc """
Finds an existing group by name (case-insensitive) or creates it.
Looks first in the pre-fetched `groups` list, then in the database (to catch
groups created earlier in the same import), and only creates a new group when
none is found. This keeps group resolution idempotent across re-imports.
"""
@spec create_or_find_group(String.t(), [Mv.Membership.Group.t()], term()) ::
{:ok, Mv.Membership.Group.t()} | {:error, term()}
def create_or_find_group(name, groups, actor) when is_binary(name) do
trimmed = String.trim(name)
normalized = normalize_name(trimmed)
case find_group_in_list(groups, normalized) do
nil -> find_or_create_group(trimmed, normalized, actor)
group -> {:ok, group}
end
end
defp find_group_in_list(groups, normalized) do
Enum.find(groups, fn group -> normalize_name(group.name) == normalized end)
end
defp find_or_create_group(trimmed, normalized, actor) do
case fetch_group_by_normalized_name(normalized, actor) do
nil -> create_group(trimmed, normalized, actor)
group -> {:ok, group}
end
end
# Normalizes the Ash code-interface return to a two-shape result.
#
# On a create failure the group may have been created concurrently by another
# import session between our read and our write (the DB unique index is the
# final arbiter, and the name validation is fail-open). Re-fetch by normalized
# name and link to the existing group rather than failing the row.
defp create_group(name, normalized, actor) do
case Mv.Membership.create_group(%{name: name}, actor: actor) do
{:ok, %Mv.Membership.Group{} = group} ->
{:ok, group}
{:error, reason} ->
case fetch_group_by_normalized_name(normalized, actor) do
nil -> {:error, reason}
group -> {:ok, group}
end
end
end
# Fetches a single group by case-insensitive name using a name-filtered query
# rather than reading the whole groups table. `normalized` is the trimmed,
# lower-cased name; the DB comparison uses LOWER(name) consistent with the
# Group resource's case-insensitive uniqueness constraint.
defp fetch_group_by_normalized_name(normalized, actor) do
require Ash.Query
Mv.Membership.Group
|> Ash.Query.filter(fragment("LOWER(?) = ?", name, ^normalized))
|> Ash.read(actor: actor, domain: Mv.Membership)
|> case do
{:ok, [group | _]} -> group
_ -> nil
end
end
@doc """
Splits a raw groups-cell value into trimmed, non-empty group names.
"""
@spec split_group_names(String.t() | nil) :: [String.t()]
def split_group_names(nil), do: []
def split_group_names(cell) when is_binary(cell) do
cell
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp unique_group_names(rows, index) do
rows
|> Enum.flat_map(fn {_line, values} ->
values
|> Enum.at(index)
|> split_group_names()
end)
|> Enum.uniq_by(&normalize_name/1)
end
defp preview_rows(rows) do
rows
|> Enum.take(@preview_row_limit)
|> Enum.map(fn {_line, values} -> values end)
end
defp list_groups(actor) do
Mv.Membership.list_groups!(actor: actor)
end
defp build_group_lookup(groups) do
Enum.reduce(groups, %{}, fn group, acc ->
Map.put(acc, normalize_name(group.name), group)
end)
end
# Case-insensitive comparison consistent with the Group resource's
# case-insensitive name uniqueness.
defp normalize_name(name) when is_binary(name) do
name |> String.trim() |> String.downcase()
end
end

View file

@ -29,12 +29,21 @@ defmodule Mv.Membership.Import.HeaderMapper do
Supports English and German header variants (e.g. "Email" / "E-Mail", "Join Date" / "Beitrittsdatum").
## Special columns
- **groups** Many-to-many relationship (through member_groups). Recognized via the
`groups_column_index` key (headers `Groups`, `Gruppen`, `Gruppe`). Comma-separated
names are resolved during processing; missing groups are auto-created.
- **membership_fee_type** Recognized via the `fee_type_column_index` key (headers
`Fee Type`, `fee_type`, `membership_fee_type`, `Beitragsart`). Names are matched to
existing fee types; unknown names fall back to the default fee type.
## Fields not supported for import
- **membership_fee_status** Computed (calculation from membership fee cycles). Not stored;
cannot be set via CSV. Export can include it.
- **groups** Many-to-many relationship (through member_groups). Import would require
resolving group names/slugs to IDs and creating associations; not in current import scope.
cannot be set via CSV. Export can include it. Fee-status header variants
(`Membership Fee Status`, `Bezahlstatus`, `Mitgliedsbeitragsstatus`) are explicitly
placed in the `ignored` list and never mapped.
## Custom Field Detection
@ -47,10 +56,10 @@ defmodule Mv.Membership.Import.HeaderMapper do
"e-mail"
iex> HeaderMapper.build_maps(["Email", "First Name"], [])
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
iex> HeaderMapper.build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
"""
@type column_map :: %{atom() => non_neg_integer()}
@ -60,6 +69,33 @@ defmodule Mv.Membership.Import.HeaderMapper do
# Required member fields
@required_member_fields [:email]
# Fee-status header variants that must never be imported (computed/read-only field).
# Stored already-normalized; checked before member, custom, groups, and fee-type mapping.
# Maintain this list when new locale translations for fee-status are added.
@ignored_normalized [
"membershipfeestatus",
"mitgliedsbeitragsstatus",
"bezahlstatus",
# DE export label for membership_fee_start_date — system-managed, not importable
"startdatummitgliedsbeitrag"
]
# Normalized header variants for the groups column. The column is resolved to
# group associations during import; it is never a member or custom field.
@groups_column_normalized [
"groups",
"gruppen",
"gruppe"
]
# Normalized header variants for the membership fee-type column. The column is
# resolved to a MembershipFeeType during import; it is never a member or custom field.
@fee_type_column_normalized [
"membershipfeetype",
"feetype",
"beitragsart"
]
# Canonical member fields with their raw variants
# These will be normalized at runtime when building the lookup map
@member_field_variants_raw %{
@ -239,30 +275,79 @@ defmodule Mv.Membership.Import.HeaderMapper do
## Returns
- `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers}}` on success
- `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers,
ignored: [non_neg_integer], groups_column_index: non_neg_integer | nil,
fee_type_column_index: non_neg_integer | nil}}` on success
- `{:error, reason}` on error (missing required field, duplicate headers)
The `ignored` list holds the indices of fee-status columns (computed/read-only),
which are never mapped to member or custom fields.
## Examples
iex> build_maps(["Email", "First Name"], [])
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
iex> build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
"""
@spec build_maps([String.t()], [map()]) ::
{:ok, %{member: column_map(), custom: custom_field_map(), unknown: unknown_headers()}}
{:ok,
%{
member: column_map(),
custom: custom_field_map(),
unknown: unknown_headers(),
ignored: [non_neg_integer()],
groups_column_index: non_neg_integer() | nil,
fee_type_column_index: non_neg_integer() | nil
}}
| {:error, String.t()}
def build_maps(headers, custom_fields) when is_list(headers) and is_list(custom_fields) do
with {:ok, member_map, unknown_after_member} <- build_member_map(headers),
ignored = ignored_indices(headers)
groups_column_index = first_matching_index(headers, @groups_column_normalized)
fee_type_column_index = first_matching_index(headers, @fee_type_column_normalized)
reserved =
[groups_column_index, fee_type_column_index | ignored]
|> Enum.reject(&is_nil/1)
|> MapSet.new()
with {:ok, member_map, unknown_after_member} <- build_member_map(headers, reserved),
{:ok, custom_map, unknown_after_custom} <-
build_custom_field_map(headers, unknown_after_member, custom_fields, member_map) do
unknown = Enum.map(unknown_after_custom, &Enum.at(headers, &1))
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}}
{:ok,
%{
member: member_map,
custom: custom_map,
unknown: unknown,
ignored: ignored,
groups_column_index: groups_column_index,
fee_type_column_index: fee_type_column_index
}}
end
end
# Returns the index of the first header whose normalized form is in `variants`,
# or nil if none match.
defp first_matching_index(headers, variants) do
headers
|> Enum.with_index()
|> Enum.find_value(fn {header, index} ->
if normalize_header(header) in variants, do: index
end)
end
# Returns the column indices whose normalized header is in the fee-status ignore list.
defp ignored_indices(headers) do
headers
|> Enum.with_index()
|> Enum.filter(fn {header, _index} -> normalize_header(header) in @ignored_normalized end)
|> Enum.map(fn {_header, index} -> index end)
end
# --- Private Functions ---
# Transliterates German umlauts and special characters
@ -304,13 +389,14 @@ defmodule Mv.Membership.Import.HeaderMapper do
|> String.replace(" ", "")
end
# Builds member field column map
defp build_member_map(headers) do
# Builds member field column map, skipping reserved (e.g. ignored) indices.
defp build_member_map(headers, reserved) do
result =
headers
|> Enum.with_index()
|> Enum.reduce_while({%{}, []}, fn {header, index}, {acc_map, acc_unknown} ->
normalized = normalize_header(header)
normalized =
if MapSet.member?(reserved, index), do: "", else: normalize_header(header)
case process_member_header(header, index, normalized, acc_map, %{}) do
{:error, reason} ->

View file

@ -80,7 +80,7 @@ defmodule Mv.Membership.Import.ImportRunner do
all_errors = progress.errors ++ chunk_result.errors
new_errors = Enum.take(all_errors, max_errors)
errors_truncated? = length(all_errors) > max_errors
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
new_warnings = Enum.uniq(progress.warnings ++ Map.get(chunk_result, :warnings, []))
chunks_processed = current_chunk_idx + 1
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
@ -97,6 +97,20 @@ defmodule Mv.Membership.Import.ImportRunner do
}
end
@doc """
Carries the in-memory group snapshot grown by a chunk back into `import_state`
so the next chunk reuses groups created earlier instead of re-reading the
Group table. When the chunk result omits `groups_found`, the state is returned
unchanged.
"""
@spec carry_groups_forward(map(), map()) :: map()
def carry_groups_forward(import_state, chunk_result) do
case Map.fetch(chunk_result, :groups_found) do
{:ok, groups_found} -> Map.put(import_state, :groups_found, groups_found)
:error -> import_state
end
end
@doc """
Returns the next action after processing a chunk: send the next chunk index or done.
"""

View file

@ -6,7 +6,7 @@ defmodule Mv.Membership.Import.MemberCSV do
This module provides the core API for CSV member import functionality:
- `prepare/2` - Parses and validates CSV content, returns import state
- `process_chunk/3` - Processes a chunk of rows and creates members
- `process_chunk/4` - Processes a chunk of rows and creates members
## Error Handling
@ -22,13 +22,24 @@ defmodule Mv.Membership.Import.MemberCSV do
- `column_map` - Map of canonical field names to column indices
- `custom_field_map` - Map of custom field names to column indices
- `warnings` - List of warning messages (e.g., unknown custom field columns)
- `headers` - The raw CSV header row
- `ignored` - Header names of ignored (fee-status) columns
- `groups_column_index` / `fee_type_column_index` - Indices for resolved columns (or nil)
- `groups_found` / `groups_to_create` - Existing and to-be-created groups from the preview
- `fee_type_map` - Normalized fee-type name to id, for matched fee types
- `fee_type_warnings` - Unmatched fee-type names surfaced in the preview
- `has_empty_fee_type_cells?` - Whether any fee-type cell is blank (default applies)
- `preview_rows` - Up to 3 sample data rows for the mapping preview
## Chunk Results
The `chunk_result` returned by `process_chunk/3` contains:
The `chunk_result` returned by `process_chunk/4` contains:
- `inserted` - Number of successfully created members
- `failed` - Number of failed member creations
- `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import)
- `groups_found` - The in-memory group snapshot grown while processing this
chunk; thread it into the next chunk's `:groups_found` opt so groups created
in an earlier chunk are reused without re-reading the Group table
## Examples
@ -37,7 +48,9 @@ defmodule Mv.Membership.Import.MemberCSV do
# Process first chunk
chunk = Enum.at(import_state.chunks, 0)
{:ok, result} = MemberCSV.process_chunk(chunk, import_state.column_map)
{:ok, result} =
MemberCSV.process_chunk(chunk, import_state.column_map, import_state.custom_field_map, [])
"""
defmodule Error do
@ -66,16 +79,29 @@ defmodule Mv.Membership.Import.MemberCSV do
custom_field_lookup: %{
String.t() => %{id: String.t(), value_type: atom(), name: String.t()}
},
warnings: list(String.t())
warnings: list(String.t()),
headers: list(String.t()),
ignored: list(String.t()),
groups_column_index: non_neg_integer() | nil,
fee_type_column_index: non_neg_integer() | nil,
groups_found: list(%{id: String.t(), name: String.t()}),
groups_to_create: list(String.t()),
fee_type_map: %{String.t() => String.t()},
fee_type_warnings: list(String.t()),
has_empty_fee_type_cells?: boolean(),
preview_rows: list(list(String.t()))
}
@type chunk_result :: %{
inserted: non_neg_integer(),
failed: non_neg_integer(),
errors: list(Error.t()),
errors_truncated?: boolean()
errors_truncated?: boolean(),
warnings: list(String.t()),
groups_found: list(Mv.Membership.Group.t() | %{id: String.t(), name: String.t()})
}
alias Mv.Membership.Import.ColumnResolver
alias Mv.Membership.Import.CsvParser
alias Mv.Membership.Import.HeaderMapper
@ -139,13 +165,27 @@ defmodule Mv.Membership.Import.MemberCSV do
# Build custom field lookup for efficient value processing
custom_field_lookup = build_custom_field_lookup(custom_fields)
# Resolve DB-backed columns (groups, fee types) read-only for the preview.
resolution = ColumnResolver.resolve(maps, rows, actor)
ignored_headers = Enum.map(maps.ignored, &Enum.at(headers, &1))
{:ok,
%{
chunks: chunks,
column_map: maps.member,
custom_field_map: maps.custom,
custom_field_lookup: custom_field_lookup,
warnings: warnings
warnings: warnings,
headers: headers,
ignored: ignored_headers,
groups_column_index: maps.groups_column_index,
fee_type_column_index: maps.fee_type_column_index,
groups_found: resolution.groups_found,
groups_to_create: resolution.groups_to_create,
fee_type_map: resolution.fee_type_map,
fee_type_warnings: resolution.fee_type_warnings,
has_empty_fee_type_cells?: resolution.has_empty_fee_type_cells?,
preview_rows: resolution.preview_rows
}}
end
end
@ -180,7 +220,7 @@ defmodule Mv.Membership.Import.MemberCSV do
end)
case HeaderMapper.build_maps(headers, custom_field_maps) do
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}} ->
{:ok, %{unknown: unknown} = maps} ->
# Build warnings for unknown custom field columns
warnings =
unknown
@ -197,7 +237,7 @@ defmodule Mv.Membership.Import.MemberCSV do
)
end)
{:ok, %{member: member_map, custom: custom_map}, warnings}
{:ok, maps, warnings}
{:error, reason} ->
{:error, reason}
@ -250,9 +290,20 @@ defmodule Mv.Membership.Import.MemberCSV do
Map.put(acc, custom_field_id, value)
end)
%{member: member_map, custom: custom_map}
%{
member: member_map,
custom: custom_map,
fee_type: cell_at(row_tuple, tuple_size, maps.fee_type_column_index),
groups: cell_at(row_tuple, tuple_size, maps.groups_column_index)
}
end
# Returns the raw cell at the given index, or nil if the column is absent.
defp cell_at(_row_tuple, _size, nil), do: nil
defp cell_at(row_tuple, size, index) when index < size, do: elem(row_tuple, index)
defp cell_at(_row_tuple, _size, _index), do: ""
@doc """
Processes a chunk of CSV rows and creates members.
@ -268,12 +319,18 @@ defmodule Mv.Membership.Import.MemberCSV do
- `chunk_rows_with_lines` - List of tuples `{csv_line_number, row_map}` where:
- `csv_line_number` - Physical line number in CSV (1-based)
- `row_map` - Map with `:member` and `:custom` keys containing field values
- `column_map` - Map of canonical field names (atoms) to column indices (for reference)
- `custom_field_map` - Map of custom field IDs (strings) to column indices (for reference)
- `column_map` - Unused; kept for backward-compatible call sites. Field values are
read from each row's pre-built `:member`/`:custom` maps, not from this argument.
- `custom_field_map` - Unused; kept for backward-compatible call sites (see above).
- `opts` - Optional keyword list for processing options:
- `:custom_field_lookup` - Map of custom field IDs to metadata (default: `%{}`)
- `:existing_error_count` - Number of errors already collected in previous chunks (default: `0`)
- `:max_errors` - Maximum number of errors to collect per import overall (default: `50`)
- `:actor` - Actor used for all writes (default: the system actor)
- `:fee_type_map` - Map of normalized fee-type name to fee-type id, used to resolve
each row's fee-type cell (default: `%{}`)
- `:groups_found` - List of pre-fetched `Group` structs seeding in-memory group
resolution; the snapshot grows as groups are auto-created (default: `[]`)
## Error Capping
@ -312,27 +369,49 @@ defmodule Mv.Membership.Import.MemberCSV do
existing_error_count = Keyword.get(opts, :existing_error_count, 0)
max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
actor = Keyword.get(opts, :actor, SystemActor.get_system_actor())
fee_type_map = Keyword.get(opts, :fee_type_map, %{})
groups_found = Keyword.get(opts, :groups_found, [])
{inserted, failed, errors, _collected_error_count, truncated?} =
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false}, fn {line_number, row_map},
{acc_inserted, acc_failed,
acc_errors, acc_error_count,
acc_truncated?} ->
base_row_opts = %{
custom_field_lookup: custom_field_lookup,
fee_type_map: fee_type_map,
actor: actor
}
{inserted, failed, errors, _collected_error_count, truncated?, warnings, groups_acc} =
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false, [], groups_found}, fn {line_number,
row_map},
{acc_inserted,
acc_failed,
acc_errors,
acc_error_count,
acc_truncated?,
acc_warnings,
acc_groups} ->
current_error_count = existing_error_count + acc_error_count
row_opts = Map.put(base_row_opts, :groups_found, acc_groups)
case process_row(row_map, line_number, custom_field_lookup, actor) do
{:ok, _member} ->
update_inserted(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}
)
case process_row(row_map, line_number, row_opts) do
{:ok, _member, row_warnings, new_groups} ->
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?} =
update_inserted(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}
)
{:error, error} ->
handle_row_error(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?},
error,
current_error_count,
max_errors
)
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?,
acc_warnings ++ row_warnings, new_groups}
{:error, error, new_groups} ->
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?} =
handle_row_error(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?},
error,
current_error_count,
max_errors
)
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?, acc_warnings,
new_groups}
end
end)
@ -341,7 +420,9 @@ defmodule Mv.Membership.Import.MemberCSV do
inserted: inserted,
failed: failed,
errors: Enum.reverse(errors),
errors_truncated?: truncated?
errors_truncated?: truncated?,
warnings: warnings,
groups_found: groups_acc
}}
end
@ -505,18 +586,27 @@ defmodule Mv.Membership.Import.MemberCSV do
defp gettext_error_message(_), do: gettext("Email is invalid.")
# Processes a single row and creates member with custom field values
# Processes a single row and creates member with custom field values.
# On success returns {:ok, member, warnings, groups}; warnings carry non-fatal
# notices such as an unresolved fee-type name. The returned groups list is the
# accumulated in-memory group snapshot (seeded from the chunk, grown with any
# group created while linking this row) so later rows reuse it instead of
# re-reading the whole Group table per row.
defp process_row(
row_map,
line_number,
custom_field_lookup,
actor
%{
custom_field_lookup: custom_field_lookup,
fee_type_map: fee_type_map,
groups_found: groups_found,
actor: actor
} = _row_opts
) do
# Validate row before database insertion
case validate_row(row_map, line_number, []) do
{:error, error} ->
# Return validation error immediately, no DB insert attempted
{:error, error}
{:error, error, groups_found}
{:ok, %{member: trimmed_member_attrs, custom: custom_attrs}} ->
# Prepare custom field values for Ash
@ -524,20 +614,119 @@ defmodule Mv.Membership.Import.MemberCSV do
{:error, validation_errors} ->
# Custom field validation errors - return first error
first_error = List.first(validation_errors)
{:error, %Error{csv_line_number: line_number, field: nil, message: first_error}}
{:error, %Error{csv_line_number: line_number, field: nil, message: first_error},
groups_found}
{:ok, custom_field_values} ->
create_member_with_custom_fields(
trimmed_member_attrs,
{fee_attrs, warnings} =
resolve_fee_type_attrs(Map.get(row_map, :fee_type), fee_type_map)
create_member_and_assign_groups(
Map.merge(trimmed_member_attrs, fee_attrs),
custom_field_values,
Map.get(row_map, :groups),
groups_found,
line_number,
actor
actor,
warnings
)
end
end
rescue
e ->
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)},
groups_found}
end
# Creates the member, then assigns groups as a post-creation step. A group
# assignment failure fails the row (the member was already created, but the
# row is reported as failed so the operator can act on it).
defp create_member_and_assign_groups(
member_attrs,
custom_field_values,
groups_cell,
groups_found,
line_number,
actor,
warnings
) do
case create_member_with_custom_fields(
member_attrs,
custom_field_values,
line_number,
actor,
warnings
) do
{:ok, member, member_warnings} ->
assign_groups(member, groups_cell, groups_found, line_number, actor, member_warnings)
{:error, error} ->
{:error, error, groups_found}
end
end
# Assigns the member to all groups listed in the cell, creating missing groups.
# Returns the (possibly grown) group snapshot so the caller can reuse it.
defp assign_groups(member, groups_cell, groups_found, line_number, actor, warnings) do
names = ColumnResolver.split_group_names(groups_cell)
Enum.reduce_while(names, {:ok, member, warnings, groups_found}, fn name,
{:ok, _m, _w, acc_groups} ->
case link_member_to_group(member, name, acc_groups, actor) do
{:ok, group} ->
{:cont, {:ok, member, warnings, add_group(acc_groups, group)}}
{:error, reason} ->
{:halt,
{:error,
%Error{
csv_line_number: line_number,
field: nil,
message: gettext("Group assignment failed: %{reason}", reason: inspect(reason))
}, acc_groups}}
end
end)
end
defp add_group(groups, group) do
if Enum.any?(groups, &(&1.id == group.id)), do: groups, else: [group | groups]
end
defp link_member_to_group(member, name, groups_found, actor) do
with {:ok, group} <- ColumnResolver.create_or_find_group(name, groups_found, actor),
{:ok, _member_group} <-
Mv.Membership.create_member_group(
%{member_id: member.id, group_id: group.id},
actor: actor
) do
{:ok, group}
end
end
# Resolves the fee-type cell into member attrs plus optional warnings.
# Empty cell -> default fee type (SetDefaultMembershipFeeType), no warning.
# Matched name -> membership_fee_type_id attr.
# Unmatched name -> no attr (default applies), warning naming the value.
defp resolve_fee_type_attrs(nil, _fee_type_map), do: {%{}, []}
defp resolve_fee_type_attrs(cell, fee_type_map) when is_binary(cell) do
trimmed = String.trim(cell)
if trimmed == "" do
{%{}, []}
else
case Map.get(fee_type_map, ColumnResolver.normalize_fee_type_name(trimmed)) do
nil ->
{%{},
[
gettext("Fee type '%{name}' not found; using the default fee type.", name: trimmed)
]}
fee_type_id ->
{%{membership_fee_type_id: fee_type_id}, []}
end
end
end
# Creates a member with custom field values, handling errors appropriately
@ -545,7 +734,8 @@ defmodule Mv.Membership.Import.MemberCSV do
trimmed_member_attrs,
custom_field_values,
line_number,
actor
actor,
warnings
) do
# Convert empty strings to nil for date fields so Ash accepts them
member_attrs = sanitize_date_fields(trimmed_member_attrs)
@ -565,7 +755,7 @@ defmodule Mv.Membership.Import.MemberCSV do
case Mv.Membership.create_member(final_attrs, actor: actor) do
{:ok, member} ->
{:ok, member}
{:ok, member, warnings}
{:error, %Ash.Error.Invalid{} = error} ->
# Extract email from final_attrs for better error messages

View file

@ -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

View file

@ -0,0 +1,70 @@
defmodule MvWeb.Helpers.JoinDescriptionRenderer do
@moduledoc """
Renders a custom field's `join_description` into Phoenix-safe HTML for the
public join form.
The renderer auto-links two patterns into `<a href="...">` tags:
- Markdown links of the form `[text](url)` (processed first)
- bare `http(s)://` URLs in the remaining text
All other content is HTML-escaped: only `<a href="...">` tags are ever
emitted, so arbitrary HTML in the input is rendered as inert text. This is a
defense-in-depth measure `join_description` is admin-set content, never
end-user input but the renderer must not become a vector for injecting
arbitrary markup.
Markdown links are matched before bare URLs and their matched region is
consumed, so a Markdown link whose URL also looks like a bare URL is linked
exactly once (no nested anchors).
"""
@markdown_link ~r/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/
@bare_url ~r/(https?:\/\/[^\s<]+)/
@bare_url_anchored ~r/\A(https?:\/\/[^\s<]+)\z/
@doc """
Converts `value` to a Phoenix-safe HTML iolist.
Returns `{:safe, ""}` for `nil`. For a string, returns `{:safe, iolist}` with
links rendered and all other text HTML-escaped.
"""
@spec render(String.t() | nil) :: Phoenix.HTML.safe()
def render(nil), do: {:safe, ""}
def render(value) when is_binary(value) do
{:safe, render_segments(value)}
end
# Split on Markdown links first; for each non-Markdown segment, link bare URLs;
# everything that is not a link is HTML-escaped.
defp render_segments(text) do
Regex.split(@markdown_link, text, include_captures: true)
|> Enum.map(&render_markdown_or_plain/1)
end
defp render_markdown_or_plain(segment) do
case Regex.run(@markdown_link, segment) do
[^segment, label, url] -> anchor(url, label)
_ -> render_plain(segment)
end
end
# Auto-link bare URLs in a plain-text segment, escaping all surrounding text.
defp render_plain(segment) do
Regex.split(@bare_url, segment, include_captures: true)
|> Enum.map(fn part ->
if Regex.match?(@bare_url_anchored, part) do
anchor(part, part)
else
escape(part)
end
end)
end
defp anchor(url, label) do
["<a href=\"", escape(url), "\" class=\"link link-primary\">", escape(label), "</a>"]
end
defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
end

View file

@ -91,6 +91,45 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
<% end %>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
<fieldset class="mb-2 fieldset">
<label>
<span class="mb-1 label flex items-center gap-2">
{gettext("Description for join form")}
<.tooltip
content={
gettext(
"You can add links: full addresses (https://…) or as [link text](https://…)."
)
}
position="right"
>
<span
data-testid="join-description-link-hint"
aria-label={
gettext(
"You can add links: full addresses (https://…) or as [link text](https://…)."
)
}
>
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
</.tooltip>
</span>
<input
type="text"
name={@form[:join_description].name}
id={@form[:join_description].id}
value={Phoenix.HTML.Form.normalize_value("text", @form[:join_description].value)}
class="w-full input"
/>
</label>
</fieldset>
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input
field={@form[:show_in_overview]}

View file

@ -99,7 +99,12 @@ defmodule MvWeb.ImportLive do
<.form_section title={gettext("Choose CSV file")}>
<Components.custom_fields_notice {assigns} />
<Components.template_links {assigns} />
<Components.import_form {assigns} />
<%= if @import_status != :preview do %>
<Components.import_form {assigns} />
<% end %>
<%= if @import_status == :preview do %>
<Components.preview {assigns} />
<% end %>
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
<Components.import_progress {assigns} />
<% end %>
@ -133,6 +138,29 @@ defmodule MvWeb.ImportLive do
end
end
@impl true
def handle_event("confirm_import", _params, socket) do
case socket.assigns do
%{import_state: import_state} when is_map(import_state) ->
start_import(socket, import_state)
_ ->
{:noreply,
put_flash(socket, :error, gettext("No prepared import to confirm. Please upload again."))}
end
end
@impl true
def handle_event("cancel_import", _params, socket) do
socket =
socket
|> assign(:import_state, nil)
|> assign(:import_progress, nil)
|> assign(:import_status, :idle)
{:noreply, socket}
end
# Checks if all prerequisites for starting an import are met.
#
# Validates:
@ -169,10 +197,10 @@ defmodule MvWeb.ImportLive do
end
end
# Processes CSV upload and starts import process.
# Processes CSV upload and enters the mapping preview.
#
# Reads the uploaded CSV file, prepares it for import, and initiates
# the chunked processing workflow.
# Reads the uploaded CSV file and prepares it (read-only at the DB level), then
# shows the mapping preview. No member is created until the user confirms.
@spec process_csv_upload(Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
defp process_csv_upload(socket) do
@ -181,7 +209,7 @@ defmodule MvWeb.ImportLive do
with {:ok, content} <- consume_and_read_csv(socket),
{:ok, import_state} <-
MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do
start_import(socket, import_state)
enter_preview(socket, import_state)
else
{:error, reason} when is_binary(reason) ->
{:noreply,
@ -193,6 +221,19 @@ defmodule MvWeb.ImportLive do
end
end
# Shows the mapping preview without starting any processing.
@spec enter_preview(Phoenix.LiveView.Socket.t(), map()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
defp enter_preview(socket, import_state) do
socket =
socket
|> assign(:import_state, import_state)
|> assign(:import_progress, nil)
|> assign(:import_status, :preview)
{:noreply, socket}
end
# Starts the import process by initializing progress tracking and scheduling the first chunk.
@spec start_import(Phoenix.LiveView.Socket.t(), map()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@ -263,7 +304,9 @@ defmodule MvWeb.ImportLive do
custom_field_lookup: import_state.custom_field_lookup,
existing_error_count: length(progress.errors),
max_errors: @max_errors,
actor: actor
actor: actor,
fee_type_map: import_state.fee_type_map,
groups_found: import_state.groups_found
]
_ =
@ -324,8 +367,11 @@ defmodule MvWeb.ImportLive do
new_progress =
ImportRunner.merge_progress(progress, chunk_result, idx, max_errors: @max_errors)
new_import_state = ImportRunner.carry_groups_forward(import_state, chunk_result)
socket =
socket
|> assign(:import_state, new_import_state)
|> assign(:import_progress, new_progress)
|> assign(:import_status, new_progress.status)
|> maybe_send_next_chunk(idx, length(import_state.chunks))

View file

@ -25,7 +25,22 @@ defmodule MvWeb.ImportLive.Components do
<div>
<p class="text-sm mb-2">
{gettext(
"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. Groups and membership fees are not supported for import."
"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."
)}
</p>
<p class="text-sm mb-2">
{gettext(
"Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
)}
</p>
<p class="text-sm mb-2">
{gettext(
"Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
)}
</p>
<p class="text-sm">
{gettext(
"Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
)}
</p>
</div>
@ -44,20 +59,12 @@ defmodule MvWeb.ImportLive.Components do
</p>
<ul class="list-disc list-inside space-y-1">
<li>
<.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>
</li>
<li>
<.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")}
</.link>
</li>
@ -108,6 +115,194 @@ defmodule MvWeb.ImportLive.Components do
"""
end
@doc """
Renders the mapping preview shown between upload and processing.
Shows the column-to-role mapping, up to 3 sample rows, and notices for
auto-created groups, unresolved fee types, empty fee-type cells, and unknown
columns. Nothing is written until the user confirms.
"""
def preview(assigns) do
state = assigns.import_state
column_roles = column_roles(state)
column_samples = column_samples(state.preview_rows, length(state.headers))
assigns =
assigns
|> assign(:column_roles, column_roles)
|> assign(:column_samples, column_samples)
~H"""
<section
class="mt-4 space-y-4"
data-testid="import-preview"
aria-labelledby="import-preview-heading"
>
<h2 id="import-preview-heading" class="text-lg font-semibold">
{gettext("Preview import")}
</h2>
<div class="overflow-x-auto">
<table class="table table-sm w-full" data-testid="preview-mapping-table">
<thead>
<tr>
<th>{gettext("Role")}</th>
<th>{gettext("Column")}</th>
<th class="text-base-content/60">{gettext("Row 1")}</th>
<th class="text-base-content/60">{gettext("Row 2")}</th>
<th class="text-base-content/60">{gettext("Row 3")}</th>
</tr>
</thead>
<tbody>
<%= for {{header, role}, samples} <- Enum.zip(@column_roles, @column_samples) do %>
<tr class={role_row_class(role)} data-testid="preview-column-row">
<td>
<span class={"badge badge-sm #{role_badge_class(role)}"}>
{role_label(role)}
</span>
</td>
<td class="font-medium">{header}</td>
<%= for sample <- samples do %>
<td class="text-base-content/70 max-w-32 truncate">{sample}</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<%= if @import_state.groups_to_create != [] do %>
<div class="alert alert-info" role="note" data-testid="preview-groups-notice">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm">
{gettext("These groups will be created automatically: %{names}",
names: Enum.join(@import_state.groups_to_create, ", ")
)}
</p>
</div>
</div>
<% end %>
<%= if @import_state.fee_type_warnings != [] do %>
<div class="alert alert-warning" role="alert" data-testid="preview-fee-type-warning">
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm">
{gettext("Unknown fee types (members get the default): %{names}",
names: Enum.join(@import_state.fee_type_warnings, ", ")
)}
</p>
<.link
navigate={~p"/membership_fee_settings/new_fee_type"}
class="link link-primary text-sm"
>
{gettext("Create fee type")}
</.link>
</div>
</div>
<% end %>
<%= if @import_state.has_empty_fee_type_cells? do %>
<div class="alert alert-info" role="note" data-testid="preview-fee-type-info">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm">
{gettext("Rows with an empty fee type will get the default fee type.")}
</p>
</div>
</div>
<% end %>
<%= if @import_state.warnings != [] do %>
<div class="alert alert-warning" role="alert" data-testid="preview-unknown-warning">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for warning <- @import_state.warnings do %>
<li>{warning}</li>
<% end %>
</ul>
<.link navigate={~p"/admin/datafields"} class="link link-primary text-sm">
{gettext("Create custom field")}
</.link>
</div>
</div>
<% end %>
<div class="flex gap-2">
<.button
type="button"
phx-click="confirm_import"
variant="primary"
data-testid="confirm-import-button"
>
{gettext("Confirm and Import")}
</.button>
<.button type="button" phx-click="cancel_import" data-testid="cancel-import-button">
{gettext("Cancel")}
</.button>
</div>
</section>
"""
end
# Pairs each CSV header with its resolved role for the preview mapping table.
defp column_roles(state) do
member_indices = MapSet.new(Map.values(state.column_map))
custom_indices = MapSet.new(Map.values(state.custom_field_map))
ignored_headers = MapSet.new(state.ignored)
state.headers
|> Enum.with_index()
|> Enum.map(fn {header, index} ->
{header, role_for(index, header, state, member_indices, custom_indices, ignored_headers)}
end)
end
defp role_for(index, header, state, member_indices, custom_indices, ignored_headers) do
cond do
index == state.groups_column_index -> :groups
index == state.fee_type_column_index -> :fee_type
MapSet.member?(ignored_headers, header) -> :ignored
MapSet.member?(member_indices, index) -> :member_field
MapSet.member?(custom_indices, index) -> :custom_field
true -> :unknown
end
end
defp role_label(:member_field), do: gettext("Member field")
defp role_label(:custom_field), do: gettext("Custom field")
defp role_label(:groups), do: gettext("Groups")
defp role_label(:fee_type), do: gettext("Fee type")
defp role_label(:ignored), do: gettext("Ignored (system-computed field)")
defp role_label(:unknown), do: gettext("Unknown (ignored)")
defp role_badge_class(:member_field), do: "badge-primary"
defp role_badge_class(:custom_field), do: "badge-secondary"
defp role_badge_class(:groups), do: "badge-success"
defp role_badge_class(:fee_type), do: "badge-warning"
defp role_badge_class(:ignored), do: "badge-ghost"
defp role_badge_class(:unknown), do: "badge-error"
defp role_row_class(:ignored), do: "opacity-50"
defp role_row_class(:unknown), do: "opacity-50"
defp role_row_class(_), do: nil
defp column_samples([], col_count), do: List.duplicate([], col_count)
defp column_samples(rows, col_count) do
Enum.map(0..(col_count - 1), fn col_idx ->
rows
|> Enum.map(fn row -> Enum.at(row, col_idx, "") end)
|> pad_to(3, "")
end)
end
defp pad_to(list, target, fill) do
list ++ List.duplicate(fill, max(0, target - length(list)))
end
@doc """
Renders import progress text and, when done or aborted, the import results section.
"""
@ -254,8 +449,10 @@ defmodule MvWeb.ImportLive.Components do
@doc """
Returns whether the Start Import button should be disabled.
"""
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
@spec import_button_disabled?(:idle | :preview | :running | :done | :error, [map()]) ::
boolean()
def import_button_disabled?(:running, _entries), do: true
def import_button_disabled?(:preview, _entries), do: true
def import_button_disabled?(_status, []), do: true
def import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
def import_button_disabled?(_status, _entries), do: false

View file

@ -8,6 +8,7 @@ defmodule MvWeb.JoinLive do
alias Ash.Resource.Info
alias Mv.Membership
alias Mv.Membership.CustomFieldLookup
alias MvWeb.Helpers.JoinDescriptionRenderer
alias MvWeb.JoinRateLimit
alias MvWeb.Translations.MemberFields
@ -96,14 +97,20 @@ defmodule MvWeb.JoinLive do
class="checkbox checkbox-sm"
/>
<span class="label-text">
{field.label}<span :if={field.required} aria-hidden="true"> *</span>
{render_field_label(field)}<span
:if={field.required}
aria-hidden="true"
> *</span>
</span>
</label>
<% else %>
<div>
<label for={"join-field-#{field.id}"} class="label">
<span class="label-text">
{field.label}<span :if={field.required} aria-hidden="true"> *</span>
{render_field_label(field)}<span
:if={field.required}
aria-hidden="true"
> *</span>
</span>
</label>
<input
@ -237,6 +244,17 @@ defmodule MvWeb.JoinLive do
|> assign(:form, to_form(params, as: "join"))}
end
# Renders a join field's label. When a custom field has a join_description it is
# rendered with auto-linked URLs/Markdown; otherwise the plain field label is used.
# Safe: join_description is admin-set settings content, never end-user input, and
# JoinDescriptionRenderer escapes all non-link text (only emits <a href> tags).
defp render_field_label(%{join_description: join_description})
when is_binary(join_description) do
JoinDescriptionRenderer.render(join_description)
end
defp render_field_label(%{label: label}), do: label
defp build_join_fields_with_labels(allowlist) do
member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
custom_field_by_id = custom_field_map(allowlist, member_field_strings)
@ -249,20 +267,36 @@ defmodule MvWeb.JoinLive do
defp build_join_field(id, required, member_field_strings, custom_field_by_id) do
if id in member_field_strings do
label = MemberFields.label(String.to_existing_atom(id))
%{id: id, label: label, required: required, input_type: member_field_input_type(id)}
%{
id: id,
label: label,
required: required,
input_type: member_field_input_type(id),
join_description: nil
}
else
custom_field = Map.get(custom_field_by_id, id)
label = if custom_field, do: custom_field.name, else: gettext("Field")
input_type = custom_field_input_type(custom_field && custom_field.value_type)
%{id: id, label: label, required: required, input_type: input_type}
%{
id: id,
label: label,
required: required,
input_type: input_type,
join_description: custom_field && custom_field.join_description
}
end
end
defp custom_field_map(allowlist, _member_field_strings) do
allowlist
|> Enum.map(& &1.id)
|> CustomFieldLookup.fetch_map_by_ids(authorize?: false, select: [:id, :name, :value_type])
|> CustomFieldLookup.fetch_map_by_ids(
authorize?: false,
select: [:id, :name, :value_type, :join_description]
)
end
defp initial_form_params(join_fields) do

View file

@ -235,6 +235,19 @@ defmodule MvWeb.MemberLive.Show do
<%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
<.data_field label={custom_field.name}>
<:label_suffix :if={custom_field.join_description}>
<.tooltip
content={"#{gettext("Join form:")} #{custom_field.join_description}"}
wrap_class="ml-1 inline-flex items-center"
>
<span data-testid="join-description-tooltip">
<.icon
name="hero-information-circle"
class="size-3.5 text-base-content/50"
/>
</span>
</.tooltip>
</:label_suffix>
{format_custom_field_value(cfv, custom_field.value_type)}
</.data_field>
<% end %>
@ -605,11 +618,14 @@ defmodule MvWeb.MemberLive.Show do
attr :value, :string, default: nil
attr :class, :string, default: ""
slot :inner_block
slot :label_suffix
defp data_field(assigns) do
~H"""
<dl class={@class}>
<dt class="text-sm font-medium text-base-content/70">{@label}</dt>
<dt class="text-sm font-medium text-base-content/70 flex items-center">
{@label}{render_slot(@label_suffix)}
</dt>
<dd class="mt-1 text-base-content">
<%= if @inner_block != [] do %>
{render_slot(@inner_block)}

View file

@ -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