From e499ed7553a53af0b3a7b505babd880c1f7e231f Mon Sep 17 00:00:00 2001
From: SimpleTest
Date: Sat, 1 Aug 2026 19:34:43 +0300
Subject: [PATCH] Redesign moderation analytics and audit workspaces
---
lib/who_need_help/catalog.ex | 31 +
lib/who_need_help/product_analytics.ex | 68 +
lib/who_need_help/trust.ex | 62 +-
.../live/admin_audit_live.ex | 291 ++--
lib/who_need_help_web/live/moderation_live.ex | 1304 ++++++++++++-----
.../live/product_analytics_live.ex | 332 ++++-
test/who_need_help/product_analytics_test.exs | 25 +
.../live/admin_live_test.exs | 23 +
.../live/mutual_aid_live_test.exs | 36 +-
9 files changed, 1609 insertions(+), 563 deletions(-)
diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex
index 579b6ca..df5f56a 100644
--- a/lib/who_need_help/catalog.ex
+++ b/lib/who_need_help/catalog.ex
@@ -190,6 +190,9 @@ defmodule WhoNeedHelp.Catalog do
cursor = Pagination.cursor(options)
CategoryProposal
+ |> maybe_proposal_status(Keyword.get(options, :status))
+ |> maybe_proposal_mode(Keyword.get(options, :mode))
+ |> maybe_proposal_search(Keyword.get(options, :search))
|> before_proposal(cursor)
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|> limit(^(limit + 1))
@@ -1004,6 +1007,34 @@ defmodule WhoNeedHelp.Catalog do
})
end
+ defp maybe_proposal_status(query, value) when value in [nil, ""], do: query
+
+ defp maybe_proposal_status(query, value),
+ do: where(query, [proposal], proposal.status == ^value)
+
+ defp maybe_proposal_mode(query, value) when value in [nil, ""], do: query
+ defp maybe_proposal_mode(query, value), do: where(query, [proposal], proposal.mode == ^value)
+
+ defp maybe_proposal_search(query, value) when value in [nil, ""], do: query
+
+ defp maybe_proposal_search(query, value) do
+ clean =
+ value
+ |> to_string()
+ |> String.trim()
+ |> String.replace("%", "")
+ |> String.replace("_", "")
+
+ pattern = "%#{clean}%"
+
+ where(
+ query,
+ [proposal],
+ ilike(proposal.proposed_name, ^pattern) or ilike(proposal.reason, ^pattern) or
+ fragment("CAST(? AS text) ILIKE ?", proposal.id, ^pattern)
+ )
+ end
+
defp before_proposal(query, nil), do: query
defp before_proposal(query, {inserted_at, id}) do
diff --git a/lib/who_need_help/product_analytics.ex b/lib/who_need_help/product_analytics.ex
index 7af0993..45a74cd 100644
--- a/lib/who_need_help/product_analytics.ex
+++ b/lib/who_need_help/product_analytics.ex
@@ -76,6 +76,10 @@ defmodule WhoNeedHelp.ProductAnalytics do
cursor = Pagination.cursor(options)
DailyMetric
+ |> maybe_metric(Keyword.get(options, :metric))
+ |> maybe_dimension(Keyword.get(options, :dimension))
+ |> maybe_date_from(Keyword.get(options, :date_from))
+ |> maybe_date_to(Keyword.get(options, :date_to))
|> before(cursor)
|> order_by([metric], desc: metric.inserted_at, desc: metric.id)
|> limit(^(limit + 1))
@@ -88,6 +92,51 @@ defmodule WhoNeedHelp.ProductAnalytics do
def allowed_metrics, do: @allowed_metrics
+ def overview(%Scope{user: user}, options \\ []) do
+ if Accounts.authorized?(user, :analytics_view) do
+ base_query =
+ DailyMetric
+ |> maybe_date_from(Keyword.get(options, :date_from))
+ |> maybe_date_to(Keyword.get(options, :date_to))
+
+ by_metric =
+ base_query
+ |> group_by([metric], metric.metric)
+ |> select([metric], {metric.metric, sum(metric.count)})
+ |> Repo.all()
+ |> Enum.map(fn {metric, count} -> {metric, normalize_count(count)} end)
+ |> Map.new()
+
+ trend =
+ base_query
+ |> group_by([metric], metric.date)
+ |> order_by([metric], desc: metric.date)
+ |> limit(14)
+ |> select([metric], %{date: metric.date, count: sum(metric.count)})
+ |> Repo.all()
+ |> Enum.map(&Map.update!(&1, :count, fn count -> normalize_count(count) end))
+ |> Enum.reverse()
+
+ %{
+ total: Enum.sum(Map.values(by_metric)),
+ registrations: Map.get(by_metric, "account.registered", 0),
+ requests_created: Map.get(by_metric, "request.created", 0),
+ requests_completed: Map.get(by_metric, "request.completed", 0),
+ by_metric: by_metric,
+ trend: trend
+ }
+ else
+ %{
+ total: 0,
+ registrations: 0,
+ requests_created: 0,
+ requests_completed: 0,
+ by_metric: %{},
+ trend: []
+ }
+ end
+ end
+
defp synthetic_email?(email) do
email
|> String.trim()
@@ -95,6 +144,22 @@ defmodule WhoNeedHelp.ProductAnalytics do
|> String.ends_with?(@synthetic_email_suffix)
end
+ defp maybe_metric(query, value) when value in [nil, ""], do: query
+ defp maybe_metric(query, value), do: where(query, [metric], metric.metric == ^value)
+
+ defp maybe_dimension(query, value) when value in [nil, ""], do: query
+ defp maybe_dimension(query, value), do: where(query, [metric], metric.dimension == ^value)
+
+ defp maybe_date_from(query, %Date{} = value),
+ do: where(query, [metric], metric.date >= ^value)
+
+ defp maybe_date_from(query, _value), do: query
+
+ defp maybe_date_to(query, %Date{} = value),
+ do: where(query, [metric], metric.date <= ^value)
+
+ defp maybe_date_to(query, _value), do: query
+
defp before(query, nil), do: query
defp before(query, {inserted_at, id}) do
@@ -105,4 +170,7 @@ defmodule WhoNeedHelp.ProductAnalytics do
(metric.inserted_at == ^inserted_at and metric.id < ^id)
)
end
+
+ defp normalize_count(%Decimal{} = count), do: Decimal.to_integer(count)
+ defp normalize_count(count) when is_integer(count), do: count
end
diff --git a/lib/who_need_help/trust.ex b/lib/who_need_help/trust.ex
index 8cee4e0..8290bad 100644
--- a/lib/who_need_help/trust.ex
+++ b/lib/who_need_help/trust.ex
@@ -338,6 +338,8 @@ defmodule WhoNeedHelp.Trust do
Report
|> maybe_status(status)
+ |> maybe_report_reason(Keyword.get(options, :reason))
+ |> maybe_report_search(Keyword.get(options, :search))
|> before_report(cursor)
|> order_by([report], desc: report.inserted_at, desc: report.id)
|> limit(^(limit + 1))
@@ -672,7 +674,9 @@ defmodule WhoNeedHelp.Trust do
cursor = Pagination.cursor(options)
AbuseSignal
- |> where([signal], signal.status == ^status)
+ |> maybe_signal_status(status)
+ |> maybe_signal_kind(Keyword.get(options, :kind))
+ |> maybe_signal_search(Keyword.get(options, :search))
|> before_abuse_signal(cursor)
|> order_by([signal], desc: signal.inserted_at, desc: signal.id)
|> limit(^(limit + 1))
@@ -936,6 +940,7 @@ defmodule WhoNeedHelp.Trust do
AuditEvent
|> maybe_audit_action(Keyword.get(options, :action))
|> maybe_audit_actor(Keyword.get(options, :actor_id))
+ |> maybe_audit_target_type(Keyword.get(options, :target_type))
|> maybe_audit_search(Keyword.get(options, :search))
|> before_audit_event(cursor)
|> order_by([event], desc: event.inserted_at, desc: event.id)
@@ -972,6 +977,11 @@ defmodule WhoNeedHelp.Trust do
end
end
+ defp maybe_audit_target_type(query, value) when value in [nil, ""], do: query
+
+ defp maybe_audit_target_type(query, value),
+ do: where(query, [event], event.target_type == ^value)
+
defp maybe_audit_search(query, value) when value in [nil, ""], do: query
defp maybe_audit_search(query, value) do
@@ -1325,8 +1335,58 @@ defmodule WhoNeedHelp.Trust do
end
defp maybe_status(query, nil), do: query
+ defp maybe_status(query, ""), do: query
defp maybe_status(query, status), do: where(query, [report], report.status == ^status)
+ defp maybe_report_reason(query, value) when value in [nil, ""], do: query
+ defp maybe_report_reason(query, value), do: where(query, [report], report.reason == ^value)
+
+ defp maybe_report_search(query, value) when value in [nil, ""], do: query
+
+ defp maybe_report_search(query, value) do
+ pattern = search_pattern(value)
+
+ query
+ |> join(:left, [report], reporter in assoc(report, :reporter))
+ |> where(
+ [report, reporter],
+ ilike(report.details, ^pattern) or ilike(reporter.email, ^pattern) or
+ ilike(reporter.display_name, ^pattern) or
+ fragment("CAST(? AS text) ILIKE ?", report.id, ^pattern)
+ )
+ end
+
+ defp maybe_signal_status(query, value) when value in [nil, ""], do: query
+ defp maybe_signal_status(query, value), do: where(query, [signal], signal.status == ^value)
+
+ defp maybe_signal_kind(query, value) when value in [nil, ""], do: query
+ defp maybe_signal_kind(query, value), do: where(query, [signal], signal.kind == ^value)
+
+ defp maybe_signal_search(query, value) when value in [nil, ""], do: query
+
+ defp maybe_signal_search(query, value) do
+ pattern = search_pattern(value)
+
+ query
+ |> join(:left, [signal], subject in assoc(signal, :subject))
+ |> where(
+ [signal, subject],
+ ilike(subject.email, ^pattern) or ilike(subject.display_name, ^pattern) or
+ fragment("CAST(? AS text) ILIKE ?", signal.id, ^pattern)
+ )
+ end
+
+ defp search_pattern(value) do
+ clean =
+ value
+ |> to_string()
+ |> String.trim()
+ |> String.replace("%", "")
+ |> String.replace("_", "")
+
+ "%#{clean}%"
+ end
+
defp clear_pair_tracking(first_user_id, second_user_id) do
assignments =
Assignment
diff --git a/lib/who_need_help_web/live/admin_audit_live.ex b/lib/who_need_help_web/live/admin_audit_live.ex
index e19e645..bbf159e 100644
--- a/lib/who_need_help_web/live/admin_audit_live.ex
+++ b/lib/who_need_help_web/live/admin_audit_live.ex
@@ -4,65 +4,141 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
alias WhoNeedHelp.Trust
alias WhoNeedHelpWeb.AdminComponents
+ @page_sizes ~w(25 50 100)
+
@impl true
def mount(_params, _session, socket) do
- filters = %{"search" => "", "action" => "", "actor_id" => ""}
+ filters = %{
+ "search" => "",
+ "action" => "",
+ "target_type" => "",
+ "actor_id" => "",
+ "limit" => "25"
+ }
{:ok,
socket
|> assign(:page_title, gettext("Audit log"))
|> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filters))
+ |> assign(:page_cursor, nil)
+ |> assign(:cursor_history, [])
+ |> assign(:page_number, 1)
|> load_events()}
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))
+ |> reset_pagination()
|> load_events()}
end
- def handle_event("load-more", _params, socket) do
- page =
- Trust.paginate_audit_events(
- socket.assigns.current_scope,
- event_options(socket, socket.assigns.events_cursor)
- )
+ def handle_event("next-page", _params, %{assigns: %{next_cursor: nil}} = socket),
+ do: {:noreply, socket}
- existing_ids = MapSet.new(socket.assigns.events, & &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.next_cursor)
+ |> assign(:page_number, socket.assigns.page_number + 1)
+ |> load_events()}
+ 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(
- :events,
- socket.assigns.events ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
- )
- |> assign(:events_cursor, page.next_cursor)}
+ |> assign(:cursor_history, history)
+ |> assign(:page_cursor, previous_cursor)
+ |> assign(:page_number, max(socket.assigns.page_number - 1, 1))
+ |> load_events()}
end
defp load_events(socket) do
- page = Trust.paginate_audit_events(socket.assigns.current_scope, event_options(socket))
- socket |> assign(:events, page.entries) |> assign(:events_cursor, page.next_cursor)
+ page =
+ Trust.paginate_audit_events(
+ socket.assigns.current_scope,
+ event_options(socket.assigns.filters, socket.assigns.page_cursor)
+ )
+
+ socket
+ |> assign(:events, page.entries)
+ |> assign(:next_cursor, page.next_cursor)
end
- defp event_options(socket, cursor \\ nil) do
- filters = socket.assigns.filters
-
+ defp event_options(filters, cursor) do
[
search: filters["search"],
action: filters["action"],
+ target_type: filters["target_type"],
actor_id: filters["actor_id"],
- after: cursor
+ after: cursor,
+ limit: page_size(filters["limit"])
]
end
+ defp reset_pagination(socket) do
+ socket
+ |> assign(:page_cursor, nil)
+ |> assign(:cursor_history, [])
+ |> assign(:page_number, 1)
+ end
+
+ defp normalize_filters(params) do
+ %{
+ "search" => normalize_text(params["search"], 160),
+ "action" => normalize_text(params["action"], 120),
+ "target_type" => normalize_text(params["target_type"], 80),
+ "actor_id" => normalize_text(params["actor_id"], 36),
+ "limit" => if(params["limit"] in @page_sizes, do: params["limit"], else: "25")
+ }
+ end
+
+ defp normalize_text(value, max),
+ do: value |> to_string() |> String.trim() |> String.slice(0, max)
+
+ defp page_size(value) do
+ case Integer.parse(value || "") do
+ {size, ""} when size in [25, 50, 100] -> size
+ _invalid -> 25
+ end
+ end
+
+ defp range([], _page, _limit), do: {0, 0}
+
+ defp range(entries, page, limit) do
+ first = (page - 1) * limit + 1
+ {first, first + length(entries) - 1}
+ end
+
+ defp actor_label(%{actor: nil}), do: gettext("System")
+
+ defp actor_label(%{actor: actor}),
+ do: actor.display_name || actor.email || gettext("Unknown staff member")
+
+ defp format_datetime(%DateTime{} = value),
+ do: Calendar.strftime(value, "%Y-%m-%d %H:%M:%S UTC")
+
@impl true
def render(assigns) do
+ limit = page_size(assigns.filters["limit"])
+
+ assigns =
+ assigns
+ |> assign(:range, range(assigns.events, assigns.page_number, limit))
+ |> assign(:page_sizes, @page_sizes)
+
~H"""
@@ -79,12 +155,12 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
for={@filter_form}
id="admin-audit-filters"
phx-change="filter"
- class="grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-3"
+ class="grid gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,1fr)_14rem_12rem_15rem_7rem]"
>
<.input
field={@filter_form[:search]}
type="search"
- label={gettext("Target or action contains")}
+ label={gettext("Search target or action")}
phx-debounce="300"
/>
<.input
@@ -93,93 +169,100 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
placeholder="user.staff_roles_changed"
phx-debounce="300"
/>
+ <.input
+ field={@filter_form[:target_type]}
+ label={gettext("Target type")}
+ placeholder="user"
+ phx-debounce="300"
+ />
<.input field={@filter_form[:actor_id]} label={gettext("Actor UUID")} phx-debounce="300" />
+ <.input
+ field={@filter_form[:limit]}
+ type="select"
+ label={gettext("Rows")}
+ options={@page_sizes}
+ />
-
-
+
+
+
+
{gettext("Recorded events")}
+
+ {gettext(
+ "Actor, action, and target remain visible; technical metadata opens on demand."
+ )}
+
+
+
+ {gettext("Rows %{first}–%{last}", first: elem(@range, 0), last: elem(@range, 1))}
+
+
+
+
{gettext("No audit events match these filters.")}
-
-
- {event.action}
-
-
-
-
-
-
- {gettext("Actor")}
-
- -
- {(event.actor && (event.actor.display_name || event.actor.email)) || gettext("System")}
-
-
-
-
- {gettext("Target")}
-
-
- {event.target_type}: {event.target_id}
-
-
-
-
- {gettext("Metadata")}
-
-
- {Jason.encode!(event.metadata, pretty: true)}
-
-
-
-
-
-
-
-
-
- | {gettext("Time")} | {gettext("Actor")} | {gettext("Action")} |
- {gettext("Target")}
- | {gettext("Metadata")} |
-
-
-
-
- |
- {gettext("No audit events match these filters.")}
- |
-
-
- | {event.inserted_at} |
-
- {(event.actor &&
- (event.actor.display_name || event.actor.email)) ||
- gettext("System")}
+
+
+
+
+
+ {actor_label(event)}
+
{event.actor_id}
-
- |
- {event.action} |
-
- {event.target_type}
- {event.target_id}
-
- |
-
- {Jason.encode!(event.metadata, pretty: true)}
- |
-
-
-
-
-
+
+
+ {event.action}
+
+
{event.target_type}
+
{event.target_id}
+
+
+ {gettext("Details")}
+ <.icon name="hero-chevron-down" class="size-4 transition group-open:rotate-180" />
+
+
+
+
+
{gettext("Actor")}: {actor_label(event)}
+
+ {gettext("Target")}: {event.target_type}: {event.target_id}
+
+
+
+ {gettext("Stored metadata")}
+
+
{Jason.encode!(event.metadata, pretty: true)}
+
+
+
+
+
+
"""
end
diff --git a/lib/who_need_help_web/live/moderation_live.ex b/lib/who_need_help_web/live/moderation_live.ex
index 6a0417e..0af3f2c 100644
--- a/lib/who_need_help_web/live/moderation_live.ex
+++ b/lib/who_need_help_web/live/moderation_live.ex
@@ -1,16 +1,127 @@
defmodule WhoNeedHelpWeb.ModerationLive do
use WhoNeedHelpWeb, :live_view
- alias WhoNeedHelp.{Catalog, Trust}
+ alias WhoNeedHelp.{Accounts, Catalog, Trust}
alias WhoNeedHelpWeb.AdminComponents
+ @page_sizes ~w(25 50 100)
+
@impl true
def mount(_params, _session, socket) do
+ categories_allowed =
+ Accounts.permission?(socket.assigns.current_scope.user, :categories_manage)
+
+ report_filters = %{
+ "search" => "",
+ "status" => "",
+ "reason" => "",
+ "limit" => "25"
+ }
+
+ signal_filters = %{
+ "search" => "",
+ "status" => "open",
+ "kind" => "",
+ "limit" => "25"
+ }
+
+ proposal_filters = %{
+ "search" => "",
+ "status" => "open",
+ "mode" => "",
+ "limit" => "25"
+ }
+
{:ok,
- socket |> assign(:page_title, gettext("Moderation")) |> assign(:evidence, nil) |> load()}
+ socket
+ |> assign(:page_title, gettext("Moderation"))
+ |> assign(:active_queue, :reports)
+ |> assign(:categories_allowed, categories_allowed)
+ |> assign(:report_filters, report_filters)
+ |> assign(:signal_filters, signal_filters)
+ |> assign(:proposal_filters, proposal_filters)
+ |> assign(:report_filter_form, to_form(report_filters, as: :report_filters))
+ |> assign(:signal_filter_form, to_form(signal_filters, as: :signal_filters))
+ |> assign(:proposal_filter_form, to_form(proposal_filters, as: :proposal_filters))
+ |> assign(:categories, if(categories_allowed, do: Catalog.list_all_categories(), else: []))
+ |> assign(:entries, [])
+ |> assign(:next_cursor, nil)
+ |> assign(:evidence, nil)
+ |> reset_pagination()}
end
@impl true
+ def handle_params(params, _uri, socket) do
+ queue = allowed_queue(params["queue"], socket.assigns.categories_allowed)
+
+ {:noreply,
+ socket
+ |> assign(:active_queue, queue)
+ |> assign(:evidence, nil)
+ |> reset_pagination()
+ |> load_queue()}
+ end
+
+ @impl true
+ def handle_event("filter-reports", %{"report_filters" => params}, socket) do
+ filters = normalize_report_filters(params)
+
+ {:noreply,
+ socket
+ |> assign(:report_filters, filters)
+ |> assign(:report_filter_form, to_form(filters, as: :report_filters))
+ |> reset_pagination()
+ |> load_queue()}
+ end
+
+ def handle_event("filter-signals", %{"signal_filters" => params}, socket) do
+ filters = normalize_signal_filters(params)
+
+ {:noreply,
+ socket
+ |> assign(:signal_filters, filters)
+ |> assign(:signal_filter_form, to_form(filters, as: :signal_filters))
+ |> reset_pagination()
+ |> load_queue()}
+ end
+
+ def handle_event("filter-proposals", %{"proposal_filters" => params}, socket) do
+ filters = normalize_proposal_filters(params)
+
+ {:noreply,
+ socket
+ |> assign(:proposal_filters, filters)
+ |> assign(:proposal_filter_form, to_form(filters, as: :proposal_filters))
+ |> reset_pagination()
+ |> load_queue()}
+ end
+
+ def handle_event("next-page", _params, %{assigns: %{next_cursor: nil}} = socket),
+ do: {:noreply, socket}
+
+ 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("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(:cursor_history, history)
+ |> assign(:page_cursor, previous_cursor)
+ |> assign(:page_number, max(socket.assigns.page_number - 1, 1))
+ |> load_queue()}
+ end
+
def handle_event("moderate-report", %{"id" => id, "moderation" => params}, socket) do
respond(
socket,
@@ -70,11 +181,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
def handle_event("approve-proposal", %{"id" => id, "category" => params}, socket) do
names =
- %{
- "en" => params["name_en"],
- "uk" => params["name_uk"],
- "ru" => params["name_ru"]
- }
+ %{"en" => params["name_en"], "uk" => params["name_uk"], "ru" => params["name_ru"]}
|> Map.reject(fn {_locale, name} -> name in [nil, ""] end)
descriptions =
@@ -103,11 +210,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
else
_ ->
{:noreply,
- put_flash(
- socket,
- :error,
- gettext("Structured fields must be a valid JSON object.")
- )}
+ put_flash(socket, :error, gettext("Structured fields must be a valid JSON object."))}
end
end
@@ -131,92 +234,138 @@ defmodule WhoNeedHelpWeb.ModerationLive do
)
end
- def handle_event("load-more-reports", _params, socket) do
- page =
- Trust.paginate_reports(socket.assigns.current_scope, nil,
- after: socket.assigns.reports_cursor
- )
-
- {:noreply, append_page(socket, :reports, page)}
+ defp respond(socket, {:ok, _value}, message) do
+ {:noreply, socket |> put_flash(:info, message) |> load_queue()}
end
- def handle_event("load-more-signals", _params, socket) do
- page =
- Trust.paginate_abuse_signals(socket.assigns.current_scope, :open,
- after: socket.assigns.signals_cursor
- )
+ defp respond(socket, {:error, reason}, _message),
+ do: {:noreply, put_flash(socket, :error, error_message(reason))}
- {:noreply, append_page(socket, :signals, page)}
+ defp reset_pagination(socket) do
+ socket
+ |> assign(:page_cursor, nil)
+ |> assign(:cursor_history, [])
+ |> assign(:page_number, 1)
end
- def handle_event("load-more-proposals", _params, socket) do
+ defp load_queue(%{assigns: %{active_queue: :reports}} = socket) do
+ filters = socket.assigns.report_filters
+
+ page =
+ Trust.paginate_reports(socket.assigns.current_scope, filters["status"],
+ search: filters["search"],
+ reason: filters["reason"],
+ after: socket.assigns.page_cursor,
+ limit: page_size(filters["limit"])
+ )
+
+ socket |> assign(:entries, page.entries) |> assign(:next_cursor, page.next_cursor)
+ end
+
+ defp load_queue(%{assigns: %{active_queue: :signals}} = socket) do
+ filters = socket.assigns.signal_filters
+
+ page =
+ Trust.paginate_abuse_signals(socket.assigns.current_scope, filters["status"],
+ search: filters["search"],
+ kind: filters["kind"],
+ after: socket.assigns.page_cursor,
+ limit: page_size(filters["limit"])
+ )
+
+ socket |> assign(:entries, page.entries) |> assign(:next_cursor, page.next_cursor)
+ end
+
+ defp load_queue(%{assigns: %{active_queue: :proposals}} = socket) do
+ filters = socket.assigns.proposal_filters
+
page =
Catalog.paginate_proposals_for_moderation(socket.assigns.current_scope,
- after: socket.assigns.proposals_cursor
+ search: filters["search"],
+ status: filters["status"],
+ mode: filters["mode"],
+ after: socket.assigns.page_cursor,
+ limit: page_size(filters["limit"])
)
- {:noreply, append_page(socket, :proposals, page)}
+ socket |> assign(:entries, page.entries) |> assign(:next_cursor, page.next_cursor)
end
- defp respond(socket, {:ok, _value}, message) do
- {:noreply, socket |> put_flash(:info, message) |> load()}
+ defp allowed_queue("signals", _categories_allowed), do: :signals
+ defp allowed_queue("proposals", true), do: :proposals
+ defp allowed_queue(_queue, _categories_allowed), do: :reports
+
+ defp normalize_report_filters(params) do
+ %{
+ "search" => normalize_search(params["search"]),
+ "status" => normalize_choice(params["status"], ~w(open reviewing resolved dismissed)),
+ "reason" =>
+ normalize_choice(
+ params["reason"],
+ ~w(dangerous_request harassment fraud prohibited_item spam impersonation other)
+ ),
+ "limit" => normalize_choice(params["limit"], @page_sizes, "25")
+ }
end
- defp respond(socket, {:error, reason}, _message) do
- {:noreply, put_flash(socket, :error, error_message(reason))}
+ defp normalize_signal_filters(params) do
+ %{
+ "search" => normalize_search(params["search"]),
+ "status" => normalize_choice(params["status"], ~w(open reviewed dismissed)),
+ "kind" =>
+ normalize_choice(
+ params["kind"],
+ ~w(velocity repeated_pair handover_without_location_evidence location_without_movement reciprocal_ring manual)
+ ),
+ "limit" => normalize_choice(params["limit"], @page_sizes, "25")
+ }
+ end
+
+ defp normalize_proposal_filters(params) do
+ %{
+ "search" => normalize_search(params["search"]),
+ "status" => normalize_choice(params["status"], ~w(open approved rejected merged)),
+ "mode" => normalize_choice(params["mode"], ~w(help activity)),
+ "limit" => normalize_choice(params["limit"], @page_sizes, "25")
+ }
+ end
+
+ defp normalize_search(value), do: value |> to_string() |> String.trim() |> String.slice(0, 160)
+
+ 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 -> 25
+ end
+ end
+
+ defp current_filters(%{active_queue: :reports, report_filters: filters}), do: filters
+ defp current_filters(%{active_queue: :signals, signal_filters: filters}), do: filters
+ defp current_filters(%{active_queue: :proposals, proposal_filters: filters}), do: filters
+
+ 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 decode_structured_fields(value) when value in [nil, ""], do: {:ok, %{}}
defp decode_structured_fields(value) when is_binary(value), do: Jason.decode(value)
defp decode_structured_fields(_value), do: {:error, :invalid_type}
- defp load(socket) do
- reports = Trust.paginate_reports(socket.assigns.current_scope)
- signals = Trust.paginate_abuse_signals(socket.assigns.current_scope)
- proposals = Catalog.paginate_proposals_for_moderation(socket.assigns.current_scope)
-
- socket
- |> assign(:reports, reports.entries)
- |> assign(:reports_cursor, reports.next_cursor)
- |> assign(:signals, signals.entries)
- |> assign(:signals_cursor, signals.next_cursor)
- |> assign(:proposals, proposals.entries)
- |> assign(:proposals_cursor, proposals.next_cursor)
- |> assign(:categories, Catalog.list_all_categories())
- end
-
- defp append_page(socket, name, page) do
- existing = socket.assigns[name]
- existing_ids = MapSet.new(existing, & &1.id)
- entries = existing ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
-
- socket
- |> assign(name, entries)
- |> assign(cursor_assign(name), page.next_cursor)
- end
-
- defp cursor_assign(:reports), do: :reports_cursor
- defp cursor_assign(:signals), do: :signals_cursor
- defp cursor_assign(:proposals), do: :proposals_cursor
-
defp error_message(:forbidden), do: gettext("Moderator access is required.")
defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.")
defp error_message(:mode_mismatch), do: gettext("Choose a category from the same mode.")
-
- defp error_message(:cannot_restrict_self),
- do: gettext("You cannot restrict your own moderator account.")
-
- defp error_message(:last_admin),
- do: gettext("The last administrator cannot demote themselves.")
-
defp error_message(:not_found), do: gettext("The selected record is no longer available.")
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
-
defp error_message(_reason), do: gettext("Could not complete moderation. Please try again.")
- defp scoped_form(data, as, scope) do
- to_form(data, as: as, id: "#{as}-#{scope}")
- end
+ defp scoped_form(data, as, scope), do: to_form(data, as: as, id: "#{as}-#{scope}")
defp reason_label(:dangerous_request), do: gettext("Dangerous request")
defp reason_label(:harassment), do: gettext("Harassment")
@@ -227,18 +376,15 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp reason_label(:other), do: gettext("Other")
defp reason_label(value), do: to_string(value)
- defp status_label(:open), do: gettext("Open")
- defp status_label(:reviewing), do: gettext("Reviewing")
- defp status_label(:resolved), do: gettext("Resolved")
- defp status_label(:dismissed), do: gettext("Dismissed")
- defp status_label(:reviewed), do: gettext("Reviewed")
- defp status_label(:approved), do: gettext("Approved")
- defp status_label(:rejected), do: gettext("Rejected")
- defp status_label(:merged), do: gettext("Merged")
- defp status_label(:active), do: gettext("Active")
- defp status_label(:restricted), do: gettext("Restricted")
- defp status_label(:suspended), do: gettext("Suspended")
- defp status_label(value), do: to_string(value)
+ defp status_label(value), do: value |> to_string() |> String.replace("_", " ")
+
+ defp status_class(value) when value in [:open, :reviewing], do: "badge-warning badge-outline"
+
+ defp status_class(value) when value in [:resolved, :reviewed, :approved],
+ do: "badge-success badge-outline"
+
+ defp status_class(value) when value in [:dismissed, :rejected], do: "badge-ghost"
+ defp status_class(_value), do: "badge-info badge-outline"
defp signal_label(:velocity), do: gettext("Unusual action rate")
defp signal_label(:repeated_pair), do: gettext("Repeated participant pair")
@@ -251,6 +397,17 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp signal_label(:manual), do: gettext("Manual review")
defp signal_label(value), do: to_string(value)
+ defp report_target(%{request_id: id}) when not is_nil(id), do: gettext("Help request")
+
+ defp report_target(%{assignment_id: id}) when not is_nil(id),
+ do: gettext("Matched conversation")
+
+ defp report_target(%{message_id: id}) when not is_nil(id), do: gettext("Chat message")
+ defp report_target(%{activity_id: id}) when not is_nil(id), do: gettext("Activity")
+
+ defp report_target(%{activity_message_id: id}) when not is_nil(id),
+ do: gettext("Activity message")
+
defp report_request(%{request: %WhoNeedHelp.Help.HelpRequest{} = request}), do: request
defp report_request(%{
@@ -271,8 +428,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp report_request(_report), do: nil
- defp report_activity(%{activity: %WhoNeedHelp.Activities.Activity{} = activity}),
- do: activity
+ defp report_activity(%{activity: %WhoNeedHelp.Activities.Activity{} = activity}), do: activity
defp report_activity(%{
activity_message: %WhoNeedHelp.Activities.Message{
@@ -283,8 +439,18 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp report_activity(_report), do: nil
+ defp format_datetime(%DateTime{} = value),
+ do: Calendar.strftime(value, "%Y-%m-%d %H:%M UTC")
+
@impl true
def render(assigns) do
+ page_size = page_size(current_filters(assigns)["limit"])
+
+ assigns =
+ assigns
+ |> assign(:result_range, result_range(assigns.entries, assigns.page_number, page_size))
+ |> assign(:page_sizes, @page_sizes)
+
~H"""
-
- {gettext("Reports")}
-
-
- <% hide_form = scoped_form(%{"note" => ""}, :moderation, "report-hide-#{report.id}") %>
- <% reported_request = report_request(report) %>
- <% reported_activity = report_activity(report) %>
- <% report_form =
- scoped_form(
- %{"status" => to_string(report.status), "resolution_note" => ""},
- :moderation,
- "report-review-#{report.id}"
- ) %>
-
- {reason_label(report.reason)}
- {status_label(report.status)}
-
- {gettext("activity")}
-
-
- {gettext("activity message")}
-
-
- {gettext("matched chat message")}
-
-
- {gettext("matched conversation")}
-
-
- {gettext("by %{name}", name: report.reporter.display_name)}
-
-
- {report.details}
-
-
- <.form
- :if={reported_request && is_nil(reported_request.hidden_at)}
- for={hide_form}
- phx-submit="hide-request"
- phx-value-id={reported_request.id}
- class="flex gap-2"
- >
- <.input
- field={hide_form[:note]}
- placeholder={gettext("Required reason for hiding")}
- required
- />
- <.button class="btn btn-sm btn-error self-end">
- {gettext("Hide request")}
-
-
-
- <.form
- :if={reported_activity && is_nil(reported_activity.hidden_at)}
- for={hide_form}
- phx-submit="hide-activity"
- phx-value-id={reported_activity.id}
- class="flex gap-2"
- >
- <.input
- field={hide_form[:note]}
- placeholder={gettext("Required reason for hiding")}
- required
- />
- <.button class="btn btn-sm btn-error self-end">
- {gettext("Hide activity")}
-
-
-
-
- <.form
- for={report_form}
- phx-submit="moderate-report"
- phx-value-id={report.id}
- class="mt-4 grid gap-2 md:grid-cols-[12rem_1fr_auto]"
- >
- <.input
- field={report_form[:status]}
- type="select"
- options={[
- {gettext("Reviewing"), "reviewing"},
- {gettext("Resolved"), "resolved"},
- {gettext("Dismissed"), "dismissed"}
- ]}
- />
- <.input
- field={report_form[:resolution_note]}
- placeholder={gettext("Resolution note")}
- />
- <.button class="btn btn-primary self-end">{gettext("Save")}
-
-
-
{gettext("No reports.")}
-
-
-
+ {gettext("Reports")}
+
+ <.queue_link
+ active={@active_queue == :signals}
+ patch={~p"/moderation?queue=signals"}
+ icon="hero-exclamation-triangle"
+ >
+ {gettext("Abuse signals")}
+
+ <.queue_link
+ :if={@categories_allowed}
+ active={@active_queue == :proposals}
+ patch={~p"/moderation?queue=proposals"}
+ icon="hero-tag"
+ >
+ {gettext("Category proposals")}
+
+
-
+ <.signal_queue
+ :if={@active_queue == :signals}
+ entries={@entries}
+ signal_filter_form={@signal_filter_form}
+ page_sizes={@page_sizes}
+ />
+ <.proposal_queue
+ :if={@active_queue == :proposals}
+ entries={@entries}
+ proposal_filter_form={@proposal_filter_form}
+ page_sizes={@page_sizes}
+ categories={@categories}
+ />
+
+ <.pagination
+ entries={@entries}
+ range={@result_range}
+ page_number={@page_number}
+ previous_disabled={@cursor_history == []}
+ next_disabled={is_nil(@next_cursor)}
+ />
+
+
-
-
{gettext("Reported evidence")}
-
-
-
- {gettext("Only the conversation linked to this report is displayed.")}
-
-
-
- {message.sender.display_name}: {message.body}
-
-
- {message.sender.display_name}: {message.body}
-
-
- {gettext("This report has no linked conversation.")}
-
-
-
-
-
- {gettext("Open abuse signals")}
-
-
- <% signal_form =
- scoped_form(
- %{"status" => "reviewed", "review_note" => ""},
- :moderation,
- "signal-#{signal.id}"
- ) %>
-
- {signal_label(signal.kind)} · {signal.subject.display_name}
+
+
+
+
+ {gettext("Reported evidence")}
+
+
+ {gettext("Only the conversation linked to this report is displayed.")}
+
-
- {gettext(
- "A signal requests human review; optional tracking absence is not proof of abuse."
- )}
-
- <.form
- for={signal_form}
- phx-submit="moderate-signal"
- phx-value-id={signal.id}
- class="mt-3 grid gap-2 md:grid-cols-[12rem_1fr_auto]"
+
-
-
-
-
- {gettext("Category proposals")}
-
-
- <% category_form =
- scoped_form(
- %{"structured_fields_json" => ~s({"fields":[]})},
- :category,
- "approve-#{proposal.id}"
- ) %>
- <% reject_form =
- scoped_form(%{"note" => ""}, :moderation, "reject-#{proposal.id}") %>
- <% merge_form =
- scoped_form(%{}, :moderation, "merge-#{proposal.id}") %>
-
- {proposal.proposed_name}
- {status_label(proposal.status)}
-
- {proposal.reason}
-
- {gettext("%{count} community votes", count: proposal.vote_count)}
-
-
- <.form
- for={category_form}
- phx-submit="approve-proposal"
- phx-value-id={proposal.id}
- class="space-y-2 rounded-xl bg-base-200 p-4"
- >
- <.input field={category_form[:slug]} label={gettext("Slug")} />
- <.input field={category_form[:name_en]} label={gettext("English name")} />
- <.input
- field={category_form[:name_uk]}
- label={gettext("Ukrainian name (optional)")}
- />
- <.input
- field={category_form[:name_ru]}
- label={gettext("Russian name (optional)")}
- />
- <.input
- field={category_form[:description_en]}
- label={gettext("English description")}
- />
- <.input
- field={category_form[:description_uk]}
- label={gettext("Ukrainian description (optional)")}
- />
- <.input
- field={category_form[:description_ru]}
- label={gettext("Russian description (optional)")}
- />
- <.input
- field={category_form[:structured_fields_json]}
- type="textarea"
- label={gettext("Structured fields JSON")}
- />
- <.input
- field={category_form[:moderation_note]}
- label={gettext("Decision note")}
- />
- <.button class="btn btn-success w-full">{gettext("Create category")}
-
-
- <.form
- for={reject_form}
- phx-submit="reject-proposal"
- phx-value-id={proposal.id}
- class="flex gap-2"
- >
- <.input field={reject_form[:note]} placeholder={gettext("Rejection note")} />
- <.button class="btn btn-error self-end">{gettext("Reject")}
-
- <.form
- for={merge_form}
- phx-submit="merge-proposal"
- phx-value-id={proposal.id}
- class="space-y-2"
- >
- <.input
- field={merge_form[:category_id]}
- type="select"
- label={gettext("Merge into")}
- options={
- @categories
- |> Enum.filter(&(&1.mode == proposal.mode))
- |> Enum.map(&{WhoNeedHelp.Catalog.category_path(&1, "en"), &1.id})
- }
- />
- <.input field={merge_form[:note]} placeholder={gettext("Merge note")} />
- <.button class="btn btn-outline w-full">{gettext("Merge")}
-
-
+ <.icon name="hero-x-mark" class="size-5" />
+
+
+
+
+ {message.sender.display_name}: {message.body}
-
-
-
-
+
+ {message.sender.display_name}: {message.body}
+
+
+ {gettext("This report has no linked conversation.")}
+
+
+
+
"""
end
+
+ attr :active, :boolean, required: true
+ attr :patch, :string, required: true
+ attr :icon, :string, required: true
+ slot :inner_block, required: true
+
+ defp queue_link(assigns) do
+ ~H"""
+ <.link
+ patch={@patch}
+ class={[
+ "btn btn-sm min-w-0 border-0 px-2 sm:px-4",
+ @active && "btn-primary",
+ !@active && "btn-ghost"
+ ]}
+ >
+ <.icon name={@icon} class="hidden size-4 shrink-0 sm:block" />
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ attr :entries, :list, required: true
+ attr :report_filter_form, :map, required: true
+ attr :page_sizes, :list, required: true
+
+ defp report_queue(assigns) do
+ ~H"""
+
+ <.queue_heading
+ title={gettext("Reports")}
+ description={gettext("User reports about requests, activities, and private conversations.")}
+ />
+ <.form
+ for={@report_filter_form}
+ id="report-filters"
+ phx-change="filter-reports"
+ class="mt-4 grid gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,1fr)_12rem_14rem_7rem]"
+ >
+ <.input
+ field={@report_filter_form[:search]}
+ type="search"
+ label={gettext("Search")}
+ placeholder={gettext("Reference, reporter, or details")}
+ phx-debounce="300"
+ />
+ <.input
+ field={@report_filter_form[:status]}
+ type="select"
+ label={gettext("Status")}
+ options={[
+ {gettext("Any status"), ""},
+ {gettext("Open"), "open"},
+ {gettext("Reviewing"), "reviewing"},
+ {gettext("Resolved"), "resolved"},
+ {gettext("Dismissed"), "dismissed"}
+ ]}
+ />
+ <.input
+ field={@report_filter_form[:reason]}
+ type="select"
+ label={gettext("Reason")}
+ options={[
+ {gettext("Any reason"), ""},
+ {reason_label(:dangerous_request), "dangerous_request"},
+ {reason_label(:harassment), "harassment"},
+ {reason_label(:fraud), "fraud"},
+ {reason_label(:prohibited_item), "prohibited_item"},
+ {reason_label(:spam), "spam"},
+ {reason_label(:impersonation), "impersonation"},
+ {reason_label(:other), "other"}
+ ]}
+ />
+ <.input
+ field={@report_filter_form[:limit]}
+ type="select"
+ label={gettext("Rows")}
+ options={@page_sizes}
+ />
+
+
+ <.empty_state :if={@entries == []} text={gettext("No reports match these filters.")} />
+
+
+
+
+
{report.details}
+
+ {report.reporter.display_name || report.reporter.email}
+
+
+ {reason_label(report.reason)}
+ {status_label(report.status)}
+ {report_target(report)}
+ <.icon name="hero-chevron-down" class="size-5 transition group-open:rotate-180" />
+
+ <.report_actions report={report} />
+
+
+
+ """
+ end
+
+ attr :report, :map, required: true
+
+ defp report_actions(assigns) do
+ hide_form = scoped_form(%{"note" => ""}, :moderation, "report-hide-#{assigns.report.id}")
+ reported_request = report_request(assigns.report)
+ reported_activity = report_activity(assigns.report)
+
+ report_form =
+ scoped_form(
+ %{"status" => to_string(assigns.report.status), "resolution_note" => ""},
+ :moderation,
+ "report-review-#{assigns.report.id}"
+ )
+
+ assigns =
+ assigns
+ |> assign(:hide_form, hide_form)
+ |> assign(:reported_request, reported_request)
+ |> assign(:reported_activity, reported_activity)
+ |> assign(:report_form, report_form)
+
+ ~H"""
+
+
+ {@report.id}
+
+
+
{@report.details}
+
+
+
+
+
+ <.form
+ :if={@reported_request && is_nil(@reported_request.hidden_at)}
+ for={@hide_form}
+ phx-submit="hide-request"
+ phx-value-id={@reported_request.id}
+ class="mt-3 grid gap-2 sm:grid-cols-[1fr_auto]"
+ >
+ <.input field={@hide_form[:note]} label={gettext("Reason for hiding the request")} required />
+ <.button class="btn btn-error self-end">{gettext("Hide request")}
+
+ <.form
+ :if={@reported_activity && is_nil(@reported_activity.hidden_at)}
+ for={@hide_form}
+ phx-submit="hide-activity"
+ phx-value-id={@reported_activity.id}
+ class="mt-3 grid gap-2 sm:grid-cols-[1fr_auto]"
+ >
+ <.input field={@hide_form[:note]} label={gettext("Reason for hiding the activity")} required />
+ <.button class="btn btn-error self-end">{gettext("Hide activity")}
+
+ <.form
+ for={@report_form}
+ phx-submit="moderate-report"
+ phx-value-id={@report.id}
+ class="mt-4 grid gap-3 rounded-2xl bg-base-200 p-4 md:grid-cols-[12rem_1fr_auto]"
+ >
+ <.input
+ field={@report_form[:status]}
+ type="select"
+ label={gettext("Decision")}
+ options={[
+ {gettext("Reviewing"), "reviewing"},
+ {gettext("Resolved"), "resolved"},
+ {gettext("Dismissed"), "dismissed"}
+ ]}
+ />
+ <.input field={@report_form[:resolution_note]} label={gettext("Resolution note")} />
+ <.button class="btn btn-primary self-end">{gettext("Save decision")}
+
+
+ """
+ end
+
+ attr :entries, :list, required: true
+ attr :signal_filter_form, :map, required: true
+ attr :page_sizes, :list, required: true
+
+ defp signal_queue(assigns) do
+ ~H"""
+
+ <.queue_heading
+ title={gettext("Abuse signals")}
+ description={gettext("Automated signals are review prompts, not proof of abuse.")}
+ />
+ <.form
+ for={@signal_filter_form}
+ id="signal-filters"
+ phx-change="filter-signals"
+ class="mt-4 grid gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,1fr)_12rem_18rem_7rem]"
+ >
+ <.input
+ field={@signal_filter_form[:search]}
+ type="search"
+ label={gettext("Search")}
+ placeholder={gettext("Reference or user")}
+ phx-debounce="300"
+ />
+ <.input
+ field={@signal_filter_form[:status]}
+ type="select"
+ label={gettext("Status")}
+ options={[
+ {gettext("Any status"), ""},
+ {gettext("Open"), "open"},
+ {gettext("Reviewed"), "reviewed"},
+ {gettext("Dismissed"), "dismissed"}
+ ]}
+ />
+ <.input
+ field={@signal_filter_form[:kind]}
+ type="select"
+ label={gettext("Signal type")}
+ options={[
+ {gettext("Any signal"), ""},
+ {signal_label(:velocity), "velocity"},
+ {signal_label(:repeated_pair), "repeated_pair"},
+ {signal_label(:handover_without_location_evidence), "handover_without_location_evidence"},
+ {signal_label(:location_without_movement), "location_without_movement"},
+ {signal_label(:reciprocal_ring), "reciprocal_ring"},
+ {signal_label(:manual), "manual"}
+ ]}
+ />
+ <.input
+ field={@signal_filter_form[:limit]}
+ type="select"
+ label={gettext("Rows")}
+ options={@page_sizes}
+ />
+
+ <.empty_state :if={@entries == []} text={gettext("No signals match these filters.")} />
+
+
+
+
+
{signal.subject.display_name || signal.subject.email}
+ {signal.subject.email}
+
+
+ {signal_label(signal.kind)}
+ {status_label(signal.status)}
+
+ <.icon name="hero-chevron-down" class="size-5 transition group-open:rotate-180" />
+
+ <.signal_actions signal={signal} />
+
+
+
+ """
+ end
+
+ attr :signal, :map, required: true
+
+ defp signal_actions(assigns) do
+ form =
+ scoped_form(
+ %{"status" => "reviewed", "review_note" => ""},
+ :moderation,
+ "signal-#{assigns.signal.id}"
+ )
+
+ assigns = assign(assigns, :form, form)
+
+ ~H"""
+
+
+ {gettext("A signal requests human review; optional tracking absence is not proof of abuse.")}
+
+
+ {gettext("Technical signal metadata")}
+ {Jason.encode!(@signal.metadata, pretty: true)}
+
+ <.form
+ for={@form}
+ phx-submit="moderate-signal"
+ phx-value-id={@signal.id}
+ class="mt-4 grid gap-3 rounded-2xl bg-base-200 p-4 md:grid-cols-[12rem_1fr_auto]"
+ >
+ <.input
+ field={@form[:status]}
+ type="select"
+ label={gettext("Decision")}
+ options={[{gettext("Reviewed"), "reviewed"}, {gettext("Dismissed"), "dismissed"}]}
+ />
+ <.input field={@form[:review_note]} label={gettext("Review note")} />
+ <.button class="btn btn-primary self-end">{gettext("Save decision")}
+
+
+ """
+ end
+
+ attr :entries, :list, required: true
+ attr :proposal_filter_form, :map, required: true
+ attr :page_sizes, :list, required: true
+ attr :categories, :list, required: true
+
+ defp proposal_queue(assigns) do
+ ~H"""
+
+ <.queue_heading
+ title={gettext("Category proposals")}
+ description={
+ gettext("Review community requests without mixing help and activity taxonomies.")
+ }
+ />
+ <.form
+ for={@proposal_filter_form}
+ id="proposal-filters"
+ phx-change="filter-proposals"
+ class="mt-4 grid gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,1fr)_12rem_12rem_7rem]"
+ >
+ <.input
+ field={@proposal_filter_form[:search]}
+ type="search"
+ label={gettext("Search")}
+ placeholder={gettext("Name, reason, or reference")}
+ phx-debounce="300"
+ />
+ <.input
+ field={@proposal_filter_form[:status]}
+ type="select"
+ label={gettext("Status")}
+ options={[
+ {gettext("Any status"), ""},
+ {gettext("Open"), "open"},
+ {gettext("Approved"), "approved"},
+ {gettext("Rejected"), "rejected"},
+ {gettext("Merged"), "merged"}
+ ]}
+ />
+ <.input
+ field={@proposal_filter_form[:mode]}
+ type="select"
+ label={gettext("Taxonomy")}
+ options={[
+ {gettext("Any taxonomy"), ""},
+ {gettext("Help"), "help"},
+ {gettext("Activities"), "activity"}
+ ]}
+ />
+ <.input
+ field={@proposal_filter_form[:limit]}
+ type="select"
+ label={gettext("Rows")}
+ options={@page_sizes}
+ />
+
+ <.empty_state :if={@entries == []} text={gettext("No proposals match these filters.")} />
+
+
+
+
+
{proposal.proposed_name}
+ {proposal.reason}
+
+
+ {status_label(proposal.mode)}
+ {status_label(proposal.status)}
+ {gettext("%{count} votes",
+ count: proposal.vote_count
+ )}
+ <.icon name="hero-chevron-down" class="size-5 transition group-open:rotate-180" />
+
+ <.proposal_actions proposal={proposal} categories={@categories} />
+
+
+
+ """
+ end
+
+ attr :proposal, :map, required: true
+ attr :categories, :list, required: true
+
+ defp proposal_actions(assigns) do
+ category_form =
+ scoped_form(
+ %{"structured_fields_json" => ~s({"fields":[]})},
+ :category,
+ "approve-#{assigns.proposal.id}"
+ )
+
+ reject_form = scoped_form(%{"note" => ""}, :moderation, "reject-#{assigns.proposal.id}")
+ merge_form = scoped_form(%{}, :moderation, "merge-#{assigns.proposal.id}")
+
+ assigns =
+ assigns
+ |> assign(:category_form, category_form)
+ |> assign(:reject_form, reject_form)
+ |> assign(:merge_form, merge_form)
+
+ ~H"""
+
+
{@proposal.reason}
+
+
+
+ {gettext("Create a category from this proposal")}
+
+ <.form
+ for={@category_form}
+ phx-submit="approve-proposal"
+ phx-value-id={@proposal.id}
+ class="mt-4 grid gap-3 sm:grid-cols-2"
+ >
+ <.input field={@category_form[:slug]} label={gettext("Slug")} />
+ <.input field={@category_form[:name_en]} label={gettext("English name")} />
+ <.input field={@category_form[:name_uk]} label={gettext("Ukrainian name (optional)")} />
+ <.input field={@category_form[:name_ru]} label={gettext("Russian name (optional)")} />
+ <.input field={@category_form[:description_en]} label={gettext("English description")} />
+ <.input
+ field={@category_form[:description_uk]}
+ label={gettext("Ukrainian description (optional)")}
+ />
+ <.input
+ field={@category_form[:description_ru]}
+ label={gettext("Russian description (optional)")}
+ />
+ <.input
+ field={@category_form[:structured_fields_json]}
+ type="textarea"
+ label={gettext("Structured fields JSON")}
+ />
+ <.input field={@category_form[:moderation_note]} label={gettext("Decision note")} />
+ <.button class="btn btn-success self-end">{gettext("Create category")}
+
+
+
+ <.form
+ for={@merge_form}
+ phx-submit="merge-proposal"
+ phx-value-id={@proposal.id}
+ class="space-y-3 rounded-2xl border border-base-300 p-4"
+ >
+ <.input
+ field={@merge_form[:category_id]}
+ type="select"
+ label={gettext("Merge into")}
+ options={
+ @categories
+ |> Enum.filter(&(&1.mode == @proposal.mode))
+ |> Enum.map(&{WhoNeedHelp.Catalog.category_path(&1, "en"), &1.id})
+ }
+ />
+ <.input field={@merge_form[:note]} label={gettext("Merge note")} />
+ <.button class="btn btn-outline w-full">{gettext("Merge")}
+
+ <.form
+ for={@reject_form}
+ phx-submit="reject-proposal"
+ phx-value-id={@proposal.id}
+ class="space-y-3 rounded-2xl border border-error/30 bg-error/5 p-4"
+ >
+ <.input field={@reject_form[:note]} label={gettext("Rejection note")} />
+ <.button class="btn btn-error w-full">{gettext("Reject")}
+
+
+
+
+ """
+ end
+
+ attr :title, :string, required: true
+ attr :description, :string, required: true
+
+ defp queue_heading(assigns) do
+ ~H"""
+
+
{@title}
+
{@description}
+
+ """
+ end
+
+ attr :text, :string, required: true
+
+ defp empty_state(assigns) do
+ ~H"""
+
+ {@text}
+
+ """
+ end
+
+ attr :entries, :list, required: true
+ attr :range, :any, required: true
+ attr :page_number, :integer, required: true
+ attr :previous_disabled, :boolean, required: true
+ attr :next_disabled, :boolean, required: true
+
+ defp pagination(assigns) do
+ ~H"""
+
+ """
+ end
end
diff --git a/lib/who_need_help_web/live/product_analytics_live.ex b/lib/who_need_help_web/live/product_analytics_live.ex
index b6786dc..0a579e6 100644
--- a/lib/who_need_help_web/live/product_analytics_live.ex
+++ b/lib/who_need_help_web/live/product_analytics_live.ex
@@ -4,37 +4,163 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
alias WhoNeedHelp.ProductAnalytics
alias WhoNeedHelpWeb.AdminComponents
+ @page_sizes ~w(25 50 100)
+
@impl true
def mount(_params, _session, socket) do
- page = ProductAnalytics.paginate(socket.assigns.current_scope)
+ filters = %{
+ "metric" => "",
+ "dimension" => "",
+ "date_from" => "",
+ "date_to" => "",
+ "limit" => "25"
+ }
{:ok,
socket
|> assign(:page_title, gettext("Product analytics"))
- |> assign(:metrics, page.entries)
- |> assign(:metrics_cursor, page.next_cursor)}
+ |> assign(:filters, filters)
+ |> assign(:filter_form, to_form(filters, as: :filters))
+ |> assign(:page_cursor, nil)
+ |> assign(:cursor_history, [])
+ |> assign(:page_number, 1)
+ |> load_metrics()}
end
@impl true
- def handle_event("load-more", _params, socket) do
- page =
- ProductAnalytics.paginate(socket.assigns.current_scope,
- after: socket.assigns.metrics_cursor
- )
-
- existing_ids = MapSet.new(socket.assigns.metrics, & &1.id)
+ def handle_event("filter", %{"filters" => params}, socket) do
+ filters = normalize_filters(params)
{:noreply,
socket
- |> assign(
- :metrics,
- socket.assigns.metrics ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
- )
- |> assign(:metrics_cursor, page.next_cursor)}
+ |> assign(:filters, filters)
+ |> assign(:filter_form, to_form(filters, as: :filters))
+ |> reset_pagination()
+ |> load_metrics()}
end
+ def handle_event("next-page", _params, %{assigns: %{next_cursor: nil}} = socket),
+ do: {:noreply, socket}
+
+ 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_metrics()}
+ 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(:cursor_history, history)
+ |> assign(:page_cursor, previous_cursor)
+ |> assign(:page_number, max(socket.assigns.page_number - 1, 1))
+ |> load_metrics()}
+ end
+
+ defp load_metrics(socket) do
+ options = metric_options(socket.assigns.filters, socket.assigns.page_cursor)
+ page = ProductAnalytics.paginate(socket.assigns.current_scope, options)
+
+ overview_options =
+ options
+ |> Keyword.take([:date_from, :date_to])
+
+ socket
+ |> assign(:metrics, page.entries)
+ |> assign(:next_cursor, page.next_cursor)
+ |> assign(
+ :overview,
+ ProductAnalytics.overview(socket.assigns.current_scope, overview_options)
+ )
+ end
+
+ defp reset_pagination(socket) do
+ socket
+ |> assign(:page_cursor, nil)
+ |> assign(:cursor_history, [])
+ |> assign(:page_number, 1)
+ end
+
+ defp metric_options(filters, cursor) do
+ [
+ metric: filters["metric"],
+ dimension: filters["dimension"],
+ date_from: parse_date(filters["date_from"]),
+ date_to: parse_date(filters["date_to"]),
+ after: cursor,
+ limit: page_size(filters["limit"])
+ ]
+ end
+
+ defp normalize_filters(params) do
+ %{
+ "metric" => normalize_choice(params["metric"], ProductAnalytics.allowed_metrics()),
+ "dimension" => normalize_text(params["dimension"], 80),
+ "date_from" => normalize_date(params["date_from"]),
+ "date_to" => normalize_date(params["date_to"]),
+ "limit" => normalize_choice(params["limit"], @page_sizes, "25")
+ }
+ end
+
+ defp normalize_choice(value, choices, fallback \\ "") do
+ if value in choices, do: value, else: fallback
+ end
+
+ defp normalize_text(value, max),
+ do: value |> to_string() |> String.trim() |> String.slice(0, max)
+
+ defp normalize_date(value) do
+ case Date.from_iso8601(to_string(value)) do
+ {:ok, _date} -> value
+ _invalid -> ""
+ end
+ end
+
+ defp parse_date(""), do: nil
+ defp parse_date(value), do: Date.from_iso8601!(value)
+
+ defp page_size(value) do
+ case Integer.parse(value || "") do
+ {size, ""} when size in [25, 50, 100] -> size
+ _invalid -> 25
+ end
+ end
+
+ defp range([], _page, _limit), do: {0, 0}
+
+ defp range(entries, page, limit) do
+ first = (page - 1) * limit + 1
+ {first, first + length(entries) - 1}
+ end
+
+ defp metric_label(metric),
+ do: metric |> to_string() |> String.replace(".", " ") |> String.replace("_", " ")
+
+ defp compact_number(value) when value >= 1_000_000,
+ do: :erlang.float_to_binary(value / 1_000_000, decimals: 1) <> "M"
+
+ defp compact_number(value) when value >= 1_000,
+ do: :erlang.float_to_binary(value / 1_000, decimals: 1) <> "K"
+
+ defp compact_number(value), do: to_string(value)
+
@impl true
def render(assigns) do
+ limit = page_size(assigns.filters["limit"])
+
+ assigns =
+ assigns
+ |> assign(:range, range(assigns.metrics, assigns.page_number, limit))
+ |> assign(:page_sizes, @page_sizes)
+
~H"""
-
-
-
-
- | {gettext("Date")} |
- {gettext("Metric")} |
- {gettext("Dimension")} |
- {gettext("Count")} |
-
-
-
-
- |
- {gettext("No aggregate events recorded yet.")}
- |
-
-
- | {metric.date} |
- {metric.metric} |
- {metric.dimension} |
- {metric.count} |
-
-
-
-
+
+ <.summary_card
+ label={gettext("All measured actions")}
+ value={@overview.total}
+ icon="hero-chart-bar"
+ />
+ <.summary_card
+ label={gettext("Registrations")}
+ value={@overview.registrations}
+ icon="hero-user-plus"
+ />
+ <.summary_card
+ label={gettext("Requests created")}
+ value={@overview.requests_created}
+ icon="hero-hand-raised"
+ />
+ <.summary_card
+ label={gettext("Requests completed")}
+ value={@overview.requests_completed}
+ icon="hero-check-circle"
+ />
+
-
+ <.input
+ field={@filter_form[:metric]}
+ type="select"
+ label={gettext("Metric")}
+ options={[
+ {gettext("All metrics"), ""}
+ | Enum.map(ProductAnalytics.allowed_metrics(), &{metric_label(&1), &1})
+ ]}
+ />
+ <.input
+ field={@filter_form[:dimension]}
+ label={gettext("Dimension")}
+ placeholder={gettext("Any dimension")}
+ phx-debounce="300"
+ />
+ <.input field={@filter_form[:date_from]} type="date" label={gettext("From")} />
+ <.input field={@filter_form[:date_to]} type="date" label={gettext("To")} />
+ <.input
+ field={@filter_form[:limit]}
+ type="select"
+ label={gettext("Rows")}
+ options={@page_sizes}
+ />
+
+
+
+
+
+
{gettext("Daily counters")}
+
+ {gettext("One row per date, metric, and non-identifying dimension.")}
+
+
+
+ {gettext("Rows %{first}–%{last}", first: elem(@range, 0), last: elem(@range, 1))}
+
+
+
+
+ {gettext("No aggregate counters match these filters.")}
+
+
+
+
+
+
{metric_label(metric.metric)}
+
{metric.date} · {metric.dimension}
+
+ {compact_number(metric.count)}
+
+
+
+
+
+
+
+ | {gettext("Date")} | {gettext("Metric")} | {gettext("Dimension")} |
+ {gettext("Count")}
+ |
+
+
+
+
+ | {metric.date} |
+ {metric_label(metric.metric)} |
+ {metric.dimension} |
+ {metric.count} |
+
+
+
+
+
+
+
"""
end
+
+ attr :label, :string, required: true
+ attr :value, :integer, required: true
+ attr :icon, :string, required: true
+
+ defp summary_card(assigns) do
+ ~H"""
+
+
+ <.icon name={@icon} class="size-5" />
+
+
+
{@label}
+
{compact_number(@value)}
+
+
+ """
+ end
end
diff --git a/test/who_need_help/product_analytics_test.exs b/test/who_need_help/product_analytics_test.exs
index d44820c..66d1ecd 100644
--- a/test/who_need_help/product_analytics_test.exs
+++ b/test/who_need_help/product_analytics_test.exs
@@ -43,4 +43,29 @@ defmodule WhoNeedHelp.ProductAnalyticsTest do
refute Repo.get_by(DailyMetric, metric: "request.created", dimension: "now")
end
+
+ test "filters aggregate rows and returns privacy-safe overview counters" do
+ analyst = [:analyst] |> staff_user_fixture() |> user_scope_fixture()
+
+ assert {:ok, _metric} = ProductAnalytics.increment("account.registered", "google")
+ assert {:ok, _metric} = ProductAnalytics.increment("request.created", "today")
+ assert {:ok, _metric} = ProductAnalytics.increment("request.created", "today")
+ assert {:ok, _metric} = ProductAnalytics.increment("request.completed", "all")
+
+ assert [%DailyMetric{metric: "request.created", dimension: "today", count: 2}] =
+ ProductAnalytics.paginate(analyst,
+ metric: "request.created",
+ dimension: "today"
+ ).entries
+
+ today = Date.utc_today()
+
+ assert %{
+ total: 4,
+ registrations: 1,
+ requests_created: 2,
+ requests_completed: 1,
+ trend: [%{date: ^today, count: 4}]
+ } = ProductAnalytics.overview(analyst, date_from: today, date_to: today)
+ 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 30b3807..dc02d79 100644
--- a/test/who_need_help_web/live/admin_live_test.exs
+++ b/test/who_need_help_web/live/admin_live_test.exs
@@ -102,6 +102,29 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
{:ok, _audit_view, audit_html} = live(conn, ~p"/admin/audit")
assert audit_html =~ "user.staff_roles_changed"
assert audit_html =~ "user.moderated"
+ assert audit_html =~ "Details"
+ assert audit_html =~ ~s( render_click()
assert evidence_html =~ "Evidence message"
+
+ proposal_html = render_patch(view, ~p"/moderation?queue=proposals")
+ assert proposal_html =~ "Bicycle repair"
end
test "message reports expose parent activity moderation and switch to restore", %{
@@ -1615,7 +1617,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
grant_staff_roles(user, [:moderator])
subject = user_fixture(display_name: "Signal subject")
- for sequence <- 1..25 do
+ for sequence <- 1..26 do
%AbuseSignal{}
|> AbuseSignal.changeset(%{
kind: :manual,
@@ -1625,29 +1627,23 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> Repo.insert!()
end
- {:ok, view, _html} = live(conn, ~p"/moderation")
+ {:ok, view, html} = live(conn, ~p"/moderation?queue=signals")
- assert has_element?(
- view,
- "#abuse-signals > button[phx-click='load-more-signals']"
- )
+ assert html =~ "Automated signals are review prompts, not proof of abuse."
+ assert length(Regex.scan(~r/id="signal-[0-9a-f-]{36}"/, html)) == 25
+ assert has_element?(view, ~s|button[phx-click="next-page"]:not([disabled])|)
- refute has_element?(view, "#report-evidence button[phx-click='load-more-signals']")
+ refute has_element?(view, "#report-evidence")
view
- |> element("#abuse-signals > button[phx-click='load-more-signals']")
+ |> element(~s(button[phx-click="next-page"]))
|> render_click()
- assert view
- |> render()
- |> LazyHTML.from_document()
- |> LazyHTML.query("#abuse-signals article")
- |> Enum.count() == 25
+ page_two = render(view)
+ assert page_two =~ "Page 2"
+ assert length(Regex.scan(~r/id="signal-[0-9a-f-]{36}"/, page_two)) == 1
- refute has_element?(
- view,
- "#abuse-signals > button[phx-click='load-more-signals']"
- )
+ assert has_element?(view, ~s|button[phx-click="previous-page"]:not([disabled])|)
end
test "request page exposes report and block controls for another user", %{conn: conn} do