fix: harden realtime forms and blocked activity privacy

This commit is contained in:
SimpleTest 2026-07-20 18:38:29 +03:00
parent ad0204830a
commit 328096eb8f
11 changed files with 269 additions and 56 deletions

View File

@ -102,6 +102,18 @@ defmodule WhoNeedHelp.Activities do
participant.activity_id == activity.id and participant.user_id == ^user.id and
participant.status in [:requested, :approved]
)
|> where(
[activity],
activity.creator_id not in subquery(
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
)
)
|> where(
[activity],
activity.creator_id not in subquery(
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
)
)
|> before_my_activity(cursor)
|> order_by([activity], desc: activity.starts_at, desc: activity.id)
|> limit(^(limit + 1))

View File

@ -48,6 +48,7 @@ defmodule WhoNeedHelp.Trust do
attrs = stringify_keys(attrs)
with {:ok, _limit} <- authorize_action(scope, :review) do
result =
Repo.transact(fn ->
current =
Assignment
@ -100,8 +101,22 @@ defmodule WhoNeedHelp.Trust do
{:error, :not_found}
end
end)
with {:ok, review} <- result do
:ok = Help.notify_request_updated(assignment.request_id)
{:ok, review}
end
end
end
def review_submitted?(%Scope{user: %User{id: user_id}}, %Assignment{id: assignment_id}) do
Repo.exists?(
from review in Review,
where: review.assignment_id == ^assignment_id and review.reviewer_id == ^user_id
)
end
def review_submitted?(_scope, _assignment), do: false
defp maybe_reveal_reviews(assignment_id, true) do
Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment_id),

View File

@ -0,0 +1,19 @@
defmodule WhoNeedHelpWeb.FormParams do
@moduledoc false
@live_view_unused_prefix "_unused_"
def drop_unused_structured_fields(%{"structured_data" => data} = params)
when is_map(data) do
data =
Map.reject(data, fn {key, _value} ->
key
|> to_string()
|> String.starts_with?(@live_view_unused_prefix)
end)
Map.put(params, "structured_data", data)
end
def drop_unused_structured_fields(params), do: params
end

View File

@ -3,6 +3,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
alias WhoNeedHelp.{Activities, Catalog}
alias WhoNeedHelp.Activities.Activity
alias WhoNeedHelpWeb.FormParams
@impl true
def mount(_params, _session, socket) do
@ -17,6 +18,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
@impl true
def handle_event("validate", %{"activity" => params}, socket) do
params = FormParams.drop_unused_structured_fields(params)
changeset =
%Activity{}
|> Activities.change_activity(normalize_datetimes(params))
@ -29,6 +32,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
end
def handle_event("save", %{"activity" => params}, socket) do
params = FormParams.drop_unused_structured_fields(params)
case Activities.create_activity(socket.assigns.current_scope, normalize_datetimes(params)) do
{:ok, activity} ->
{:noreply,

View File

@ -80,7 +80,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
params
) do
{:ok, message} ->
{:noreply, put_realtime_message(socket, message)}
{:noreply,
socket
|> put_realtime_message(message)
|> push_event("reset-message-form", %{id: "activity-message-form"})}
{:error, reason} ->
{:noreply, put_flash(socket, :error, error_message(reason))}
@ -120,7 +123,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
socket
|> assign(:report_form, report_form())
|> assign(:report_message_id, nil)
|> put_flash(:info, gettext("Report sent to moderators."))}
|> put_flash(:info, gettext("Report sent to moderators."))
|> push_event("reset-message-form", %{id: "activity-report-form"})}
{:error, reason} ->
{:noreply, put_flash(socket, :error, error_message(reason))}
@ -527,7 +531,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
</p>
</div>
<button
:if={@viewer_participant && @viewer_participant.status == :approved}
:if={
@activity.status == :open && @viewer_participant &&
@viewer_participant.status == :approved
}
phx-click="leave"
class="btn btn-outline btn-error w-full"
>
@ -640,7 +647,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
>
{gettext("Block organizer")}
</button>
<.form for={@report_form} phx-submit="report" class="mt-4 space-y-2">
<.form
for={@report_form}
id="activity-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"

View File

@ -16,7 +16,8 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
{:noreply,
socket
|> put_flash(:info, gettext("Proposal published for community voting."))
|> load()}
|> load()
|> push_event("reset-message-form", %{id: "category-proposal-form"})}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
@ -85,7 +86,12 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
<p class="mt-3 text-base-content/65">
{gettext("Proposals and votes guide moderators. Approval remains a human decision.")}
</p>
<.form for={@form} phx-submit="propose" class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6">
<.form
for={@form}
id="category-proposal-form"
phx-submit="propose"
class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6"
>
<.input
field={@form[:proposed_name]}
label={gettext("Proposed category")}

View File

@ -4,6 +4,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
alias WhoNeedHelp.Catalog
alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.HelpRequest
alias WhoNeedHelpWeb.FormParams
@impl true
def mount(_params, _session, socket) do
@ -28,6 +29,8 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
@impl true
def handle_event("validate", %{"help_request" => params}, socket) do
params = FormParams.drop_unused_structured_fields(params)
changeset =
%HelpRequest{}
|> Help.change_request(normalize_expiry(params))
@ -40,6 +43,8 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
end
def handle_event("save", %{"help_request" => params}, socket) do
params = FormParams.drop_unused_structured_fields(params)
case Help.create_request(socket.assigns.current_scope, normalize_expiry(params)) do
{:ok, request} ->
{:noreply,

View File

@ -258,7 +258,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
socket
|> assign(:report_form, report_form())
|> assign(:report_message_id, nil)
|> put_flash(:info, gettext("Report sent to moderators."))}
|> put_flash(:info, gettext("Report sent to moderators."))
|> push_event("reset-message-form", %{id: "request-report-form"})}
{:error, reason} ->
{:noreply, put_flash(socket, :error, message(reason))}
@ -422,6 +423,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
)
|> assign(:assignment, assignment)
|> assign(:participant, participant)
|> assign(:review_submitted, Trust.review_submitted?(socket.assigns.current_scope, assignment))
|> assign(:other_user_id, other_user_id)
|> assign(
:blocked_by_current,
@ -816,7 +818,15 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
<p class="mt-1 text-sm text-base-content/60">
{gettext("Your review is revealed only after both participants submit.")}
</p>
<.form for={@review_form} phx-submit="review" class="mt-4 space-y-3">
<p :if={@review_submitted} class="mt-4 text-sm text-base-content/70">
{gettext("Review saved. It appears after both participants review.")}
</p>
<.form
:if={!@review_submitted}
for={@review_form}
phx-submit="review"
class="mt-4 space-y-3"
>
<.input
field={@review_form[:rating]}
type="select"
@ -1068,7 +1078,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
>
{gettext("Unblock this user")}
</button>
<.form for={@report_form} phx-submit="report" class="mt-4 space-y-2">
<.form
for={@report_form}
id="request-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-error/10 p-3 text-xs"

View File

@ -360,6 +360,18 @@ defmodule WhoNeedHelp.ActivitiesTest do
assert request.status == :requested
end
test "my activities exclude organizers blocked in either direction", context do
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
{:ok, _request} = Activities.request_to_join(context.participant_scope, activity.id)
assert Enum.map(Activities.list_my_activities(context.participant_scope), & &1.id) == [
activity.id
]
assert {:ok, _block} = Trust.block(context.participant_scope, context.organizer.id)
assert Activities.list_my_activities(context.participant_scope) == []
end
test "activity message reports expose only the linked group to audited moderators", context do
moderator =
user_fixture(display_name: "Activity moderator")

View File

@ -370,11 +370,14 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
"comment" => "Kind"
})
assert Trust.review_submitted?(context.requester_scope, assignment)
refute Trust.review_submitted?(context.helper_scope, assignment)
assert Trust.visible_reviews(context.helper.id) == []
{:ok, _} =
Trust.submit_review(context.helper_scope, assignment, %{"rating" => 5, "comment" => "Clear"})
assert Trust.review_submitted?(context.helper_scope, assignment)
assert [%{rating: 5}] = Trust.visible_reviews(context.helper.id)
assert [%{rating: 5}] = Trust.visible_reviews(context.requester.id)
end

View File

@ -199,6 +199,20 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Medicine pickup status"
assert html =~ "Pickup and deliver a legal medicine"
assert html =~ "help_request[structured_data][pickup_status]"
html =
render_change(view, "validate", %{
"help_request" => %{
"category_id" => category.id,
"structured_data" => %{
"_unused_pickup_status" => "",
"pickup_status" => ""
}
}
})
assert html =~ "pickup_status is required"
refute html =~ "_unused_pickup_status is not allowed"
end
test "category descriptions use the current Ukrainian locale", %{conn: conn} do
@ -273,6 +287,20 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "activity[structured_data][route_plan]"
assert html =~ "activity[structured_data][safety_plan_confirmed]"
assert html =~ "approve participants individually"
html =
render_change(view, "validate", %{
"activity" => %{
"category_id" => hiking.id,
"structured_data" => %{
"_unused_route_plan" => "",
"route_plan" => ""
}
}
})
assert html =~ "route_plan is required"
refute html =~ "_unused_route_plan is not allowed"
end
test "organizer approves an activity participant and group chat updates live", %{
@ -354,8 +382,17 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> render_submit()
assert html =~ "I will be there."
assert_push_event(participant_view, "reset-message-form", %{id: "activity-message-form"})
assert render(organizer_view) =~ "I will be there."
participant_view
|> form("#activity-report-form",
report: %{reason: "harassment", details: "Review this activity context."}
)
|> render_submit()
assert_push_event(participant_view, "reset-message-form", %{id: "activity-report-form"})
now = DateTime.utc_now(:second)
for index <- 1..51 do
@ -384,6 +421,13 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
refute bounded_activity_html =~ "oldest-realtime-activity-message"
assert bounded_activity_html =~ "newest-realtime-activity-message"
organizer_view
|> element("button[phx-click='complete']")
|> render_click()
assert render(participant_view) =~ "Completed"
refute has_element?(participant_view, "button[phx-click='leave']")
stop_live_view(organizer_view)
stop_live_view(participant_view)
end
@ -561,12 +605,77 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
requester = user_fixture(display_name: "Requester")
{:ok, request} = Help.create_request(user_scope_fixture(requester), request_attrs(category))
{:ok, _view, html} = live(conn, ~p"/requests/#{request.id}")
{:ok, view, html} = live(conn, ~p"/requests/#{request.id}")
assert html =~ "Safety controls"
assert html =~ "Block this user"
assert html =~ "Send report"
assert html =~ "This is not an emergency service"
view
|> form("#request-report-form",
report: %{reason: "dangerous_request", details: "Review the request context."}
)
|> render_submit()
assert_push_event(view, "reset-message-form", %{id: "request-report-form"})
end
test "successful category proposal resets the browser form", %{conn: conn} do
Catalog.seed_defaults()
{:ok, view, _html} = live(conn, ~p"/categories/proposals")
view
|> form("#category-proposal-form",
category_proposal: %{
proposed_name: "Chain repair",
mode: "help",
reason: "A reusable category for urgent roadside bicycle chain repair."
}
)
|> render_submit()
assert_push_event(view, "reset-message-form", %{id: "category-proposal-form"})
assert render(view) =~ "Chain repair"
end
test "review submission updates both participant pages and hides the submitted form" do
category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Review requester")
helper = user_fixture(display_name: "Review helper")
requester_scope = user_scope_fixture(requester)
helper_scope = user_scope_fixture(helper)
{:ok, request} = Help.create_request(requester_scope, request_attrs(category))
{:ok, assignment} = Help.accept_request(helper_scope, request.id)
{:ok, _} = Help.confirm_completion(requester_scope, assignment.id)
{:ok, _} = Help.confirm_completion(helper_scope, assignment.id)
{:ok, _assignment} =
Help.verify_handover(helper_scope, assignment.id, Help.handover_code(request.id))
{:ok, requester_view, _html} =
build_conn() |> log_in_user(requester) |> live(~p"/requests/#{request.id}")
{:ok, helper_view, _html} =
build_conn() |> log_in_user(helper) |> live(~p"/requests/#{request.id}")
requester_view
|> form("form[phx-submit='review']", review: %{rating: "5", comment: "Kind"})
|> render_submit()
refute has_element?(requester_view, "form[phx-submit='review']")
assert has_element?(helper_view, "form[phx-submit='review']")
helper_view
|> form("form[phx-submit='review']", review: %{rating: "4", comment: "Clear"})
|> render_submit()
refute has_element?(helper_view, "form[phx-submit='review']")
assert render(requester_view) =~ "rating 4.0"
assert render(helper_view) =~ "rating 4.0"
assert render(requester_view) =~ ">5.0<"
assert render(helper_view) =~ ">5.0<"
end
defp request_attrs(category) do