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

View File

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

View File

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

View File

@ -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"""
<AdminComponents.shell
flash={@flash}
@ -71,7 +147,7 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
title={gettext("Audit log")}
description={
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}
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}
/>
</.form>
<div class="space-y-3 md:hidden">
<p
:if={@events == []}
class="rounded-3xl border border-dashed border-base-300 p-10 text-center text-base-content/60"
>
<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("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.")}
</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">
<table class="table">
<thead>
<tr>
<th>{gettext("Time")}</th><th>{gettext("Actor")}</th><th>{gettext("Action")}</th><th>
{gettext("Target")}
</th><th>{gettext("Metadata")}</th>
</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">
<div :if={@events != []} class="divide-y divide-base-300">
<details :for={event <- @events} id={"audit-event-#{event.id}"} class="group">
<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">
<time class="text-xs text-base-content/60">{format_datetime(event.inserted_at)}</time>
<div class="min-w-0">
<p class="truncate font-semibold">{actor_label(event)}</p>
<p :if={event.actor_id} class="truncate font-mono text-[0.7rem] text-base-content/45">
{event.actor_id}
</div>
</td>
<td class="font-mono text-xs">{event.action}</td>
<td>
<span>{event.target_type}</span><div class="font-mono text-xs opacity-55">
{event.target_id}
</div>
</td>
<td>
<pre class="max-w-md whitespace-pre-wrap break-words text-xs">{Jason.encode!(event.metadata, pretty: true)}</pre>
</td>
</tr>
</tbody>
</table>
</div>
<button :if={@events_cursor} phx-click="load-more" class="btn btn-outline">{gettext(
"Load more events"
)}</button>
</p>
</div>
<code class="break-all text-xs font-bold text-primary">{event.action}</code>
<div class="min-w-0">
<p class="truncate text-sm font-semibold">{event.target_type}</p>
<p class="truncate font-mono text-[0.7rem] text-base-content/45">{event.target_id}</p>
</div>
<span class="flex items-center gap-2 text-sm font-semibold text-base-content/65">
{gettext("Details")}
<.icon name="hero-chevron-down" class="size-4 transition group-open:rotate-180" />
</span>
</summary>
<div class="border-t border-base-300 bg-base-200/50 p-4">
<div class="mb-3 grid gap-3 text-sm sm:grid-cols-2 lg:hidden">
<p><strong>{gettext("Actor")}:</strong> {actor_label(event)}</p>
<p class="break-all">
<strong>{gettext("Target")}:</strong> {event.target_type}: {event.target_id}
</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>
"""
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 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"""
<AdminComponents.shell
flash={@flash}
@ -43,45 +169,155 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
title={gettext("Product analytics")}
description={
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">
<table class="table">
<thead>
<tr>
<th>{gettext("Date")}</th>
<th>{gettext("Metric")}</th>
<th>{gettext("Dimension")}</th>
<th>{gettext("Count")}</th>
</tr>
</thead>
<tbody>
<tr :if={@metrics == []}>
<td colspan="4" class="py-10 text-center text-base-content/60">
{gettext("No aggregate events recorded yet.")}
</td>
</tr>
<tr :for={metric <- @metrics}>
<td>{metric.date}</td>
<td class="font-mono text-xs">{metric.metric}</td>
<td>{metric.dimension}</td>
<td class="font-bold">{metric.count}</td>
</tr>
</tbody>
</table>
</div>
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<.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"
/>
</section>
<button
:if={@metrics_cursor}
type="button"
phx-click="load-more"
class="btn btn-outline btn-sm"
<.form
for={@filter_form}
id="analytics-filters"
phx-change="filter"
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")}
</button>
<.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}
/>
</.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>
"""
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

View File

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

View File

@ -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(<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
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")
assert html =~ "Moderation"
assert html =~ "Trust and safety"
assert html =~ "Reports"
assert html =~ "Open abuse signals"
assert html =~ "Abuse signals"
assert html =~ "Category proposals"
refute html =~ "Users & roles"
assert html =~ "Please review the matched conversation."
assert html =~ "Bicycle repair"
refute has_element?(view, "#moderation-user-status-#{user.id}")
assert has_element?(
@ -1552,6 +1551,9 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> 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