Redesign staff directories and case workflows

This commit is contained in:
SimpleTest 2026-08-01 17:33:00 +03:00
parent 9279835243
commit 76e1ab7fd8
13 changed files with 1793 additions and 817 deletions

View File

@ -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,14 +1150,20 @@ defmodule WhoNeedHelp.Accounts do
defp maybe_user_search(query, value) do
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
pattern = "%#{term}%"
if String.length(term) < 3 do
where(query, [user], false)
else
pattern = "%#{String.downcase(term)}%"
where(
query,
[user],
ilike(user.email, ^pattern) or ilike(user.display_name, ^pattern)
fragment("lower(?::text) LIKE ?", user.email, ^pattern) or
fragment("lower(?) LIKE ?", user.display_name, ^pattern)
)
end
end
defp cast_id(value) do
case Ecto.UUID.cast(value) do

View File

@ -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}%"
if String.length(term) < 3 do
where(query, [notice], false)
else
pattern = "%#{String.downcase(term)}%"
where(
query,
[notice],
ilike(notice.reference, ^pattern) or ilike(notice.contact_email, ^pattern) or
ilike(notice.submitter_name, ^pattern)
fragment(
"lower(coalesce(?, '') || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) LIKE ?",
notice.reference,
notice.contact_email,
notice.submitter_name,
^pattern
)
)
end
end
defp normalize_assignment(attrs, permission) do

View File

@ -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}%"
if String.length(term) < 3 do
where(query, [request], false)
else
pattern = "%#{String.downcase(term)}%"
where(
query,
[request],
ilike(request.reference, ^pattern) or ilike(request.contact_email, ^pattern) or
ilike(request.subject, ^pattern)
fragment(
"lower(coalesce(?, '') || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) LIKE ?",
request.reference,
request.contact_email,
request.subject,
^pattern
)
)
end
end
defp normalize_assignment(attrs, permission) do

View File

@ -13,7 +13,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
def shell(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="grid gap-6 lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start">
<div class="grid gap-4 lg:grid-cols-[14rem_minmax(0,1fr)] lg:items-start">
<details
id="staff-mobile-navigation"
class="collapse collapse-arrow rounded-2xl border border-base-300 bg-base-100 lg:hidden"
@ -36,7 +36,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
</div>
</details>
<aside class="hidden rounded-3xl border border-base-300 bg-base-100 p-3 lg:sticky lg:top-24 lg:block">
<aside class="hidden rounded-2xl border border-base-300 bg-base-100 p-2 lg:sticky lg:top-24 lg:block">
<div class="px-3 pb-3 pt-2">
<p class="text-xs font-black uppercase tracking-[0.18em] text-primary">
{gettext("Staff workspace")}
@ -51,7 +51,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
</aside>
<div class="min-w-0">
<header class="rounded-3xl border border-base-300 bg-base-100 p-5 sm:p-7">
<header class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
<div class="flex items-start gap-3">
<span class="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary/10 text-primary">
<.icon name="hero-lock-closed" class="size-5" />
@ -60,7 +60,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
<p class="text-xs font-black uppercase tracking-[0.18em] text-primary">
{gettext("Restricted workspace")}
</p>
<h1 class="mt-1 text-3xl font-black tracking-tight sm:text-4xl">{@title}</h1>
<h1 class="mt-1 text-2xl font-black tracking-tight sm:text-3xl">{@title}</h1>
<p
:if={@description}
class="mt-2 max-w-3xl text-sm leading-6 text-base-content/65 sm:text-base"
@ -71,7 +71,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
</div>
</header>
<div class="mt-6 space-y-6">{render_slot(@inner_block)}</div>
<div class="mt-4 space-y-4">{render_slot(@inner_block)}</div>
</div>
</div>
</Layouts.app>

View File

@ -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"""
<AdminComponents.shell
flash={@flash}
current_scope={@current_scope}
active={:users}
title={gettext("Manage user")}
description={gettext("Review account access and staff permissions for one user.")}
>
<.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")}
</.link>
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="truncate text-xl font-black">
{@user.display_name || gettext("Unnamed user")}
</h2>
<span class={["badge badge-sm", status_class(@user.moderation_status)]}>
{status_label(@user.moderation_status)}
</span>
</div>
<p class="mt-1 break-all text-sm text-base-content/60">{@user.email}</p>
<p class="mt-2 font-mono text-xs text-base-content/45">{@user.id}</p>
</div>
<div class="flex flex-wrap gap-1 sm:max-w-sm sm:justify-end">
<span :if={Accounts.loaded_staff_roles(@user) == []} class="badge badge-ghost badge-sm">
{gettext("Regular user")}
</span>
<span
:for={role <- Accounts.loaded_staff_roles(@user)}
class="badge badge-primary badge-outline badge-sm"
>
{role_label(role)}
</span>
</div>
</div>
</section>
<div class="grid gap-4 xl:grid-cols-2">
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
<div>
<h2 class="text-lg font-black">{gettext("Account access")}</h2>
<p class="mt-1 text-sm text-base-content/60">
{gettext("Restrict or suspend access and record an internal reason.")}
</p>
</div>
<.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"
/>
<button class="btn btn-primary w-full sm:w-auto" phx-disable-with={gettext("Saving…")}>
{gettext("Save account status")}
</button>
</.form>
<p
:if={!can_moderate?(@current_scope.user, @user)}
class="mt-4 rounded-xl bg-base-200 p-4 text-sm text-base-content/65"
>
{gettext("You can view this account but cannot change its access.")}
</p>
</section>
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
<div>
<h2 class="text-lg font-black">{gettext("Staff roles")}</h2>
<p class="mt-1 text-sm text-base-content/60">
{gettext("A staff member may hold several fixed roles.")}
</p>
</div>
<.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"
>
<fieldset class="space-y-2">
<legend class="sr-only">{gettext("Staff roles")}</legend>
<input type="hidden" name="staff[roles][]" value="" />
<label
:for={role <- @roles}
class="flex cursor-pointer items-start gap-3 rounded-xl border border-base-300 p-3 transition hover:border-primary/45"
>
<input
type="checkbox"
name="staff[roles][]"
value={Atom.to_string(role)}
checked={role in Accounts.loaded_staff_roles(@user)}
class="checkbox checkbox-primary mt-0.5"
/>
<span class="min-w-0">
<span class="block font-bold">{role_label(role)}</span>
<span class="block text-xs leading-5 text-base-content/55">
{role_description(role)}
</span>
</span>
</label>
</fieldset>
<button
class="btn btn-warning mt-4 w-full sm:w-auto"
phx-disable-with={gettext("Updating…")}
>
{gettext("Update staff roles")}
</button>
</.form>
<p
:if={!Accounts.permission?(@current_scope.user, :staff_manage)}
class="mt-4 rounded-xl bg-base-200 p-4 text-sm text-base-content/65"
>
{gettext("Only administrators can change staff roles.")}
</p>
</section>
</div>
</AdminComponents.shell>
"""
end
end

View File

@ -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"""
<AdminComponents.shell
flash={@flash}
@ -133,7 +158,7 @@ defmodule WhoNeedHelpWeb.AdminUsersLive do
title={gettext("Users and staff access")}
description={
gettext(
"Search accounts, restrict or suspend access, and combine fixed staff roles without creating ambiguous custom permissions."
"Search accounts, review access, and manage fixed staff roles from a dedicated account page."
)
}
>
@ -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)})
}
/>
</.form>
<div class="space-y-4">
<article
:for={user <- @users}
class="rounded-3xl border border-base-300 bg-base-100 p-4 sm:p-6"
>
<% status_form =
to_form(
%{
"moderation_status" => to_string(user.moderation_status),
"moderation_note" => user.moderation_note || ""
},
as: :moderation,
id: "status-#{user.id}"
) %>
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="truncate text-lg font-black">{user.display_name}</h2>
<span class={[
"badge",
user.moderation_status == :active && "badge-success badge-outline",
user.moderation_status == :restricted && "badge-warning",
user.moderation_status == :suspended && "badge-error"
]}>{user.moderation_status}</span>
</div>
<p class="mt-1 break-all text-sm text-base-content/60">{user.email}</p>
<div class="mt-3 flex flex-wrap gap-2">
<span :if={Accounts.loaded_staff_roles(user) == []} class="badge badge-ghost">{gettext(
"Regular user"
)}</span>
<span
:for={role <- Accounts.loaded_staff_roles(user)}
class="badge badge-primary badge-outline"
>{role_label(role)}</span>
</div>
</div>
<.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]}
field={@filter_form[:limit]}
type="select"
label={gettext("Status")}
options={[
{gettext("Active"), "active"},
{gettext("Restricted"), "restricted"},
{gettext("Suspended"), "suspended"}
]}
label={gettext("Rows")}
options={[{"25", "25"}, {"50", "50"}, {"100", "100"}]}
/>
<.input
field={status_form[:moderation_note]}
label={gettext("Internal note")}
maxlength="1000"
/>
<button class="btn btn-primary self-end" phx-disable-with={gettext("Saving…")}>{gettext(
"Save"
)}</button>
</.form>
</div>
<.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"
>
<div class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<fieldset class="grid min-w-0 flex-1 gap-2 sm:grid-cols-2 xl:grid-cols-5">
<legend class="sr-only">{gettext("Staff roles")}</legend>
<input type="hidden" name="staff[roles][]" value="" />
<label
:for={role <- @roles}
class="flex cursor-pointer items-start gap-3 rounded-2xl border border-base-300 p-3 hover:border-primary/40"
>
<input
type="checkbox"
name="staff[roles][]"
value={Atom.to_string(role)}
checked={role in Accounts.loaded_staff_roles(user)}
class="checkbox checkbox-primary mt-0.5"
/>
<span class="min-w-0">
<span class="block font-bold">{role_label(role)}</span>
<span class="mt-0.5 block text-xs leading-5 text-base-content/55">{role_description(
role
)}</span>
</span>
</label>
</fieldset>
<button class="btn btn-warning shrink-0" phx-disable-with={gettext("Updating…")}>{gettext(
"Update roles"
)}</button>
</div>
</.form>
</article>
<p
:if={@users == []}
class="rounded-3xl border border-dashed border-base-300 p-10 text-center text-base-content/60"
>
<section class="overflow-hidden rounded-2xl border border-base-300 bg-base-100">
<div class="flex flex-col gap-2 border-b border-base-300 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div aria-live="polite">
<p class="font-bold">{gettext("User directory")}</p>
<p class="text-sm text-base-content/60">
<%= 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 %>
</p>
</div>
<p class="text-sm font-semibold text-base-content/60">
{gettext("Page %{page}", page: @page_number)}
</p>
</div>
<button :if={@users_cursor} phx-click="load-more" class="btn btn-outline">{gettext(
"Load more users"
)}</button>
<div class="hidden overflow-x-auto md:block">
<table class="table table-sm">
<thead>
<tr>
<th>{gettext("User")}</th>
<th>{gettext("Status")}</th>
<th>{gettext("Roles")}</th>
<th>{gettext("Joined")}</th>
<th class="text-right"><span class="sr-only">{gettext("Actions")}</span></th>
</tr>
</thead>
<tbody>
<tr :for={user <- @users} id={"admin-user-row-#{user.id}"}>
<td class="min-w-64">
<p class="max-w-80 truncate font-bold" title={account_name(user)}>
{account_name(user)}
</p>
<p class="max-w-80 truncate text-xs text-base-content/55" title={user.email}>
{user.email}
</p>
</td>
<td>
<span class={["badge badge-sm", status_class(user.moderation_status)]}>
{status_label(user.moderation_status)}
</span>
</td>
<td class="max-w-72">
<div class="flex flex-wrap gap-1">
<span
:if={Accounts.loaded_staff_roles(user) == []}
class="badge badge-ghost badge-sm"
>
{gettext("Regular user")}
</span>
<span
:for={role <- Accounts.loaded_staff_roles(user)}
class="badge badge-primary badge-outline badge-sm"
>
{role_label(role)}
</span>
</div>
</td>
<td class="whitespace-nowrap text-sm text-base-content/65">
{joined_on(user.inserted_at)}
</td>
<td class="text-right">
<.link
navigate={~p"/admin/users/#{user.id}"}
class="btn btn-outline btn-sm whitespace-nowrap"
aria-label={gettext("Manage %{name}", name: account_name(user))}
>
{gettext("Manage")}
<.icon name="hero-chevron-right" class="size-4" />
</.link>
</td>
</tr>
</tbody>
</table>
</div>
<div class="divide-y divide-base-300 md:hidden">
<article :for={user <- @users} id={"admin-user-card-#{user.id}"} class="p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate font-bold">{account_name(user)}</p>
<p class="truncate text-sm text-base-content/55">{user.email}</p>
</div>
<span class={["badge badge-sm shrink-0", status_class(user.moderation_status)]}>
{status_label(user.moderation_status)}
</span>
</div>
<div class="mt-3 flex flex-wrap gap-1">
<span :if={Accounts.loaded_staff_roles(user) == []} class="badge badge-ghost badge-sm">
{gettext("Regular user")}
</span>
<span
:for={role <- Accounts.loaded_staff_roles(user)}
class="badge badge-primary badge-outline badge-sm"
>
{role_label(role)}
</span>
</div>
<div class="mt-3 flex items-center justify-between gap-3">
<span class="text-xs text-base-content/55">
{gettext("Joined %{date}", date: joined_on(user.inserted_at))}
</span>
<.link navigate={~p"/admin/users/#{user.id}"} class="btn btn-outline btn-sm">
{gettext("Manage")}
</.link>
</div>
</article>
</div>
</section>
<nav
aria-label={gettext("User directory pages")}
class="flex items-center justify-between gap-3 rounded-2xl border border-base-300 bg-base-100 p-3"
>
<button
type="button"
phx-click="previous-page"
disabled={@cursor_history == []}
class="btn btn-outline btn-sm"
>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Previous")}
</button>
<span class="text-sm font-semibold">{gettext("Page %{page}", page: @page_number)}</span>
<button
type="button"
phx-click="next-page"
disabled={is_nil(@users_cursor)}
class="btn btn-outline btn-sm"
>
{gettext("Next")}
<.icon name="hero-arrow-right" class="size-4" />
</button>
</nav>
</AdminComponents.shell>
"""
end

View File

@ -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"""
<AdminComponents.shell
flash={@flash}
current_scope={@current_scope}
active={:support}
title={if @queue == :support, do: gettext("Support request"), else: gettext("Legal notice")}
description={gettext("Review the complete record and make one audited update at a time.")}
>
<.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")}
</.link>
<.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}
/>
</AdminComponents.shell>
"""
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"""
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-primary">{@record.reference}</span><span class="badge badge-outline">{support_kind(
@record.kind
)}</span><span class={["badge", status_class(@record.status)]}>{status_label(
@record.status
)}</span>
</div><h2 class="mt-3 text-xl font-black">{@record.subject}</h2><p class="mt-1 break-all text-sm text-base-content/60">
{@record.contact_email}
</p>
</div>
<div class="text-sm text-base-content/60 sm:text-right">
<p>{gettext("Received")}</p><p class="font-semibold text-base-content">
{format_datetime(@record.inserted_at)}
</p>
</div>
</div>
<p class="mt-4 whitespace-pre-wrap text-sm leading-6">{@record.details}</p>
</section>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(22rem,0.7fr)] xl:items-start">
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
<h2 class="text-lg font-black">{gettext("Conversation")}</h2>
<div class="mt-3 space-y-2">
<article
:for={message <- @record.conversation_messages}
class={[
"rounded-xl border p-3 text-sm",
message.sender_role == :requester && "border-primary/25 bg-primary/5",
message.sender_role == :staff && "border-base-300 bg-base-100"
]}
>
<div class="flex flex-wrap items-center justify-between gap-2">
<strong>{if message.sender_role == :staff,
do: gettext("Support team"),
else: gettext("Requester")}</strong><time class="text-xs text-base-content/55">{format_datetime(
message.inserted_at
)}</time>
</div><p class="mt-1 whitespace-pre-wrap">{message.body}</p>
</article>
<p
:if={@record.conversation_messages == []}
class="rounded-xl bg-base-200 p-4 text-sm text-base-content/55"
>
{gettext("No conversation messages yet.")}
</p>
</div>
<details class="mt-4 rounded-xl border border-base-300 p-3">
<summary class="cursor-pointer font-semibold">{gettext("Status history")}</summary><ol class="mt-2 space-y-1 text-xs text-base-content/65">
<li :for={event <- @record.status_events}>
{status_label(event.to_status)} · {event.actor_role} · {format_datetime(
event.inserted_at
)}
</li><li :if={@record.status_events == []}>
{gettext("No status history is available for this older case.")}
</li>
</ol>
</details>
</section>
<div class="space-y-4 xl:sticky xl:top-24">
<section
:if={@record.kind == :account_deletion}
class="rounded-2xl border border-warning/30 bg-warning/5 p-4"
>
<h2 class="font-black">{gettext("Account deletion preflight")}</h2>
<button type="button" phx-click="assess-deletion" class="btn btn-outline btn-sm mt-3">{gettext(
"Run deletion preflight"
)}</button>
<div :if={@assessment} class="mt-3 space-y-2 text-sm">
<p class="font-semibold">
{if @assessment.technically_idle,
do: gettext("No active product workflow was found."),
else: gettext("Active or unresolved product state must be handled first.")}
</p><ul :if={@assessment.blockers != []} class="list-disc pl-5">
<li :for={blocker <- @assessment.blockers}>{blocker.kind}: {blocker.count}</li>
</ul>
</div>
</section>
<.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"
>
<h2 class="text-lg font-black">{gettext("Operator action")}</h2><p class="mt-1 text-sm text-base-content/60">
{gettext("Update status, assignment, and optionally reply.")}
</p>
<div class="mt-4 space-y-4">
<.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…")}
/>
</div>
<p class="mt-2 text-xs text-base-content/55">
{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.")}
</p>
<.button class="btn btn-primary mt-4 w-full" phx-disable-with={gettext("Saving…")}>{gettext(
"Save and notify"
)}</.button>
</.form>
</div>
</div>
"""
end
attr :record, :any, required: true
attr :form, :any, required: true
attr :assignees, :list, required: true
defp legal_case(assigns) do
~H"""
<section class="rounded-2xl border border-base-300 bg-base-100 p-4 sm:p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-error">{@record.reference}</span><span class="badge badge-outline">{status_label(
@record.regime
)}</span><span class={["badge", status_class(@record.status)]}>{status_label(
@record.status
)}</span>
</div><h2 class="mt-3 text-xl font-black">{removal_category(@record.category)}</h2><p class="mt-1 break-all text-sm text-base-content/60">
{@record.contact_email || gettext("No contact email")}
</p>
</div><div class="text-sm text-base-content/60 sm:text-right">
<p>{gettext("Received")}</p><p class="font-semibold text-base-content">
{format_datetime(@record.inserted_at)}
</p><p :if={@record.response_due_at} class="mt-2 text-error">
{gettext("Review by %{time}", time: format_datetime(@record.response_due_at))}
</p>
</div>
</div>
<dl class="mt-5 grid gap-2 text-sm sm:grid-cols-[10rem_1fr]">
<dt class="font-semibold">{gettext("Submitter")}</dt><dd>
{@record.submitter_name || gettext("Not provided")}
</dd><dt class="font-semibold">{gettext("Content locations")}</dt><dd class="space-y-1">
<p
:for={url <- WhoNeedHelp.ContentRemoval.Notice.content_urls(@record)}
class="break-all font-mono text-xs"
>
{url}
</p>
</dd>
</dl>
<div class="mt-4 space-y-3 text-sm leading-6">
<p class="whitespace-pre-wrap">{@record.explanation}</p><p
:if={@record.legal_basis not in [nil, ""]}
class="whitespace-pre-wrap text-base-content/65"
>
<strong>{gettext("Stated basis:")}</strong> {@record.legal_basis}
</p>
</div>
</section>
<.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"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 class="text-lg font-black">{gettext("Operator action")}</h2><p class="mt-1 text-sm text-base-content/60">
{gettext("Record the decision and notify the submitter when contact is available.")}
</p>
</div><span class="badge badge-error badge-outline">{gettext("Audited update")}</span>
</div>
<div class="mt-4 grid gap-4 xl:grid-cols-[14rem_14rem_minmax(0,1fr)]">
<.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…")}
/>
</div>
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-base-content/55">
{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.")}
</p><.button class="btn btn-error w-full sm:w-auto" phx-disable-with={gettext("Saving…")}>{gettext(
"Save and notify"
)}</.button>
</div>
</.form>
"""
end
end

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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