MemberExport allowlist and insert_fee_type; Build load/sort/cell_value; MemberPdfExportController allow membership_fee_type and groups.
160 lines
4.3 KiB
Elixir
160 lines
4.3 KiB
Elixir
defmodule MvWeb.MemberPdfExportController do
|
|
@moduledoc """
|
|
PDF export for members.
|
|
|
|
Expects `payload` as JSON string form param.
|
|
Uses the same actor/permissions as the member overview.
|
|
"""
|
|
|
|
use MvWeb, :controller
|
|
|
|
require Logger
|
|
|
|
alias Mv.Authorization.Actor
|
|
alias Mv.Membership.{MemberExport, MemberExport.Build, MembersPDF}
|
|
alias MvWeb.Translations.MemberFields
|
|
|
|
use Gettext, backend: MvWeb.Gettext
|
|
|
|
@payload_required_message "payload required"
|
|
@invalid_json_message "invalid JSON"
|
|
@export_failed_message "Failed to generate PDF export"
|
|
|
|
@allowed_member_field_strings (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
|
["membership_fee_type", "groups"]
|
|
|
|
def export(conn, %{"payload" => payload}) when is_binary(payload) do
|
|
actor = current_actor(conn)
|
|
|
|
if is_nil(actor) do
|
|
forbidden(conn)
|
|
else
|
|
locale = get_locale(conn)
|
|
club_name = get_club_name()
|
|
|
|
with {:ok, decoded} <- decode_json_map(payload),
|
|
parsed <- MemberExport.parse_params(decoded),
|
|
{:ok, export_data} <- Build.build(actor, parsed, &label_for_column/1),
|
|
{:ok, pdf_binary} <-
|
|
MembersPDF.render(export_data, locale: locale, club_name: club_name) do
|
|
filename = "members-#{Date.utc_today()}.pdf"
|
|
|
|
send_download(
|
|
conn,
|
|
{:binary, pdf_binary},
|
|
filename: filename,
|
|
content_type: "application/pdf"
|
|
)
|
|
else
|
|
{:error, :invalid_json} ->
|
|
bad_request(conn, @invalid_json_message)
|
|
|
|
{:error, :forbidden} ->
|
|
forbidden(conn)
|
|
|
|
{:error, {:row_limit_exceeded, row_count, max_rows}} ->
|
|
unprocessable_entity(conn, %{
|
|
error: "row_limit_exceeded",
|
|
message:
|
|
gettext("Export contains %{count} rows, maximum is %{max}",
|
|
count: row_count,
|
|
max: max_rows
|
|
),
|
|
row_count: row_count,
|
|
max_rows: max_rows
|
|
})
|
|
|
|
{:error, reason} ->
|
|
Logger.warning("PDF export failed: #{inspect(reason)}")
|
|
|
|
internal_error(conn, %{
|
|
error: "export_failed",
|
|
message: gettext(@export_failed_message)
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
def export(conn, _params) do
|
|
bad_request(conn, @payload_required_message)
|
|
end
|
|
|
|
# --- Actor / auth ---
|
|
|
|
defp current_actor(conn) do
|
|
conn.assigns[:current_user]
|
|
|> Actor.ensure_loaded()
|
|
end
|
|
|
|
defp forbidden(conn) do
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: "forbidden", message: "Forbidden"})
|
|
|> halt()
|
|
end
|
|
|
|
# --- Decoding / validation ---
|
|
|
|
defp decode_json_map(payload) when is_binary(payload) do
|
|
case Jason.decode(payload) do
|
|
{:ok, decoded} when is_map(decoded) -> {:ok, decoded}
|
|
_ -> {:error, :invalid_json}
|
|
end
|
|
end
|
|
|
|
# --- Column labels ---
|
|
|
|
# Goal: translate known member fields to UI labels, but never crash.
|
|
# - Atoms: label directly.
|
|
# - Binaries: only translate if they are known member fields (allowlist); otherwise return the string.
|
|
# This avoids String.to_existing_atom/1 exceptions for arbitrary keys (e.g., "custom_field_...").
|
|
defp label_for_column(key) when is_atom(key) do
|
|
MemberFields.label(key)
|
|
end
|
|
|
|
defp label_for_column(key) when is_binary(key) do
|
|
if key in @allowed_member_field_strings do
|
|
# Safe because key is in allowlist which originates from existing atoms
|
|
MemberFields.label(String.to_existing_atom(key))
|
|
else
|
|
key
|
|
end
|
|
end
|
|
|
|
defp label_for_column(key) do
|
|
to_string(key)
|
|
end
|
|
|
|
# --- Locale and club name ---
|
|
|
|
defp get_locale(conn) do
|
|
conn.assigns[:locale] || Gettext.get_locale(MvWeb.Gettext) || "en"
|
|
end
|
|
|
|
defp get_club_name do
|
|
case Mv.Membership.get_settings() do
|
|
{:ok, settings} -> settings.club_name
|
|
_ -> "Club"
|
|
end
|
|
end
|
|
|
|
# --- JSON responses ---
|
|
|
|
defp bad_request(conn, message) when is_binary(message) do
|
|
conn
|
|
|> put_status(:bad_request)
|
|
|> json(%{error: "bad_request", message: message})
|
|
end
|
|
|
|
defp unprocessable_entity(conn, body) when is_map(body) do
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(body)
|
|
end
|
|
|
|
defp internal_error(conn, body) when is_map(body) do
|
|
conn
|
|
|> put_status(:internal_server_error)
|
|
|> json(body)
|
|
end
|
|
end
|