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 - Separate social Activity mode for coffee, cinema, walks, and hikes, with
organizer-approved membership, capacity-safe joins, private group chat, and organizer-approved membership, capacity-safe joins, private group chat, and
exact coordinates visible only to approved participants. Activities never 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 - Request lifecycle: `open → matched → in_progress → completed`, plus cancel
and expiry paths. and expiry paths.
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix - 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 count, and timestamp. The report itself contains the reason. Browsing private
conversations without a qualifying report is not a product capability. 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 Report, request, signal, category, role, and account moderation are role
protected. The local Codex protected. The local Codex
batch receives proposal text and aggregate vote counts only. It receives no 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 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. | | 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. | | 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. | | 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. | | 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. | | 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 ## 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. on Elixir 1.20.2 and Erlang/OTP 29.0.3.
- The generated Activity migration was rolled back by exactly one step and - The generated Activity migration was rolled back by exactly one step and
migrated forward again against `who_need_help_test`; both directions passed. 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. - `mix format --check-formatted`: passed in the final run.
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and - Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
`assembleDebug` passed; the final lint report contains no errors or warnings. `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), def subscribe_activity(id),
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "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 def list_open_activities(%Scope{user: user}, filters \\ %{}) do
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)

View File

@ -5,6 +5,9 @@ defmodule WhoNeedHelp.Trust do
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.{Scope, User} 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
alias WhoNeedHelp.Help.{Assignment, HelpRequest} alias WhoNeedHelp.Help.{Assignment, HelpRequest}
alias WhoNeedHelp.Messaging.Message alias WhoNeedHelp.Messaging.Message
@ -203,7 +206,9 @@ defmodule WhoNeedHelp.Trust do
:reviewed_by, :reviewed_by,
request: :requester, request: :requester,
assignment: :helper, assignment: :helper,
message: :sender message: :sender,
activity: :creator,
activity_message: :sender
]) ])
|> Repo.all() |> Repo.all()
else else
@ -243,7 +248,14 @@ defmodule WhoNeedHelp.Trust do
report = report =
Report Report
|> Repo.get(report_id) |> 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 if report do
assignment_id = assignment_id =
@ -264,12 +276,31 @@ defmodule WhoNeedHelp.Trust do
[] []
end 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} <- with {:ok, _audit} <-
audit(moderator.id, "report.evidence_viewed", "report", report.id, %{ audit(moderator.id, "report.evidence_viewed", "report", report.id, %{
"assignment_id" => assignment_id, "assignment_id" => assignment_id,
"message_count" => length(messages) "activity_id" => activity_id,
"message_count" => length(messages),
"activity_message_count" => length(activity_messages)
}) do }) do
{:ok, %{report: report, messages: messages}} {:ok, %{report: report, messages: messages, activity_messages: activity_messages}}
end end
else else
{:error, :not_found} {:error, :not_found}
@ -498,6 +529,68 @@ defmodule WhoNeedHelp.Trust do
end end
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 def moderate_user(%Scope{user: moderator}, user_id, attrs) do
Repo.transact(fn -> Repo.transact(fn ->
with {:ok, user} <- Accounts.moderate_user(moderator, user_id, attrs), with {:ok, user} <- Accounts.moderate_user(moderator, user_id, attrs),
@ -606,8 +699,52 @@ defmodule WhoNeedHelp.Trust do
end end
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 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 defp create_signal_once(kind, subject_id, assignment_id, metadata) do
query = query =
from signal in AbuseSignal, from signal in AbuseSignal,

View File

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

View File

@ -1,7 +1,7 @@
defmodule WhoNeedHelpWeb.ActivityLive.Show do defmodule WhoNeedHelpWeb.ActivityLive.Show do
use WhoNeedHelpWeb, :live_view use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.Activities alias WhoNeedHelp.{Activities, Trust}
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do def mount(%{"id" => id}, _session, socket) do
@ -82,6 +82,57 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
end end
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, {:ok, _value}), do: {:noreply, reload(socket)}
defp respond(socket, {:error, reason}) do defp respond(socket, {:error, reason}) do
@ -131,6 +182,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|> assign(:coordinates, coordinates) |> assign(:coordinates, coordinates)
|> assign(:markers, Jason.encode!(markers)) |> assign(:markers, Jason.encode!(markers))
|> assign(:message_form, to_form(%{"body" => ""}, as: :activity_message)) |> assign(:message_form, to_form(%{"body" => ""}, as: :activity_message))
|> assign(:report_message_id, nil)
|> assign(:report_form, report_form())
end end
defp can_request_join?(activity, nil), do: activity.status == :open 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 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(: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(:capacity_reached), do: "The approved group has reached its capacity."
defp error_message(:join_closed), do: "Join requests are closed." defp error_message(:join_closed), do: "Join requests are closed."
defp error_message(:blocked), do: "This action is unavailable." 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(:forbidden), do: "You are not allowed to do that."
defp error_message(:invalid_transition), do: "This activity state has already changed." defp error_message(:invalid_transition), do: "This activity state has already changed."
defp error_message(:not_open), do: "This activity is no longer open." 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)} :for={message <- ordered_messages(@activity)}
class="rounded-2xl bg-base-100 p-4" class="rounded-2xl bg-base-100 p-4"
> >
<div class="text-xs font-semibold text-info"> <div class="flex items-center justify-between gap-2">
{message.sender.display_name || "Participant"} <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> </div>
<p class="mt-1 whitespace-pre-wrap text-sm">{message.body}</p> <p class="mt-1 whitespace-pre-wrap text-sm">{message.body}</p>
</article> </article>
@ -351,6 +420,50 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
</li> </li>
</ul> </ul>
</section> </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> </aside>
</div> </div>
</Layouts.app> </Layouts.app>

View File

@ -66,6 +66,22 @@ defmodule WhoNeedHelpWeb.ModerationLive do
) )
end 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 def handle_event("approve-proposal", %{"id" => id, "category" => params}, socket) do
names = names =
%{ %{
@ -168,6 +184,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="badge badge-error">{report.reason}</span> <span class="badge badge-error">{report.reason}</span>
<span class="badge">{report.status}</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> <span class="text-xs text-base-content/50">by {report.reporter.display_name}</span>
</div> </div>
<p class="mt-3 whitespace-pre-wrap text-sm">{report.details}</p> <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" /> <.input field={hide_form[:note]} placeholder="Hide reason" />
<.button class="btn btn-sm btn-error self-end">Hide request</.button> <.button class="btn btn-sm btn-error self-end">Hide request</.button>
</.form> </.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> </div>
<.form <.form
for={report_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"> <div :for={message <- @evidence.messages} class="rounded-xl bg-white/10 p-3 text-sm">
<strong>{message.sender.display_name}:</strong> {message.body} <strong>{message.sender.display_name}:</strong> {message.body}
</div> </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. This report has no linked conversation.
</p> </p>
</div> </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, Catalog, Trust}
alias WhoNeedHelp.Activities.Activity alias WhoNeedHelp.Activities.Activity
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust.AuditEvent
setup do setup do
Catalog.seed_defaults() Catalog.seed_defaults()
@ -155,4 +157,91 @@ defmodule WhoNeedHelp.ActivitiesTest do
assert {:error, :blocked} = assert {:error, :blocked} =
Activities.request_to_join(context.participant_scope, activity.id) Activities.request_to_join(context.participant_scope, activity.id)
end 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 end

View File

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