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 Mv.Membership.CustomFieldLookup 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(:custom_field_by_id, %{}) |> assign(:join_form_field_ids, []) |> Layouts.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) custom_field_by_id = CustomFieldLookup.fetch_map_by_ids(field_ids ++ Map.keys(request.form_data || %{}), actor: actor, select: [:id, :name, :value_type] ) {:noreply, socket |> assign(:join_request, request) |> assign(:custom_field_by_id, custom_field_by_id) |> assign(:join_form_field_ids, field_ids) |> Layouts.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")} {@content_title} <%= 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 || [], @custom_field_by_id || %{} ) 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, @browser_timezone)} /> <.field_row label={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, @browser_timezone) } /> <% end %> <%= if @join_request.rejected_at do %> <.field_row label={gettext("Rejected at")} value={ DateFormatter.format_datetime(@join_request.rejected_at, @browser_timezone) } /> <% 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 slot :inner_block defp field_row(assigns) do ~H"""
{@label}:
<%= cond do %> <% @inner_block != [] -> %> {render_slot(@inner_block)} <% @value && @value != "" -> %> {@value} <% true -> %> {@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, custom_field_by_id) 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, custom_field_by_id) value_type = field_key_to_value_type(key, member_field_strings, custom_field_by_id) {label, format_applicant_value(value, value_type)} 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, custom_field_by_id) value_type = field_key_to_value_type(key, member_field_strings, custom_field_by_id) {label, format_applicant_value(form_data[key], value_type)} end) in_order ++ legacy_entries end defp format_applicant_value(nil, _type), do: nil defp format_applicant_value("", _type), do: nil defp format_applicant_value(%Date{} = date, _type), do: DateFormatter.format_date(date) defp format_applicant_value(value, type) when is_map(value), do: format_applicant_value_from_map(value, type) defp format_applicant_value(value, _type) when is_boolean(value), do: if(value, do: gettext("Yes"), else: gettext("No")) defp format_applicant_value(value, type) when is_binary(value), do: format_binary_applicant_value(value, type) defp format_applicant_value(value, _type) when is_number(value), do: to_string(value) defp format_applicant_value(value, _type), do: to_string(value) defp format_binary_applicant_value(value, type) do trimmed_value = String.trim(value) cond do trimmed_value == "" -> nil String.downcase(trimmed_value) in ["on", "true", "1"] -> gettext("Yes") String.downcase(trimmed_value) in ["off", "false", "0"] -> gettext("No") type in [:date, Ash.Type.Date] -> format_iso_date_string(trimmed_value) true -> trimmed_value end end defp format_iso_date_string(value) do case Date.from_iso8601(value) do {:ok, date} -> DateFormatter.format_date(date) _ -> value end end defp format_applicant_value_from_map(value, fallback_type) do raw = Map.get(value, "_union_value") || Map.get(value, "value") type = Map.get(value, "_union_type") || Map.get(value, "type") effective_type = type || fallback_type if raw && effective_type in ["date", :date, Ash.Type.Date] do format_applicant_value(raw, :date) 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, custom_field_by_id) when is_binary(key) do if key in member_field_strings do MemberFieldsTranslations.label(String.to_existing_atom(key)) else case Map.get(custom_field_by_id, key) do %{name: name} -> name _ -> key end end end defp field_key_to_label(key, _, _), do: to_string(key) defp field_key_to_value_type("email", _member_field_strings, _custom_field_by_id), do: :string defp field_key_to_value_type("first_name", _member_field_strings, _custom_field_by_id), do: :string defp field_key_to_value_type("last_name", _member_field_strings, _custom_field_by_id), do: :string defp field_key_to_value_type(key, member_field_strings, custom_field_by_id) when is_binary(key) do if key in member_field_strings do :string else case Map.get(custom_field_by_id, key) do %{value_type: value_type} -> value_type _ -> nil end end end defp field_key_to_value_type(_key, _member_field_strings, _custom_field_by_id), do: nil end