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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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, 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user