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

+ + {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("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("No open signals.")} -

-
- -
- -
-

{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)} +
+
+ + +
+ +
""" 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