feat: add scoped activity reports
This commit is contained in:
parent
633fa6cf34
commit
2a1a2d93eb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,9 +294,20 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
:for={message <- ordered_messages(@activity)}
|
||||
class="rounded-2xl bg-base-100 p-4"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user