feat: add scoped activity reports

This commit is contained in:
SimpleTest 2026-07-18 20:48:31 +03:00
parent 633fa6cf34
commit 2a1a2d93eb
11 changed files with 457 additions and 13 deletions

View File

@ -20,7 +20,8 @@ thank-you link; money goes directly between users outside the platform.
- Separate social Activity mode for coffee, cinema, walks, and hikes, with
organizer-approved membership, capacity-safe joins, private group chat, and
exact coordinates visible only to approved participants. Activities never
affect urgent-helper reputation.
affect urgent-helper reputation; Activity and Activity-message reports expose
only the linked evidence to audited moderators.
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel
and expiry paths.
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix

View File

@ -99,6 +99,11 @@ report. Every access records the moderator, report, linked assignment, message
count, and timestamp. The report itself contains the reason. Browsing private
conversations without a qualifying report is not a product capability.
The same boundary applies to Activity group chat. Reporting an Activity itself
does not reveal its private chat. Only a report targeting a specific Activity
message permits the moderator to load that Activity's group conversation, and
the evidence access audit records the Activity and message count.
Report, request, signal, category, role, and account moderation are role
protected. The local Codex
batch receives proposal text and aggregate vote counts only. It receives no

View File

@ -10,7 +10,7 @@ results from product limits and unknown production properties.
| Urgent medicine-help flow | Implemented and tested | Request creation, discovery, matching, start, handover, two-party completion, and review rules are covered by the Phoenix test suite and exercised in the local UI. | The product coordinates pickup of an already purchased or reserved legal item; it is not a pharmacy, medical, or emergency service. |
| Urgent roadside help | Implemented and tested | Fuel, car wheel, bicycle, motorcycle, vehicle-breakdown, and secured-incident categories are seeded as a translated hierarchy. Server and LiveView tests exercise category paths, required fields, allowed values, boolean normalization, unknown-field rejection, and request creation. | Roadside requests require no immediate danger; this is not emergency response or professional recovery. |
| Extensible categories | Implemented and tested | Categories and validated text/select/boolean fields are stored in PostgreSQL. Proposal, vote, approve, reject, and merge paths have automated tests. | Coffee, cinema, hiking, and other social activities remain separate from urgent-help safety and ranking rules. |
| Separate Activity mode | Implemented and tested | Coffee, cinema, walk, and hiking categories use a separate activity lifecycle. Domain and two-client LiveView tests cover creation, join request, organizer approval, capacity enforcement, public/pending/chat privacy, exact-location disclosure to approved users, group chat, blocking, completion, and zero impact on helper reputation. | Activity reporting and moderator evidence are handled as part of the broader trust/moderation rollout; this does not guarantee participant identity or physical safety. |
| Separate Activity mode | Implemented and tested | Coffee, cinema, walk, and hiking categories use a separate activity lifecycle. Domain and two-client LiveView tests cover creation, join request, organizer approval, capacity enforcement, public/pending/chat privacy, exact-location disclosure to approved users, group chat, blocking, completion, and zero impact on helper reputation. Activity and message reports expose only the linked group conversation to an audited moderator; moderators can hide and restore reported activities. | This does not guarantee participant identity or physical safety. |
| Map and discovery | Implemented and browser-verified | A headed Chrome session rendered the MapLibre request map, marker, controls, attribution, and configured OpenStreetMap raster tiles. Tile requests returned HTTP 200 during the check. | A production operator must configure a tile provider appropriate for its policy and traffic. |
| Private matched chat | Implemented and cross-client verified | A message sent from the helper browser appeared in the requester's browser without reload. An earlier Android emulator run also sent a message that appeared in the requester browser in real time. | There is no unsolicited general-purpose inbox. |
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
@ -23,10 +23,13 @@ results from product limits and unknown production properties.
## Reproducible checks
- `./scripts/test.sh`: 134 tests, 0 failures after the Activity rollout
- `./scripts/test.sh`: 136 tests, 0 failures after scoped Activity reporting
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
- The generated Activity migration was rolled back by exactly one step and
migrated forward again against `who_need_help_test`; both directions passed.
- The Activity-report migration was also rolled back and migrated forward. The
observed database constraint changed from exactly one of 3 urgent-help
targets to exactly one of 5 urgent-help/Activity targets.
- `mix format --check-formatted`: passed in the final run.
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
`assembleDebug` passed; the final lint report contains no errors or warnings.

View File

@ -20,6 +20,13 @@ defmodule WhoNeedHelp.Activities do
def subscribe_activity(id),
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "activity:#{id}")
def notify_activity_updated(id) do
activity = load_activity(id)
broadcast({:activity_updated, activity})
broadcast_activity(id, {:activity_updated, activity})
:ok
end
def list_open_activities(%Scope{user: user}, filters \\ %{}) do
now = DateTime.utc_now(:second)

View File

@ -5,6 +5,9 @@ defmodule WhoNeedHelp.Trust do
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.Activities
alias WhoNeedHelp.Activities.{Activity, Participant}
alias WhoNeedHelp.Activities.Message, as: ActivityMessage
alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
alias WhoNeedHelp.Messaging.Message
@ -203,7 +206,9 @@ defmodule WhoNeedHelp.Trust do
:reviewed_by,
request: :requester,
assignment: :helper,
message: :sender
message: :sender,
activity: :creator,
activity_message: :sender
])
|> Repo.all()
else
@ -243,7 +248,14 @@ defmodule WhoNeedHelp.Trust do
report =
Report
|> Repo.get(report_id)
|> Repo.preload([:reporter, :request, :assignment, message: :assignment])
|> Repo.preload([
:reporter,
:request,
:assignment,
:activity,
message: :assignment,
activity_message: :activity
])
if report do
assignment_id =
@ -264,12 +276,31 @@ defmodule WhoNeedHelp.Trust do
[]
end
activity_id =
case report.activity_message do
%ActivityMessage{activity_id: activity_id} -> activity_id
_ -> nil
end
activity_messages =
if activity_id do
ActivityMessage
|> where([message], message.activity_id == ^activity_id)
|> order_by([message], asc: message.inserted_at)
|> preload(:sender)
|> Repo.all()
else
[]
end
with {:ok, _audit} <-
audit(moderator.id, "report.evidence_viewed", "report", report.id, %{
"assignment_id" => assignment_id,
"message_count" => length(messages)
"activity_id" => activity_id,
"message_count" => length(messages),
"activity_message_count" => length(activity_messages)
}) do
{:ok, %{report: report, messages: messages}}
{:ok, %{report: report, messages: messages, activity_messages: activity_messages}}
end
else
{:error, :not_found}
@ -498,6 +529,68 @@ defmodule WhoNeedHelp.Trust do
end
end
def hide_activity(%Scope{user: moderator}, activity_id, reason) do
result =
if Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
activity =
Activity
|> where([activity], activity.id == ^activity_id)
|> lock("FOR UPDATE")
|> Repo.one!()
with {:ok, activity} <-
activity
|> Activity.moderation_changeset(%{
hidden_at: DateTime.utc_now(:second),
hidden_reason: reason
})
|> Repo.update() do
audit(moderator.id, "activity.hidden", "activity", activity.id, %{
"reason" => reason
})
{:ok, activity}
end
end)
else
{:error, :forbidden}
end
with {:ok, activity} <- result do
Activities.notify_activity_updated(activity.id)
{:ok, activity}
end
end
def restore_activity(%Scope{user: moderator}, activity_id) do
result =
if Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
activity =
Activity
|> where([activity], activity.id == ^activity_id)
|> lock("FOR UPDATE")
|> Repo.one!()
with {:ok, activity} <-
activity
|> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
|> Repo.update() do
audit(moderator.id, "activity.restored", "activity", activity.id)
{:ok, activity}
end
end)
else
{:error, :forbidden}
end
with {:ok, activity} <- result do
Activities.notify_activity_updated(activity.id)
{:ok, activity}
end
end
def moderate_user(%Scope{user: moderator}, user_id, attrs) do
Repo.transact(fn ->
with {:ok, user} <- Accounts.moderate_user(moderator, user_id, attrs),
@ -606,8 +699,52 @@ defmodule WhoNeedHelp.Trust do
end
end
defp authorize_report_target(%Scope{user: user}, %{"activity_id" => activity_id})
when is_binary(activity_id) do
case Repo.get(Activity, activity_id) do
%Activity{creator_id: creator_id} when creator_id == user.id ->
{:error, :cannot_report_self}
%Activity{} = activity ->
if is_nil(activity.hidden_at) or activity_participant?(activity.id, user.id),
do: :ok,
else: {:error, :not_found}
nil ->
{:error, :not_found}
end
end
defp authorize_report_target(
%Scope{user: user},
%{"activity_message_id" => message_id}
)
when is_binary(message_id) do
case Repo.get(ActivityMessage, message_id) do
%ActivityMessage{sender_id: sender_id} when sender_id == user.id ->
{:error, :cannot_report_self}
%ActivityMessage{activity_id: activity_id} ->
if activity_participant?(activity_id, user.id),
do: :ok,
else: {:error, :forbidden}
nil ->
{:error, :not_found}
end
end
defp authorize_report_target(_scope, _attrs), do: {:error, :invalid_target}
defp activity_participant?(activity_id, user_id) do
Repo.exists?(
from participant in Participant,
where:
participant.activity_id == ^activity_id and participant.user_id == ^user_id and
participant.status == :approved
)
end
defp create_signal_once(kind, subject_id, assignment_id, metadata) do
query =
from signal in AbuseSignal,

View File

@ -26,12 +26,22 @@ defmodule WhoNeedHelp.Trust.Report do
belongs_to :request, WhoNeedHelp.Help.HelpRequest
belongs_to :assignment, WhoNeedHelp.Help.Assignment
belongs_to :message, WhoNeedHelp.Messaging.Message
belongs_to :activity, WhoNeedHelp.Activities.Activity
belongs_to :activity_message, WhoNeedHelp.Activities.Message
timestamps(type: :utc_datetime)
end
def changeset(report, attrs) do
report
|> cast(attrs, [:reason, :details, :request_id, :assignment_id, :message_id])
|> cast(attrs, [
:reason,
:details,
:request_id,
:assignment_id,
:message_id,
:activity_id,
:activity_message_id
])
|> validate_required([:reason, :details, :reporter_id])
|> validate_length(:details, min: 5, max: 2_000)
|> validate_single_target()
@ -47,7 +57,7 @@ defmodule WhoNeedHelp.Trust.Report do
defp validate_single_target(changeset) do
targets =
[:request_id, :assignment_id, :message_id]
[:request_id, :assignment_id, :message_id, :activity_id, :activity_message_id]
|> Enum.count(&(not is_nil(get_field(changeset, &1))))
if targets == 1,

View File

@ -1,7 +1,7 @@
defmodule WhoNeedHelpWeb.ActivityLive.Show do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.Activities
alias WhoNeedHelp.{Activities, Trust}
@impl true
def mount(%{"id" => id}, _session, socket) do
@ -82,6 +82,57 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
end
end
def handle_event("report", %{"report" => params}, socket) do
target =
if socket.assigns.report_message_id do
%{"activity_message_id" => socket.assigns.report_message_id}
else
%{"activity_id" => socket.assigns.activity.id}
end
case Trust.report(socket.assigns.current_scope, Map.merge(params, target)) do
{:ok, _report} ->
{:noreply,
socket
|> assign(:report_form, report_form())
|> assign(:report_message_id, nil)
|> put_flash(:info, "Report sent to moderators.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, error_message(reason))}
end
end
def handle_event("select-report-message", %{"id" => message_id}, socket) do
message =
Enum.find(socket.assigns.activity.messages, fn message ->
message.id == message_id and message.sender_id != socket.assigns.current_scope.user.id
end)
if message do
{:noreply, assign(socket, :report_message_id, message.id)}
else
{:noreply, put_flash(socket, :error, "That message cannot be reported from this view.")}
end
end
def handle_event("clear-report-message", _params, socket) do
{:noreply, assign(socket, :report_message_id, nil)}
end
def handle_event("block-organizer", _params, socket) do
case Trust.block(socket.assigns.current_scope, socket.assigns.activity.creator_id) do
{:ok, _block} ->
{:noreply,
socket
|> put_flash(:info, "Organizer blocked. Their activities and messages are hidden.")
|> push_navigate(to: ~p"/activities")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, error_message(reason))}
end
end
defp respond(socket, {:ok, _value}), do: {:noreply, reload(socket)}
defp respond(socket, {:error, reason}) do
@ -131,6 +182,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|> assign(:coordinates, coordinates)
|> assign(:markers, Jason.encode!(markers))
|> assign(:message_form, to_form(%{"body" => ""}, as: :activity_message))
|> assign(:report_message_id, nil)
|> assign(:report_form, report_form())
end
defp can_request_join?(activity, nil), do: activity.status == :open
@ -149,10 +202,15 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
defp ordered_messages(activity), do: Enum.sort_by(activity.messages, & &1.inserted_at, DateTime)
defp report_form do
to_form(%{"reason" => "harassment", "details" => ""}, as: :report)
end
defp error_message(:already_joined), do: "You already have a join request for this activity."
defp error_message(:capacity_reached), do: "The approved group has reached its capacity."
defp error_message(:join_closed), do: "Join requests are closed."
defp error_message(:blocked), do: "This action is unavailable."
defp error_message(:cannot_report_self), do: "You cannot report your own content."
defp error_message(:forbidden), do: "You are not allowed to do that."
defp error_message(:invalid_transition), do: "This activity state has already changed."
defp error_message(:not_open), do: "This activity is no longer open."
@ -236,8 +294,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
:for={message <- ordered_messages(@activity)}
class="rounded-2xl bg-base-100 p-4"
>
<div class="text-xs font-semibold text-info">
{message.sender.display_name || "Participant"}
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-semibold text-info">
{message.sender.display_name || "Participant"}
</div>
<button
:if={message.sender_id != @current_scope.user.id}
type="button"
phx-click="select-report-message"
phx-value-id={message.id}
class="btn btn-ghost btn-xs"
>
Report
</button>
</div>
<p class="mt-1 whitespace-pre-wrap text-sm">{message.body}</p>
</article>
@ -351,6 +420,50 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
</li>
</ul>
</section>
<section
:if={not @organizer? or @report_message_id}
class="rounded-2xl border border-error/30 p-5"
>
<h2 class="font-bold">Safety controls</h2>
<button
:if={not @organizer?}
phx-click="block-organizer"
class="btn btn-outline btn-error btn-sm mt-3 w-full"
>
Block organizer
</button>
<.form for={@report_form} phx-submit="report" class="mt-4 space-y-2">
<div
:if={@report_message_id}
class="flex items-center justify-between rounded-xl bg-warning/10 p-3 text-xs"
>
<span>The report is scoped to the selected group message.</span>
<button
type="button"
phx-click="clear-report-message"
class="btn btn-ghost btn-xs"
>
Clear
</button>
</div>
<.input
field={@report_form[:reason]}
type="select"
label="Reason"
options={[
{"Harassment", "harassment"},
{"Dangerous plan", "dangerous_request"},
{"Fraud", "fraud"},
{"Spam", "spam"},
{"Impersonation", "impersonation"},
{"Other", "other"}
]}
/>
<.input field={@report_form[:details]} type="textarea" label="What happened?" />
<.button class="btn btn-error btn-outline w-full">Send report</.button>
</.form>
</section>
</aside>
</div>
</Layouts.app>

View File

@ -66,6 +66,22 @@ defmodule WhoNeedHelpWeb.ModerationLive do
)
end
def handle_event("hide-activity", %{"id" => id, "moderation" => %{"note" => note}}, socket) do
respond(
socket,
Trust.hide_activity(socket.assigns.current_scope, id, note),
"Activity hidden."
)
end
def handle_event("restore-activity", %{"id" => id}, socket) do
respond(
socket,
Trust.restore_activity(socket.assigns.current_scope, id),
"Activity restored."
)
end
def handle_event("approve-proposal", %{"id" => id, "category" => params}, socket) do
names =
%{
@ -168,6 +184,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-error">{report.reason}</span>
<span class="badge">{report.status}</span>
<span :if={report.activity_id} class="badge badge-info badge-outline">activity</span>
<span :if={report.activity_message_id} class="badge badge-info badge-outline">
activity message
</span>
<span class="text-xs text-base-content/50">by {report.reporter.display_name}</span>
</div>
<p class="mt-3 whitespace-pre-wrap text-sm">{report.details}</p>
@ -189,6 +209,16 @@ defmodule WhoNeedHelpWeb.ModerationLive do
<.input field={hide_form[:note]} placeholder="Hide reason" />
<.button class="btn btn-sm btn-error self-end">Hide request</.button>
</.form>
<.form
:if={report.activity_id}
for={hide_form}
phx-submit="hide-activity"
phx-value-id={report.activity_id}
class="flex gap-2"
>
<.input field={hide_form[:note]} placeholder="Hide reason" />
<.button class="btn btn-sm btn-error self-end">Hide activity</.button>
</.form>
</div>
<.form
for={report_form}
@ -221,7 +251,16 @@ defmodule WhoNeedHelpWeb.ModerationLive do
<div :for={message <- @evidence.messages} class="rounded-xl bg-white/10 p-3 text-sm">
<strong>{message.sender.display_name}:</strong> {message.body}
</div>
<p :if={@evidence.messages == []} class="text-sm opacity-60">
<div
:for={message <- @evidence.activity_messages}
class="rounded-xl bg-info/20 p-3 text-sm"
>
<strong>{message.sender.display_name}:</strong> {message.body}
</div>
<p
:if={@evidence.messages == [] and @evidence.activity_messages == []}
class="text-sm opacity-60"
>
This report has no linked conversation.
</p>
</div>

View File

@ -0,0 +1,37 @@
defmodule WhoNeedHelp.Repo.Migrations.ExtendReportsForActivities do
use Ecto.Migration
def up do
execute "ALTER TABLE reports DROP CONSTRAINT IF EXISTS report_exactly_one_target"
alter table(:reports) do
add :activity_id, references(:activities, type: :binary_id, on_delete: :nilify_all)
add :activity_message_id,
references(:activity_messages, type: :binary_id, on_delete: :nilify_all)
end
create index(:reports, [:activity_id])
create index(:reports, [:activity_message_id])
create constraint(:reports, :report_exactly_one_target,
check:
"num_nonnulls(request_id, assignment_id, message_id, activity_id, activity_message_id) = 1"
)
end
def down do
execute "ALTER TABLE reports DROP CONSTRAINT IF EXISTS report_exactly_one_target"
drop index(:reports, [:activity_message_id])
drop index(:reports, [:activity_id])
alter table(:reports) do
remove :activity_message_id
remove :activity_id
end
create constraint(:reports, :report_exactly_one_target,
check: "num_nonnulls(request_id, assignment_id, message_id) = 1"
)
end
end

View File

@ -5,6 +5,8 @@ defmodule WhoNeedHelp.ActivitiesTest do
alias WhoNeedHelp.{Activities, Catalog, Trust}
alias WhoNeedHelp.Activities.Activity
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust.AuditEvent
setup do
Catalog.seed_defaults()
@ -155,4 +157,91 @@ defmodule WhoNeedHelp.ActivitiesTest do
assert {:error, :blocked} =
Activities.request_to_join(context.participant_scope, activity.id)
end
test "activity message reports expose only the linked group to audited moderators", context do
moderator =
user_fixture(display_name: "Activity moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
attrs = Map.put(context.attrs, "capacity", 3)
{:ok, activity} = Activities.create_activity(context.organizer_scope, attrs)
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
{:ok, _approved} = Activities.approve_participant(context.organizer_scope, request.id)
{:ok, organizer_message} =
Activities.send_message(context.organizer_scope, activity.id, %{
"body" => "Meet beside the public entrance."
})
{:ok, _participant_message} =
Activities.send_message(context.participant_scope, activity.id, %{
"body" => "Understood."
})
assert {:error, :forbidden} =
Trust.report(context.outsider_scope, %{
"reason" => "harassment",
"details" => "I should not see this private message.",
"activity_message_id" => organizer_message.id
})
assert {:ok, report} =
Trust.report(context.participant_scope, %{
"reason" => "harassment",
"details" => "Please review this approved group conversation.",
"activity_message_id" => organizer_message.id
})
assert {:ok, evidence} =
Trust.report_evidence(user_scope_fixture(moderator), report.id)
assert evidence.messages == []
assert Enum.map(evidence.activity_messages, & &1.body) == [
"Meet beside the public entrance.",
"Understood."
]
assert Repo.exists?(
from event in AuditEvent,
where:
event.action == "report.evidence_viewed" and event.target_id == ^report.id and
event.actor_id == ^moderator.id
)
end
test "moderators can hide and restore a reported activity", context do
moderator =
user_fixture(display_name: "Activity moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
moderator_scope = user_scope_fixture(moderator)
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
assert {:error, :cannot_report_self} =
Trust.report(context.organizer_scope, %{
"reason" => "other",
"details" => "Cannot report my own activity.",
"activity_id" => activity.id
})
assert {:ok, _report} =
Trust.report(context.outsider_scope, %{
"reason" => "dangerous_request",
"details" => "The public plan needs moderator review.",
"activity_id" => activity.id
})
assert {:ok, hidden} =
Trust.hide_activity(moderator_scope, activity.id, "Safety review")
assert hidden.hidden_at
assert {:error, :not_found} = Activities.get_activity(context.outsider_scope, activity.id)
assert {:ok, restored} = Trust.restore_activity(moderator_scope, activity.id)
assert is_nil(restored.hidden_at)
assert {:ok, _activity} = Activities.get_activity(context.outsider_scope, activity.id)
end
end

View File

@ -120,6 +120,9 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
{:ok, organizer_view, _html} = live(organizer_conn, ~p"/activities/#{activity.id}")
{:ok, participant_view, html} = live(participant_conn, ~p"/activities/#{activity.id}")
assert html =~ "Request to join"
assert html =~ "Safety controls"
assert html =~ "Block organizer"
assert html =~ "Send report"
refute html =~ "Approved group chat"
participant_view