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 alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers alias MvWeb.Translations.MemberFields, as: MemberFieldsTranslations @impl true def mount(_params, _session, socket) do if Membership.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 Membership.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 @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 %>
<%!-- Single block: all applicant-provided data in join form order --%>

{gettext("Applicant data")}

<%= for {label, value} <- applicant_data_rows(@join_request, @join_form_field_ids || []) do %> <.field_row label={label} value={value} empty_text={gettext("Not specified")} /> <% end %>
<%!-- Status and review (submitted_at, status; if decided: approved/rejected at, reviewed by) --%>

{gettext("Status and review")}

<.field_row label={gettext("Submitted at")} value={DateFormatter.format_datetime(@join_request.submitted_at)} />
{gettext("Status")}: <.badge variant={JoinRequestHelpers.status_badge_variant(@join_request.status)}> {JoinRequestHelpers.format_status(@join_request.status)}
<%= if @join_request.status in [:approved, :rejected] do %> <%= 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={JoinRequestHelpers.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 # Builds a single list of {label, display_value} for all applicant-provided data in join form # order. Typed fields (email, first_name, last_name) and form_data are merged; legacy # form_data keys (not in current join form config) are appended at the end. defp applicant_data_rows(join_request, ordered_field_ids) do member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1) form_data = join_request.form_data || %{} typed = %{ "email" => join_request.email, "first_name" => join_request.first_name, "last_name" => join_request.last_name } in_order = ordered_field_ids |> Enum.map(fn key -> value = Map.get(typed, key) || Map.get(form_data, key) label = field_key_to_label(key, member_field_strings) {label, format_applicant_value(value)} end) legacy_keys = form_data |> Map.keys() |> Enum.reject(fn k -> k in ordered_field_ids or k in ["email", "first_name", "last_name"] end) |> Enum.sort() legacy_entries = Enum.map(legacy_keys, fn key -> label = field_key_to_label(key, member_field_strings) {label, format_applicant_value(form_data[key])} end) in_order ++ legacy_entries end defp format_applicant_value(nil), do: nil defp format_applicant_value(""), do: nil defp format_applicant_value(%Date{} = date), do: DateFormatter.format_date(date) defp format_applicant_value(value) when is_map(value), do: format_applicant_value_from_map(value) defp format_applicant_value(value) when is_boolean(value), do: if(value, do: gettext("Yes"), else: gettext("No")) defp format_applicant_value(value) when is_binary(value) or is_number(value), do: to_string(value) defp format_applicant_value(value), do: to_string(value) defp format_applicant_value_from_map(value) do raw = Map.get(value, "_union_value") || Map.get(value, "value") type = Map.get(value, "_union_type") || Map.get(value, "type") if raw && type in ["date", :date] do format_applicant_value(raw) else format_applicant_value_simple(raw, value) end end defp format_applicant_value_simple(raw, _value) when is_binary(raw), do: raw defp format_applicant_value_simple(raw, _value) when is_boolean(raw), do: if(raw, do: gettext("Yes"), else: gettext("No")) defp format_applicant_value_simple(raw, _value) when is_integer(raw), do: to_string(raw) defp format_applicant_value_simple(_raw, value), do: to_string(value) defp field_key_to_label(key, member_field_strings) when is_binary(key) do if key in member_field_strings, do: MemberFieldsTranslations.label(String.to_existing_atom(key)), else: key end defp field_key_to_label(key, _), do: to_string(key) end