- {gettext("Helper: %{name}", name: @assignment.helper.display_name)}
+ {gettext("Helper:")}
+ <.link
+ navigate={~p"/people/#{@assignment.helper.id}"}
+ class="link ml-1 font-medium"
+ >
+ {@assignment.helper.display_name}
+
diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex
index 2f119fd..963baf1 100644
--- a/lib/who_need_help_web/router.ex
+++ b/lib/who_need_help_web/router.ex
@@ -84,7 +84,6 @@ defmodule WhoNeedHelpWeb.Router do
get "/legal/content-removal", ContentRemovalController, :new
post "/legal/content-removal", ContentRemovalController, :create
get "/legal/content-removal/received", ContentRemovalController, :received
- get "/legal/content-removal/:id", ContentRemovalController, :show
get "/legal/take-it-down", ContentRemovalController, :take_it_down
post "/legal/take-it-down", ContentRemovalController, :create_take_it_down
end
@@ -138,6 +137,13 @@ defmodule WhoNeedHelpWeb.Router do
get "/auth/social/:provider", SocialOAuthController, :request
get "/auth/social/:provider/callback", SocialOAuthController, :callback
get "/support/requests", SupportController, :index
+ get "/legal/content-removal/requests", ContentRemovalController, :index
+ end
+
+ scope "/", WhoNeedHelpWeb do
+ pipe_through :browser
+
+ get "/legal/content-removal/:id", ContentRemovalController, :show
end
scope "/", WhoNeedHelpWeb do
@@ -153,6 +159,7 @@ defmodule WhoNeedHelpWeb.Router do
live "/activities/:id", ActivityLive.Show, :show
live "/categories/proposals", CategoryProposalLive, :index
live "/profile", ProfileLive, :edit
+ live "/people/:id", PublicProfileLive, :show
live "/leaderboard", LeaderboardLive, :index
end
diff --git a/test/who_need_help/accounts_test.exs b/test/who_need_help/accounts_test.exs
index 3c7a7d8..94a42ee 100644
--- a/test/who_need_help/accounts_test.exs
+++ b/test/who_need_help/accounts_test.exs
@@ -89,6 +89,56 @@ defmodule WhoNeedHelp.AccountsTest do
end
end
+ describe "get_public_user/1" do
+ test "loads only the public projection and public social identities" do
+ user =
+ user_fixture()
+ |> set_password()
+ |> Ecto.Changeset.change(
+ bio: "A short public bio",
+ role: :admin,
+ moderation_note: "private moderator note"
+ )
+ |> Repo.update!()
+
+ {:ok, identity} =
+ Accounts.add_social_identity(user, %{
+ "provider" => "telegram",
+ "profile_url" => "https://t.me/public_profile_test",
+ "handle" => "@public_profile_test"
+ })
+
+ assert {:ok, public_user} = Accounts.get_public_user(user.id)
+ assert public_user.id == user.id
+ assert public_user.bio == "A short public bio"
+
+ assert [%{id: identity_id, profile_url: "https://t.me/public_profile_test"}] =
+ public_user.social_identities
+
+ assert identity_id == identity.id
+ assert is_nil(public_user.email)
+ assert is_nil(public_user.hashed_password)
+ assert public_user.role == :user
+ assert is_nil(public_user.moderation_note)
+ end
+
+ test "does not expose invalid, missing, or suspended accounts" do
+ user = user_fixture()
+
+ assert {:error, :not_found} = Accounts.get_public_user("not-a-uuid")
+
+ assert {:error, :not_found} =
+ Accounts.get_public_user("11111111-1111-1111-1111-111111111111")
+
+ {1, nil} =
+ Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
+ set: [moderation_status: :suspended]
+ )
+
+ assert {:error, :not_found} = Accounts.get_public_user(user.id)
+ end
+ end
+
describe "register_user/1" do
test "requires email to be set" do
{:error, changeset} = Accounts.register_user(%{})
diff --git a/test/who_need_help/mutual_aid_flow_test.exs b/test/who_need_help/mutual_aid_flow_test.exs
index d6b75d7..4d94b23 100644
--- a/test/who_need_help/mutual_aid_flow_test.exs
+++ b/test/who_need_help/mutual_aid_flow_test.exs
@@ -508,10 +508,30 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert [%{id: listed_id, vote_count: 1}] = Catalog.list_proposals()
assert listed_id == proposal.id
+ assert [%{voted_by_current_user: true}] =
+ Catalog.paginate_proposals_for(context.helper_scope).entries
+
+ assert {:ok, 1} = Catalog.unvote(context.helper_scope, proposal.id)
+
+ assert [%{vote_count: 0, voted_by_current_user: false}] =
+ Catalog.paginate_proposals_for(context.helper_scope).entries
+
refute export =~ context.requester.email
refute export =~ context.requester.display_name
end
+ test "proposal parents include taxonomy containers and stay within their mode", _context do
+ Catalog.seed_defaults()
+
+ help_parents = Catalog.list_proposal_parents(:help)
+ activity_parents = Catalog.list_proposal_parents(:activity)
+
+ assert Enum.any?(help_parents, &(&1.slug == "roadside-help" and &1.active == false))
+ assert Enum.all?(help_parents, &(&1.mode == :help))
+ assert Enum.all?(activity_parents, &(&1.mode == :activity))
+ refute Enum.any?(activity_parents, &(&1.slug == "roadside-help"))
+ end
+
test "default urgent and roadside categories are hierarchical and idempotent", context do
first_ids =
Catalog.list_categories()
diff --git a/test/who_need_help/support_and_content_removal_test.exs b/test/who_need_help/support_and_content_removal_test.exs
index a902c4e..83115cc 100644
--- a/test/who_need_help/support_and_content_removal_test.exs
+++ b/test/who_need_help/support_and_content_removal_test.exs
@@ -211,6 +211,32 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert request.contact_verified_at
end
+ test "content-removal ownership lookup is scoped to the submitting account" do
+ owner = user_fixture()
+ outsider = user_fixture()
+
+ assert {:ok, notice} =
+ ContentRemoval.create_notice(user_scope_fixture(owner), :general, %{
+ "category" => "privacy_violation",
+ "submitter_name" => "Notice Owner",
+ "relationship" => "self",
+ "content_locations" => "https://example.test/requests/scoped-notice",
+ "explanation" =>
+ "This notice verifies account-scoped access to a removal request.",
+ "electronic_signature" => "Notice Owner",
+ "good_faith" => "true",
+ "accurate_complete" => "true"
+ })
+
+ assert {:ok, ^notice} = ContentRemoval.get_for_user(user_scope_fixture(owner), notice.id)
+
+ assert {:error, :not_found} =
+ ContentRemoval.get_for_user(user_scope_fixture(outsider), notice.id)
+
+ assert [^notice] = ContentRemoval.list_for_user(user_scope_fixture(owner))
+ assert [] = ContentRemoval.list_for_user(user_scope_fixture(outsider))
+ end
+
defp moderator_scope do
user_fixture()
|> Ecto.Changeset.change(role: :moderator)
diff --git a/test/who_need_help/trust_safety_test.exs b/test/who_need_help/trust_safety_test.exs
index 3096bbf..a092a14 100644
--- a/test/who_need_help/trust_safety_test.exs
+++ b/test/who_need_help/trust_safety_test.exs
@@ -114,6 +114,67 @@ defmodule WhoNeedHelp.TrustSafetyTest do
)
end
+ test "category moderation cannot cross help and activity taxonomies", context do
+ moderator =
+ user_fixture(display_name: "Category moderator")
+ |> Ecto.Changeset.change(role: :moderator)
+ |> Repo.update!()
+
+ moderator_scope = user_scope_fixture(moderator)
+ activity_category = Catalog.list_categories(:activity) |> List.first()
+
+ {:ok, activity_proposal} =
+ Catalog.propose(context.requester_scope, %{
+ "proposed_name" => "Board games",
+ "mode" => "activity",
+ "reason" => "A reusable social activity category for public board game meetings."
+ })
+
+ assert {:error, :mode_mismatch} =
+ Catalog.merge_proposal(
+ moderator_scope,
+ activity_proposal.id,
+ context.category.id,
+ "Wrong mode"
+ )
+
+ assert {:ok, merged} =
+ Catalog.merge_proposal(
+ moderator_scope,
+ activity_proposal.id,
+ activity_category.id,
+ "Same mode"
+ )
+
+ assert merged.status == :merged
+ assert merged.merged_into_id == activity_category.id
+
+ roadside_parent =
+ Catalog.list_proposal_parents(:help)
+ |> Enum.find(&(&1.slug == "roadside-help"))
+
+ {:ok, help_proposal} =
+ Catalog.propose(context.requester_scope, %{
+ "proposed_name" => "Cargo bicycle",
+ "parent_id" => roadside_parent.id,
+ "mode" => "activity",
+ "reason" => "A reusable help category for cargo bicycle roadside problems."
+ })
+
+ assert help_proposal.mode == :help
+
+ assert {:ok, %{category: created}} =
+ Catalog.approve_proposal(moderator_scope, help_proposal.id, %{
+ "slug" => "cargo-bicycle-#{System.unique_integer([:positive])}",
+ "names" => %{"en" => "Cargo bicycle help"},
+ "mode" => "activity",
+ "parent_id" => activity_category.id
+ })
+
+ assert created.mode == :help
+ assert created.parent_id == roadside_parent.id
+ end
+
test "tracking derives movement and proximity from browser accuracy envelopes", context do
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
diff --git a/test/who_need_help_web/controllers/support_controller_test.exs b/test/who_need_help_web/controllers/support_controller_test.exs
index ca69253..3060d1b 100644
--- a/test/who_need_help_web/controllers/support_controller_test.exs
+++ b/test/who_need_help_web/controllers/support_controller_test.exs
@@ -80,7 +80,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
describe "authenticated support" do
setup :register_and_log_in_user
- test "lists the current user's cases and links account deletion from settings", %{
+ test "lists the current user's support cases and removal notices", %{
conn: conn,
user: user
} do
@@ -91,9 +91,50 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
"details" => "I need assistance understanding an account setting."
})
+ {:ok, notice} =
+ WhoNeedHelp.ContentRemoval.create_notice(user_scope_fixture(user), :general, %{
+ "category" => "privacy_violation",
+ "submitter_name" => "Account Owner",
+ "relationship" => "self",
+ "content_locations" => "https://example.test/requests/private-content",
+ "explanation" =>
+ "This notice verifies that a signed-in account can revisit its private case.",
+ "electronic_signature" => "Account Owner",
+ "good_faith" => "true",
+ "accurate_complete" => "true"
+ })
+
assert html_response(get(conn, ~p"/support/requests"), 200) =~ request.reference
+
+ assert html_response(get(conn, ~p"/legal/content-removal/requests"), 200) =~
+ notice.reference
+
+ assert html_response(get(conn, ~p"/legal/content-removal/#{notice.id}"), 200) =~
+ notice.reference
+
assert html_response(get(conn, ~p"/users/settings"), 200) =~ ~p"/account/delete"
end
+
+ test "does not expose another account's removal notice without its token", %{
+ conn: conn
+ } do
+ owner = user_fixture()
+
+ {:ok, notice} =
+ WhoNeedHelp.ContentRemoval.create_notice(user_scope_fixture(owner), :general, %{
+ "category" => "privacy_violation",
+ "submitter_name" => "Different Owner",
+ "relationship" => "self",
+ "content_locations" => "https://example.test/requests/other-account",
+ "explanation" =>
+ "This notice must remain private from a different authenticated account.",
+ "electronic_signature" => "Different Owner",
+ "good_faith" => "true",
+ "accurate_complete" => "true"
+ })
+
+ assert response(get(conn, ~p"/legal/content-removal/#{notice.id}"), 404) == "Not found"
+ end
end
describe "operator queue authorization" do
diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs
index 371c2ab..8dcdece 100644
--- a/test/who_need_help_web/live/mutual_aid_live_test.exs
+++ b/test/who_need_help_web/live/mutual_aid_live_test.exs
@@ -15,6 +15,71 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Repeated help between the same pair"
end
+ test "public profile reveals trust data without exposing private account fields", %{conn: conn} do
+ category = Catalog.seed_defaults()
+
+ requester =
+ user_fixture(display_name: "Public reviewer")
+ |> Ecto.Changeset.change(moderation_note: "never render this note")
+ |> Repo.update!()
+
+ helper = user_fixture(display_name: "Public helper")
+
+ {:ok, helper} =
+ Accounts.update_user_profile(helper, %{
+ "display_name" => helper.display_name,
+ "bio" => "I help with bicycles and medicine pickup.",
+ "locale" => helper.locale,
+ "location_visibility" => helper.location_visibility,
+ "direct_message_policy" => helper.direct_message_policy,
+ "tip_url" => "https://example.com/thanks"
+ })
+
+ {:ok, _identity} =
+ Accounts.add_social_identity(helper, %{
+ "provider" => "telegram",
+ "profile_url" => "https://t.me/public_helper",
+ "handle" => "@public_helper"
+ })
+
+ {:ok, request} =
+ Help.create_request(Accounts.Scope.for_user(requester), request_attrs(category))
+
+ {:ok, assignment} = Help.accept_request(Accounts.Scope.for_user(helper), request.id)
+ {:ok, _} = Help.confirm_completion(Accounts.Scope.for_user(requester), assignment.id)
+ {:ok, _} = Help.confirm_completion(Accounts.Scope.for_user(helper), assignment.id)
+
+ {:ok, assignment} =
+ Help.verify_handover(
+ Accounts.Scope.for_user(helper),
+ assignment.id,
+ Help.handover_code(request.id)
+ )
+
+ {:ok, _} =
+ Trust.submit_review(Accounts.Scope.for_user(requester), assignment, %{
+ "rating" => "5",
+ "comment" => "Reliable and kind."
+ })
+
+ {:ok, _} =
+ Trust.submit_review(Accounts.Scope.for_user(helper), assignment, %{
+ "rating" => "4",
+ "comment" => "Clear request."
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/people/#{helper.id}")
+
+ assert html =~ "Public helper"
+ assert html =~ "I help with bicycles and medicine pickup."
+ assert html =~ "Reliable and kind."
+ assert html =~ "Public reviewer"
+ assert html =~ "https://t.me/public_helper"
+ assert html =~ "https://example.com/thanks"
+ refute html =~ helper.email
+ refute html =~ "never render this note"
+ end
+
test "authenticated LiveView honors the locale stored by the browser pipeline", %{conn: conn} do
conn = get(conn, ~p"/?locale=uk")
{:ok, _view, html} = live(conn, ~p"/requests")
@@ -860,6 +925,55 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert render(view) =~ "Chain repair"
end
+ test "category voting toggles and parent choices follow the selected mode", %{
+ conn: conn,
+ user: user
+ } do
+ Catalog.seed_defaults()
+
+ {:ok, proposal} =
+ Catalog.propose(Accounts.Scope.for_user(user), %{
+ "proposed_name" => "Cargo bicycle help",
+ "mode" => "help",
+ "reason" => "A reusable category for cargo bicycle roadside assistance."
+ })
+
+ roadside = Enum.find(Catalog.list_proposal_parents(:help), &(&1.slug == "roadside-help"))
+ activity_parent = Catalog.list_proposal_parents(:activity) |> List.first()
+
+ {:ok, view, _html} = live(conn, ~p"/categories/proposals")
+
+ assert has_element?(view, "#proposal-vote-#{proposal.id}[aria-pressed='false']")
+ assert has_element?(view, "#category-proposal-form option[value='#{roadside.id}']")
+
+ view
+ |> element("#proposal-vote-#{proposal.id}")
+ |> render_click()
+
+ assert has_element?(view, "#proposal-unvote-#{proposal.id}[aria-pressed='true']")
+ assert render(view) =~ "Voted · 1"
+
+ view
+ |> element("#proposal-unvote-#{proposal.id}")
+ |> render_click()
+
+ assert has_element?(view, "#proposal-vote-#{proposal.id}[aria-pressed='false']")
+ assert render(view) =~ "Vote · 0"
+
+ view
+ |> form("#category-proposal-form", category_proposal: %{mode: "activity"})
+ |> render_change()
+
+ refute has_element?(view, "#category-proposal-form option[value='#{roadside.id}']")
+
+ if activity_parent do
+ assert has_element?(
+ view,
+ "#category-proposal-form option[value='#{activity_parent.id}']"
+ )
+ end
+ 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")