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