diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex index 0cf3aee..d9a71fb 100644 --- a/lib/who_need_help/accounts.ex +++ b/lib/who_need_help/accounts.ex @@ -242,6 +242,18 @@ defmodule WhoNeedHelp.Accounts do end end + def get_user_for_moderation(%Scope{user: actor}, user_id) do + with true <- authorized?(actor, :users_view), + {:ok, user_id} <- cast_id(user_id) do + User + |> where([user], user.id == ^user_id) + |> preload(:staff_role_assignments) + |> Repo.one() + else + _unauthorized_or_invalid -> nil + end + end + def moderate_user(%User{} = moderator, user_id, attrs) do with {:ok, user_id} <- cast_id(user_id), true <- authorized?(moderator, :users_moderate) do @@ -1138,13 +1150,19 @@ defmodule WhoNeedHelp.Accounts do defp maybe_user_search(query, value) do term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "") - pattern = "%#{term}%" - where( - query, - [user], - ilike(user.email, ^pattern) or ilike(user.display_name, ^pattern) - ) + if String.length(term) < 3 do + where(query, [user], false) + else + pattern = "%#{String.downcase(term)}%" + + where( + query, + [user], + fragment("lower(?::text) LIKE ?", user.email, ^pattern) or + fragment("lower(?) LIKE ?", user.display_name, ^pattern) + ) + end end defp cast_id(value) do diff --git a/lib/who_need_help/content_removal.ex b/lib/who_need_help/content_removal.ex index 1f0a0f8..d9c0ee9 100644 --- a/lib/who_need_help/content_removal.ex +++ b/lib/who_need_help/content_removal.ex @@ -127,6 +127,22 @@ defmodule WhoNeedHelp.ContentRemoval do end end + def get_for_staff(%Scope{user: user}, id) do + with true <- Accounts.authorized?(user, :legal_view), + {:ok, id} <- Ecto.UUID.cast(id), + %Notice{} = notice <- + Notice + |> visible_to_staff() + |> where([notice], notice.id == ^id) + |> preload([:requester, :reviewed_by, :assigned_to]) + |> Repo.one() do + {:ok, notice} + else + false -> {:error, :forbidden} + _ -> {:error, :not_found} + end + end + def moderate(%Scope{user: moderator}, id, attrs) do with {:ok, id} <- Ecto.UUID.cast(id), true <- Accounts.authorized?(moderator, :legal_manage) do @@ -306,14 +322,24 @@ defmodule WhoNeedHelp.ContentRemoval do defp maybe_search(query, value) do term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "") - pattern = "%#{term}%" - where( - query, - [notice], - ilike(notice.reference, ^pattern) or ilike(notice.contact_email, ^pattern) or - ilike(notice.submitter_name, ^pattern) - ) + if String.length(term) < 3 do + where(query, [notice], false) + else + pattern = "%#{String.downcase(term)}%" + + where( + query, + [notice], + fragment( + "lower(coalesce(?, '') || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) LIKE ?", + notice.reference, + notice.contact_email, + notice.submitter_name, + ^pattern + ) + ) + end end defp normalize_assignment(attrs, permission) do diff --git a/lib/who_need_help/support.ex b/lib/who_need_help/support.ex index 38c6160..4c7245f 100644 --- a/lib/who_need_help/support.ex +++ b/lib/who_need_help/support.ex @@ -154,13 +154,7 @@ defmodule WhoNeedHelp.Support do |> before(cursor) |> order_by([request], desc: request.inserted_at, desc: request.id) |> limit(^(limit + 1)) - |> preload([ - :requester, - :reviewed_by, - :assigned_to, - conversation_messages: :sender, - status_events: :actor - ]) + |> preload([:requester, :assigned_to]) |> Repo.all() |> Pagination.page(limit, &{&1.inserted_at, &1.id}) else @@ -168,6 +162,20 @@ defmodule WhoNeedHelp.Support do end end + def get_for_staff(%Scope{user: user}, id) do + with true <- Accounts.authorized?(user, :support_view), + {:ok, id} <- Ecto.UUID.cast(id), + %SupportRequest{} = request <- + SupportRequest + |> where([request], request.id == ^id and not is_nil(request.contact_verified_at)) + |> Repo.one() do + {:ok, preload_conversation(request)} + else + false -> {:error, :forbidden} + _ -> {:error, :not_found} + end + end + def moderate(%Scope{user: moderator}, id, attrs) do with {:ok, id} <- Ecto.UUID.cast(id), true <- Accounts.authorized?(moderator, :support_manage) do @@ -595,14 +603,24 @@ defmodule WhoNeedHelp.Support do defp maybe_search(query, value) do term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "") - pattern = "%#{term}%" - where( - query, - [request], - ilike(request.reference, ^pattern) or ilike(request.contact_email, ^pattern) or - ilike(request.subject, ^pattern) - ) + if String.length(term) < 3 do + where(query, [request], false) + else + pattern = "%#{String.downcase(term)}%" + + where( + query, + [request], + fragment( + "lower(coalesce(?, '') || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) LIKE ?", + request.reference, + request.contact_email, + request.subject, + ^pattern + ) + ) + end end defp normalize_assignment(attrs, permission) do diff --git a/lib/who_need_help_web/components/admin_components.ex b/lib/who_need_help_web/components/admin_components.ex index 5835f7c..856b85b 100644 --- a/lib/who_need_help_web/components/admin_components.ex +++ b/lib/who_need_help_web/components/admin_components.ex @@ -13,7 +13,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do def shell(assigns) do ~H""" -
+
-
diff --git a/lib/who_need_help_web/live/admin_user_live.ex b/lib/who_need_help_web/live/admin_user_live.ex new file mode 100644 index 0000000..a48298a --- /dev/null +++ b/lib/who_need_help_web/live/admin_user_live.ex @@ -0,0 +1,269 @@ +defmodule WhoNeedHelpWeb.AdminUserLive do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.{Accounts, Trust} + alias WhoNeedHelp.Accounts.StaffPermissions + alias WhoNeedHelpWeb.AdminComponents + + @impl true + def mount(%{"id" => user_id}, _session, socket) do + case Accounts.get_user_for_moderation(socket.assigns.current_scope, user_id) do + nil -> + {:ok, + socket + |> put_flash(:error, gettext("The selected account is no longer available.")) + |> push_navigate(to: ~p"/admin/users")} + + user -> + {:ok, + socket + |> assign(:page_title, gettext("Manage user")) + |> assign(:roles, StaffPermissions.roles()) + |> assign_user(user)} + end + end + + @impl true + def handle_event("moderate-user", %{"moderation" => params}, socket) do + respond( + socket, + Trust.moderate_user(socket.assigns.current_scope, socket.assigns.user.id, params), + gettext("Account status updated.") + ) + end + + def handle_event("set-staff-roles", %{"staff" => params}, socket) do + roles = params |> Map.get("roles", []) |> List.wrap() |> Enum.reject(&(&1 == "")) + + respond( + socket, + Trust.set_staff_roles(socket.assigns.current_scope, socket.assigns.user.id, roles), + gettext("Staff roles updated.") + ) + end + + defp respond(socket, {:ok, _record}, message) do + case Accounts.get_user_for_moderation( + socket.assigns.current_scope, + socket.assigns.user.id + ) do + nil -> + {:noreply, + socket + |> put_flash(:error, gettext("The selected account is no longer available.")) + |> push_navigate(to: ~p"/admin/users")} + + user -> + {:noreply, socket |> put_flash(:info, message) |> assign_user(user)} + end + end + + defp respond(socket, {:error, reason}, _message), + do: {:noreply, put_flash(socket, :error, error_message(reason))} + + defp assign_user(socket, user) do + moderation = %{ + "moderation_status" => to_string(user.moderation_status), + "moderation_note" => user.moderation_note || "" + } + + socket + |> assign(:user, user) + |> assign(:moderation_form, to_form(moderation, as: :moderation)) + |> assign(:staff_form, to_form(%{}, as: :staff)) + end + + defp error_message(:forbidden), + do: + gettext( + "You do not have permission for this action. Role changes also require a recent sign-in." + ) + + defp error_message(:cannot_restrict_self), + do: gettext("You cannot restrict or suspend your own account.") + + defp error_message(:last_admin), + do: + gettext("The last active administrator cannot lose administrator access or be restricted.") + + defp error_message(:invalid_roles), do: gettext("One or more staff roles are invalid.") + defp error_message(:not_found), do: gettext("The selected account is no longer available.") + defp error_message(%Ecto.Changeset{}), do: gettext("Check the status and internal note.") + defp error_message(_reason), do: gettext("The account could not be updated.") + + defp role_label(:support), do: gettext("Support") + defp role_label(:moderator), do: gettext("Moderator") + defp role_label(:legal), do: gettext("Legal") + defp role_label(:analyst), do: gettext("Analyst") + defp role_label(:admin), do: gettext("Administrator") + + defp role_description(:support), do: gettext("Support conversations and verified support cases") + + defp role_description(:moderator), + do: gettext("Reports, safety signals, user restrictions, and categories") + + defp role_description(:legal), do: gettext("Content-removal and legal notice queue") + defp role_description(:analyst), do: gettext("Privacy-preserving aggregate product analytics") + defp role_description(:admin), do: gettext("All permissions, staff roles, and audit log") + + defp status_label(:active), do: gettext("Active") + defp status_label(:restricted), do: gettext("Restricted") + defp status_label(:suspended), do: gettext("Suspended") + + defp status_class(:active), do: "badge-success badge-outline" + defp status_class(:restricted), do: "badge-warning" + defp status_class(:suspended), do: "badge-error" + + defp target_has_staff_roles?(user), do: Accounts.loaded_staff_roles(user) != [] + + defp can_moderate?(actor, target) do + Accounts.permission?(actor, :users_moderate) and + (not target_has_staff_roles?(target) or Accounts.permission?(actor, :staff_manage)) + end + + @impl true + def render(assigns) do + ~H""" + + <.link navigate={~p"/admin/users"} class="btn btn-ghost btn-sm w-fit px-1"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back to users")} + + +
+
+
+
+

+ {@user.display_name || gettext("Unnamed user")} +

+ + {status_label(@user.moderation_status)} + +
+

{@user.email}

+

{@user.id}

+
+
+ + {gettext("Regular user")} + + + {role_label(role)} + +
+
+
+ +
+
+
+

{gettext("Account access")}

+

+ {gettext("Restrict or suspend access and record an internal reason.")} +

+
+ + <.form + :if={can_moderate?(@current_scope.user, @user)} + for={@moderation_form} + id={"moderate-user-#{@user.id}"} + phx-submit="moderate-user" + class="mt-4 space-y-4" + > + <.input + field={@moderation_form[:moderation_status]} + type="select" + label={gettext("Status")} + options={[ + {gettext("Active"), "active"}, + {gettext("Restricted"), "restricted"}, + {gettext("Suspended"), "suspended"} + ]} + /> + <.input + field={@moderation_form[:moderation_note]} + type="textarea" + label={gettext("Internal note")} + maxlength="1000" + rows="4" + /> + + + +

+ {gettext("You can view this account but cannot change its access.")} +

+
+ +
+
+

{gettext("Staff roles")}

+

+ {gettext("A staff member may hold several fixed roles.")} +

+
+ + <.form + :if={Accounts.permission?(@current_scope.user, :staff_manage)} + for={@staff_form} + id={"staff-roles-#{@user.id}"} + phx-submit="set-staff-roles" + class="mt-4" + > +
+ {gettext("Staff roles")} + + +
+ + + +

+ {gettext("Only administrators can change staff roles.")} +

+
+
+
+ """ + end +end diff --git a/lib/who_need_help_web/live/admin_users_live.ex b/lib/who_need_help_web/live/admin_users_live.ex index 1efe468..c17de6d 100644 --- a/lib/who_need_help_web/live/admin_users_live.ex +++ b/lib/who_need_help_web/live/admin_users_live.ex @@ -1,13 +1,15 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do use WhoNeedHelpWeb, :live_view - alias WhoNeedHelp.{Accounts, Trust} + alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts.StaffPermissions alias WhoNeedHelpWeb.AdminComponents + @page_sizes ~w(25 50 100) + @impl true def mount(_params, _session, socket) do - filters = %{"search" => "", "status" => "", "role" => ""} + filters = default_filters() {:ok, socket @@ -15,91 +17,104 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do |> assign(:filters, filters) |> assign(:filter_form, to_form(filters, as: :filters)) |> assign(:roles, StaffPermissions.roles()) + |> assign(:page_cursor, nil) + |> assign(:cursor_history, []) + |> assign(:page_number, 1) + |> assign(:page_size, 50) |> load_users()} end @impl true - def handle_event("filter", %{"filters" => filters}, socket) do - filters = Map.merge(socket.assigns.filters, filters) + def handle_event("filter", %{"filters" => params}, socket) do + filters = normalize_filters(params) {:noreply, socket |> assign(:filters, filters) |> assign(:filter_form, to_form(filters, as: :filters)) + |> assign(:page_cursor, nil) + |> assign(:cursor_history, []) + |> assign(:page_number, 1) + |> assign(:page_size, page_size(filters["limit"])) |> load_users()} end - def handle_event("load-more", _params, socket) do - page = - Accounts.paginate_users_for_moderation( - socket.assigns.current_scope, - user_options(socket, socket.assigns.users_cursor) - ) + def handle_event("next-page", _params, %{assigns: %{users_cursor: nil}} = socket), + do: {:noreply, socket} - existing_ids = MapSet.new(socket.assigns.users, & &1.id) + def handle_event("next-page", _params, socket) do + {:noreply, + socket + |> assign(:cursor_history, [socket.assigns.page_cursor | socket.assigns.cursor_history]) + |> assign(:page_cursor, socket.assigns.users_cursor) + |> assign(:page_number, socket.assigns.page_number + 1) + |> load_users()} + end + + def handle_event("previous-page", _params, %{assigns: %{cursor_history: []}} = socket), + do: {:noreply, socket} + + def handle_event("previous-page", _params, socket) do + [previous_cursor | history] = socket.assigns.cursor_history {:noreply, socket - |> assign( - :users, - socket.assigns.users ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id)) - ) - |> assign(:users_cursor, page.next_cursor)} + |> assign(:cursor_history, history) + |> assign(:page_cursor, previous_cursor) + |> assign(:page_number, max(socket.assigns.page_number - 1, 1)) + |> load_users()} end - def handle_event("moderate-user", %{"id" => id, "moderation" => params}, socket) do - respond( - socket, - Trust.moderate_user(socket.assigns.current_scope, id, params), - gettext("Account status updated.") - ) - end - - def handle_event("set-staff-roles", %{"id" => id, "staff" => params}, socket) do - roles = params |> Map.get("roles", []) |> List.wrap() |> Enum.reject(&(&1 == "")) - - respond( - socket, - Trust.set_staff_roles(socket.assigns.current_scope, id, roles), - gettext("Staff roles updated.") - ) - end - - defp respond(socket, {:ok, _record}, message), - do: {:noreply, socket |> put_flash(:info, message) |> load_users()} - - defp respond(socket, {:error, reason}, _message), - do: {:noreply, put_flash(socket, :error, error_message(reason))} - defp load_users(socket) do page = - Accounts.paginate_users_for_moderation(socket.assigns.current_scope, user_options(socket)) - - socket |> assign(:users, page.entries) |> assign(:users_cursor, page.next_cursor) - end - - defp user_options(socket, cursor \\ nil) do - filters = socket.assigns.filters - [search: filters["search"], status: filters["status"], role: filters["role"], after: cursor] - end - - defp error_message(:forbidden), - do: - gettext( - "You do not have permission for this action. Role changes also require a recent sign-in." + Accounts.paginate_users_for_moderation( + socket.assigns.current_scope, + user_options(socket) ) - defp error_message(:cannot_restrict_self), - do: gettext("You cannot restrict or suspend your own account.") + socket + |> assign(:users, page.entries) + |> assign(:users_cursor, page.next_cursor) + end - defp error_message(:last_admin), - do: - gettext("The last active administrator cannot lose administrator access or be restricted.") + defp user_options(socket) do + filters = socket.assigns.filters - defp error_message(:invalid_roles), do: gettext("One or more staff roles are invalid.") - defp error_message(:not_found), do: gettext("The selected account is no longer available.") - defp error_message(%Ecto.Changeset{}), do: gettext("Check the status and internal note.") - defp error_message(_reason), do: gettext("The account could not be updated.") + [ + search: filters["search"], + status: filters["status"], + role: filters["role"], + after: socket.assigns.page_cursor, + limit: socket.assigns.page_size + ] + end + + defp default_filters, + do: %{"search" => "", "status" => "", "role" => "", "limit" => "50"} + + defp normalize_filters(params) do + %{ + "search" => params |> Map.get("search", "") |> String.trim() |> String.slice(0, 160), + "status" => normalize_choice(params["status"], ~w(active restricted suspended)), + "role" => + normalize_choice( + params["role"], + ["user" | Enum.map(StaffPermissions.roles(), &Atom.to_string/1)] + ), + "limit" => normalize_choice(params["limit"], @page_sizes, "50") + } + end + + defp normalize_choice(value, choices, fallback \\ "") do + if value in choices, do: value, else: fallback + end + + defp page_size(value) do + case Integer.parse(value || "") do + {size, ""} when size in [25, 50, 100] -> size + _invalid -> 50 + end + end defp role_label(:support), do: gettext("Support") defp role_label(:moderator), do: gettext("Moderator") @@ -107,24 +122,34 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do defp role_label(:analyst), do: gettext("Analyst") defp role_label(:admin), do: gettext("Administrator") - defp role_description(:support), do: gettext("Support conversations and verified support cases") + defp status_label(:active), do: gettext("Active") + defp status_label(:restricted), do: gettext("Restricted") + defp status_label(:suspended), do: gettext("Suspended") - defp role_description(:moderator), - do: gettext("Reports, safety signals, user restrictions, and categories") + defp account_name(user), do: user.display_name || gettext("Unnamed user") - defp role_description(:legal), do: gettext("Content-removal and legal notice queue") - defp role_description(:analyst), do: gettext("Privacy-preserving aggregate product analytics") - defp role_description(:admin), do: gettext("All permissions, staff roles, and audit log") + defp joined_on(%DateTime{} = inserted_at), do: Calendar.strftime(inserted_at, "%Y-%m-%d") - defp target_has_staff_roles?(user), do: Accounts.loaded_staff_roles(user) != [] + defp result_range([], _page_number, _page_size), do: {0, 0} - defp can_moderate?(actor, target) do - Accounts.permission?(actor, :users_moderate) and - (not target_has_staff_roles?(target) or Accounts.permission?(actor, :staff_manage)) + defp result_range(users, page_number, page_size) do + first = (page_number - 1) * page_size + 1 + {first, first + length(users) - 1} end + defp status_class(:active), do: "badge-success badge-outline" + defp status_class(:restricted), do: "badge-warning" + defp status_class(:suspended), do: "badge-error" + @impl true def render(assigns) do + assigns = + assign( + assigns, + :result_range, + result_range(assigns.users, assigns.page_number, assigns.page_size) + ) + ~H""" @@ -141,13 +166,14 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do for={@filter_form} id="admin-user-filters" phx-change="filter" - class="grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-[minmax(0,1fr)_12rem_12rem]" + class="grid gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 md:grid-cols-[minmax(16rem,1fr)_11rem_11rem_8rem]" > <.input field={@filter_form[:search]} type="search" label={gettext("Search users")} placeholder={gettext("Email or display name")} + minlength="3" phx-debounce="300" /> <.input @@ -170,123 +196,156 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do Enum.map(@roles, &{role_label(&1), Atom.to_string(&1)}) } /> + <.input + field={@filter_form[:limit]} + type="select" + label={gettext("Rows")} + options={[{"25", "25"}, {"50", "50"}, {"100", "100"}]} + /> -
-
- <% status_form = - to_form( - %{ - "moderation_status" => to_string(user.moderation_status), - "moderation_note" => user.moderation_note || "" - }, - as: :moderation, - id: "status-#{user.id}" - ) %> -
-
-
-

{user.display_name}

- {user.moderation_status} -
-

{user.email}

-
- {gettext( - "Regular user" - )} - {role_label(role)} -
-
- - <.form - :if={can_moderate?(@current_scope.user, user)} - for={status_form} - id={"moderate-user-#{user.id}"} - phx-submit="moderate-user" - phx-value-id={user.id} - class="grid min-w-0 gap-3 sm:grid-cols-[11rem_minmax(14rem,1fr)_auto] xl:w-[42rem]" - > - <.input - field={status_form[:moderation_status]} - type="select" - label={gettext("Status")} - options={[ - {gettext("Active"), "active"}, - {gettext("Restricted"), "restricted"}, - {gettext("Suspended"), "suspended"} - ]} - /> - <.input - field={status_form[:moderation_note]} - label={gettext("Internal note")} - maxlength="1000" - /> - - +
+
+
+

{gettext("User directory")}

+

+ <%= if String.length(@filters["search"]) in 1..2 do %> + {gettext("Enter at least 3 characters to search.")} + <% else %> + <%= if @users == [] do %> + {gettext("No users match these filters.")} + <% else %> + {gettext("Showing %{first}–%{last}", + first: elem(@result_range, 0), + last: elem(@result_range, 1) + )} + <% end %> + <% end %> +

+

+ {gettext("Page %{page}", page: @page_number)} +

+
- <.form - :if={Accounts.permission?(@current_scope.user, :staff_manage)} - for={to_form(%{}, as: :staff, id: "roles-#{user.id}")} - id={"staff-roles-#{user.id}"} - phx-submit="set-staff-roles" - phx-value-id={user.id} - class="mt-5 border-t border-base-300 pt-5" - > -
-
- {gettext("Staff roles")} - -
+
+ -

+

- - + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Previous")} + + {gettext("Page %{page}", page: @page_number)} + + """ end diff --git a/lib/who_need_help_web/live/support_operation_live.ex b/lib/who_need_help_web/live/support_operation_live.ex new file mode 100644 index 0000000..71b5f28 --- /dev/null +++ b/lib/who_need_help_web/live/support_operation_live.ex @@ -0,0 +1,439 @@ +defmodule WhoNeedHelpWeb.SupportOperationLive do + use WhoNeedHelpWeb, :live_view + + alias WhoNeedHelp.{Accounts, ContentRemoval, Support} + alias WhoNeedHelpWeb.AdminComponents + + @impl true + def mount(%{"queue" => queue, "id" => id}, _session, socket) do + with {:ok, queue} <- parse_queue(queue), + {:ok, record} <- get_record(socket.assigns.current_scope, queue, id) do + if connected?(socket) and queue == :support, do: Support.subscribe_request(record.id) + + {:ok, + socket + |> assign(:page_title, gettext("Operations case")) + |> assign(:queue, queue) + |> assign(:support_assignees, staff_assignees(socket, :support_manage)) + |> assign(:legal_assignees, staff_assignees(socket, :legal_manage)) + |> assign(:deletion_assessment, nil) + |> assign_record(record)} + else + _error -> + {:ok, + socket + |> put_flash(:error, gettext("The selected record is no longer available.")) + |> push_navigate(to: ~p"/support/operations")} + end + end + + @impl true + def handle_info({:support_request_updated, request_id}, socket) do + if socket.assigns.queue == :support and socket.assigns.record.id == request_id do + case get_record(socket.assigns.current_scope, :support, request_id) do + {:ok, record} -> {:noreply, assign_record(socket, record)} + _error -> {:noreply, push_navigate(socket, to: ~p"/support/operations?queue=support")} + end + else + {:noreply, socket} + end + end + + @impl true + def handle_event("moderate-support", %{"moderation" => params}, socket) do + respond( + socket, + Support.moderate(socket.assigns.current_scope, socket.assigns.record.id, params), + gettext("Support request updated.") + ) + end + + def handle_event("moderate-removal", %{"moderation" => params}, socket) do + respond( + socket, + ContentRemoval.moderate(socket.assigns.current_scope, socket.assigns.record.id, params), + gettext("Removal notice updated.") + ) + end + + def handle_event("assess-deletion", _params, socket) do + case Support.deletion_assessment(socket.assigns.current_scope, socket.assigns.record.id) do + {:ok, assessment} -> {:noreply, assign(socket, :deletion_assessment, assessment)} + {:error, reason} -> {:noreply, put_flash(socket, :error, error_message(reason))} + end + end + + defp respond(socket, {:ok, _record}, message) do + case get_record(socket.assigns.current_scope, socket.assigns.queue, socket.assigns.record.id) do + {:ok, record} -> {:noreply, socket |> put_flash(:info, message) |> assign_record(record)} + _error -> {:noreply, push_navigate(socket, to: queue_path(socket.assigns.queue))} + end + end + + defp respond(socket, {:error, reason}, _message), + do: {:noreply, put_flash(socket, :error, error_message(reason))} + + defp parse_queue("support"), do: {:ok, :support} + defp parse_queue("legal"), do: {:ok, :legal} + defp parse_queue(_queue), do: {:error, :not_found} + + defp get_record(scope, :support, id), do: Support.get_for_staff(scope, id) + defp get_record(scope, :legal, id), do: ContentRemoval.get_for_staff(scope, id) + + defp assign_record(socket, record) do + form = + case socket.assigns.queue do + :support -> + %{ + "status" => to_string(record.status), + "assigned_to_id" => record.assigned_to_id || "", + "response" => "" + } + + :legal -> + %{ + "status" => to_string(record.status), + "assigned_to_id" => record.assigned_to_id || "", + "resolution_note" => record.resolution_note || "" + } + end + + socket + |> assign(:record, record) + |> assign(:moderation_form, to_form(form, as: :moderation)) + end + + defp staff_assignees(socket, permission) do + if Accounts.permission?(socket.assigns.current_scope.user, permission) do + Accounts.list_staff_for_permission(permission) + else + [] + end + end + + defp assignment_options(users) do + [{gettext("Unassigned"), ""} | Enum.map(users, &{&1.display_name || &1.email, &1.id})] + end + + defp queue_path(:support), do: ~p"/support/operations?queue=support" + defp queue_path(:legal), do: ~p"/support/operations?queue=legal" + + defp error_message(:forbidden), do: gettext("You do not have permission for this action.") + defp error_message(:not_found), do: gettext("The selected record is no longer available.") + defp error_message(:not_deletion_request), do: gettext("This is not an account-deletion case.") + defp error_message(:account_not_linked), do: gettext("The request is not linked to an account.") + defp error_message(:contact_not_verified), do: gettext("Verify the requester contact first.") + defp error_message(:account_not_found), do: gettext("The linked account no longer exists.") + + defp error_message(:invalid_assignee), + do: gettext("The selected assignee cannot manage this queue.") + + defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.") + defp error_message(_reason), do: gettext("Could not update the request. Please try again.") + + defp support_kind(:account_access), do: gettext("Account access") + defp support_kind(:technical_issue), do: gettext("Technical issue") + defp support_kind(:safety_concern), do: gettext("Safety concern") + defp support_kind(:moderation_appeal), do: gettext("Moderation appeal") + defp support_kind(:account_deletion), do: gettext("Account deletion") + defp support_kind(:data_export), do: gettext("Data export") + defp support_kind(:privacy_request), do: gettext("Privacy request") + defp support_kind(:other), do: gettext("Other") + defp support_kind(value), do: to_string(value) + + defp removal_category(:illegal_content), do: gettext("Illegal content") + defp removal_category(:privacy_violation), do: gettext("Privacy violation") + defp removal_category(:copyright), do: gettext("Copyright") + defp removal_category(:impersonation), do: gettext("Impersonation") + + defp removal_category(:non_consensual_intimate_media), + do: gettext("Non-consensual intimate image or video") + + defp removal_category(:child_sexual_abuse_material), + do: gettext("Sexual material involving a minor") + + defp removal_category(:threat_to_life_or_safety), do: gettext("Threat to life or safety") + defp removal_category(:other), do: gettext("Other") + defp removal_category(value), do: to_string(value) + + defp status_label(value), do: value |> to_string() |> String.replace("_", " ") + + defp status_class(value) when value in [:urgent_review], do: "badge-error" + defp status_class(value) when value in [:open, :reviewing], do: "badge-warning badge-outline" + defp status_class(value) when value in [:resolved, :actioned], do: "badge-success badge-outline" + defp status_class(_value), do: "badge-ghost" + + defp format_datetime(nil), do: gettext("Not available") + defp format_datetime(%DateTime{} = value), do: Calendar.strftime(value, "%Y-%m-%d %H:%M UTC") + + @impl true + def render(assigns) do + ~H""" + + <.link navigate={queue_path(@queue)} class="btn btn-ghost btn-sm w-fit px-1"> + <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back to queue")} + + + <.support_case + :if={@queue == :support} + record={@record} + form={@moderation_form} + assignees={@support_assignees} + assessment={@deletion_assessment} + /> + <.legal_case + :if={@queue == :legal} + record={@record} + form={@moderation_form} + assignees={@legal_assignees} + /> + + """ + end + + attr :record, :any, required: true + attr :form, :any, required: true + attr :assignees, :list, required: true + attr :assessment, :any, default: nil + + defp support_case(assigns) do + ~H""" +
+
+
+
+ {@record.reference}{support_kind( + @record.kind + )}{status_label( + @record.status + )} +

{@record.subject}

+ {@record.contact_email} +

+
+
+

{gettext("Received")}

+ {format_datetime(@record.inserted_at)} +

+
+
+

{@record.details}

+
+ +
+
+

{gettext("Conversation")}

+
+
+
+ {if message.sender_role == :staff, + do: gettext("Support team"), + else: gettext("Requester")} +

{message.body}

+
+

+ {gettext("No conversation messages yet.")} +

+
+
+ {gettext("Status history")}
    +
  1. + {status_label(event.to_status)} · {event.actor_role} · {format_datetime( + event.inserted_at + )} +
  2. + {gettext("No status history is available for this older case.")} +
  3. +
+
+
+ +
+
+

{gettext("Account deletion preflight")}

+ +
+

+ {if @assessment.technically_idle, + do: gettext("No active product workflow was found."), + else: gettext("Active or unresolved product state must be handled first.")} +

    +
  • {blocker.kind}: {blocker.count}
  • +
+
+
+ + <.form + for={@form} + id={"support-moderation-#{@record.id}"} + phx-submit="moderate-support" + class="rounded-2xl border border-primary/25 bg-base-100 p-4 sm:p-5" + > +

{gettext("Operator action")}

+ {gettext("Update status, assignment, and optionally reply.")} +

+
+ <.input + field={@form[:status]} + type="select" + label={gettext("Case status")} + options={[ + {gettext("Open"), "open"}, + {gettext("Reviewing"), "reviewing"}, + {gettext("Waiting for requester"), "waiting_for_requester"}, + {gettext("Resolved"), "resolved"}, + {gettext("Closed"), "closed"} + ]} + /><.input + field={@form[:assigned_to_id]} + type="select" + label={gettext("Assigned operator")} + options={assignment_options(@assignees)} + /><.input + field={@form[:response]} + type="textarea" + rows="5" + maxlength="5000" + label={gettext("Reply to requester (optional)")} + placeholder={gettext("Write the decision, next step, or information you need…")} + /> +
+

+ {if @record.contact_verified_at, + do: gettext("The verified contact will receive a new reply by email."), + else: gettext("Email is disabled until contact is verified.")} +

+ <.button class="btn btn-primary mt-4 w-full" phx-disable-with={gettext("Saving…")}>{gettext( + "Save and notify" + )} + +
+
+ """ + end + + attr :record, :any, required: true + attr :form, :any, required: true + attr :assignees, :list, required: true + + defp legal_case(assigns) do + ~H""" +
+
+
+
+ {@record.reference}{status_label( + @record.regime + )}{status_label( + @record.status + )} +

{removal_category(@record.category)}

+ {@record.contact_email || gettext("No contact email")} +

+
+

{gettext("Received")}

+ {format_datetime(@record.inserted_at)} +

+ {gettext("Review by %{time}", time: format_datetime(@record.response_due_at))} +

+
+
+
+
{gettext("Submitter")}
+ {@record.submitter_name || gettext("Not provided")} +
{gettext("Content locations")}
+

+ {url} +

+
+
+
+

{@record.explanation}

+ {gettext("Stated basis:")} {@record.legal_basis} +

+
+
+ + <.form + for={@form} + id={"removal-moderation-#{@record.id}"} + phx-submit="moderate-removal" + class="rounded-2xl border border-error/25 bg-base-100 p-4 sm:p-5" + > +
+
+

{gettext("Operator action")}

+ {gettext("Record the decision and notify the submitter when contact is available.")} +

+
{gettext("Audited update")} +
+
+ <.input + field={@form[:status]} + type="select" + label={gettext("Case status")} + options={[ + {gettext("Open"), "open"}, + {gettext("Urgent review"), "urgent_review"}, + {gettext("Reviewing"), "reviewing"}, + {gettext("More information needed"), "needs_information"}, + {gettext("Action taken"), "actioned"}, + {gettext("Rejected"), "rejected"}, + {gettext("Closed"), "closed"} + ]} + /><.input + field={@form[:assigned_to_id]} + type="select" + label={gettext("Assigned operator")} + options={assignment_options(@assignees)} + /><.input + field={@form[:resolution_note]} + type="textarea" + rows="6" + maxlength="10000" + label={gettext("Decision or information request")} + placeholder={gettext("Explain the decision, action, or missing information…")} + /> +
+
+

+ {if @record.contact_verified_at, + do: gettext("The verified contact will receive this update by email."), + else: gettext("Email is disabled until contact is verified.")} +

<.button class="btn btn-error w-full sm:w-auto" phx-disable-with={gettext("Saving…")}>{gettext( + "Save and notify" + )} +
+ + """ + end +end diff --git a/lib/who_need_help_web/live/support_operations_live.ex b/lib/who_need_help_web/live/support_operations_live.ex index a25c767..08a39b3 100644 --- a/lib/who_need_help_web/live/support_operations_live.ex +++ b/lib/who_need_help_web/live/support_operations_live.ex @@ -4,6 +4,8 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do alias WhoNeedHelp.{Accounts, ContentRemoval, Support} alias WhoNeedHelpWeb.AdminComponents + @page_sizes ~w(25 50 100) + @impl true def mount(_params, _session, socket) do support_allowed = Accounts.permission?(socket.assigns.current_scope.user, :support_view) @@ -11,131 +13,115 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do if connected?(socket) and support_allowed, do: Support.subscribe_staff() - support_filters = %{"search" => "", "status" => "", "kind" => "", "assigned_to_id" => ""} - legal_filters = %{"search" => "", "status" => "", "regime" => "", "assigned_to_id" => ""} + support_filters = support_filter_defaults() + legal_filters = legal_filter_defaults() {:ok, socket |> assign(:page_title, gettext("Support operations")) |> assign(:operations_title, operations_title(support_allowed, legal_allowed)) |> assign(:operations_description, operations_description(support_allowed, legal_allowed)) - |> assign(:deletion_assessments, %{}) |> assign(:support_allowed, support_allowed) |> assign(:legal_allowed, legal_allowed) + |> assign(:active_queue, default_queue(support_allowed, legal_allowed)) |> assign(:support_filters, support_filters) |> assign(:legal_filters, legal_filters) |> assign(:support_filter_form, to_form(support_filters, as: :support_filters)) |> assign(:legal_filter_form, to_form(legal_filters, as: :legal_filters)) |> assign(:support_assignees, staff_assignees(support_allowed, :support_manage)) |> assign(:legal_assignees, staff_assignees(legal_allowed, :legal_manage)) - |> load()} + |> assign(:entries, []) + |> assign(:next_cursor, nil) + |> reset_pagination()} + end + + @impl true + def handle_params(params, _uri, socket) do + queue = allowed_queue(params["queue"], socket.assigns) + + {:noreply, + socket + |> assign(:active_queue, queue) + |> reset_pagination() + |> load_queue()} end @impl true def handle_info({:support_request_updated, _request_id}, socket) do - {:noreply, load(socket)} + if socket.assigns.active_queue == :support do + {:noreply, load_queue(socket)} + else + {:noreply, socket} + end end @impl true - def handle_event("moderate-support", %{"id" => id, "moderation" => params}, socket) do - respond( - socket, - Support.moderate(socket.assigns.current_scope, id, params), - gettext("Support request updated.") - ) - end - - def handle_event("moderate-removal", %{"id" => id, "moderation" => params}, socket) do - respond( - socket, - ContentRemoval.moderate(socket.assigns.current_scope, id, params), - gettext("Removal notice updated.") - ) - end - - def handle_event("filter-support", %{"support_filters" => filters}, socket) do - filters = Map.merge(socket.assigns.support_filters, filters) + def handle_event("filter-support", %{"support_filters" => params}, socket) do + filters = normalize_support_filters(params) {:noreply, socket |> assign(:support_filters, filters) |> assign(:support_filter_form, to_form(filters, as: :support_filters)) - |> load_support()} + |> reset_pagination() + |> load_queue()} end - def handle_event("filter-legal", %{"legal_filters" => filters}, socket) do - filters = Map.merge(socket.assigns.legal_filters, filters) + def handle_event("filter-legal", %{"legal_filters" => params}, socket) do + filters = normalize_legal_filters(params) {:noreply, socket |> assign(:legal_filters, filters) |> assign(:legal_filter_form, to_form(filters, as: :legal_filters)) - |> load_removals()} + |> reset_pagination() + |> load_queue()} end - def handle_event("assess-deletion", %{"id" => id}, socket) do - case Support.deletion_assessment(socket.assigns.current_scope, id) do - {:ok, assessment} -> - {:noreply, update(socket, :deletion_assessments, &Map.put(&1, id, assessment))} + def handle_event("next-page", _params, %{assigns: %{next_cursor: nil}} = socket), + do: {:noreply, socket} - {:error, reason} -> - {:noreply, put_flash(socket, :error, error_message(reason))} - end + def handle_event("next-page", _params, socket) do + {:noreply, + socket + |> assign(:cursor_history, [socket.assigns.page_cursor | socket.assigns.cursor_history]) + |> assign(:page_cursor, socket.assigns.next_cursor) + |> assign(:page_number, socket.assigns.page_number + 1) + |> load_queue()} end - def handle_event("load-more-support", _params, socket) do - page = - Support.paginate_for_staff( - socket.assigns.current_scope, - support_options(socket, socket.assigns.support_cursor) - ) + def handle_event("previous-page", _params, %{assigns: %{cursor_history: []}} = socket), + do: {:noreply, socket} - {:noreply, append_page(socket, :support_requests, :support_cursor, page)} + def handle_event("previous-page", _params, socket) do + [previous_cursor | history] = socket.assigns.cursor_history + + {:noreply, + socket + |> assign(:cursor_history, history) + |> assign(:page_cursor, previous_cursor) + |> assign(:page_number, max(socket.assigns.page_number - 1, 1)) + |> load_queue()} end - def handle_event("load-more-removals", _params, socket) do - page = - ContentRemoval.paginate_for_staff( - socket.assigns.current_scope, - legal_options(socket, socket.assigns.removal_cursor) - ) - - {:noreply, append_page(socket, :removal_notices, :removal_cursor, page)} - end - - defp respond(socket, {:ok, _record}, message) do - {:noreply, socket |> put_flash(:info, message) |> load()} - end - - defp respond(socket, {:error, reason}, _message) do - {:noreply, put_flash(socket, :error, error_message(reason))} - end - - defp load(socket) do + defp reset_pagination(socket) do socket - |> load_support() - |> load_removals() + |> assign(:page_cursor, nil) + |> assign(:cursor_history, []) + |> assign(:page_number, 1) end - defp load_support(%{assigns: %{support_allowed: false}} = socket) do - socket |> assign(:support_requests, []) |> assign(:support_cursor, nil) - end - - defp load_support(socket) do + defp load_queue(%{assigns: %{active_queue: :support}} = socket) do page = Support.paginate_for_staff(socket.assigns.current_scope, support_options(socket)) - socket |> assign(:support_requests, page.entries) |> assign(:support_cursor, page.next_cursor) + socket |> assign(:entries, page.entries) |> assign(:next_cursor, page.next_cursor) end - defp load_removals(%{assigns: %{legal_allowed: false}} = socket) do - socket |> assign(:removal_notices, []) |> assign(:removal_cursor, nil) - end - - defp load_removals(socket) do + defp load_queue(%{assigns: %{active_queue: :legal}} = socket) do page = ContentRemoval.paginate_for_staff(socket.assigns.current_scope, legal_options(socket)) - socket |> assign(:removal_notices, page.entries) |> assign(:removal_cursor, page.next_cursor) + socket |> assign(:entries, page.entries) |> assign(:next_cursor, page.next_cursor) end - defp support_options(socket, cursor \\ nil) do + defp support_options(socket) do filters = socket.assigns.support_filters [ @@ -143,11 +129,12 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do status: filters["status"], kind: filters["kind"], assigned_to_id: filters["assigned_to_id"], - after: cursor + after: socket.assigns.page_cursor, + limit: page_size(filters["limit"]) ] end - defp legal_options(socket, cursor \\ nil) do + defp legal_options(socket) do filters = socket.assigns.legal_filters [ @@ -155,10 +142,87 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do status: filters["status"], regime: filters["regime"], assigned_to_id: filters["assigned_to_id"], - after: cursor + after: socket.assigns.page_cursor, + limit: page_size(filters["limit"]) ] end + defp default_queue(true, _legal_allowed), do: :support + defp default_queue(false, true), do: :legal + + defp allowed_queue("legal", %{legal_allowed: true}), do: :legal + defp allowed_queue("support", %{support_allowed: true}), do: :support + + defp allowed_queue(_queue, assigns), + do: default_queue(assigns.support_allowed, assigns.legal_allowed) + + defp support_filter_defaults, + do: %{"search" => "", "status" => "", "kind" => "", "assigned_to_id" => "", "limit" => "50"} + + defp legal_filter_defaults, + do: %{"search" => "", "status" => "", "regime" => "", "assigned_to_id" => "", "limit" => "50"} + + defp normalize_support_filters(params) do + %{ + "search" => normalize_search(params["search"]), + "status" => + normalize_choice( + params["status"], + ~w(open reviewing waiting_for_requester resolved closed) + ), + "kind" => + normalize_choice( + params["kind"], + ~w(account_access technical_issue safety_concern moderation_appeal account_deletion data_export privacy_request other) + ), + "assigned_to_id" => normalize_assignment_filter(params["assigned_to_id"]), + "limit" => normalize_choice(params["limit"], @page_sizes, "50") + } + end + + defp normalize_legal_filters(params) do + %{ + "search" => normalize_search(params["search"]), + "status" => + normalize_choice( + params["status"], + ~w(open urgent_review reviewing needs_information actioned rejected closed) + ), + "regime" => normalize_choice(params["regime"], ~w(general dsa take_it_down)), + "assigned_to_id" => normalize_assignment_filter(params["assigned_to_id"]), + "limit" => normalize_choice(params["limit"], @page_sizes, "50") + } + end + + defp normalize_search(value), do: value |> to_string() |> String.trim() |> String.slice(0, 160) + + defp normalize_assignment_filter(value) when value in [nil, ""], do: "" + defp normalize_assignment_filter(value) when value in ["unassigned", "mine"], do: value + + defp normalize_assignment_filter(value) do + case Ecto.UUID.cast(value) do + {:ok, id} -> id + :error -> "" + end + end + + defp normalize_choice(value, choices, fallback \\ "") do + if value in choices, do: value, else: fallback + end + + defp page_size(value) do + case Integer.parse(value || "") do + {size, ""} when size in [25, 50, 100] -> size + _invalid -> 50 + end + end + + defp current_page_size(%{active_queue: :support, support_filters: filters}), + do: page_size(filters["limit"]) + + defp current_page_size(%{active_queue: :legal, legal_filters: filters}), + do: page_size(filters["limit"]) + defp staff_assignees(false, _permission), do: [] defp staff_assignees(true, permission), do: Accounts.list_staff_for_permission(permission) @@ -167,37 +231,10 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do {gettext("Any assignee"), ""}, {gettext("Unassigned"), "unassigned"}, {gettext("Assigned to me"), "mine"} - | Enum.map(users, fn user -> - {user.display_name || user.email, user.id} - end) + | Enum.map(users, fn user -> {user.display_name || user.email, user.id} end) ] end - defp assignment_options(users) do - [{gettext("Unassigned"), ""} | Enum.map(users, &{&1.display_name || &1.email, &1.id})] - end - - defp append_page(socket, entries_key, cursor_key, page) do - existing = socket.assigns[entries_key] - existing_ids = MapSet.new(existing, & &1.id) - entries = existing ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id)) - - socket |> assign(entries_key, entries) |> assign(cursor_key, page.next_cursor) - end - - defp error_message(:forbidden), do: gettext("Moderator access is required.") - defp error_message(:not_found), do: gettext("The selected record is no longer available.") - defp error_message(:not_deletion_request), do: gettext("This is not an account-deletion case.") - defp error_message(:account_not_linked), do: gettext("The request is not linked to an account.") - defp error_message(:contact_not_verified), do: gettext("Verify the requester contact first.") - defp error_message(:account_not_found), do: gettext("The linked account no longer exists.") - - defp error_message(:invalid_assignee), - do: gettext("The selected assignee cannot manage this queue.") - - defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.") - defp error_message(_reason), do: gettext("Could not update the request. Please try again.") - defp support_kind(:account_access), do: gettext("Account access") defp support_kind(:technical_issue), do: gettext("Technical issue") defp support_kind(:safety_concern), do: gettext("Safety concern") @@ -225,32 +262,53 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do defp status_label(value), do: value |> to_string() |> String.replace("_", " ") - defp scoped_form(data, id), do: to_form(data, as: :moderation, id: id) + defp status_class(value) when value in [:urgent_review, :suspended], do: "badge-error" + defp status_class(value) when value in [:open, :reviewing], do: "badge-warning badge-outline" + defp status_class(value) when value in [:resolved, :actioned], do: "badge-success badge-outline" + defp status_class(_value), do: "badge-ghost" + + defp assignee_name(%{assigned_to: nil}), do: gettext("Unassigned") + + defp assignee_name(%{assigned_to: assigned_to}), + do: assigned_to.display_name || assigned_to.email + + defp format_datetime(%DateTime{} = value), + do: Calendar.strftime(value, "%Y-%m-%d %H:%M UTC") + + defp result_range([], _page_number, _page_size), do: {0, 0} + + defp result_range(entries, page_number, page_size) do + first = (page_number - 1) * page_size + 1 + {first, first + length(entries) - 1} + end defp operations_title(true, true), do: gettext("Support and legal operations") defp operations_title(true, false), do: gettext("Support operations") defp operations_title(false, true), do: gettext("Legal and content removal operations") - defp operations_description(true, true) do - gettext( - "Verified support conversations and legal removal notices stay in separate permission-scoped queues. Assignments, decisions, and operator identity are audited." - ) - end + defp operations_description(true, true), + do: + gettext( + "Work one permission-scoped queue at a time. Assignments and operator actions are audited." + ) - defp operations_description(true, false) do - gettext( - "Verified support conversations stay in a permission-scoped queue. Assignments, replies, decisions, and operator identity are audited." - ) - end + defp operations_description(true, false), + do: gettext("Review verified support conversations in a permission-scoped queue.") - defp operations_description(false, true) do - gettext( - "Verified legal and content-removal notices stay in a permission-scoped queue. Assignments, decisions, and operator identity are audited." - ) - end + defp operations_description(false, true), + do: gettext("Review verified legal and content-removal notices in a permission-scoped queue.") @impl true def render(assigns) do + page_size = current_page_size(assigns) + + assigns = + assign( + assigns, + :result_range, + result_range(assigns.entries, assigns.page_number, page_size) + ) + ~H""" -
-

{gettext("Content removal and TAKE IT DOWN")}

+ + +
+
+

{gettext("Support requests")}

+

+ {gettext("Verified conversations that need staff review.")} +

+
+ <.form + for={@support_filter_form} + id="support-case-filters" + phx-change="filter-support" + class="grid gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-[minmax(15rem,1fr)_11rem_12rem_13rem_7rem]" + > + <.input + field={@support_filter_form[:search]} + type="search" + label={gettext("Search")} + placeholder={gettext("Reference, email, or subject")} + minlength="3" + phx-debounce="300" + /> + <.input + field={@support_filter_form[:status]} + type="select" + label={gettext("Status")} + options={[ + {gettext("Any status"), ""}, + {gettext("Open"), "open"}, + {gettext("Reviewing"), "reviewing"}, + {gettext("Waiting"), "waiting_for_requester"}, + {gettext("Resolved"), "resolved"}, + {gettext("Closed"), "closed"} + ]} + /> + <.input + field={@support_filter_form[:kind]} + type="select" + label={gettext("Type")} + options={[ + {gettext("Any type"), ""}, + {gettext("Account access"), "account_access"}, + {gettext("Technical issue"), "technical_issue"}, + {gettext("Safety concern"), "safety_concern"}, + {gettext("Moderation appeal"), "moderation_appeal"}, + {gettext("Account deletion"), "account_deletion"}, + {gettext("Data export"), "data_export"}, + {gettext("Privacy request"), "privacy_request"}, + {gettext("Other"), "other"} + ]} + /> + <.input + field={@support_filter_form[:assigned_to_id]} + type="select" + label={gettext("Assignee")} + options={assignee_options(@support_assignees)} + /> + <.input + field={@support_filter_form[:limit]} + type="select" + label={gettext("Rows")} + options={[{"25", "25"}, {"50", "50"}, {"100", "100"}]} + /> + + <.queue_table_header + entries={@entries} + result_range={@result_range} + page_number={@page_number} + /> + <.support_queue entries={@entries} /> +
+ +
+
+

{gettext("Content removal and TAKE IT DOWN")}

+

+ {gettext("Legal notices, deadlines, and content-removal decisions.")} +

+
<.form for={@legal_filter_form} id="legal-case-filters" phx-change="filter-legal" - class="mt-4 grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-4" + class="grid gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-[minmax(15rem,1fr)_11rem_11rem_13rem_7rem]" > <.input field={@legal_filter_form[:search]} type="search" - label={gettext("Search legal cases")} - placeholder={gettext("Reference, email, URL, or explanation")} + label={gettext("Search")} + placeholder={gettext("Reference, email, or submitter")} + minlength="3" phx-debounce="300" /> <.input @@ -306,446 +470,228 @@ defmodule WhoNeedHelpWeb.SupportOperationsLive do label={gettext("Assignee")} options={assignee_options(@legal_assignees)} /> + <.input + field={@legal_filter_form[:limit]} + type="select" + label={gettext("Rows")} + options={[{"25", "25"}, {"50", "50"}, {"100", "100"}]} + /> -
-
- <% form = - scoped_form( - %{ - "status" => to_string(notice.status), - "assigned_to_id" => notice.assigned_to_id || "", - "resolution_note" => notice.resolution_note || "" - }, - "removal-#{notice.id}" - ) %> -
- {notice.reference} - {notice.regime} - {status_label(notice.status)} - - {gettext("contact unverified")} - - {removal_category(notice.category)} - - {gettext("review by %{time}", time: notice.response_due_at)} - - - {if notice.assigned_to, - do: - gettext("Assigned: %{name}", - name: notice.assigned_to.display_name || notice.assigned_to.email - ), - else: gettext("Unassigned")} - -
-
-
{gettext("Submitter")}
-
{notice.submitter_name || gettext("Not provided")}
-
{gettext("Contact")}
-
{notice.contact_email || gettext("Not provided")}
-
{gettext("Locations")}
-
-
- {url} -
-
-
-

{notice.explanation}

-

- {gettext("Stated basis:")} {notice.legal_basis} -

- <.form - for={form} - id={"removal-moderation-#{notice.id}"} - phx-submit="moderate-removal" - phx-value-id={notice.id} - class="mt-5 overflow-hidden rounded-2xl border border-error/25 bg-base-100 shadow-sm" - > -
-
- - <.icon name="hero-document-check" class="size-5" /> - -
-

{gettext("Operator action")}

-

- {gettext( - "Record the decision and notify the submitter when contact is available." - )} -

-
-
- - {gettext("Audited update")} - -
-
-
- <.input - field={form[:status]} - type="select" - label={gettext("Case status")} - class="select h-12 w-full rounded-xl border-base-300 bg-base-100 shadow-none focus:border-error focus:outline-none focus:ring-3 focus:ring-error/15" - options={[ - {gettext("Open"), "open"}, - {gettext("Urgent review"), "urgent_review"}, - {gettext("Reviewing"), "reviewing"}, - {gettext("More information needed"), "needs_information"}, - {gettext("Action taken"), "actioned"}, - {gettext("Rejected"), "rejected"}, - {gettext("Closed"), "closed"} - ]} - /> -

- {gettext("Choose the state that will appear in the case history.")} -

-
-
- <.input - field={form[:assigned_to_id]} - type="select" - label={gettext("Assigned operator")} - options={assignment_options(@legal_assignees)} - /> -
-
- <.input - field={form[:resolution_note]} - type="textarea" - rows="4" - maxlength="10000" - autocomplete="off" - aria-describedby={"removal-note-help-#{notice.id}"} - label={gettext("Decision or information request")} - placeholder={ - gettext( - "Explain what was decided, what action was taken, or what information is still needed…" - ) - } - class="textarea min-h-36 w-full resize-y rounded-xl border-base-300 bg-base-100 px-4 py-3 text-sm leading-6 shadow-none transition-[border-color,box-shadow] placeholder:text-base-content/40 focus:border-error focus:outline-none focus:ring-3 focus:ring-error/15" - /> -
- {gettext("Write for the submitter; avoid internal-only notes.")} - {gettext("Up to 10,000 characters")} -
-
-
-
-
- <.icon name="hero-bell-alert" class="mt-0.5 size-4 shrink-0" /> -

- {if notice.contact_verified_at, - do: gettext("The verified contact will receive this update by email."), - else: - gettext( - "This update is recorded, but email is disabled until contact is verified." - )} -

-
- <.button - phx-disable-with={gettext("Saving…")} - class="btn btn-error min-w-48 gap-2 sm:self-end" - > - <.icon name="hero-paper-airplane" class="size-4" /> - {gettext("Save and notify")} - -
- -
-

- {gettext("No removal notices.")} -

-
- + <.queue_table_header + entries={@entries} + result_range={@result_range} + page_number={@page_number} + /> + <.legal_queue entries={@entries} />
-
-

{gettext("General support and data requests")}

- <.form - for={@support_filter_form} - id="support-case-filters" - phx-change="filter-support" - class="mt-4 grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-2 xl:grid-cols-4" - > - <.input - field={@support_filter_form[:search]} - type="search" - label={gettext("Search support cases")} - placeholder={gettext("Reference, email, subject, or details")} - phx-debounce="300" - /> - <.input - field={@support_filter_form[:status]} - type="select" - label={gettext("Status")} - options={[ - {gettext("Any status"), ""}, - {gettext("Open"), "open"}, - {gettext("Reviewing"), "reviewing"}, - {gettext("Waiting for requester"), "waiting_for_requester"}, - {gettext("Resolved"), "resolved"}, - {gettext("Closed"), "closed"} - ]} - /> - <.input - field={@support_filter_form[:kind]} - type="select" - label={gettext("Request type")} - options={[ - {gettext("Any type"), ""}, - {gettext("Account access"), "account_access"}, - {gettext("Technical issue"), "technical_issue"}, - {gettext("Safety concern"), "safety_concern"}, - {gettext("Moderation appeal"), "moderation_appeal"}, - {gettext("Account deletion"), "account_deletion"}, - {gettext("Data export"), "data_export"}, - {gettext("Privacy request"), "privacy_request"}, - {gettext("Other"), "other"} - ]} - /> - <.input - field={@support_filter_form[:assigned_to_id]} - type="select" - label={gettext("Assignee")} - options={assignee_options(@support_assignees)} - /> - -
-
- <% form = - scoped_form( - %{ - "status" => to_string(request.status), - "assigned_to_id" => request.assigned_to_id || "", - "response" => "" - }, - "support-#{request.id}" - ) %> -
- {request.reference} - {status_label(request.status)} - - {gettext("contact unverified")} - - {support_kind(request.kind)} - - {if request.assigned_to, - do: - gettext("Assigned: %{name}", - name: request.assigned_to.display_name || request.assigned_to.email - ), - else: gettext("Unassigned")} - -
-

{request.subject}

-

{request.contact_email}

-

{request.details}

-
-

{gettext("Conversation")}

-
-
-
- - {if message.sender_role == :staff, - do: gettext("Support team"), - else: gettext("Requester")} - - -
-

{message.body}

-
-

- {gettext("No conversation messages yet.")} -

-
-
- - {gettext("Status history")} - -
    -
  1. - {status_label(event.to_status)} · {event.actor_role} · {event.inserted_at} -
  2. -
  3. - {gettext("No status history is available for this older case.")} -
  4. -
-
-
-
- -
-

- {if assessment.technically_idle, - do: gettext("No active product workflow was found."), - else: gettext("Active or unresolved product state must be handled first.")} -

-
    -
  • - {blocker.kind}: {blocker.count} -
  • -
-

- {gettext( - "Automatic erasure is disabled: no jurisdiction-specific retention and anonymisation policy is configured. Do not mark the case resolved until the approved data action is completed and recorded." - )} -

-
-
- <.form - for={form} - id={"support-moderation-#{request.id}"} - phx-submit="moderate-support" - phx-value-id={request.id} - class="mt-5 overflow-hidden rounded-2xl border border-primary/25 bg-base-100 shadow-sm" - > -
-
- - <.icon name="hero-chat-bubble-left-right" class="size-5" /> - -
-

{gettext("Operator action")}

-

- {gettext("Update the case status and add a private reply when needed.")} -

-
-
- - {gettext("Audited update")} - -
-
-
- <.input - field={form[:status]} - type="select" - label={gettext("Case status")} - class="select h-12 w-full rounded-xl border-base-300 bg-base-100 shadow-none focus:border-primary focus:outline-none focus:ring-3 focus:ring-primary/15" - options={[ - {gettext("Open"), "open"}, - {gettext("Reviewing"), "reviewing"}, - {gettext("Waiting for requester"), "waiting_for_requester"}, - {gettext("Resolved"), "resolved"}, - {gettext("Closed"), "closed"} - ]} - /> -

- {gettext("Choose the state that will appear in the case history.")} -

-
-
- <.input - field={form[:assigned_to_id]} - type="select" - label={gettext("Assigned operator")} - options={assignment_options(@support_assignees)} - /> -
-
- <.input - field={form[:response]} - type="textarea" - rows="4" - maxlength="5000" - autocomplete="off" - aria-describedby={"support-reply-help-#{request.id}"} - label={gettext("Reply to requester (optional)")} - placeholder={ - gettext( - "Write a concise reply with the decision, next step, or information you need…" - ) - } - class="textarea min-h-36 w-full resize-y rounded-xl border-base-300 bg-base-100 px-4 py-3 text-sm leading-6 shadow-none transition-[border-color,box-shadow] placeholder:text-base-content/40 focus:border-primary focus:outline-none focus:ring-3 focus:ring-primary/15" - /> -
- {gettext("This reply becomes part of the case conversation.")} - {gettext("Up to 5,000 characters")} -
-
-
-
-
- <.icon name="hero-bell-alert" class="mt-0.5 size-4 shrink-0" /> -

- {if request.contact_verified_at, - do: gettext("The verified contact will receive this update by email."), - else: - gettext( - "This update is recorded, but email is disabled until contact is verified." - )} -

-
- <.button - phx-disable-with={gettext("Saving…")} - class="btn btn-primary min-w-48 gap-2 sm:self-end" - > - <.icon name="hero-paper-airplane" class="size-4" /> - {gettext("Save and notify")} - -
- -
-

- {gettext("No support requests.")} -

-
+
+ {gettext("Page %{page}", page: @page_number)} + + """ end + + attr :entries, :list, required: true + attr :result_range, :any, required: true + attr :page_number, :integer, required: true + + defp queue_table_header(assigns) do + ~H""" +
+

+ <%= if @entries == [] do %> + {gettext("No cases match these filters.")} + <% else %> + {gettext("Showing %{first}–%{last}", + first: elem(@result_range, 0), + last: elem(@result_range, 1) + )} + <% end %> +

+ {gettext("Page %{page}", page: @page_number)} +
+ """ + end + + attr :entries, :list, required: true + + defp support_queue(assigns) do + ~H""" +
+ +
+
+
+
+

{request.reference}

+ {request.subject} +

+
{status_label( + request.status + )} +
+
+ {support_kind(request.kind)}{assignee_name( + request + )} +
+
+ {format_datetime(request.inserted_at)}<.link + navigate={~p"/support/operations/support/#{request.id}"} + class="btn btn-outline btn-sm" + >{gettext("Open")} +
+
+
+
+ """ + end + + attr :entries, :list, required: true + + defp legal_queue(assigns) do + ~H""" +
+ +
+
+
+
+

{notice.reference}

+ {removal_category(notice.category)} +

+
{status_label( + notice.status + )} +
+
+ {status_label(notice.regime)}{assignee_name( + notice + )} +
+
+ {format_datetime(notice.inserted_at)}<.link + navigate={~p"/support/operations/legal/#{notice.id}"} + class="btn btn-outline btn-sm" + >{gettext("Open")} +
+
+
+
+ """ + end end diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex index 4d4c2ff..d0a9ded 100644 --- a/lib/who_need_help_web/router.ex +++ b/lib/who_need_help_web/router.ex @@ -186,6 +186,7 @@ defmodule WhoNeedHelpWeb.Router do live_session :staff_users, on_mount: [{WhoNeedHelpWeb.UserAuth, {:ensure_permission, :users_view}}] do live "/admin/users", AdminUsersLive, :index + live "/admin/users/:id", AdminUserLive, :show end live_session :staff_audit, @@ -208,6 +209,7 @@ defmodule WhoNeedHelpWeb.Router do {WhoNeedHelpWeb.UserAuth, {:ensure_any_permission, [:support_view, :legal_view]}} ] do live "/support/operations", SupportOperationsLive, :index + live "/support/operations/:queue/:id", SupportOperationLive, :show end end diff --git a/priv/repo/migrations/20260801123806_add_user_directory_search_indexes.exs b/priv/repo/migrations/20260801123806_add_user_directory_search_indexes.exs new file mode 100644 index 0000000..4433796 --- /dev/null +++ b/priv/repo/migrations/20260801123806_add_user_directory_search_indexes.exs @@ -0,0 +1,34 @@ +defmodule WhoNeedHelp.Repo.Migrations.AddUserDirectorySearchIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + + create index(:users, ["lower(email::text) gin_trgm_ops"], + name: :users_email_trigram_index, + using: :gin, + concurrently: true + ) + + create index(:users, ["lower(display_name) gin_trgm_ops"], + name: :users_display_name_trigram_index, + using: :gin, + concurrently: true + ) + end + + def down do + drop index(:users, ["lower(display_name) gin_trgm_ops"], + name: :users_display_name_trigram_index, + concurrently: true + ) + + drop index(:users, ["lower(email::text) gin_trgm_ops"], + name: :users_email_trigram_index, + concurrently: true + ) + end +end diff --git a/priv/repo/migrations/20260801141743_add_operations_queue_search_indexes.exs b/priv/repo/migrations/20260801141743_add_operations_queue_search_indexes.exs new file mode 100644 index 0000000..981ad6e --- /dev/null +++ b/priv/repo/migrations/20260801141743_add_operations_queue_search_indexes.exs @@ -0,0 +1,50 @@ +defmodule WhoNeedHelp.Repo.Migrations.AddOperationsQueueSearchIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + + create index( + :support_requests, + [ + "lower(coalesce(reference, '') || ' ' || coalesce(contact_email, '') || ' ' || coalesce(subject, '')) gin_trgm_ops" + ], + name: :support_requests_staff_search_index, + using: :gin, + concurrently: true + ) + + create index( + :content_removal_notices, + [ + "lower(coalesce(reference, '') || ' ' || coalesce(contact_email, '') || ' ' || coalesce(submitter_name, '')) gin_trgm_ops" + ], + name: :content_removal_notices_staff_search_index, + using: :gin, + concurrently: true + ) + end + + def down do + drop index( + :content_removal_notices, + [ + "lower(coalesce(reference, '') || ' ' || coalesce(contact_email, '') || ' ' || coalesce(submitter_name, '')) gin_trgm_ops" + ], + name: :content_removal_notices_staff_search_index, + concurrently: true + ) + + drop index( + :support_requests, + [ + "lower(coalesce(reference, '') || ' ' || coalesce(contact_email, '') || ' ' || coalesce(subject, '')) gin_trgm_ops" + ], + name: :support_requests_staff_search_index, + concurrently: true + ) + end +end diff --git a/test/who_need_help_web/controllers/support_controller_test.exs b/test/who_need_help_web/controllers/support_controller_test.exs index 56a379b..838fed3 100644 --- a/test/who_need_help_web/controllers/support_controller_test.exs +++ b/test/who_need_help_web/controllers/support_controller_test.exs @@ -5,6 +5,9 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do import WhoNeedHelp.AccountsFixtures import Swoosh.TestAssertions + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Support.SupportRequest + describe "public support and removal intake" do test "renders each public form", %{conn: conn} do support = html_response(get(conn, ~p"/support"), 200) @@ -273,7 +276,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do {:ok, operator_view, _html} = build_conn() |> log_in_user(moderator) - |> live(~p"/support/operations") + |> live(~p"/support/operations/support/#{request.id}") operator_view |> form("#support-moderation-#{request.id}", %{ @@ -344,51 +347,123 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do WhoNeedHelp.Support.access_token(support_request) ) - response = + staff_conn = log_in_user(conn, moderator) + {:ok, queue_view, response} = live(staff_conn, ~p"/support/operations") + + assert response =~ "Support requests" + assert response =~ "Legal and removal" + assert has_element?(queue_view, "#support-row-#{support_request.id}") + refute response =~ "Operator action" + + queue_view + |> element(~s(a[href="/support/operations?queue=legal"])) + |> render_click() + + legal_queue = render(queue_view) + assert legal_queue =~ "Content removal and TAKE IT DOWN" + assert has_element?(queue_view, "#legal-row-#{removal_notice.id}") + + {:ok, removal_view, removal_response} = + live(staff_conn, ~p"/support/operations/legal/#{removal_notice.id}") + + removal_document = LazyHTML.from_document(removal_response) + + assert Enum.count( + LazyHTML.query( + removal_document, + "select[name='moderation[status]'] option[value='open'][selected]" + ) + ) == 1 + + assert has_element?(removal_view, "#removal-moderation-#{removal_notice.id}") + + assert Enum.count( + LazyHTML.query( + removal_document, + "form[id^='removal-moderation-'] textarea[rows='6'][maxlength='10000']" + ) + ) == 1 + + {:ok, support_view, support_response} = + live(staff_conn, ~p"/support/operations/support/#{support_request.id}") + + support_document = LazyHTML.from_document(support_response) + + assert Enum.count( + LazyHTML.query( + support_document, + "select[name='moderation[status]'] option[value='open'][selected]" + ) + ) == 1 + + assert has_element?(support_view, "#support-moderation-#{support_request.id}") + + assert Enum.count( + LazyHTML.query( + support_document, + "form[id^='support-moderation-'] textarea[rows='5'][maxlength='5000']" + ) + ) == 1 + + assert support_response =~ "Operator action" + assert support_response =~ "Reply to requester (optional)" + assert removal_response =~ "Audited update" + + refute support_response =~ + "This update is recorded, but email is disabled until contact is verified." + end + + test "paginates the support directory without expanding case details", %{conn: conn} do + moderator = staff_user_fixture([:support]) + verified_at = DateTime.utc_now(:second) + + for number <- 1..30 do + Repo.insert!(%SupportRequest{ + reference: "SUP-PAGE-#{String.pad_leading(Integer.to_string(number), 3, "0")}", + kind: :technical_issue, + status: :open, + contact_email: "queue-page-#{number}@example.test", + subject: "Paginated support request #{number}", + details: "This verified fixture exercises keyset pagination in the staff queue.", + contact_verified_at: verified_at + }) + end + + {:ok, view, html} = conn |> log_in_user(moderator) - |> get(~p"/support/operations") - |> html_response(200) + |> live(~p"/support/operations") - assert response =~ "Content removal and TAKE IT DOWN" - assert response =~ "General support and data requests" + assert html =~ "Showing 1–30" - document = LazyHTML.from_document(response) + assert Enum.count(LazyHTML.query(LazyHTML.from_document(html), "tr[id^='support-row-']")) == + 30 + + refute html =~ "Operator action" + + view + |> form("#support-case-filters", %{"support_filters" => %{"limit" => "25"}}) + |> render_change() + + first_page = render(view) + + assert first_page =~ "Showing 1–25" assert Enum.count( - LazyHTML.query( - document, - "select[id^='removal-'] option[value='open'][selected]" - ) - ) == 1 + LazyHTML.query(LazyHTML.from_document(first_page), "tr[id^='support-row-']") + ) == 25 + + view |> element("button[phx-click='next-page']") |> render_click() + second_page = render(view) + + assert second_page =~ "Showing 26–30" assert Enum.count( - LazyHTML.query( - document, - "select[id^='support-'] option[value='open'][selected]" - ) - ) == 1 + LazyHTML.query(LazyHTML.from_document(second_page), "tr[id^='support-row-']") + ) == 5 - assert Enum.count( - LazyHTML.query( - document, - "form[id^='removal-moderation-'] textarea[rows='4'][maxlength='10000']" - ) - ) == 1 - - assert Enum.count( - LazyHTML.query( - document, - "form[id^='support-moderation-'] textarea[rows='4'][maxlength='5000']" - ) - ) == 1 - - assert response =~ "Operator action" - assert response =~ "Reply to requester (optional)" - assert response =~ "Audited update" - - refute response =~ - "This update is recorded, but email is disabled until contact is verified." + view |> element("button[phx-click='previous-page']") |> render_click() + assert render(view) =~ "Showing 1–25" end end end diff --git a/test/who_need_help_web/live/admin_live_test.exs b/test/who_need_help_web/live/admin_live_test.exs index 2dcb87a..30b3807 100644 --- a/test/who_need_help_web/live/admin_live_test.exs +++ b/test/who_need_help_web/live/admin_live_test.exs @@ -58,9 +58,14 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do {:ok, users_view, html} = live(conn, ~p"/admin/users") assert html =~ "Users and staff access" - assert has_element?(users_view, "#staff-roles-#{target.id}") + assert has_element?(users_view, "#admin-user-row-#{target.id}") + assert has_element?(users_view, ~s(a[href="/admin/users/#{target.id}"])) - users_view + {:ok, user_view, html} = live(conn, ~p"/admin/users/#{target.id}") + assert html =~ "Account under review" + assert has_element?(user_view, "#staff-roles-#{target.id}") + + user_view |> form("#staff-roles-#{target.id}", %{ "staff" => %{"roles" => ["support", "moderator"]} }) @@ -69,7 +74,7 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do updated = target |> Repo.reload!() |> Accounts.preload_staff_roles() assert Accounts.loaded_staff_roles(updated) == [:moderator, :support] - users_view + user_view |> form("#moderate-user-#{target.id}", %{ "moderation" => %{ "moderation_status" => "suspended", @@ -103,7 +108,7 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do admin = staff_user_fixture([:admin], display_name: "Only administrator") conn = log_in_user(conn, admin) - {:ok, view, _html} = live(conn, ~p"/admin/users") + {:ok, view, _html} = live(conn, ~p"/admin/users/#{admin.id}") html = view @@ -113,4 +118,39 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do assert html =~ "last active administrator" assert Accounts.staff_roles(admin) == [:admin] end + + test "user directory paginates without expanding every account action", %{conn: conn} do + admin = staff_user_fixture([:admin], display_name: "Directory administrator") + + for number <- 1..30 do + user_fixture(display_name: "Directory user #{number}") + end + + {:ok, view, html} = conn |> log_in_user(admin) |> live(~p"/admin/users") + + assert html =~ "Page 1" + assert length(has_element_ids(view, "admin-user-row-")) == 31 + refute html =~ ~s(id="staff-roles-) + + view + |> form("#admin-user-filters", %{"filters" => %{"limit" => "25"}}) + |> render_change() + + assert length(has_element_ids(view, "admin-user-row-")) == 25 + assert has_element?(view, "button[phx-click=next-page]:not([disabled])") + + view |> element(~s(button[phx-click="next-page"])) |> render_click() + assert render(view) =~ "Page 2" + assert length(has_element_ids(view, "admin-user-row-")) == 6 + + view |> element(~s(button[phx-click="previous-page"])) |> render_click() + assert render(view) =~ "Page 1" + assert length(has_element_ids(view, "admin-user-row-")) == 25 + end + + defp has_element_ids(view, prefix) do + view + |> render() + |> then(&Regex.scan(~r/id="#{Regex.escape(prefix)}/, &1)) + end end