defmodule MvWeb.JoinRequestLive.Show do @moduledoc """ LiveView for displaying a single join request and performing approve/reject actions. ## Features - Show all request data (typed fields + form_data rendered by field) - Approve action: transitions to :approved, creates Member - Reject action: transitions to :rejected (no Member created) - Actions only available when status is :submitted ## Security - Page access controlled by CheckPagePermission plug and can_access_page? guard - Ash policy (HasPermission) enforces JoinRequest update :all for normal_user and admin """ use MvWeb, :live_view require Logger import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.Authorization alias Mv.Constants alias Mv.Membership alias MvWeb.Helpers.DateFormatter @impl true def mount(_params, _session, socket) do if join_form_enabled?() do {:ok, socket |> assign(:join_request, nil) |> assign(:join_form_field_ids, []) |> assign(:page_title, gettext("Join request"))} else {:ok, redirect(socket, to: ~p"/members")} end end @impl true def handle_params(%{"id" => id}, _url, socket) do actor = current_actor(socket) if join_form_enabled?() and can_access_page?(actor, "/join_requests/:id") do case Membership.get_join_request(id, actor: actor) do {:ok, nil} -> {:noreply, socket |> put_flash(:error, gettext("Join request not found.")) |> push_navigate(to: ~p"/join_requests")} {:ok, request} -> field_ids = Membership.get_join_form_allowlist() |> Enum.map(& &1.id) {:noreply, socket |> assign(:join_request, request) |> assign(:join_form_field_ids, field_ids) |> assign(:page_title, gettext("Join request – %{email}", email: request.email))} {:error, _error} -> {:noreply, socket |> put_flash(:error, gettext("Failed to load join request.")) |> push_navigate(to: ~p"/join_requests")} end else {:noreply, redirect(socket, to: ~p"/members")} end end @impl true def handle_event("approve", _params, socket) do actor = current_actor(socket) request = socket.assigns.join_request case Membership.approve_join_request(request.id, actor: actor) do {:ok, _approved} -> {:noreply, socket |> put_flash(:info, gettext("Join request approved. Member created.")) |> push_navigate(to: ~p"/join_requests")} {:error, error} -> Logger.warning("Failed to approve join request #{request.id}: #{inspect(error)}") {:noreply, put_flash(socket, :error, gettext("Failed to approve join request."))} end end @impl true def handle_event("reject", _params, socket) do actor = current_actor(socket) request = socket.assigns.join_request case Membership.reject_join_request(request.id, actor: actor) do {:ok, _rejected} -> {:noreply, socket |> put_flash(:info, gettext("Join request rejected.")) |> push_navigate(to: ~p"/join_requests")} {:error, error} -> Logger.warning("Failed to reject join request #{request.id}: #{inspect(error)}") {:noreply, put_flash(socket, :error, gettext("Failed to reject join request."))} end end defp join_form_enabled? do case Membership.get_settings() do {:ok, %{join_form_enabled: true}} -> true _ -> false end end @impl true def render(assigns) do ~H""" <.header> <:leading> <.button navigate={~p"/join_requests"} variant="neutral" aria-label={gettext("Back to join requests")} > <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} {gettext("Join request")} <%= if @join_request do %>

{gettext("Request data")}

<.field_row label={gettext("Email")} value={@join_request.email} /> <.field_row label={gettext("First name")} value={@join_request.first_name} empty_text={gettext("Not specified")} /> <.field_row label={gettext("Last name")} value={@join_request.last_name} empty_text={gettext("Not specified")} /> <.field_row label={gettext("Submitted at")} value={DateFormatter.format_datetime(@join_request.submitted_at)} />
{gettext("Status")}: <.badge variant={status_badge_variant(@join_request.status)}> {format_status(@join_request.status)}
<%= if map_size(@join_request.form_data || %{}) > 0 do %>

{gettext("Additional form data")}

<%= for {key, value} <- format_form_data(@join_request.form_data, @join_form_field_ids || []) do %> <.field_row label={key} value={to_string(value)} /> <% end %>
<% end %> <%= if @join_request.status in [:approved, :rejected] do %>

{gettext("Review information")}

<%= if @join_request.approved_at do %> <.field_row label={gettext("Approved at")} value={DateFormatter.format_datetime(@join_request.approved_at)} /> <% end %> <%= if @join_request.rejected_at do %> <.field_row label={gettext("Rejected at")} value={DateFormatter.format_datetime(@join_request.rejected_at)} /> <% end %> <.field_row label={gettext("Review by")} value={reviewer_display(@join_request)} empty_text="-" />
<% end %> <%= if @join_request.status == :submitted do %>
<.button variant="danger" phx-click="reject" data-confirm={gettext("Reject this join request?")} data-testid="join-request-reject-btn" > {gettext("Reject")} <.button variant="primary" phx-click="approve" data-confirm={gettext("Approve this join request and create a member?")} data-testid="join-request-approve-btn" > {gettext("Approve")}
<% end %>
<% end %>
""" end attr :label, :string, required: true attr :value, :any, default: nil attr :empty_text, :string, default: nil defp field_row(assigns) do ~H"""
{@label}: <%= if @value && @value != "" do %> {@value} <% else %> {@empty_text || gettext("Not specified")} <% end %>
""" end defp format_status(:pending_confirmation), do: gettext("Pending confirmation") defp format_status(:submitted), do: gettext("Submitted") defp format_status(:approved), do: gettext("Approved") defp format_status(:rejected), do: gettext("Rejected") defp format_status(other), do: to_string(other) defp status_badge_variant(:submitted), do: :info defp status_badge_variant(:approved), do: :success defp status_badge_variant(:rejected), do: :error defp status_badge_variant(_), do: :neutral defp reviewer_display(%{reviewed_by_user: user}) do case user do nil -> nil %{email: email} when not is_nil(email) -> s = to_string(email) |> String.trim() if s == "", do: nil, else: s _ -> nil end end defp reviewer_display(_), do: nil # Formats form_data for display in join-form order; legacy keys (not in current # join_form_field_ids) are appended at the end, sorted by label for stability. # Labels: member field keys → human-readable; UUID keys kept as-is (custom field IDs). defp format_form_data(nil, _ordered_field_ids), do: [] defp format_form_data(form_data, ordered_field_ids) when is_map(form_data) do member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1) # First: entries in current join form order (only keys present in form_data) in_order = ordered_field_ids |> Enum.filter(&Map.has_key?(form_data, &1)) |> Enum.map(fn key -> value = form_data[key] label = field_key_to_label(key, member_field_strings) {label, value} end) # Then: keys in form_data that are not in current settings (e.g. removed fields on old requests) legacy_keys = form_data |> Map.keys() |> Enum.reject(&(&1 in ordered_field_ids)) |> Enum.sort() legacy_entries = Enum.map(legacy_keys, fn key -> label = field_key_to_label(key, member_field_strings) {label, form_data[key]} end) in_order ++ legacy_entries end defp field_key_to_label(key, member_field_strings) when is_binary(key) do if key in member_field_strings, do: humanize_field(key), else: key end defp field_key_to_label(key, _), do: to_string(key) defp humanize_field(key) when is_binary(key) do key |> String.replace("_", " ") |> String.capitalize() end end