mitgliederverwaltung/lib/mv_web/controllers/member_export_controller.ex
Moritz 04ab05f556 fix(member-export): forbid request without actor instead of falling through
The nil-actor guard used a one-armed if and continued into the export path regardless. The CheckPagePermission plug already halts unauthenticated requests before this controller runs, so the corrected early return preserves observable behavior while removing the dead fall-through. The export action is split into per-payload clauses so the guard reads as a flat early return.
2026-06-02 11:39:04 +02:00

684 lines
21 KiB
Elixir

defmodule MvWeb.MemberExportController do
@moduledoc """
Controller for CSV export of members.
POST /members/export.csv with form param "payload" (JSON string).
Same permission and actor context as the member overview; 403 if unauthorized.
"""
use MvWeb, :controller
require Ash.Query
import Ash.Expr
alias Mv.Authorization.Actor
alias Mv.Membership.CustomField
alias Mv.Membership.Member
alias Mv.Membership.MemberExport
alias Mv.Membership.MembersCSV
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.Translations.MemberFields
use Gettext, backend: MvWeb.Gettext
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_type", "groups"]
@computed_export_fields ["membership_fee_status"]
@custom_field_prefix Mv.Constants.custom_field_prefix()
def export(conn, params) do
case current_actor(conn) do
nil -> return_forbidden(conn)
actor -> export_with_actor(conn, actor, params["payload"])
end
end
defp export_with_actor(conn, actor, payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) ->
run_export(conn, actor, parse_and_validate(decoded))
_ ->
json_error(conn, "invalid JSON")
end
end
defp export_with_actor(conn, _actor, _payload) do
json_error(conn, "payload required")
end
defp json_error(conn, message) do
conn
|> put_status(400)
|> put_resp_content_type("application/json")
|> json(%{error: message})
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
defp parse_and_validate(params) do
member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
{selectable_member_fields, computed_fields} = split_member_fields(member_fields)
custom_field_ids = filter_valid_uuids(extract_list(params, "custom_field_ids"))
boolean_filters = extract_boolean_filters(params)
custom_field_ids_union = (custom_field_ids ++ Map.keys(boolean_filters)) |> Enum.uniq()
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
member_fields: member_fields,
selectable_member_fields: selectable_member_fields,
computed_fields:
computed_fields ++ filter_existing_atoms(extract_list(params, "computed_fields")),
custom_field_ids: custom_field_ids,
custom_field_ids_union: custom_field_ids_union,
query: extract_string(params, "query"),
sort_field: extract_string(params, "sort_field"),
sort_order: extract_sort_order(params),
show_current_cycle: extract_boolean(params, "show_current_cycle"),
cycle_status_filter: extract_cycle_status_filter(params),
boolean_filters: boolean_filters
}
end
# Only paid and unpaid are supported for list/export filter. :suspended exists in the
# domain (e.g. membership fee status display) but is not used as a filter in the member index.
defp extract_cycle_status_filter(params) do
case Map.get(params, "cycle_status_filter") do
"paid" -> :paid
"unpaid" -> :unpaid
_ -> nil
end
end
# Normalizes values so that "true"/"false" from query/form encoding are accepted as well as JSON booleans.
defp extract_boolean_filters(params) do
case Map.get(params, "boolean_filters") do
map when is_map(map) ->
map
|> Enum.filter(fn {k, v} ->
is_binary(k) and match?({:ok, _}, Ecto.UUID.cast(k)) and boolean_value?(v)
end)
|> Enum.map(fn {k, v} -> {k, normalize_boolean_value(v)} end)
|> Enum.into(%{})
_ ->
%{}
end
end
defp boolean_value?(v) when is_boolean(v), do: true
defp boolean_value?(v) when v in ["true", "false"], do: true
defp boolean_value?(_), do: false
defp normalize_boolean_value(v) when is_boolean(v), do: v
defp normalize_boolean_value("true"), do: true
defp normalize_boolean_value("false"), do: false
defp split_member_fields(member_fields) do
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
# "groups" is neither a domain field nor a computed field, it's handled separately
{selectable, computed}
end
defp extract_boolean(params, key) do
case Map.get(params, key) do
true -> true
"true" -> true
_ -> false
end
end
defp filter_existing_atoms(list) when is_list(list) do
list
|> Enum.filter(fn name ->
is_binary(name) and atom_exists?(name)
end)
|> Enum.uniq()
end
defp atom_exists?(name) do
_ = String.to_existing_atom(name)
true
rescue
ArgumentError -> false
end
defp extract_list(params, key) do
case Map.get(params, key) do
list when is_list(list) -> list
_ -> []
end
end
defp extract_string(params, key) do
case Map.get(params, key) do
s when is_binary(s) -> s
_ -> nil
end
end
defp extract_sort_order(params) do
case Map.get(params, "sort_order") do
"asc" -> "asc"
"desc" -> "desc"
_ -> nil
end
end
defp filter_allowed_member_fields(field_list) do
allowlist = MapSet.new(@member_fields_allowlist)
field_list
|> Enum.filter(fn field -> is_binary(field) and MapSet.member?(allowlist, field) end)
|> Enum.uniq()
end
defp filter_valid_uuids(id_list) when is_list(id_list) do
id_list
|> Enum.filter(fn id ->
is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id))
end)
|> Enum.uniq()
end
defp run_export(conn, actor, parsed) do
# FIX: Wenn nach einem Custom Field sortiert wird, muss dieses Feld geladen werden,
# auch wenn es nicht exportiert wird (sonst kann Export nicht korrekt sortieren).
parsed =
parsed
|> ensure_sort_custom_field_loaded()
with {:ok, custom_fields_by_id} <-
load_custom_fields_by_id(parsed.custom_field_ids_union, actor),
{:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do
columns = build_columns(conn, parsed, custom_fields_by_id)
csv_iodata = MembersCSV.export(members, columns)
filename = "members-#{Date.utc_today()}.csv"
send_download(
conn,
{:binary, IO.iodata_to_binary(csv_iodata)},
filename: filename,
content_type: "text/csv; charset=utf-8"
)
else
{:error, :forbidden} ->
return_forbidden(conn)
end
end
defp ensure_sort_custom_field_loaded(
%{custom_field_ids: ids, custom_field_ids_union: union, sort_field: sort_field} = parsed
) do
case extract_sort_custom_field_id(sort_field) do
nil ->
parsed
id ->
%{
parsed
| custom_field_ids: Enum.uniq([id | ids]),
custom_field_ids_union: Enum.uniq([id | union])
}
end
end
defp extract_sort_custom_field_id(field) when is_binary(field) do
if String.starts_with?(field, @custom_field_prefix) do
String.trim_leading(field, @custom_field_prefix)
else
nil
end
end
defp extract_sort_custom_field_id(_), do: nil
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
defp load_custom_fields_by_id(custom_field_ids, actor) do
query =
CustomField
|> Ash.Query.filter(expr(id in ^custom_field_ids))
|> Ash.Query.select([:id, :name, :value_type])
query
|> Ash.read(actor: actor)
|> handle_custom_fields_read_result(custom_field_ids)
end
defp handle_custom_fields_read_result({:ok, custom_fields}, custom_field_ids) do
by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
{:ok, by_id}
end
defp handle_custom_fields_read_result({:error, %Ash.Error.Forbidden{}}, _custom_field_ids) do
{:error, :forbidden}
end
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
find_and_add_custom_field(acc, id, custom_fields)
end)
end
defp find_and_add_custom_field(acc, id, custom_fields) do
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
nil -> acc
cf -> Map.put(acc, id, cf)
end
end
defp load_members_for_export(actor, parsed, custom_fields_by_id) do
select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
need_cycles =
(parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields) or
parsed.cycle_status_filter != nil
need_groups = "groups" in parsed.member_fields
need_membership_fee_type =
"membership_fee_type" in parsed.member_fields or
parsed.sort_field == "membership_fee_type"
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups)
|> maybe_load_membership_fee_type(need_membership_fee_type)
query =
if parsed.selected_ids != [] do
# selected export: filtert die Menge, aber die Sortierung muss trotzdem wie in der Tabelle angewandt werden
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
else
query
|> apply_search_export(parsed.query)
end
# FIX: Sortierung IMMER anwenden (auch bei selected_ids)
{query, sort_after_load} = maybe_sort_export(query, parsed.sort_field, parsed.sort_order)
case Ash.read(query, actor: actor) do
{:ok, members} ->
members =
if sort_after_load do
sort_members_by_custom_field_export(
members,
parsed.sort_field,
parsed.sort_order,
Map.values(custom_fields_by_id)
)
else
members
end
# When exporting "all" (no selected_ids), apply same filters as PDF: cycle status and boolean custom fields
members =
MemberExport.apply_export_filters(members, parsed, custom_fields_by_id)
# Calculate membership_fee_status for computed fields
members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)
{:ok, members}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
defp maybe_load_cycles(query, false, _show_current), do: query
defp maybe_load_cycles(query, true, show_current) do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
end
defp maybe_load_groups(query, false), do: query
defp maybe_load_groups(query, true) do
# Load groups with id and name only (for export formatting)
Ash.Query.load(query, groups: [:id, :name])
end
defp maybe_load_membership_fee_type(query, false), do: query
defp maybe_load_membership_fee_type(query, true) do
Ash.Query.load(query, membership_fee_type: [:id, :name])
end
# Adds computed field values to members (e.g. membership_fee_status)
defp add_computed_fields(members, computed_fields, show_current_cycle) do
if "membership_fee_status" in computed_fields do
Enum.map(members, fn member ->
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
status_string = format_membership_fee_status(status)
Map.put(member, :membership_fee_status, status_string)
end)
else
members
end
end
# Formats membership fee status as German string
defp format_membership_fee_status(:paid), do: gettext("paid")
defp format_membership_fee_status(:unpaid), do: gettext("unpaid")
defp format_membership_fee_status(:suspended), do: gettext("suspended")
defp format_membership_fee_status(nil), do: ""
defp load_custom_field_values_query(query, []), do: query
defp load_custom_field_values_query(query, custom_field_ids) do
cfv_query =
Mv.Membership.CustomFieldValue
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
Ash.Query.load(query, custom_field_values: cfv_query)
end
defp apply_search_export(query, nil), do: query
defp apply_search_export(query, ""), do: query
defp apply_search_export(query, q) when is_binary(q) do
if String.trim(q) != "" do
Member.fuzzy_search(query, %{query: q})
else
query
end
end
defp maybe_sort_export(query, nil, _order), do: {query, false}
defp maybe_sort_export(query, _field, nil), do: {query, false}
defp maybe_sort_export(query, field, order) when is_binary(field) do
cond do
field == "groups" ->
{query, true}
field == "membership_fee_type" ->
apply_membership_fee_type_sort_export(query, order)
custom_field_sort?(field) ->
{query, true}
true ->
apply_member_field_sort_export(query, field, order)
end
rescue
ArgumentError -> {query, false}
end
defp apply_membership_fee_type_sort_export(query, order) do
order_atom = if order == "desc", do: :desc, else: :asc
{Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false}
end
defp apply_member_field_sort_export(query, field, order) do
field_atom = String.to_existing_atom(field)
sortable =
field_atom in (Mv.Constants.member_fields() -- [:notes]) or
field_atom == :membership_fee_type
if sortable do
order_atom = if order == "desc", do: :desc, else: :asc
sort_field =
if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom
{Ash.Query.sort(query, [{sort_field, order_atom}]), false}
else
{query, false}
end
end
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
# ------------------------------------------------------------------
# Custom field sorting (match member table behavior)
# ------------------------------------------------------------------
defp sort_members_by_custom_field_export(members, _field, _order, _custom_fields)
when members == [],
do: []
defp sort_members_by_custom_field_export(members, field, order, custom_fields)
when is_binary(field) do
order = order || "asc"
if field == "groups" do
sort_members_by_groups_export(members, order)
else
sort_by_custom_field_value(members, field, order, custom_fields)
end
end
defp sort_by_custom_field_value(members, field, order, custom_fields) do
id_str = String.trim_leading(field, @custom_field_prefix)
custom_field =
Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
if is_nil(custom_field) do
members
else
# Match table:
# 1) values first, empty last
# 2) sort only values
# 3) for desc, reverse only the values-part
{with_values, without_values} =
Enum.split_with(members, fn member ->
has_non_empty_custom_field_value?(member, custom_field)
end)
sorted_with_values =
Enum.sort_by(with_values, fn member ->
extract_member_sort_value(member, custom_field)
end)
sorted_with_values =
if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values
sorted_with_values ++ without_values
end
end
defp sort_members_by_groups_export(members, order) do
# Members with groups first, then by first group name alphabetically (min = first by sort order)
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
first_group_name = fn member ->
(member.groups || [])
|> Enum.map(& &1.name)
|> Enum.min(fn -> nil end)
end
members
|> Enum.sort_by(fn member ->
name = first_group_name.(member)
# Nil (no groups) sorts last in asc, first in desc
{name == nil, name || ""}
end)
|> then(fn list ->
if order == "desc", do: Enum.reverse(list), else: list
end)
end
defp has_non_empty_custom_field_value?(member, custom_field) do
case find_cfv(member, custom_field) do
nil ->
false
cfv ->
extracted = extract_sort_value(cfv.value, custom_field.value_type)
not empty_value?(extracted, custom_field.value_type)
end
end
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) == ""
end
defp empty_value?(_value, _type), do: false
defp find_cfv(member, custom_field) do
(member.custom_field_values || [])
|> Enum.find(fn cfv ->
to_string(cfv.custom_field_id) == to_string(custom_field.id) or
(Map.get(cfv, :custom_field) &&
to_string(cfv.custom_field.id) == to_string(custom_field.id))
end)
end
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)
end
end
defp build_columns(conn, parsed, custom_fields_by_id) do
member_cols =
Enum.map(parsed.selectable_member_fields, fn field ->
%{
header: member_field_header(conn, field),
kind: :member_field,
key: field
}
end)
computed_cols =
Enum.map(parsed.computed_fields, fn key ->
%{
header: computed_field_header(conn, key),
kind: :computed,
key: String.to_existing_atom(key)
}
end)
membership_fee_type_col =
if "membership_fee_type" in parsed.member_fields do
[
%{
header: membership_fee_type_field_header(conn),
kind: :membership_fee_type,
key: :membership_fee_type
}
]
else
[]
end
groups_col =
if "groups" in parsed.member_fields do
[
%{
header: groups_field_header(conn),
kind: :groups,
key: :groups
}
]
else
[]
end
custom_cols =
parsed.custom_field_ids
|> Enum.map(fn id ->
cf = Map.get(custom_fields_by_id, id) || Map.get(custom_fields_by_id, to_string(id))
if cf do
%{
header: custom_field_header(conn, cf),
kind: :custom_field,
key: to_string(id),
custom_field: cf
}
else
nil
end
end)
|> Enum.reject(&is_nil/1)
# Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom
member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols
end
# --- headers: use MemberFields.label for translations ---
defp member_field_header(_conn, field) when is_binary(field) do
field
|> String.to_existing_atom()
|> MemberFields.label()
rescue
ArgumentError ->
# Fallback for unknown fields
humanize_field(field)
end
defp computed_field_header(_conn, key) when is_atom(key) do
# Map export-only alias to canonical UI key for translation
atom_key = if key == :payment_status, do: :membership_fee_status, else: key
MemberFields.label(atom_key)
end
defp computed_field_header(_conn, key) when is_binary(key) do
# Map export-only alias to canonical UI key for translation
atom_key =
case key do
"payment_status" -> :membership_fee_status
_ -> String.to_existing_atom(key)
end
MemberFields.label(atom_key)
rescue
ArgumentError ->
# Fallback for unknown computed fields
humanize_field(key)
end
defp custom_field_header(_conn, cf) do
# Custom fields: meist ist cf.name bereits der Display Name
cf.name
end
defp membership_fee_type_field_header(_conn) do
MemberFields.label(:membership_fee_type)
end
defp groups_field_header(_conn) do
MemberFields.label(:groups)
end
defp humanize_field(str) do
str
|> String.replace("_", " ")
|> 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