Redesign moderation analytics and audit workspaces

This commit is contained in:
SimpleTest 2026-08-01 19:34:43 +03:00
parent 76e1ab7fd8
commit e499ed7553
9 changed files with 1609 additions and 563 deletions

View File

@ -190,6 +190,9 @@ defmodule WhoNeedHelp.Catalog do
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
CategoryProposal 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) |> before_proposal(cursor)
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id) |> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
@ -1004,6 +1007,34 @@ defmodule WhoNeedHelp.Catalog do
}) })
end 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, nil), do: query
defp before_proposal(query, {inserted_at, id}) do defp before_proposal(query, {inserted_at, id}) do

View File

@ -76,6 +76,10 @@ defmodule WhoNeedHelp.ProductAnalytics do
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
DailyMetric 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) |> before(cursor)
|> order_by([metric], desc: metric.inserted_at, desc: metric.id) |> order_by([metric], desc: metric.inserted_at, desc: metric.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
@ -88,6 +92,51 @@ defmodule WhoNeedHelp.ProductAnalytics do
def allowed_metrics, do: @allowed_metrics 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 defp synthetic_email?(email) do
email email
|> String.trim() |> String.trim()
@ -95,6 +144,22 @@ defmodule WhoNeedHelp.ProductAnalytics do
|> String.ends_with?(@synthetic_email_suffix) |> String.ends_with?(@synthetic_email_suffix)
end 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, nil), do: query
defp before(query, {inserted_at, id}) do defp before(query, {inserted_at, id}) do
@ -105,4 +170,7 @@ defmodule WhoNeedHelp.ProductAnalytics do
(metric.inserted_at == ^inserted_at and metric.id < ^id) (metric.inserted_at == ^inserted_at and metric.id < ^id)
) )
end end
defp normalize_count(%Decimal{} = count), do: Decimal.to_integer(count)
defp normalize_count(count) when is_integer(count), do: count
end end

View File

@ -338,6 +338,8 @@ defmodule WhoNeedHelp.Trust do
Report Report
|> maybe_status(status) |> maybe_status(status)
|> maybe_report_reason(Keyword.get(options, :reason))
|> maybe_report_search(Keyword.get(options, :search))
|> before_report(cursor) |> before_report(cursor)
|> order_by([report], desc: report.inserted_at, desc: report.id) |> order_by([report], desc: report.inserted_at, desc: report.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
@ -672,7 +674,9 @@ defmodule WhoNeedHelp.Trust do
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
AbuseSignal 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) |> before_abuse_signal(cursor)
|> order_by([signal], desc: signal.inserted_at, desc: signal.id) |> order_by([signal], desc: signal.inserted_at, desc: signal.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
@ -936,6 +940,7 @@ defmodule WhoNeedHelp.Trust do
AuditEvent AuditEvent
|> maybe_audit_action(Keyword.get(options, :action)) |> maybe_audit_action(Keyword.get(options, :action))
|> maybe_audit_actor(Keyword.get(options, :actor_id)) |> maybe_audit_actor(Keyword.get(options, :actor_id))
|> maybe_audit_target_type(Keyword.get(options, :target_type))
|> maybe_audit_search(Keyword.get(options, :search)) |> maybe_audit_search(Keyword.get(options, :search))
|> before_audit_event(cursor) |> before_audit_event(cursor)
|> order_by([event], desc: event.inserted_at, desc: event.id) |> order_by([event], desc: event.inserted_at, desc: event.id)
@ -972,6 +977,11 @@ defmodule WhoNeedHelp.Trust do
end end
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) when value in [nil, ""], do: query
defp maybe_audit_search(query, value) do defp maybe_audit_search(query, value) do
@ -1325,8 +1335,58 @@ defmodule WhoNeedHelp.Trust do
end end
defp maybe_status(query, nil), do: query 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_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 defp clear_pair_tracking(first_user_id, second_user_id) do
assignments = assignments =
Assignment Assignment

View File

@ -4,65 +4,141 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
alias WhoNeedHelp.Trust alias WhoNeedHelp.Trust
alias WhoNeedHelpWeb.AdminComponents alias WhoNeedHelpWeb.AdminComponents
@page_sizes ~w(25 50 100)
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
filters = %{"search" => "", "action" => "", "actor_id" => ""} filters = %{
"search" => "",
"action" => "",
"target_type" => "",
"actor_id" => "",
"limit" => "25"
}
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Audit log")) |> assign(:page_title, gettext("Audit log"))
|> assign(:filters, filters) |> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filters)) |> assign(:filter_form, to_form(filters, as: :filters))
|> assign(:page_cursor, nil)
|> assign(:cursor_history, [])
|> assign(:page_number, 1)
|> load_events()} |> load_events()}
end end
@impl true @impl true
def handle_event("filter", %{"filters" => filters}, socket) do def handle_event("filter", %{"filters" => params}, socket) do
filters = Map.merge(socket.assigns.filters, filters) filters = normalize_filters(params)
{:noreply, {:noreply,
socket socket
|> assign(:filters, filters) |> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filters)) |> assign(:filter_form, to_form(filters, as: :filters))
|> reset_pagination()
|> load_events()} |> load_events()}
end end
def handle_event("load-more", _params, socket) do def handle_event("next-page", _params, %{assigns: %{next_cursor: nil}} = socket),
page = do: {:noreply, socket}
Trust.paginate_audit_events(
socket.assigns.current_scope,
event_options(socket, socket.assigns.events_cursor)
)
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, {:noreply,
socket socket
|> assign( |> assign(:cursor_history, history)
:events, |> assign(:page_cursor, previous_cursor)
socket.assigns.events ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id)) |> assign(:page_number, max(socket.assigns.page_number - 1, 1))
) |> load_events()}
|> assign(:events_cursor, page.next_cursor)}
end end
defp load_events(socket) do defp load_events(socket) do
page = Trust.paginate_audit_events(socket.assigns.current_scope, event_options(socket)) page =
socket |> assign(:events, page.entries) |> assign(:events_cursor, page.next_cursor) 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 end
defp event_options(socket, cursor \\ nil) do defp event_options(filters, cursor) do
filters = socket.assigns.filters
[ [
search: filters["search"], search: filters["search"],
action: filters["action"], action: filters["action"],
target_type: filters["target_type"],
actor_id: filters["actor_id"], actor_id: filters["actor_id"],
after: cursor after: cursor,
limit: page_size(filters["limit"])
] ]
end 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 @impl true
def render(assigns) do 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""" ~H"""
<AdminComponents.shell <AdminComponents.shell
flash={@flash} flash={@flash}
@ -71,7 +147,7 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
title={gettext("Audit log")} title={gettext("Audit log")}
description={ description={
gettext( gettext(
"Immutable records of sensitive staff and trust actions. Metadata is shown exactly as stored." "Immutable records of sensitive staff and trust actions. Details stay collapsed until an authorised operator needs them."
) )
} }
> >
@ -79,12 +155,12 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
for={@filter_form} for={@filter_form}
id="admin-audit-filters" id="admin-audit-filters"
phx-change="filter" 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 <.input
field={@filter_form[:search]} field={@filter_form[:search]}
type="search" type="search"
label={gettext("Target or action contains")} label={gettext("Search target or action")}
phx-debounce="300" phx-debounce="300"
/> />
<.input <.input
@ -93,93 +169,100 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
placeholder="user.staff_roles_changed" placeholder="user.staff_roles_changed"
phx-debounce="300" 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[:actor_id]} label={gettext("Actor UUID")} phx-debounce="300" />
<.input
field={@filter_form[:limit]}
type="select"
label={gettext("Rows")}
options={@page_sizes}
/>
</.form> </.form>
<div class="space-y-3 md:hidden"> <section class="rounded-2xl border border-base-300 bg-base-100">
<p <div class="flex flex-wrap items-center justify-between gap-3 border-b border-base-300 p-4">
:if={@events == []} <div>
class="rounded-3xl border border-dashed border-base-300 p-10 text-center text-base-content/60" <h2 class="font-black">{gettext("Recorded events")}</h2>
> <p class="text-sm text-base-content/55">
{gettext(
"Actor, action, and target remain visible; technical metadata opens on demand."
)}
</p>
</div>
<span :if={@events != []} class="badge badge-outline">
{gettext("Rows %{first}–%{last}", first: elem(@range, 0), last: elem(@range, 1))}
</span>
</div>
<p :if={@events == []} class="p-10 text-center text-base-content/60">
{gettext("No audit events match these filters.")} {gettext("No audit events match these filters.")}
</p> </p>
<article
:for={event <- @events}
class="rounded-3xl border border-base-300 bg-base-100 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<code class="break-all text-xs font-bold text-primary">{event.action}</code>
<time class="text-xs text-base-content/55">{event.inserted_at}</time>
</div>
<dl class="mt-4 grid gap-3 text-sm">
<div>
<dt class="text-xs font-bold uppercase tracking-wide text-base-content/45">
{gettext("Actor")}
</dt>
<dd class="mt-1 break-all">
{(event.actor && (event.actor.display_name || event.actor.email)) || gettext("System")}
</dd>
</div>
<div>
<dt class="text-xs font-bold uppercase tracking-wide text-base-content/45">
{gettext("Target")}
</dt>
<dd class="mt-1 break-all font-mono text-xs">
{event.target_type}: {event.target_id}
</dd>
</div>
<div>
<dt class="text-xs font-bold uppercase tracking-wide text-base-content/45">
{gettext("Metadata")}
</dt>
<dd>
<pre class="mt-1 whitespace-pre-wrap break-words rounded-2xl bg-base-200 p-3 text-xs">{Jason.encode!(event.metadata, pretty: true)}</pre>
</dd>
</div>
</dl>
</article>
</div>
<div class="hidden overflow-x-auto rounded-3xl border border-base-300 bg-base-100 md:block"> <div :if={@events != []} class="divide-y divide-base-300">
<table class="table"> <details :for={event <- @events} id={"audit-event-#{event.id}"} class="group">
<thead> <summary class="grid cursor-pointer list-none gap-3 p-4 marker:hidden lg:grid-cols-[11rem_minmax(10rem,0.75fr)_minmax(13rem,1fr)_minmax(12rem,0.8fr)_auto] lg:items-center">
<tr> <time class="text-xs text-base-content/60">{format_datetime(event.inserted_at)}</time>
<th>{gettext("Time")}</th><th>{gettext("Actor")}</th><th>{gettext("Action")}</th><th> <div class="min-w-0">
{gettext("Target")} <p class="truncate font-semibold">{actor_label(event)}</p>
</th><th>{gettext("Metadata")}</th> <p :if={event.actor_id} class="truncate font-mono text-[0.7rem] text-base-content/45">
</tr>
</thead>
<tbody>
<tr :if={@events == []}>
<td colspan="5" class="py-10 text-center text-base-content/60">
{gettext("No audit events match these filters.")}
</td>
</tr>
<tr :for={event <- @events}>
<td class="whitespace-nowrap text-xs">{event.inserted_at}</td>
<td>
<span class="font-semibold">{(event.actor &&
(event.actor.display_name || event.actor.email)) ||
gettext("System")}</span><div :if={event.actor} class="text-xs opacity-55">
{event.actor_id} {event.actor_id}
</div> </p>
</td> </div>
<td class="font-mono text-xs">{event.action}</td> <code class="break-all text-xs font-bold text-primary">{event.action}</code>
<td> <div class="min-w-0">
<span>{event.target_type}</span><div class="font-mono text-xs opacity-55"> <p class="truncate text-sm font-semibold">{event.target_type}</p>
{event.target_id} <p class="truncate font-mono text-[0.7rem] text-base-content/45">{event.target_id}</p>
</div> </div>
</td> <span class="flex items-center gap-2 text-sm font-semibold text-base-content/65">
<td> {gettext("Details")}
<pre class="max-w-md whitespace-pre-wrap break-words text-xs">{Jason.encode!(event.metadata, pretty: true)}</pre> <.icon name="hero-chevron-down" class="size-4 transition group-open:rotate-180" />
</td> </span>
</tr> </summary>
</tbody> <div class="border-t border-base-300 bg-base-200/50 p-4">
</table> <div class="mb-3 grid gap-3 text-sm sm:grid-cols-2 lg:hidden">
</div> <p><strong>{gettext("Actor")}:</strong> {actor_label(event)}</p>
<button :if={@events_cursor} phx-click="load-more" class="btn btn-outline">{gettext( <p class="break-all">
"Load more events" <strong>{gettext("Target")}:</strong> {event.target_type}: {event.target_id}
)}</button> </p>
</div>
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-base-content/50">
{gettext("Stored metadata")}
</p>
<pre class="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-2xl border border-base-300 bg-base-100 p-3 text-xs">{Jason.encode!(event.metadata, pretty: true)}</pre>
</div>
</details>
</div>
</section>
<nav
aria-label={gettext("Audit pages")}
class="flex items-center justify-between rounded-2xl border border-base-300 bg-base-100 p-3"
>
<button
type="button"
phx-click="previous-page"
disabled={@cursor_history == []}
class="btn btn-sm btn-outline"
>
<.icon name="hero-arrow-left" class="size-4" />
<span class="hidden sm:inline">{gettext("Previous")}</span>
</button>
<span class="text-sm font-bold">{gettext("Page %{page}", page: @page_number)}</span>
<button
type="button"
phx-click="next-page"
disabled={is_nil(@next_cursor)}
class="btn btn-sm btn-outline"
>
<span class="hidden sm:inline">{gettext("Next")}</span>
<.icon name="hero-arrow-right" class="size-4" />
</button>
</nav>
</AdminComponents.shell> </AdminComponents.shell>
""" """
end end

File diff suppressed because it is too large Load Diff

View File

@ -4,37 +4,163 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
alias WhoNeedHelp.ProductAnalytics alias WhoNeedHelp.ProductAnalytics
alias WhoNeedHelpWeb.AdminComponents alias WhoNeedHelpWeb.AdminComponents
@page_sizes ~w(25 50 100)
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
page = ProductAnalytics.paginate(socket.assigns.current_scope) filters = %{
"metric" => "",
"dimension" => "",
"date_from" => "",
"date_to" => "",
"limit" => "25"
}
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Product analytics")) |> assign(:page_title, gettext("Product analytics"))
|> assign(:metrics, page.entries) |> assign(:filters, filters)
|> assign(:metrics_cursor, page.next_cursor)} |> assign(:filter_form, to_form(filters, as: :filters))
|> assign(:page_cursor, nil)
|> assign(:cursor_history, [])
|> assign(:page_number, 1)
|> load_metrics()}
end end
@impl true @impl true
def handle_event("load-more", _params, socket) do def handle_event("filter", %{"filters" => params}, socket) do
page = filters = normalize_filters(params)
ProductAnalytics.paginate(socket.assigns.current_scope,
after: socket.assigns.metrics_cursor
)
existing_ids = MapSet.new(socket.assigns.metrics, & &1.id)
{:noreply, {:noreply,
socket socket
|> assign( |> assign(:filters, filters)
:metrics, |> assign(:filter_form, to_form(filters, as: :filters))
socket.assigns.metrics ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id)) |> reset_pagination()
) |> load_metrics()}
|> assign(:metrics_cursor, page.next_cursor)}
end 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 @impl true
def render(assigns) do 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""" ~H"""
<AdminComponents.shell <AdminComponents.shell
flash={@flash} flash={@flash}
@ -43,45 +169,155 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
title={gettext("Product analytics")} title={gettext("Product analytics")}
description={ description={
gettext( gettext(
"Daily aggregate counters only. No user ID, email, coordinate, request text, chat text, medicine name, or device credential is stored here." "Privacy-preserving daily totals. This workspace never stores people, email, coordinates, request text, chat content, medicine names, or device credentials."
) )
} }
> >
<div class="overflow-x-auto rounded-3xl border border-base-300 bg-base-100"> <section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<table class="table"> <.summary_card
<thead> label={gettext("All measured actions")}
<tr> value={@overview.total}
<th>{gettext("Date")}</th> icon="hero-chart-bar"
<th>{gettext("Metric")}</th> />
<th>{gettext("Dimension")}</th> <.summary_card
<th>{gettext("Count")}</th> label={gettext("Registrations")}
</tr> value={@overview.registrations}
</thead> icon="hero-user-plus"
<tbody> />
<tr :if={@metrics == []}> <.summary_card
<td colspan="4" class="py-10 text-center text-base-content/60"> label={gettext("Requests created")}
{gettext("No aggregate events recorded yet.")} value={@overview.requests_created}
</td> icon="hero-hand-raised"
</tr> />
<tr :for={metric <- @metrics}> <.summary_card
<td>{metric.date}</td> label={gettext("Requests completed")}
<td class="font-mono text-xs">{metric.metric}</td> value={@overview.requests_completed}
<td>{metric.dimension}</td> icon="hero-check-circle"
<td class="font-bold">{metric.count}</td> />
</tr> </section>
</tbody>
</table>
</div>
<button <.form
:if={@metrics_cursor} for={@filter_form}
type="button" id="analytics-filters"
phx-click="load-more" phx-change="filter"
class="btn btn-outline btn-sm" class="grid gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 sm:grid-cols-2 xl:grid-cols-[minmax(12rem,1fr)_minmax(10rem,0.8fr)_11rem_11rem_7rem]"
> >
{gettext("Load more")} <.input
</button> 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}
/>
</.form>
<section class="rounded-2xl border border-base-300 bg-base-100">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-base-300 p-4">
<div>
<h2 class="font-black">{gettext("Daily counters")}</h2>
<p class="text-sm text-base-content/55">
{gettext("One row per date, metric, and non-identifying dimension.")}
</p>
</div>
<span :if={@metrics != []} class="badge badge-outline">
{gettext("Rows %{first}–%{last}", first: elem(@range, 0), last: elem(@range, 1))}
</span>
</div>
<p :if={@metrics == []} class="p-10 text-center text-base-content/60">
{gettext("No aggregate counters match these filters.")}
</p>
<div :if={@metrics != []} class="divide-y divide-base-300 md:hidden">
<article :for={metric <- @metrics} class="grid grid-cols-[1fr_auto] gap-3 p-4">
<div class="min-w-0">
<p class="truncate font-bold capitalize">{metric_label(metric.metric)}</p>
<p class="mt-1 text-sm text-base-content/55">{metric.date} · {metric.dimension}</p>
</div>
<strong class="text-lg">{compact_number(metric.count)}</strong>
</article>
</div>
<div :if={@metrics != []} class="hidden overflow-x-auto md:block">
<table class="table">
<thead>
<tr>
<th>{gettext("Date")}</th><th>{gettext("Metric")}</th><th>{gettext("Dimension")}</th><th class="text-right">
{gettext("Count")}
</th>
</tr>
</thead>
<tbody>
<tr :for={metric <- @metrics}>
<td class="whitespace-nowrap">{metric.date}</td>
<td class="font-semibold capitalize">{metric_label(metric.metric)}</td>
<td><span class="badge badge-ghost">{metric.dimension}</span></td>
<td class="text-right text-lg font-black">{metric.count}</td>
</tr>
</tbody>
</table>
</div>
</section>
<nav
aria-label={gettext("Analytics pages")}
class="flex items-center justify-between rounded-2xl border border-base-300 bg-base-100 p-3"
>
<button
type="button"
phx-click="previous-page"
disabled={@cursor_history == []}
class="btn btn-sm btn-outline"
>
<.icon name="hero-arrow-left" class="size-4" />
<span class="hidden sm:inline">{gettext("Previous")}</span>
</button>
<span class="text-sm font-bold">{gettext("Page %{page}", page: @page_number)}</span>
<button
type="button"
phx-click="next-page"
disabled={is_nil(@next_cursor)}
class="btn btn-sm btn-outline"
>
<span class="hidden sm:inline">{gettext("Next")}</span>
<.icon name="hero-arrow-right" class="size-4" />
</button>
</nav>
</AdminComponents.shell> </AdminComponents.shell>
""" """
end end
attr :label, :string, required: true
attr :value, :integer, required: true
attr :icon, :string, required: true
defp summary_card(assigns) do
~H"""
<article class="flex items-center gap-4 rounded-2xl border border-base-300 bg-base-100 p-4">
<span class="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary/10 text-primary">
<.icon name={@icon} class="size-5" />
</span>
<div class="min-w-0">
<p class="truncate text-sm text-base-content/55">{@label}</p>
<p class="text-2xl font-black">{compact_number(@value)}</p>
</div>
</article>
"""
end
end end

View File

@ -43,4 +43,29 @@ defmodule WhoNeedHelp.ProductAnalyticsTest do
refute Repo.get_by(DailyMetric, metric: "request.created", dimension: "now") refute Repo.get_by(DailyMetric, metric: "request.created", dimension: "now")
end 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 end

View File

@ -102,6 +102,29 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
{:ok, _audit_view, audit_html} = live(conn, ~p"/admin/audit") {:ok, _audit_view, audit_html} = live(conn, ~p"/admin/audit")
assert audit_html =~ "user.staff_roles_changed" assert audit_html =~ "user.staff_roles_changed"
assert audit_html =~ "user.moderated" assert audit_html =~ "user.moderated"
assert audit_html =~ "Details"
assert audit_html =~ ~s(<details id="audit-event-)
end
test "administrator workspaces expose compact filters and paginated queues", %{conn: conn} do
admin = staff_user_fixture([:admin], display_name: "Workspace administrator")
conn = log_in_user(conn, admin)
{:ok, moderation, moderation_html} = live(conn, ~p"/moderation")
assert moderation_html =~ "Work one review queue at a time"
assert has_element?(moderation, "#report-filters")
assert has_element?(moderation, ~s(a[href="/moderation?queue=signals"]))
assert has_element?(moderation, ~s(a[href="/moderation?queue=proposals"]))
{:ok, analytics, analytics_html} = live(conn, ~p"/analytics")
assert analytics_html =~ "Privacy-preserving daily totals"
assert has_element?(analytics, "#analytics-filters")
assert analytics_html =~ "Daily counters"
{:ok, audit, audit_html} = live(conn, ~p"/admin/audit")
assert has_element?(audit, "#admin-audit-filters")
assert audit_html =~ "Recorded events"
assert audit_html =~ "Page 1"
end end
test "administrator UI protects the final active administrator", %{conn: conn} do test "administrator UI protects the final active administrator", %{conn: conn} do

View File

@ -1532,13 +1532,12 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
}) })
{:ok, view, html} = live(conn, ~p"/moderation") {:ok, view, html} = live(conn, ~p"/moderation")
assert html =~ "Moderation" assert html =~ "Trust and safety"
assert html =~ "Reports" assert html =~ "Reports"
assert html =~ "Open abuse signals" assert html =~ "Abuse signals"
assert html =~ "Category proposals" assert html =~ "Category proposals"
refute html =~ "Users & roles" refute html =~ "Users & roles"
assert html =~ "Please review the matched conversation." assert html =~ "Please review the matched conversation."
assert html =~ "Bicycle repair"
refute has_element?(view, "#moderation-user-status-#{user.id}") refute has_element?(view, "#moderation-user-status-#{user.id}")
assert has_element?( assert has_element?(
@ -1552,6 +1551,9 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> render_click() |> render_click()
assert evidence_html =~ "Evidence message" assert evidence_html =~ "Evidence message"
proposal_html = render_patch(view, ~p"/moderation?queue=proposals")
assert proposal_html =~ "Bicycle repair"
end end
test "message reports expose parent activity moderation and switch to restore", %{ test "message reports expose parent activity moderation and switch to restore", %{
@ -1615,7 +1617,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
grant_staff_roles(user, [:moderator]) grant_staff_roles(user, [:moderator])
subject = user_fixture(display_name: "Signal subject") subject = user_fixture(display_name: "Signal subject")
for sequence <- 1..25 do for sequence <- 1..26 do
%AbuseSignal{} %AbuseSignal{}
|> AbuseSignal.changeset(%{ |> AbuseSignal.changeset(%{
kind: :manual, kind: :manual,
@ -1625,29 +1627,23 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> Repo.insert!() |> Repo.insert!()
end end
{:ok, view, _html} = live(conn, ~p"/moderation") {:ok, view, html} = live(conn, ~p"/moderation?queue=signals")
assert has_element?( assert html =~ "Automated signals are review prompts, not proof of abuse."
view, assert length(Regex.scan(~r/id="signal-[0-9a-f-]{36}"/, html)) == 25
"#abuse-signals > button[phx-click='load-more-signals']" 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 view
|> element("#abuse-signals > button[phx-click='load-more-signals']") |> element(~s(button[phx-click="next-page"]))
|> render_click() |> render_click()
assert view page_two = render(view)
|> render() assert page_two =~ "Page 2"
|> LazyHTML.from_document() assert length(Regex.scan(~r/id="signal-[0-9a-f-]{36}"/, page_two)) == 1
|> LazyHTML.query("#abuse-signals article")
|> Enum.count() == 25
refute has_element?( assert has_element?(view, ~s|button[phx-click="previous-page"]:not([disabled])|)
view,
"#abuse-signals > button[phx-click='load-more-signals']"
)
end end
test "request page exposes report and block controls for another user", %{conn: conn} do test "request page exposes report and block controls for another user", %{conn: conn} do