defmodule MvWeb.GlobalSettingsLive do @moduledoc """ LiveView for managing global application settings (Vereinsdaten). ## Features - Edit the association/club name - Manage custom fields - Real-time form validation - Success/error feedback - CSV member import (admin only) ## Settings - `club_name` - The name of the association/club (required) ## Events - `validate` - Real-time form validation - `save` - Save settings changes - `start_import` - Start CSV member import (admin only) ## CSV Import The CSV import feature allows administrators to upload CSV files and import members. ### File Upload Files are uploaded automatically when selected (`auto_upload: true`). No manual upload trigger is required. ### Rate Limiting Currently, there is no rate limiting for CSV imports. Administrators can start multiple imports in quick succession. This is intentional for bulk data migration scenarios, but should be monitored in production. ### Limits - Maximum file size: 10 MB - Maximum rows: 1,000 rows (excluding header) - Processing: chunks of 200 rows - Errors: capped at 50 per import ## Note Settings is a singleton resource - there is only one settings record. The club_name can also be set via the `ASSOCIATION_NAME` environment variable. """ use MvWeb, :live_view alias Mv.Membership alias Mv.Membership.Import.MemberCSV alias MvWeb.Authorization on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} # CSV Import configuration constants # 10 MB @max_file_size_bytes 10_485_760 @max_errors 50 @impl true def mount(_params, _session, socket) do {:ok, settings} = Membership.get_settings() socket = socket |> assign(:page_title, gettext("Settings")) |> assign(:settings, settings) |> assign(:active_editing_section, nil) |> assign(:import_state, nil) |> assign(:import_progress, nil) |> assign(:import_status, :idle) |> assign_form() # Configure file upload with auto-upload enabled # Files are uploaded automatically when selected, no need for manual trigger |> allow_upload(:csv_file, accept: ~w(.csv), max_entries: 1, max_file_size: @max_file_size_bytes, auto_upload: true ) {:ok, socket} end @impl true def render(assigns) do ~H""" <.header> {gettext("Settings")} <:subtitle> {gettext("Manage global settings for the association.")} <%!-- Club Settings Section --%> <.form_section title={gettext("Club Settings")}> <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
<.input field={@form[:club_name]} type="text" label={gettext("Association Name")} required />
<.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save Settings")} <%!-- Memberdata Section --%> <.form_section title={gettext("Memberdata")}> <.live_component :if={@active_editing_section != :custom_fields} module={MvWeb.MemberFieldLive.IndexComponent} id="member-fields-component" settings={@settings} /> <%!-- Custom Fields Section --%> <.live_component :if={@active_editing_section != :member_fields} module={MvWeb.CustomFieldLive.IndexComponent} id="custom-fields-component" /> <%!-- CSV Import Section (Admin only) --%> <%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %> <.form_section title={gettext("Import Members (CSV)")}>

{gettext( "Custom fields must be created in Mila before importing CSV files with custom field columns" )}

{gettext( "Use the custom field name as the CSV column header (same normalization as member fields applies)" )}

<.link navigate={~p"/custom_field_values"} class="link link-primary" > {gettext("Manage Custom Fields")}

{gettext("Download CSV templates:")}

<.form id="csv-upload-form" for={%{}} multipart={true} phx-change="validate_csv_upload" phx-submit="start_import" data-testid="csv-upload-form" >
<.live_file_input upload={@uploads.csv_file} id="csv_file" class="file-input file-input-bordered w-full" aria-describedby="csv_file_help" />
<.button type="submit" phx-disable-with={gettext("Starting import...")} variant="primary" disabled={ Enum.empty?(@uploads.csv_file.entries) or @uploads.csv_file.entries |> List.first() |> then(&(&1 && not &1.done?)) } data-testid="start-import-button" > {gettext("Start Import")} <%= if @import_status == :running or @import_status == :done do %> <%= if @import_progress do %>
<%= if @import_status == :running do %>

{gettext("Processing chunk %{current} of %{total}...", current: @import_progress.current_chunk, total: @import_progress.total_chunks )}

<% end %> <%= if @import_status == :done do %>

{gettext("Import Results")}

{gettext("Summary")}

<.icon name="hero-check-circle" class="size-4 inline mr-1" aria-hidden="true" /> {gettext("Successfully inserted: %{count} member(s)", count: @import_progress.inserted )}

<%= if @import_progress.failed > 0 do %>

<.icon name="hero-exclamation-circle" class="size-4 inline mr-1" aria-hidden="true" /> {gettext("Failed: %{count} row(s)", count: @import_progress.failed)}

<% end %> <%= if @import_progress.errors_truncated? do %>

<.icon name="hero-information-circle" class="size-4 inline mr-1" aria-hidden="true" /> {gettext("Error list truncated to %{count} entries", count: @max_errors )}

<% end %>
<%= if length(@import_progress.errors) > 0 do %>

<.icon name="hero-exclamation-circle" class="size-4 inline mr-1" aria-hidden="true" /> {gettext("Errors")}

    <%= for error <- @import_progress.errors do %>
  • {gettext("Line %{line}: %{message}", line: error.csv_line_number || "?", message: error.message || gettext("Unknown error") )} <%= if error.field do %> {gettext(" (Field: %{field})", field: error.field)} <% end %>
  • <% end %>
<% end %> <%= if length(@import_progress.warnings) > 0 do %>
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />

{gettext("Warnings")}

    <%= for warning <- @import_progress.warnings do %>
  • {warning}
  • <% end %>
<% end %>
<% end %>
<% end %> <% end %> <% end %>
""" end @impl true def handle_event("validate", %{"setting" => setting_params}, socket) do {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} end @impl true def handle_event("save", %{"setting" => setting_params}, socket) do actor = MvWeb.LiveHelpers.current_actor(socket) case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do {:ok, _updated_settings} -> # Reload settings from database to ensure all dependent data is updated {:ok, fresh_settings} = Membership.get_settings() socket = socket |> assign(:settings, fresh_settings) |> put_flash(:info, gettext("Settings updated successfully")) |> assign_form() {:noreply, socket} {:error, form} -> {:noreply, assign(socket, form: form)} end end @impl true def handle_event("validate_csv_upload", _params, socket) do {:noreply, socket} end @impl true def handle_event("start_import", _params, socket) do # Server-side admin check if Authorization.can?(socket.assigns[:current_user], :create, Mv.Membership.Member) do # Check if upload is completed upload_entries = socket.assigns.uploads.csv_file.entries if Enum.empty?(upload_entries) do {:noreply, put_flash( socket, :error, gettext("Please select a CSV file to import.") )} else entry = List.first(upload_entries) if entry.done? do with {:ok, content} <- consume_and_read_csv(socket) do case MemberCSV.prepare(content) do {:ok, import_state} -> total_chunks = length(import_state.chunks) progress = %{ inserted: 0, failed: 0, errors: [], warnings: import_state.warnings || [], status: :running, current_chunk: 0, total_chunks: total_chunks } socket = socket |> assign(:import_state, import_state) |> assign(:import_progress, progress) |> assign(:import_status, :running) send(self(), {:process_chunk, 0}) {:noreply, socket} {:error, error} -> error_message = case error do %{message: msg} when is_binary(msg) -> msg %{errors: errors} when is_list(errors) -> inspect(errors) reason when is_binary(reason) -> reason other -> inspect(other) end {:noreply, put_flash( socket, :error, gettext("Failed to prepare CSV import: %{error}", error: error_message) )} end else {:error, reason} when is_binary(reason) -> {:noreply, put_flash( socket, :error, gettext("Failed to prepare CSV import: %{reason}", reason: reason) )} {:error, error} -> error_message = case error do %{message: msg} when is_binary(msg) -> msg %{errors: errors} when is_list(errors) -> inspect(errors) other -> inspect(other) end {:noreply, put_flash( socket, :error, gettext("Failed to prepare CSV import: %{error}", error: error_message) )} end else {:noreply, put_flash( socket, :error, gettext("Please wait for the file upload to complete before starting the import.") )} end end else {:noreply, put_flash( socket, :error, gettext("Only administrators can import members from CSV files.") )} end end @impl true def handle_info({:custom_field_saved, _custom_field, action}, socket) do send_update(MvWeb.CustomFieldLive.IndexComponent, id: "custom-fields-component", show_form: false ) {:noreply, socket |> assign(:active_editing_section, nil) |> put_flash(:info, gettext("Data field %{action} successfully", action: action))} end @impl true def handle_info({:custom_field_deleted, _custom_field}, socket) do {:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))} end @impl true def handle_info({:custom_field_delete_error, error}, socket) do {:noreply, put_flash( socket, :error, gettext("Failed to delete data field: %{error}", error: inspect(error)) )} end @impl true def handle_info(:custom_field_slug_mismatch, socket) do {:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))} end @impl true def handle_info({:editing_section_changed, section}, socket) do {:noreply, assign(socket, :active_editing_section, section)} end @impl true def handle_info({:member_field_saved, _member_field, action}, socket) do # Reload settings to get updated member_field_visibility {:ok, updated_settings} = Membership.get_settings() # Send update to member fields component to close form send_update(MvWeb.MemberFieldLive.IndexComponent, id: "member-fields-component", show_form: false, settings: updated_settings ) {:noreply, socket |> assign(:settings, updated_settings) |> assign(:active_editing_section, nil) |> put_flash(:info, gettext("Member field %{action} successfully", action: action))} end @impl true def handle_info({:member_field_visibility_updated}, socket) do # Legacy event - reload settings and update component {:ok, updated_settings} = Membership.get_settings() send_update(MvWeb.MemberFieldLive.IndexComponent, id: "member-fields-component", settings: updated_settings ) {:noreply, assign(socket, :settings, updated_settings)} end @impl true def handle_info({:process_chunk, idx}, socket) do case socket.assigns do %{import_state: import_state, import_progress: progress} when is_map(import_state) and is_map(progress) -> if idx >= 0 and idx < length(import_state.chunks) do start_chunk_processing_task(socket, import_state, progress, idx) else handle_chunk_error(socket, :invalid_index, idx) end _ -> # Missing required assigns - mark as error handle_chunk_error(socket, :missing_state, idx) end end @impl true def handle_info({:chunk_done, idx, result}, socket) do case socket.assigns do %{import_state: import_state, import_progress: progress} when is_map(import_state) and is_map(progress) -> handle_chunk_result(socket, import_state, progress, idx, result) _ -> # Missing required assigns - mark as error handle_chunk_error(socket, :missing_state, idx) end end @impl true def handle_info({:chunk_error, idx, reason}, socket) do handle_chunk_error(socket, :processing_failed, idx, reason) end # Starts async task to process a chunk defp start_chunk_processing_task(socket, import_state, progress, idx) do chunk = Enum.at(import_state.chunks, idx) actor = socket.assigns[:current_user] live_view_pid = self() # Process chunk with existing error count for capping opts = [ custom_field_lookup: import_state.custom_field_lookup, existing_error_count: length(progress.errors), max_errors: @max_errors, actor: actor ] # Start async task to process chunk Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn -> case MemberCSV.process_chunk( chunk, import_state.column_map, import_state.custom_field_map, opts ) do {:ok, chunk_result} -> send(live_view_pid, {:chunk_done, idx, chunk_result}) {:error, reason} -> send(live_view_pid, {:chunk_error, idx, reason}) end end) {:noreply, socket} end # Handles chunk processing result from async task defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do # Merge progress new_progress = merge_progress(progress, chunk_result, idx) socket = socket |> assign(:import_progress, new_progress) |> assign(:import_status, new_progress.status) # Schedule next chunk or mark as done socket = schedule_next_chunk(socket, idx, length(import_state.chunks)) {:noreply, socket} end # Handles chunk processing errors defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do error_message = case error_type do :invalid_index -> gettext("Invalid chunk index: %{idx}", idx: idx) :missing_state -> gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx) :processing_failed -> gettext("Failed to process chunk %{idx}: %{reason}", idx: idx, reason: inspect(reason) ) end socket = socket |> assign(:import_status, :error) |> put_flash(:error, error_message) {:noreply, socket} end defp assign_form(%{assigns: %{settings: settings}} = socket) do form = AshPhoenix.Form.for_update( settings, :update, api: Membership, as: "setting", forms: [auto?: true] ) assign(socket, form: to_form(form)) end defp consume_and_read_csv(socket) do consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry -> case File.read(path) do {:ok, content} -> {:ok, content} {:error, reason} -> {:error, Exception.message(reason)} end end) |> case do [{_name, {:ok, content}}] when is_binary(content) -> {:ok, content} [{_name, {:error, reason}}] -> {:error, gettext("Failed to read file: %{reason}", reason: reason)} [] -> {:error, gettext("No file was uploaded")} _other -> {:error, gettext("Failed to read uploaded file")} end end defp merge_progress(progress, chunk_result, current_chunk_idx) do # Merge errors with cap of @max_errors overall all_errors = progress.errors ++ chunk_result.errors new_errors = Enum.take(all_errors, @max_errors) errors_truncated? = length(all_errors) > @max_errors # Merge warnings (optional dedupe - simple append for now) new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, []) # Update status based on whether we're done # current_chunk_idx is 0-based, so after processing chunk 0, we've processed 1 chunk chunks_processed = current_chunk_idx + 1 new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running %{ inserted: progress.inserted + chunk_result.inserted, failed: progress.failed + chunk_result.failed, errors: new_errors, warnings: new_warnings, status: new_status, current_chunk: chunks_processed, total_chunks: progress.total_chunks, errors_truncated?: errors_truncated? || chunk_result.errors_truncated? } end defp schedule_next_chunk(socket, current_idx, total_chunks) do next_idx = current_idx + 1 if next_idx < total_chunks do # Schedule next chunk send(self(), {:process_chunk, next_idx}) socket else # All chunks processed - status already set to :done in merge_progress socket end end end