mitgliederverwaltung/lib/mv_web/controllers/member_export_controller.ex

433 lines
12 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.MembersCSV
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
@custom_field_prefix Mv.Constants.custom_field_prefix()
def export(conn, params) do
actor = current_actor(conn)
if is_nil(actor), do: return_forbidden(conn)
case params["payload"] do
nil ->
conn
|> put_status(400)
|> put_resp_content_type("application/json")
|> json(%{error: "payload required"})
payload when is_binary(payload) ->
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) ->
parsed = parse_and_validate(decoded)
run_export(conn, actor, parsed)
_ ->
conn
|> put_status(400)
|> put_resp_content_type("application/json")
|> json(%{error: "invalid JSON"})
end
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
defp parse_and_validate(params) do
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
member_fields: filter_allowed_member_fields(extract_list(params, "member_fields")),
computed_fields: filter_existing_atoms(extract_list(params, "computed_fields")),
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
query: extract_string(params, "query"),
sort_field: extract_string(params, "sort_field"),
sort_order: extract_sort_order(params)
}
end
defp filter_existing_atoms(list) when is_list(list) do
list
|> Enum.filter(&is_binary/1)
|> Enum.filter(fn name ->
try do
_ = String.to_existing_atom(name)
true
rescue
ArgumentError -> false
end
end)
|> Enum.uniq()
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, 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, 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])}
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 ->
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)
end
defp load_members_for_export(actor, parsed, custom_fields_by_id) do
select_fields = [:id] ++ Enum.map(parsed.member_fields, &String.to_existing_atom/1)
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids)
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
{:ok, members}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
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
custom_field_sort?(field) ->
# Custom field sort → in-memory nach dem Read (wie Tabelle)
{query, true}
true ->
field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end
rescue
ArgumentError -> {query, false}
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"
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 ->
member
|> find_cfv(custom_field)
|> case do
nil -> nil
cfv -> extract_sort_value(cfv.value, custom_field.value_type)
end
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 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 build_columns(conn, parsed, custom_fields_by_id) do
member_cols =
Enum.map(parsed.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: key
}
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)
member_cols ++ computed_cols ++ custom_cols
end
# --- headers: hier solltest du idealerweise eure bestehenden "display name" Helfer verwenden ---
defp member_field_header(_conn, field) when is_binary(field) do
# TODO: hier euren bestehenden display-name helper verwenden (wie Tabelle)
humanize_field(field)
end
defp computed_field_header(_conn, key) when is_binary(key) do
# TODO: display-name helper für computed fields verwenden
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 humanize_field(str) do
str
|> String.replace("_", " ")
|> String.capitalize()
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