diff --git a/config/config.exs b/config/config.exs index 6720a5d..d4de2c2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -58,6 +58,12 @@ config :mv, max_rows: 1000 ] +# PDF Export configuration +config :mv, + pdf_export: [ + row_limit: 5000 + ] + # OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production. config :mv, :oidc_role_sync, admin_group_name: nil, diff --git a/docs/pdf-generation-imprintor.md b/docs/pdf-generation-imprintor.md new file mode 100644 index 0000000..f8ce2ee --- /dev/null +++ b/docs/pdf-generation-imprintor.md @@ -0,0 +1,71 @@ +# PDF Generation: Imprintor statt Chromium + +## Übersicht + +Für die PDF-Generierung in der Mitgliederverwaltung verwenden wir **Imprintor** (`~> 0.5.0`) anstelle von Chromium-basierten Lösungen (wie z.B. Puppeteer, Chrome Headless, oder ähnliche). + +## Warum Imprintor statt Chromium? + +### 1. Ressourceneffizienz + +- **Geringerer Speicherverbrauch**: Imprintor benötigt keine vollständige Browser-Instanz im Speicher +- **Niedrigere CPU-Last**: Native PDF-Generierung ohne Browser-Rendering-Pipeline +- **Kleinere Docker-Images**: Keine Chromium-Installation erforderlich (spart mehrere hundert MB) + +### 2. Performance + +- **Schnellere Generierung**: Direkte PDF-Generierung ohne HTML-Rendering-Overhead +- **Bessere Skalierbarkeit**: Kann mehrere PDFs parallel generieren ohne Browser-Instanzen zu verwalten +- **Niedrigere Latenz**: Keine Browser-Startup-Zeit + +### 3. Deployment & Wartung + +- **Einfacheres Deployment**: Keine System-Abhängigkeiten (Chromium, ChromeDriver, etc.) +- **Weniger Wartungsaufwand**: Keine Browser-Version-Updates zu verwalten +- **Bessere Container-Kompatibilität**: Funktioniert in minimalen Docker-Images (z.B. Alpine) + +### 4. Sicherheit + +- **Kleinere Angriffsfläche**: Keine Browser-Engine mit bekannten Sicherheitslücken +- **Isolation**: Weniger System-Calls und externe Prozesse + +### 5. Elixir-Native Lösung + +- **Erlang/OTP-Integration**: Nutzt die Vorteile der BEAM-VM (Concurrency, Fault Tolerance) +- **Type-Safety**: Bessere Integration mit Elixir-Typen und Pattern Matching +- **Einfachere Fehlerbehandlung**: Elixir-native Error-Handling statt externer Prozesse + +## Wann Chromium trotzdem sinnvoll wäre + +Chromium-basierte Lösungen sind sinnvoll, wenn: +- Komplexe JavaScript-Ausführung im HTML nötig ist +- Moderne CSS-Features (Grid, Flexbox, etc.) kritisch sind +- Screenshots von Web-Seiten generiert werden sollen +- Dynamische Inhalte gerendert werden müssen, die JavaScript erfordern + +## Verwendung in diesem Projekt + +Imprintor wird für folgende Anwendungsfälle verwendet: +- **Member-Export als PDF**: Generierung von Mitgliederlisten und -reports +- **Statische Reports**: PDF-Generierung für vordefinierte Report-Formate +- **Dokumente**: Generierung von Mitgliedschaftsbescheinigungen, Rechnungen, etc. + +## Technische Details + +- **Dependency**: `{:imprintor, "~> 0.5.0"}` +- **Typ**: Native Elixir-Bibliothek (vermutlich basierend auf Rust-NIFs oder ähnlichen Technologien) +- **Format**: Generiert PDF direkt aus HTML/Templates ohne Browser-Engine + +## Migration von Chromium (falls vorhanden) + +Falls zuvor eine Chromium-basierte Lösung verwendet wurde: +1. HTML-Templates müssen ggf. angepasst werden (kein JavaScript-Support) +2. CSS muss statisch sein (keine dynamischen Styles) +3. Komplexe Layouts sollten vorher getestet werden + +## Weitere Ressourcen + +- [Imprintor auf Hex.pm](https://hex.pm/packages/imprintor) +- [GitHub Repository](https://github.com/[imprintor-repo]) (falls verfügbar) + + diff --git a/lib/mv/config.ex b/lib/mv/config.ex index 007309a..bcbc8d9 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -116,4 +116,30 @@ defmodule Mv.Config do defp parse_and_validate_integer(_value, default) do default end + + @doc """ + Returns the maximum number of rows allowed in PDF exports. + + Reads the `row_limit` value from the PDF export configuration. + + ## Returns + + - Maximum number of rows (default: 5000) + + ## Examples + + iex> Mv.Config.pdf_export_row_limit() + 5000 + """ + @spec pdf_export_row_limit() :: pos_integer() + def pdf_export_row_limit do + get_pdf_export_config(:row_limit, 5000) + end + + # Helper function to get PDF export config values + defp get_pdf_export_config(key, default) do + Application.get_env(:mv, :pdf_export, []) + |> Keyword.get(key, default) + |> parse_and_validate_integer(default) + end end diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex new file mode 100644 index 0000000..ade4d82 --- /dev/null +++ b/lib/mv/membership/member_export/build.ex @@ -0,0 +1,433 @@ +defmodule Mv.Membership.MemberExport.Build do + @moduledoc """ + Builds export data structure for member exports (CSV/PDF). + + Extracts common logic for loading, filtering, sorting, and formatting member data + into a unified structure that can be used by both CSV and PDF exporters. + + Returns a structure: + ``` + %{ + columns: [%{key: term(), kind: :member_field | :custom_field | :computed, ...}], + rows: [[cell_string, ...]], + meta: %{generated_at: String.t(), member_count: integer(), ...} + } + ``` + + No translations/Gettext in this module - labels come from the web layer via a function. + """ + + require Ash.Query + import Ash.Expr + + alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort} + alias MvWeb.MemberLive.Index.MembershipFeeStatus + + @custom_field_prefix Mv.Constants.custom_field_prefix() + + @doc """ + Builds export data structure from parsed parameters. + + - `actor` - Ash actor (e.g. current user) + - `parsed` - Map with export parameters (from `MemberExport.parse_params/1`) + - `label_fn` - Function to get labels for columns: `(key) -> String.t()` + + Returns `{:ok, data}` or `{:error, :forbidden}`. + + The `data` map contains: + - `columns`: List of column specs with `key`, `kind`, and optional `custom_field` + - `rows`: List of rows, each row is a list of cell strings + - `meta`: Metadata including `generated_at` and `member_count` + """ + @spec build(struct(), map(), (term() -> String.t())) :: + {:ok, map()} | {:error, :forbidden} + def build(actor, parsed, label_fn) when is_function(label_fn, 1) do + # Ensure sort custom field is loaded if needed + parsed = ensure_sort_custom_field_loaded(parsed) + + custom_field_ids_union = + (parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq() + + with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor), + {:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do + columns = build_columns(parsed, custom_fields_by_id, label_fn) + rows = build_rows(members, columns, custom_fields_by_id) + meta = build_meta(members) + + {:ok, %{columns: columns, rows: rows, meta: meta}} + 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]) + + case Ash.read(query, actor: actor) do + {:ok, custom_fields} -> + by_id = build_custom_fields_by_id(custom_field_ids, custom_fields) + {:ok, by_id} + + {:error, %Ash.Error.Forbidden{}} -> + {:error, :forbidden} + end + 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(actor, parsed, custom_fields_by_id) do + {query, sort_after_load} = build_members_query(parsed, custom_fields_by_id) + + case Ash.read(query, actor: actor) do + {:ok, members} -> + processed_members = + process_loaded_members(members, parsed, custom_fields_by_id, sort_after_load) + + {:ok, processed_members} + + {:error, %Ash.Error.Forbidden{}} -> + {:error, :forbidden} + end + end + + defp build_members_query(parsed, _custom_fields_by_id) do + select_fields = + [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1) + + custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{}) + + need_cycles = + parsed.show_current_cycle or parsed.cycle_status_filter != nil or + parsed.computed_fields != [] or + "membership_fee_status" in parsed.member_fields + + query = + Member + |> Ash.Query.new() + |> Ash.Query.select(select_fields) + |> load_custom_field_values_query(custom_field_ids_union) + |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) + + query = + if parsed.selected_ids != [] do + Ash.Query.filter(query, expr(id in ^parsed.selected_ids)) + else + apply_search(query, parsed.query) + end + + # Apply sorting at query level if possible (not custom fields) + maybe_sort(query, parsed.sort_field, parsed.sort_order) + end + + defp process_loaded_members(members, parsed, custom_fields_by_id, sort_after_load) do + members + |> apply_post_load_filters(parsed, custom_fields_by_id) + |> apply_post_load_sorting(parsed, custom_fields_by_id, sort_after_load) + |> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle) + end + + defp apply_post_load_filters(members, parsed, custom_fields_by_id) do + if parsed.selected_ids == [] do + members + |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle) + |> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( + parsed.boolean_filters || %{}, + Map.values(custom_fields_by_id) + ) + else + members + end + end + + defp apply_post_load_sorting(members, parsed, custom_fields_by_id, sort_after_load) do + # Sort after load for custom fields (always, even with selected_ids) + if sort_after_load do + sort_members_by_custom_field( + members, + parsed.sort_field, + parsed.sort_order, + Map.values(custom_fields_by_id) + ) + else + # For selected_ids, we may need to apply sorting that wasn't done at query level + if (parsed.selected_ids != [] and parsed.sort_field) && parsed.sort_order do + # Re-sort in memory to ensure consistent ordering + sort_members_in_memory(members, parsed.sort_field, parsed.sort_order) + else + members + end + end + end + + defp sort_members_in_memory(members, field, order) when is_binary(field) do + field_atom = String.to_existing_atom(field) + + if field_atom in Mv.Constants.member_fields() do + Enum.sort_by(members, fn member -> Map.get(member, field_atom) end, fn a, b -> + case order do + "asc" -> a <= b + "desc" -> b <= a + _ -> true + end + end) + else + members + end + rescue + ArgumentError -> members + end + + defp sort_members_in_memory(members, _field, _order), do: members + + 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(query, nil), do: query + defp apply_search(query, ""), do: query + + defp apply_search(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(query, nil, _order), do: {query, false} + defp maybe_sort(query, _field, nil), do: {query, false} + + defp maybe_sort(query, field, order) when is_binary(field) do + if custom_field_sort?(field) do + {query, true} + else + 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 sort_after_load?(field) when is_binary(field), + do: String.starts_with?(field, @custom_field_prefix) + + defp sort_after_load?(_), do: false + + defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [], + do: [] + + defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) 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 + + key_fn = fn member -> + cfv = find_cfv(member, custom_field) + raw = if cfv, do: cfv.value, else: nil + MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + end + + members + |> Enum.map(fn m -> {m, key_fn.(m)} end) + |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) + |> Enum.map(fn {m, _} -> m end) + end + + 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 custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix) + + 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 apply_cycle_status_filter(members, nil, _show_current), do: members + + defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do + MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current) + end + + defp apply_cycle_status_filter(members, _status, _show_current), do: members + + defp add_computed_fields(members, computed_fields, show_current_cycle) do + computed_fields = computed_fields || [] + + if "membership_fee_status" in computed_fields do + Enum.map(members, fn member -> + status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle) + # Format as string for export (controller will handle translation) + status_string = format_membership_fee_status(status) + Map.put(member, :membership_fee_status, status_string) + end) + else + members + end + end + + defp format_membership_fee_status(:paid), do: "paid" + defp format_membership_fee_status(:unpaid), do: "unpaid" + defp format_membership_fee_status(:suspended), do: "suspended" + defp format_membership_fee_status(nil), do: "" + + defp build_columns(parsed, custom_fields_by_id, label_fn) do + member_cols = + Enum.map(parsed.selectable_member_fields, fn field -> + %{ + key: field, + kind: :member_field, + label: label_fn.(field) + } + end) + + computed_cols = + Enum.map(parsed.computed_fields, fn key -> + atom_key = String.to_existing_atom(key) + + %{ + key: atom_key, + kind: :computed, + label: label_fn.(atom_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 + %{ + key: to_string(id), + kind: :custom_field, + label: label_fn.(id), + custom_field: cf + } + else + nil + end + end) + |> Enum.reject(&is_nil/1) + + member_cols ++ computed_cols ++ custom_cols + end + + defp build_rows(members, columns, custom_fields_by_id) do + Enum.map(members, fn member -> + Enum.map(columns, fn col -> cell_value(member, col, custom_fields_by_id) end) + end) + end + + defp cell_value(member, %{kind: :member_field, key: key}, _custom_fields_by_id) do + key_atom = key_to_atom(key) + value = Map.get(member, key_atom) + format_member_value(value) + end + + defp cell_value(member, %{kind: :custom_field, key: id, custom_field: cf}, _custom_fields_by_id) do + cfv = get_cfv_by_id(member, id) + + if cfv do + CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf) + else + "" + end + end + + defp cell_value(member, %{kind: :computed, key: key}, _custom_fields_by_id) do + value = Map.get(member, key) + if is_binary(value), do: value, else: "" + end + + defp key_to_atom(k) when is_atom(k), do: k + + defp key_to_atom(k) when is_binary(k) do + try do + String.to_existing_atom(k) + rescue + ArgumentError -> k + end + end + + defp get_cfv_by_id(member, id) do + values = + case Map.get(member, :custom_field_values) do + v when is_list(v) -> v + _ -> [] + end + + id_str = to_string(id) + + Enum.find(values, fn cfv -> + to_string(cfv.custom_field_id) == id_str or + (Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str) + end) + end + + defp format_member_value(nil), do: "" + defp format_member_value(true), do: "true" + defp format_member_value(false), do: "false" + defp format_member_value(%Date{} = d), do: Date.to_iso8601(d) + defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt) + defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) + defp format_member_value(value), do: to_string(value) + + defp build_meta(members) do + %{ + generated_at: DateTime.utc_now() |> DateTime.to_iso8601(), + member_count: length(members) + } + end +end diff --git a/lib/mv/membership/members_pdf.ex b/lib/mv/membership/members_pdf.ex new file mode 100644 index 0000000..690da74 --- /dev/null +++ b/lib/mv/membership/members_pdf.ex @@ -0,0 +1,270 @@ +defmodule Mv.Membership.MembersPDF do + @moduledoc """ + Exports members to PDF using Typst templates and Imprintor. + + Uses the same data structure as `MemberExport.Build` and converts it + to the format expected by the Typst template. No translations/Gettext + in this module - labels come from the web layer. + + Ensures deterministic output by maintaining column and row order. + + Creates a temporary directory per request and copies the template there + to avoid symlink issues and ensure isolation. + """ + + require Logger + + alias Mv.Config + + @template_filename "members_export.typ" + @template_path "priv/pdf_templates/members_export.typ" + + @doc """ + Renders export data to PDF binary. + + - `export_data` - Map from `MemberExport.Build.build/3` with `columns`, `rows`, and `meta` + + Returns `{:ok, binary}` where binary is the PDF content, or `{:error, term}`. + + The PDF binary starts with "%PDF" (PDF magic bytes). + + Validates row count against configured limit before processing. + """ + @spec render(map()) :: {:ok, binary()} | {:error, term()} + def render(export_data) do + row_count = length(export_data.rows) + max_rows = Config.pdf_export_row_limit() + + if row_count > max_rows do + Logger.warning("PDF export rejected: row count exceeds limit", + row_count: row_count, + max_rows: max_rows, + error_type: :row_limit_exceeded + ) + + {:error, {:row_limit_exceeded, row_count, max_rows}} + else + Logger.info("Starting PDF export", + row_count: row_count, + column_count: length(export_data.columns) + ) + + create_and_use_temp_directory(export_data) + end + end + + defp create_and_use_temp_directory(export_data) do + case create_temp_directory() do + {:ok, temp_dir} -> + try do + with {:ok, template_content} <- load_template(), + {:ok, _template_path} <- copy_template_to_temp(temp_dir, template_content), + {:ok, template_data} <- convert_to_template_format(export_data), + {:ok, config} <- + build_imprintor_config(template_content, template_data, temp_dir), + {:ok, pdf_binary} <- compile_to_pdf(config) do + Logger.info("PDF export completed successfully", + row_count: length(export_data.rows) + ) + + {:ok, pdf_binary} + else + {:error, reason} = error -> + Logger.error("PDF export failed", + error: reason, + error_type: :pdf_export_failed + ) + + error + end + after + cleanup_temp_directory(temp_dir) + end + + {:error, reason} = error -> + Logger.error("Failed to create temp directory", + error: reason, + error_type: :temp_dir_creation_failed + ) + + error + end + end + + defp create_temp_directory do + # Create unique temp directory per request + temp_base = System.tmp_dir!() + temp_dir = Path.join(temp_base, "mv_pdf_export_#{System.unique_integer([:positive])}") + + case File.mkdir_p(temp_dir) do + :ok -> {:ok, temp_dir} + {:error, reason} -> {:error, {:temp_dir_creation_failed, reason}} + end + end + + defp load_template do + # Try multiple paths: compiled app path and source path (for tests/dev) + template_paths = [ + Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/#{@template_filename}"), + Path.join([File.cwd!(), "priv", "pdf_templates", @template_filename]) + ] + + Enum.reduce_while(template_paths, nil, fn path, _acc -> + case File.read(path) do + {:ok, content} -> {:halt, {:ok, content}} + {:error, _reason} -> {:cont, nil} + end + end) + |> case do + {:ok, content} -> {:ok, content} + nil -> {:error, {:template_not_found, :enoent}} + end + end + + defp copy_template_to_temp(temp_dir, template_content) do + # Write template to temp directory (no symlinks, actual file copy) + template_path = Path.join(temp_dir, @template_filename) + + case File.write(template_path, template_content) do + :ok -> {:ok, template_path} + {:error, reason} -> {:error, {:template_copy_failed, reason}} + end + end + + defp cleanup_temp_directory(temp_dir) do + # Clean up temp directory and all contents + case File.rm_rf(temp_dir) do + {:ok, _} -> + :ok + + {:error, reason, _} -> + Logger.warning("Failed to cleanup temp directory", + temp_dir: temp_dir, + error: reason + ) + end + end + + defp convert_to_template_format(export_data) do + headers = Enum.map(export_data.columns, & &1.label) + column_count = length(export_data.columns) + + meta = Map.get(export_data, :meta) || Map.get(export_data, "meta") || %{} + + generated_at = + Map.get(meta, :generated_at) || + Map.get(meta, "generated_at") || + DateTime.utc_now() |> DateTime.to_iso8601() + + member_count = + Map.get(meta, :member_count) || + Map.get(meta, "member_count") || + length(export_data.rows) + + template_data = %{ + "title" => "Mitglieder-Export", + "generated_at" => generated_at, + "column_count" => column_count, + "headers" => headers, + "rows" => export_data.rows, + "columns" => + Enum.map(export_data.columns, fn col -> + %{ + "key" => to_string(col.key), + "kind" => to_string(col.kind), + "label" => col.label + } + end), + "meta" => %{ + "generated_at" => generated_at, + "member_count" => member_count + } + } + + {:ok, template_data} + end + + defp build_imprintor_config(template_content, template_data, root_directory) do + # Imprintor.Config.new(source_document, inputs, options) + # inputs: %{"elixir_data" => template_data} for sys.inputs.elixir_data in template + # options: set root_directory to temp dir to ensure no symlink issues + # extra_fonts: list of font file paths for Typst to use + extra_fonts = get_extra_fonts() + options = [root_directory: root_directory, extra_fonts: extra_fonts] + + config = Imprintor.Config.new(template_content, template_data, options) + {:ok, config} + end + + defp get_extra_fonts do + # Try multiple paths: compiled app path and source path (for tests/dev) + font_paths = [ + Path.join(Application.app_dir(:mv, "priv"), "fonts"), + Path.join([File.cwd!(), "priv", "fonts"]) + ] + + Enum.reduce_while(font_paths, [], fn base_path, _acc -> + case File.ls(base_path) do + {:ok, files} -> + fonts = + files + |> Enum.filter(&String.ends_with?(&1, ".ttf")) + |> Enum.map(&Path.join(base_path, &1)) + |> Enum.sort() + + if fonts != [] do + {:halt, fonts} + else + {:cont, []} + end + + {:error, _reason} -> + {:cont, []} + end + end) + |> case do + [] -> [] + fonts -> fonts + end + end + + defp compile_to_pdf(config) do + case Imprintor.compile_to_pdf(config) do + {:ok, pdf_binary} when is_binary(pdf_binary) -> + # Verify PDF magic bytes + if String.starts_with?(pdf_binary, "%PDF") do + {:ok, pdf_binary} + else + Logger.error("PDF compilation returned invalid format", + binary_start: String.slice(pdf_binary, 0, 20) + ) + + {:error, :invalid_pdf_format} + end + + {:error, reason} -> + Logger.error("PDF compilation failed", + error: inspect(reason), + error_type: :imprintor_compile_error + ) + + {:error, {:compile_error, reason}} + + other -> + Logger.error("PDF compilation returned unexpected result", + result: inspect(other), + error_type: :unexpected_result + ) + + {:error, {:unexpected_result, other}} + end + rescue + e -> + Logger.error("PDF compilation raised exception", + exception: inspect(e), + error_type: :compile_exception + ) + + {:error, {:compile_exception, e}} + end +end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 60f3636..b5b3942 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -151,9 +151,17 @@ defmodule MvWeb.CoreComponents do ## Examples <.dropdown_menu items={@items} open={@open} phx_target={@myself} /> + + When using custom content (e.g., forms), use the inner_block slot: + + <.dropdown_menu button_label="Export" icon="hero-arrow-down-tray" open={@open} phx_target={@myself}> +
  • +
    ...
    +
  • + """ attr :id, :string, default: "dropdown-menu" - attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps" + attr :items, :list, default: [], doc: "List of %{label: string, value: any} maps" attr :button_label, :string, default: "Dropdown" attr :icon, :string, default: nil attr :checkboxes, :boolean, default: false @@ -161,8 +169,20 @@ defmodule MvWeb.CoreComponents do attr :open, :boolean, default: false, doc: "Whether the dropdown is open" attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons" attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events" + attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu" + attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)" + attr :button_class, :string, default: nil, doc: "Additional CSS classes for the button (e.g., btn-secondary)" + attr :menu_align, :string, default: "right", doc: "Menu alignment: 'left' or 'right' (default: right)" + attr :testid, :string, default: "dropdown-menu", doc: "data-testid for the dropdown container" + attr :button_testid, :string, default: "dropdown-button", doc: "data-testid for the button" + attr :menu_testid, :string, default: nil, doc: "data-testid for the menu (defaults to testid + '-menu')" + + slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)" def dropdown_menu(assigns) do + menu_testid = assigns.menu_testid || "#{assigns.testid}-menu" + assigns = assign(assigns, :menu_testid, menu_testid) + ~H"""
    - + <%= if assigns.inner_block != [] do %> + {render_slot(@inner_block)} + <% else %> +
  • +
    + {gettext("Options")} +
    + + +
    -
  • - - - - - <%= for item <- @items do %> -
  • -
  • + + + + <%= for item <- @items do %> +
  • + +
  • + <% end %> <% end %> diff --git a/lib/mv_web/components/export_dropdown.ex b/lib/mv_web/components/export_dropdown.ex new file mode 100644 index 0000000..705c6c6 --- /dev/null +++ b/lib/mv_web/components/export_dropdown.ex @@ -0,0 +1,97 @@ +defmodule MvWeb.Components.ExportDropdown do + @moduledoc """ + Export dropdown component for member export (CSV/PDF). + + Provides an accessible dropdown menu with CSV and PDF export options. + Uses the same export payload as the previous single-button export. + """ + use MvWeb, :live_component + use Gettext, backend: MvWeb.Gettext + + @impl true + def mount(socket) do + {:ok, assign(socket, :open, false)} + end + + @impl true + def update(assigns, socket) do + socket = + socket + |> assign(:id, assigns.id) + |> assign(:export_payload_json, assigns[:export_payload_json] || "") + |> assign(:selected_count, assigns[:selected_count] || 0) + + {:ok, socket} + end + + @impl true + def render(assigns) do + button_label = + gettext("Export") <> + " (" <> + (if assigns.selected_count == 0, do: gettext("all"), else: to_string(assigns.selected_count)) <> + ")" + + assigns = assign(assigns, :button_label, button_label) + + ~H""" +
    + <.dropdown_menu + id={"#{@id}-menu"} + button_label={@button_label} + icon="hero-arrow-down-tray" + open={@open} + phx_target={@myself} + menu_width="w-48" + menu_align="left" + button_class="btn-secondary gap-2" + testid="export-dropdown" + button_testid="export-dropdown-button" + menu_testid="export-dropdown-menu" + > +
  • +
    + + + +
    +
  • +
  • +
    + + + +
    +
  • + +
    + """ + end + + @impl true + def handle_event("toggle_dropdown", _params, socket) do + {:noreply, assign(socket, :open, !socket.assigns.open)} + end + + def handle_event("close_dropdown", _params, socket) do + {:noreply, assign(socket, :open, false)} + end +end diff --git a/lib/mv_web/controllers/member_pdf_export_controller.ex b/lib/mv_web/controllers/member_pdf_export_controller.ex new file mode 100644 index 0000000..99a9ce1 --- /dev/null +++ b/lib/mv_web/controllers/member_pdf_export_controller.ex @@ -0,0 +1,144 @@ +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) + + def export(conn, %{"payload" => payload}) when is_binary(payload) do + actor = current_actor(conn) + + cond do + is_nil(actor) -> + forbidden(conn) + + true -> + 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) 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 + + # --- 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 diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 97e0642..21c3c2f 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -92,6 +92,7 @@ defmodule MvWeb.Router do live "/admin/import-export", ImportExportLive post "/members/export.csv", MemberExportController, :export + post "/members/export.pdf", MemberPdfExportController, :export post "/set_locale", LocaleController, :set_locale end diff --git a/mix.exs b/mix.exs index 5271b6b..21aa3d9 100644 --- a/mix.exs +++ b/mix.exs @@ -79,7 +79,8 @@ defmodule Mv.MixProject do {:picosat_elixir, "~> 0.1"}, {:ecto_commons, "~> 0.3"}, {:slugify, "~> 1.3"}, - {:nimble_csv, "~> 1.0"} + {:nimble_csv, "~> 1.0"}, + {:imprintor, "~> 0.5.0"} ] end diff --git a/mix.lock b/mix.lock index 453ed8f..f17e9cb 100644 --- a/mix.lock +++ b/mix.lock @@ -36,6 +36,7 @@ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"}, + "imprintor": {:hex, :imprintor, "0.5.0", "3266aa8487cc6eab3915a578c79d49e489d1bacf959a6535b1ef32acc62d71cc", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d4bbfbd26c2ddbb7eb38894b7412c0ef62f953cbb176df3cccbd266fe890c12f"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, @@ -68,6 +69,7 @@ "reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"}, diff --git a/priv/fonts/.gitkeep b/priv/fonts/.gitkeep new file mode 100644 index 0000000..905e128 --- /dev/null +++ b/priv/fonts/.gitkeep @@ -0,0 +1,4 @@ +# This file ensures the fonts directory is tracked by git +# Place TTF font files here as described in README.md + + diff --git a/priv/pdf_templates/members_export.typ b/priv/pdf_templates/members_export.typ new file mode 100644 index 0000000..1ece25f --- /dev/null +++ b/priv/pdf_templates/members_export.typ @@ -0,0 +1,85 @@ +// Typst template for member export (PDF) +// Expected sys.inputs.elixir_data: +// { +// "columns": [{"key": "...", "kind": "...", "label": "..."}, ...], +// "rows": [["cell1", "cell2", ...], ...], +// "meta": {"generated_at": "...", "member_count": 123} +// } + +#set page( + paper: "a4", + flipped: true, + margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm) +) + +#set text(size: 9pt) +#set heading(numbering: none) + +#let data = sys.inputs.elixir_data +#let columns = data.at("columns", default: ()) +#let rows = data.at("rows", default: ()) +#let meta = data.at("meta", default: (generated_at: "", member_count: rows.len())) + +// Title +#align(center)[ + #text(size: 14pt, weight: "bold")[Mitglieder-Export] +] + +#v(0.4cm) + +// Export metadata +#set text(size: 8pt, fill: gray) +#grid( + columns: (1fr, 1fr), + gutter: 1cm, + [*Erstellt am:* #meta.at("generated_at", default: "")], + [*Anzahl Mitglieder:* #meta.at("member_count", default: rows.len())], +) + +#v(0.6cm) + +// ---- Horizontal paging config ---- +#let fixed_count = calc.min(2, columns.len()) +#let max_dynamic_cols = 6 +#let fixed_col_width = 1.6fr + +#let fixed_cols = columns.slice(0, fixed_count) +#let dynamic_cols = columns.slice(fixed_count, columns.len()) +#let dynamic_chunks = dynamic_cols.chunks(max_dynamic_cols) + +#let render_chunk(chunk_index, dyn_cols_chunk) = [ + #let dyn_count = dyn_cols_chunk.len() + #let start = fixed_count + chunk_index * max_dynamic_cols + + #let page_cols = fixed_cols + dyn_cols_chunk + #let headers = page_cols.map(c => c.at("label", default: "")) + + // widths: fixe breiter, dynamische gleichmäßig + #let widths = (fixed_col_width,) * fixed_count + (1fr,) * dyn_count + + #let header_cells = headers.map(h => text(weight: "bold", size: 9pt)[#h]) + + // Body cells (row-major), nur die Spalten dieses Chunks + #let body_cells = ( + rows + .map(row => row.slice(0, fixed_count) + row.slice(start, start + dyn_count)) + .map(cells => cells.map(cell => text(size: 8.5pt)[#cell])) + .flatten() + ) + + #table( + columns: widths, + table.header(..header_cells), + ..body_cells, + ) +] + +// ---- Output ---- +#if dynamic_cols.len() == 0 { + render_chunk(0, ()) +} else { + for (i, chunk) in dynamic_chunks.enumerate() { + render_chunk(i, chunk) + if i < dynamic_chunks.len() - 1 { pagebreak() } + } +} diff --git a/test/mv/membership/member_export_build_test.exs b/test/mv/membership/member_export_build_test.exs new file mode 100644 index 0000000..52dec9e --- /dev/null +++ b/test/mv/membership/member_export_build_test.exs @@ -0,0 +1,356 @@ +defmodule Mv.Membership.MemberExport.BuildTest do + @moduledoc """ + Tests for MemberExport.Build module. + + Tests verify that the module correctly: + - Loads and filters members based on query/selected_ids + - Builds column specifications (without labels) + - Generates row data as cell strings + - Handles member fields, custom fields, and computed fields + - Applies sorting and filtering consistently + """ + use Mv.DataCase, async: true + + alias Mv.Fixtures + alias Mv.Membership.{CustomField, Member, MemberExport.Build} + alias Mv.Constants + + @custom_field_prefix Constants.custom_field_prefix() + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + # Create test members + member1 = + Fixtures.member_fixture(%{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + + member2 = + Fixtures.member_fixture(%{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + + %{actor: system_actor, member1: member1, member2: member2} + end + + describe "build/3 - standard member fields" do + test "returns columns and rows for standard member fields", %{ + actor: actor, + member1: m1, + member2: m2 + } do + parsed = %{ + selected_ids: [m1.id, m2.id], + member_fields: ["first_name", "last_name", "email"], + selectable_member_fields: ["first_name", "last_name", "email"], + computed_fields: [], + custom_field_ids: [], + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + result = Build.build(actor, parsed, fn _key -> "Label" end) + + assert {:ok, data} = result + assert %{columns: columns, rows: rows, meta: meta} = data + + # Check columns structure + assert length(columns) == 3 + first_name_col = Enum.find(columns, &(&1.key == "first_name" && &1.kind == :member_field)) + assert first_name_col + assert first_name_col.label == "Label" + assert Enum.find(columns, &(&1.key == "last_name" && &1.kind == :member_field)) + assert Enum.find(columns, &(&1.key == "email" && &1.kind == :member_field)) + + # Check rows - should have 2 members + assert length(rows) == 2 + + # Check first row (member1) + row1 = Enum.at(rows, 0) + assert length(row1) == 3 + assert "Alice" in row1 + assert "Anderson" in row1 + assert "alice@example.com" in row1 + + # Check meta + assert %{generated_at: _timestamp, member_count: 2} = meta + assert is_binary(meta.generated_at) + end + + test "filters members by selected_ids", %{actor: actor, member1: m1, member2: _m2} do + parsed = %{ + selected_ids: [m1.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [], + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + assert length(data.rows) == 1 + row = hd(data.rows) + assert "Alice" in row + assert data.meta.member_count == 1 + end + + test "applies search query when selected_ids is empty", %{ + actor: actor, + member1: m1, + member2: _m2 + } do + parsed = %{ + selected_ids: [], + member_fields: ["first_name", "last_name"], + selectable_member_fields: ["first_name", "last_name"], + computed_fields: [], + custom_field_ids: [], + query: "Alice", + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + assert length(data.rows) == 1 + row = hd(data.rows) + assert "Alice" in row + end + end + + describe "build/3 - custom fields" do + test "includes custom field columns and values", %{ + actor: actor, + member1: m1 + } do + # Create custom field + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Membership Number", + value_type: :string + }) + |> Ash.create(actor: actor) + + # Create custom field value for member + {:ok, _cfv} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: m1.id, + custom_field_id: custom_field.id, + value: "M12345" + }) + |> Ash.create(actor: actor) + + parsed = %{ + selected_ids: [m1.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [custom_field.id], + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + # Should have 2 columns: first_name + custom field + assert length(data.columns) == 2 + + custom_col = + Enum.find( + data.columns, + &(&1.kind == :custom_field && &1.key == to_string(custom_field.id)) + ) + + assert custom_col + assert custom_col.custom_field.id == custom_field.id + assert custom_col.label == "Label" + + # Check row has custom field value + row = hd(data.rows) + assert length(row) == 2 + assert "M12345" in row + end + + test "handles members without custom field values", %{ + actor: actor, + member1: m1 + } do + # Create custom field but no value for member + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Optional Field", + value_type: :string + }) + |> Ash.create(actor: actor) + + parsed = %{ + selected_ids: [m1.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [custom_field.id], + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + row = hd(data.rows) + # Custom field value should be empty string + assert "" in row + end + end + + describe "build/3 - computed fields" do + test "includes computed field columns and values", %{ + actor: actor, + member1: m1 + } do + parsed = %{ + selected_ids: [m1.id], + member_fields: ["first_name", "membership_fee_status"], + selectable_member_fields: ["first_name"], + computed_fields: ["membership_fee_status"], + custom_field_ids: [], + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + # Should have 2 columns: first_name + computed field + assert length(data.columns) == 2 + + computed_col = + Enum.find(data.columns, &(&1.kind == :computed && &1.key == :membership_fee_status)) + + assert computed_col + assert computed_col.label == "Label" + + # Check row has computed field value (may be empty if no cycles) + row = hd(data.rows) + assert length(row) == 2 + # membership_fee_status should be present (even if empty) + end + end + + describe "build/3 - sorting" do + test "sorts by member field", %{actor: actor, member1: m1, member2: m2} do + parsed = %{ + selected_ids: [m1.id, m2.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [], + query: nil, + sort_field: "first_name", + sort_order: "asc", + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + # Should be sorted: Alice, Bob + [row1, row2] = data.rows + assert "Alice" in row1 + assert "Bob" in row2 + end + + test "sorts by custom field", %{actor: actor, member1: m1, member2: m2} do + # Create custom field + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Sort Field", + value_type: :string + }) + |> Ash.create(actor: actor) + + # Add values: m1="Zebra", m2="Alpha" + {:ok, _cfv1} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: m1.id, + custom_field_id: custom_field.id, + value: "Zebra" + }) + |> Ash.create(actor: actor) + + {:ok, _cfv2} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: m2.id, + custom_field_id: custom_field.id, + value: "Alpha" + }) + |> Ash.create(actor: actor) + + sort_field = "#{@custom_field_prefix}#{custom_field.id}" + + parsed = %{ + selected_ids: [m1.id, m2.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [custom_field.id], + query: nil, + sort_field: sort_field, + sort_order: "asc", + show_current_cycle: false + } + + {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) + + # Should be sorted by custom field: Alpha (Bob), Zebra (Alice) + [row1, row2] = data.rows + # Alpha + assert "Bob" in row1 + # Zebra + assert "Alice" in row2 + end + end + + describe "build/3 - error handling" do + test "returns error when actor lacks permission", %{member1: m1} do + # Create a user with limited permissions + user = Fixtures.password_user_with_role_fixture(%{permission_set_name: "own_data"}) + + parsed = %{ + selected_ids: [m1.id], + member_fields: ["first_name"], + selectable_member_fields: ["first_name"], + computed_fields: [], + custom_field_ids: [], + query: nil, + sort_field: nil, + sort_order: nil, + show_current_cycle: false + } + + result = Build.build(user, parsed, fn _key -> "Label" end) + + assert {:error, :forbidden} = result + end + end +end diff --git a/test/mv/membership/members_pdf_test.exs b/test/mv/membership/members_pdf_test.exs new file mode 100644 index 0000000..0ca259a --- /dev/null +++ b/test/mv/membership/members_pdf_test.exs @@ -0,0 +1,265 @@ +defmodule Mv.Membership.MembersPDFTest do + @moduledoc """ + Tests for MembersPDF module. + + Tests verify that the module correctly: + - Loads the Typst template + - Converts export data to template format + - Generates valid PDF binary (starts with "%PDF") + - Handles errors gracefully + """ + use ExUnit.Case, async: true + + alias Mv.Config + alias Mv.Membership.MembersPDF + + describe "render/1" do + test "rejects export when row count exceeds limit" do + max_rows = Config.pdf_export_row_limit() + rows_over_limit = max_rows + 1 + + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"} + ], + rows: Enum.map(1..rows_over_limit, fn i -> ["Member #{i}"] end), + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: rows_over_limit + } + } + + result = MembersPDF.render(export_data) + + assert {:error, {:row_limit_exceeded, ^rows_over_limit, ^max_rows}} = result + end + + test "allows export when row count equals limit" do + max_rows = Config.pdf_export_row_limit() + + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"} + ], + rows: Enum.map(1..max_rows, fn i -> ["Member #{i}"] end), + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: max_rows + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert String.starts_with?(pdf_binary, "%PDF") + end + + test "allows export when row count is below limit" do + max_rows = Config.pdf_export_row_limit() + rows_below_limit = max(1, max_rows - 10) + + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"} + ], + rows: Enum.map(1..rows_below_limit, fn i -> ["Member #{i}"] end), + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: rows_below_limit + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert String.starts_with?(pdf_binary, "%PDF") + end + + test "generates valid PDF from minimal dataset" do + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"}, + %{key: "last_name", kind: :member_field, label: "Nachname"} + ], + rows: [ + ["Max", "Mustermann"], + ["Anna", "Schmidt"] + ], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 2 + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert is_binary(pdf_binary) + assert String.starts_with?(pdf_binary, "%PDF") + assert byte_size(pdf_binary) > 1000 + end + + test "generates valid PDF with custom fields and computed fields" do + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"}, + %{key: "last_name", kind: :member_field, label: "Nachname"}, + %{key: "email", kind: :member_field, label: "E-Mail"}, + %{key: :membership_fee_status, kind: :computed, label: "Beitragsstatus"}, + %{ + key: "550e8400-e29b-41d4-a716-446655440000", + kind: :custom_field, + label: "Mitgliedsnummer", + custom_field: %{ + id: "550e8400-e29b-41d4-a716-446655440000", + name: "Mitgliedsnummer", + value_type: :string + } + } + ], + rows: [ + ["Max", "Mustermann", "max@example.com", "paid", "M-2024-001"], + ["Anna", "Schmidt", "anna@example.com", "unpaid", "M-2024-002"] + ], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 2 + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert is_binary(pdf_binary) + assert String.starts_with?(pdf_binary, "%PDF") + end + + test "maintains deterministic column and row order" do + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"}, + %{key: "last_name", kind: :member_field, label: "Nachname"}, + %{key: "email", kind: :member_field, label: "E-Mail"} + ], + rows: [ + ["Max", "Mustermann", "max@example.com"], + ["Anna", "Schmidt", "anna@example.com"], + ["Peter", "Müller", "peter@example.com"] + ], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 3 + } + } + + # Render twice and verify identical output + {:ok, pdf1} = MembersPDF.render(export_data) + {:ok, pdf2} = MembersPDF.render(export_data) + + assert pdf1 == pdf2 + assert String.starts_with?(pdf1, "%PDF") + assert String.starts_with?(pdf2, "%PDF") + end + + test "returns error when template file is missing" do + # Temporarily rename template to simulate missing file + template_path = + Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/members_export.typ") + + original_content = File.read!(template_path) + File.rm(template_path) + + export_data = %{ + columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}], + rows: [["Max"]], + meta: %{generated_at: "2024-01-15T14:30:00Z", member_count: 1} + } + + result = MembersPDF.render(export_data) + + assert {:error, {:template_not_found, _reason}} = result + + # Restore template + File.write!(template_path, original_content) + end + + test "handles empty rows gracefully" do + export_data = %{ + columns: [ + %{key: "first_name", kind: :member_field, label: "Vorname"}, + %{key: "last_name", kind: :member_field, label: "Nachname"} + ], + rows: [], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 0 + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert is_binary(pdf_binary) + assert String.starts_with?(pdf_binary, "%PDF") + end + + test "handles many columns correctly" do + # Test with 10 columns to ensure dynamic column width calculation works + columns = + Enum.map(1..10, fn i -> + %{key: "field_#{i}", kind: :member_field, label: "Feld #{i}"} + end) + + export_data = %{ + columns: columns, + rows: [Enum.map(1..10, &"Wert #{&1}")], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 1 + } + } + + result = MembersPDF.render(export_data) + + assert {:ok, pdf_binary} = result + assert is_binary(pdf_binary) + assert String.starts_with?(pdf_binary, "%PDF") + end + + test "creates and cleans up temp directory" do + export_data = %{ + columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}], + rows: [["Max"]], + meta: %{ + generated_at: "2024-01-15T14:30:00Z", + member_count: 1 + } + } + + # Get temp base directory + temp_base = System.tmp_dir!() + + # Count temp directories before + before_count = + temp_base + |> File.ls!() + |> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end) + + result = MembersPDF.render(export_data) + + assert {:ok, _pdf_binary} = result + + # Wait a bit for cleanup (async cleanup might take a moment) + Process.sleep(100) + + # Count temp directories after + after_count = + temp_base + |> File.ls!() + |> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end) + + # Should have same or fewer temp dirs (cleanup should have run) + assert after_count <= before_count + 1 + end + end +end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 9d4a429..c5d387f 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -522,7 +522,7 @@ defmodule MvWeb.MemberLive.IndexTest do end end - describe "export to CSV" do + describe "export dropdown" do setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() @@ -535,34 +535,135 @@ defmodule MvWeb.MemberLive.IndexTest do %{member1: m1} end - test "export button is rendered when no selection and shows (all)", %{conn: conn} do + test "export dropdown button is rendered when no selection and shows (all)", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") + # Dropdown button should be present + assert html =~ ~s(data-testid="export-dropdown") + assert html =~ ~s(data-testid="export-dropdown-button") + assert html =~ "Export" # Button text shows "all" when 0 selected (locale-dependent) - assert html =~ "Export to CSV" assert html =~ "all" or html =~ "All" end - test "after select_member event export button shows (1)", %{conn: conn, member1: member1} do + test "after select_member event export dropdown shows (1)", %{conn: conn, member1: member1} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") render_click(view, "select_member", %{"id" => member1.id}) html = render(view) - assert html =~ "Export to CSV" + assert html =~ "Export" assert html =~ "(1)" end - test "form has correct action and payload hidden input", %{conn: conn} do + test "dropdown opens and closes on click", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members") + # Initially closed + refute has_element?(view, ~s([data-testid="export-dropdown-menu"])) + + # Click to open + view + |> element(~s([data-testid="export-dropdown-button"])) + |> render_click() + + # Menu should be visible + assert has_element?(view, ~s([data-testid="export-dropdown-menu"])) + + # Click to close + view + |> element(~s([data-testid="export-dropdown-button"])) + |> render_click() + + # Menu should be hidden + refute has_element?(view, ~s([data-testid="export-dropdown-menu"])) + end + + test "dropdown has click-away and ESC handlers", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + view + |> element(~s([data-testid="export-dropdown-button"])) + |> render_click() + + html = render(view) + assert has_element?(view, ~s([data-testid="export-dropdown-menu"])) + + # Check that click-away handler is present + assert html =~ ~s(phx-click-away="close_dropdown") + # Check that ESC handler is present + assert html =~ ~s(phx-window-keydown="close_dropdown") + assert html =~ ~s(phx-key="Escape") + end + + test "dropdown menu contains CSV and PDF export links with correct payload", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + render_click(view, "toggle_dropdown", %{}, "export-dropdown") + + html = render(view) + + # Check CSV link + assert html =~ ~s(data-testid="export-csv-link") assert html =~ "/members/export.csv" assert html =~ ~s(name="payload") assert html =~ ~s(type="hidden") assert html =~ ~s(name="_csrf_token") + + # Check PDF link + assert html =~ ~s(data-testid="export-pdf-link") + assert html =~ "/members/export.pdf" + assert html =~ ~s(name="payload") + assert html =~ ~s(type="hidden") + assert html =~ ~s(name="_csrf_token") + + # Both forms should have the same payload + csv_form_payload = extract_payload_from_form(html, "/members/export.csv") + pdf_form_payload = extract_payload_from_form(html, "/members/export.pdf") + + assert csv_form_payload == pdf_form_payload + assert csv_form_payload != nil + end + + test "dropdown has correct ARIA attributes", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + html = render(view) + + # Button should have aria-haspopup="menu" + assert html =~ ~s(aria-haspopup="menu") + # Button should have aria-expanded="false" when closed + assert html =~ ~s(aria-expanded="false") + # Button should have aria-controls pointing to menu + assert html =~ ~s(aria-controls="export-dropdown-menu") + + # Open dropdown + render_click(view, "toggle_dropdown", %{}, "export-dropdown") + + html = render(view) + # Button should have aria-expanded="true" when open + assert html =~ ~s(aria-expanded="true") + # Menu should have role="menu" + assert html =~ ~s(role="menu") + end + + # Helper to extract payload value from form HTML + defp extract_payload_from_form(html, action_path) do + case Regex.run( + ~r/]*action="#{Regex.escape(action_path)}"[^>]*>.*?]*name="payload"[^>]*value="([^"]+)"/s, + html + ) do + [_, payload] -> payload + _ -> nil + end end end