Improve trust profiles and account case history

This commit is contained in:
SimpleTest 2026-07-22 06:58:42 +03:00
parent f97b00bda8
commit c634c136c9
28 changed files with 805 additions and 32 deletions

View File

@ -63,6 +63,25 @@ defmodule WhoNeedHelp.Accounts do
select: struct(identity, ^@public_social_identity_fields)
end
@doc """
Loads the public profile projection for an account that has not been suspended.
The returned struct deliberately contains only `public_user_query/1` fields and
public social identities. It never loads email, authentication, role, or
moderation-note fields.
"""
def get_public_user(id) do
with {:ok, id} <- cast_id(id),
%User{} = user <-
public_user_query(social_identities: true)
|> where([user], user.id == ^id and user.moderation_status != :suspended)
|> Repo.one() do
{:ok, user}
else
_invalid_missing_or_suspended -> {:error, :not_found}
end
end
@doc """
Gets a user by email.

View File

@ -25,6 +25,14 @@ defmodule WhoNeedHelp.Catalog do
|> Repo.all()
end
def list_proposal_parents(mode) when mode in [:help, :activity] do
Category
|> where([category], category.mode == ^mode)
|> order_by([category], asc: category.sort_order, asc: category.slug)
|> preload(:parent)
|> Repo.all()
end
def get_category!(id), do: Repo.get!(Category, id)
def category_path(%Category{parent: %Category{} = parent} = category, locale) do
@ -38,6 +46,14 @@ defmodule WhoNeedHelp.Catalog do
end
def paginate_proposals(options \\ []) do
paginate_proposals_for_user(nil, options)
end
def paginate_proposals_for(%Scope{user: %{id: user_id}}, options \\ []) do
paginate_proposals_for_user(user_id, options)
end
defp paginate_proposals_for_user(user_id, options) do
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query()
@ -48,6 +64,7 @@ defmodule WhoNeedHelp.Catalog do
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|> limit(^(limit + 1))
|> with_vote_count()
|> with_current_vote(user_id)
|> preload([proposal], proposer: ^public_user, parent: [])
|> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
@ -201,8 +218,8 @@ defmodule WhoNeedHelp.Catalog do
category_attrs
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|> normalize_descriptions()
|> Map.put_new("parent_id", proposal.parent_id)
|> Map.put_new("mode", to_string(proposal.mode))
|> Map.put("parent_id", proposal.parent_id)
|> Map.put("mode", to_string(proposal.mode))
with {:ok, category} <-
%Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
@ -242,12 +259,10 @@ defmodule WhoNeedHelp.Catalog do
end
def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do
with {:ok, category_id} <- cast_id(category_id),
%Category{} <- Repo.get(Category, category_id) do
with {:ok, category_id} <- cast_id(category_id) do
moderate_proposal(moderator, proposal_id, :merged, category_id, note)
else
{:error, :not_found} = error -> error
nil -> {:error, :not_found}
end
end
@ -257,10 +272,24 @@ defmodule WhoNeedHelp.Catalog do
Repo.transact(fn ->
proposal = locked_proposal(proposal_id)
merge_target =
if status == :merged do
Category
|> where([category], category.id == ^merged_into_id)
|> lock("FOR SHARE")
|> Repo.one()
end
cond do
is_nil(proposal) ->
{:error, :not_found}
status == :merged and is_nil(merge_target) ->
{:error, :not_found}
status == :merged and merge_target.mode != proposal.mode ->
{:error, :mode_mismatch}
proposal.status == :open ->
with {:ok, proposal} <-
proposal
@ -889,6 +918,21 @@ defmodule WhoNeedHelp.Catalog do
})
end
defp with_current_vote(query, nil) do
select_merge(query, [proposal], %{voted_by_current_user: false})
end
defp with_current_vote(query, user_id) do
query
|> join(:left, [proposal], vote in CategoryVote,
on: vote.proposal_id == proposal.id and vote.user_id == ^user_id,
as: :current_vote
)
|> select_merge([current_vote: vote], %{
voted_by_current_user: not is_nil(vote.id)
})
end
defp before_proposal(query, nil), do: query
defp before_proposal(query, {inserted_at, id}) do

View File

@ -17,6 +17,7 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
field :reviewed_at, :utc_datetime
field :moderation_note, :string
field :vote_count, :integer, virtual: true, default: 0
field :voted_by_current_user, :boolean, virtual: true, default: false
has_many :votes, WhoNeedHelp.Catalog.CategoryVote, foreign_key: :proposal_id
timestamps(type: :utc_datetime)

View File

@ -72,6 +72,15 @@ defmodule WhoNeedHelp.ContentRemoval do
|> Repo.all()
end
def get_for_user(%Scope{user: %User{id: user_id}}, id) do
with {:ok, id} <- Ecto.UUID.cast(id),
%Notice{} = notice <- Repo.get_by(Notice, id: id, requester_id: user_id) do
{:ok, notice}
else
_ -> {:error, :not_found}
end
end
def get_by_access_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <-

View File

@ -4,6 +4,11 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
alias WhoNeedHelp.ContentRemoval
alias WhoNeedHelp.ContentRemoval.Notice
def index(conn, _params) do
notices = ContentRemoval.list_for_user(conn.assigns.current_scope)
render(conn, :index, notices: notices)
end
def new(conn, params) do
attrs =
case internal_location(params["location"]) do
@ -27,15 +32,25 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
def create_take_it_down(conn, _params),
do: send_resp(conn, :bad_request, "Bad Request")
def show(conn, %{"id" => id, "token" => token}) do
case ContentRemoval.get_by_access_token(id, token) do
def show(conn, %{"id" => id} = params) do
result =
case conn.assigns.current_scope do
nil ->
ContentRemoval.get_by_access_token(id, params["token"])
scope ->
case ContentRemoval.get_for_user(scope, id) do
{:ok, notice} -> {:ok, notice}
_ -> ContentRemoval.get_by_access_token(id, params["token"])
end
end
case result do
{:ok, notice} -> render(conn, :show, notice: notice)
{:error, :not_found} -> send_resp(conn, :not_found, "Not found")
end
end
def show(conn, _params), do: send_resp(conn, :not_found, "Not found")
defp create_notice(conn, regime, params) do
case ContentRemoval.create_notice(conn.assigns.current_scope, regime, params) do
{:ok, notice} ->

View File

@ -0,0 +1,44 @@
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}>
<div>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-error">{gettext("CONTENT REMOVAL")}</div>
<h1 class="mt-1 text-4xl font-black">{gettext("Your notices")}</h1>
</div>
<.link navigate={~p"/legal/content-removal"} class="btn btn-error">
{gettext("New notice")}
</.link>
</div>
<p class="mt-3 text-sm text-base-content/60">
{gettext(
"Only notices submitted while signed in appear here. Notices submitted without an account remain accessible through their private email link."
)}
</p>
<div class="mt-8 space-y-3">
<.link
:for={notice <- @notices}
navigate={~p"/legal/content-removal/#{notice.id}"}
class="block rounded-2xl border border-base-300 p-5 transition hover:border-error"
>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-outline">{notice.reference}</span>
<span class={[
"badge",
notice.status == :urgent_review && "badge-error",
notice.status != :urgent_review && "badge-primary"
]}>
{status_label(notice.status)}
</span>
<span class="text-xs text-base-content/55">{category_label(notice.category)}</span>
</div>
<p class="mt-3 line-clamp-2 text-sm text-base-content/70">{notice.explanation}</p>
<time class="mt-2 block text-xs text-base-content/50">{notice.inserted_at}</time>
</.link>
<p :if={@notices == []} class="rounded-2xl bg-base-200 p-6 text-base-content/60">
{gettext("You have not submitted any content-removal notices while signed in.")}
</p>
</div>
</div>
</Layouts.app>

View File

@ -12,6 +12,13 @@
"Provide exact Who Need Help URLs. Do not upload or paste intimate imagery, identity documents, passwords, access codes, or unnecessary medical information."
)}
</div>
<.link
:if={@current_scope}
navigate={~p"/legal/content-removal/requests"}
class="btn btn-outline btn-sm mt-5"
>
{gettext("View my notices")}
</.link>
<.form for={@form} action={~p"/legal/content-removal"} class="mt-8 space-y-4">
<.input
@ -39,6 +46,9 @@
value={@contact_email || @form[:contact_email].value}
readonly={not is_nil(@contact_email)}
/>
<p :if={@contact_email} class="text-xs text-base-content/55">
{gettext("Status updates go to the email address of your signed-in account.")}
</p>
<.input
field={@form[:relationship]}
type="select"

View File

@ -14,8 +14,17 @@
"A private status link has been sent when a contact email was provided and delivery is configured. Opening that link verifies the contact address. Keep the reference for follow-up."
)}
</p>
<.link navigate={~p"/legal/content-removal"} class="btn btn-primary mt-7">
{gettext("Back to removal form")}
</.link>
<div class="mt-7 flex flex-wrap justify-center gap-3">
<.link
:if={@current_scope}
navigate={~p"/legal/content-removal/requests"}
class="btn btn-primary"
>
{gettext("View my notices")}
</.link>
<.link navigate={~p"/legal/content-removal"} class="btn btn-outline">
{gettext("Back to removal form")}
</.link>
</div>
</div>
</Layouts.app>

View File

@ -1,5 +1,12 @@
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:compact}>
<div>
<.link
:if={@current_scope && @notice.requester_id == @current_scope.user.id}
navigate={~p"/legal/content-removal/requests"}
class="link text-sm"
>
← {gettext("Back to my notices")}
</.link>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-outline">{@notice.reference}</span>
<span class={[

View File

@ -14,6 +14,13 @@
"Do not upload the image or video. Provide only the exact Who Need Help URL where it appears and enough text to identify it safely."
)}
</div>
<.link
:if={@current_scope}
navigate={~p"/legal/content-removal/requests"}
class="btn btn-outline btn-sm mt-5"
>
{gettext("View my notices")}
</.link>
<.form for={@form} action={~p"/legal/take-it-down"} class="mt-8 space-y-4">
<.input
@ -36,6 +43,9 @@
readonly={not is_nil(@contact_email)}
required
/>
<p :if={@contact_email} class="text-xs text-base-content/55">
{gettext("Status updates go to the email address of your signed-in account.")}
</p>
<.input
field={@form[:relationship]}
type="select"

View File

@ -28,6 +28,9 @@
readonly={not is_nil(@contact_email)}
required
/>
<p :if={@contact_email} class="text-xs text-base-content/55">
{gettext("This request is linked to your signed-in account and its email address.")}
</p>
<.input
field={@form[:details]}
type="textarea"

View File

@ -9,6 +9,9 @@
</p>
<div class="mt-5 flex flex-wrap gap-2">
<.link :if={@current_scope} navigate={~p"/support/requests"} class="btn btn-outline btn-sm">
{gettext("View my requests")}
</.link>
<.link navigate={~p"/legal/content-removal"} class="btn btn-outline btn-sm">
{gettext("Report content")}
</.link>
@ -45,6 +48,11 @@
readonly={not is_nil(@contact_email)}
required
/>
<p :if={@contact_email} class="text-xs text-base-content/55">
{gettext(
"This request is linked to your signed-in account. Replies go to its email address."
)}
</p>
<.input field={@form[:subject]} label={gettext("Subject")} required />
<.input
field={@form[:details]}

View File

@ -13,6 +13,11 @@
reference: @reference
)}
</p>
<.link navigate={~p"/support"} class="btn btn-primary mt-7">{gettext("Back to support")}</.link>
<div class="mt-7 flex flex-wrap justify-center gap-3">
<.link :if={@current_scope} navigate={~p"/support/requests"} class="btn btn-primary">
{gettext("View my requests")}
</.link>
<.link navigate={~p"/support"} class="btn btn-outline">{gettext("Back to support")}</.link>
</div>
</div>
</Layouts.app>

View File

@ -125,6 +125,9 @@
<.link navigate={~p"/support/requests"} class="btn btn-outline">
{gettext("View support requests")}
</.link>
<.link navigate={~p"/legal/content-removal/requests"} class="btn btn-outline">
{gettext("View content-removal notices")}
</.link>
</div>
</section>
</Layouts.app>

View File

@ -393,7 +393,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
<div>
<dt class="text-xs text-base-content/50">{gettext("Organizer")}</dt>
<dd class="font-semibold">
{@activity.creator.display_name || gettext("Community member")}
<.link navigate={~p"/people/#{@activity.creator.id}"} class="link">
{@activity.creator.display_name || gettext("Community member")}
</.link>
</dd>
</div>
<div
@ -469,7 +471,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
>
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-semibold text-info">
{message.sender.display_name || gettext("Participant")}
<.link navigate={~p"/people/#{message.sender.id}"} class="link">
{message.sender.display_name || gettext("Participant")}
</.link>
</div>
<time
id={"activity-message-time-#{message.id}"}
@ -608,7 +612,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
class="rounded-xl bg-base-200 p-3"
>
<div class="font-semibold">
{participant.user.display_name || gettext("Community member")}
<.link navigate={~p"/people/#{participant.user.id}"} class="link">
{participant.user.display_name || gettext("Community member")}
</.link>
</div>
<div
:if={participant.user.social_identities != []}
@ -665,7 +671,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
<h2 class="font-bold">{gettext("Approved participants")}</h2>
<ul class="mt-3 space-y-2 text-sm">
<li :for={participant <- approved_participants(@activity)}>
{participant.user.display_name || gettext("Community member")}
<.link navigate={~p"/people/#{participant.user.id}"} class="link">
{participant.user.display_name || gettext("Community member")}
</.link>
<span :if={participant.role == :organizer} class="badge badge-xs ml-1">
{gettext("organizer")}
</span>

View File

@ -27,6 +27,22 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
end
end
def handle_event("validate", %{"category_proposal" => params}, socket) do
mode = proposal_mode(params["mode"])
form =
%CategoryProposal{}
|> Catalog.change_proposal(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply,
socket
|> assign(:form, form)
|> assign(:proposal_mode, mode)
|> assign(:parent_categories, Catalog.list_proposal_parents(mode))}
end
def handle_event("vote", %{"id" => id}, socket) do
case Catalog.vote(socket.assigns.current_scope, id) do
{:ok, _} -> {:noreply, load(socket)}
@ -34,8 +50,19 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
end
end
def handle_event("unvote", %{"id" => id}, socket) do
case Catalog.unvote(socket.assigns.current_scope, id) do
{:ok, _count} -> {:noreply, load(socket)}
{:error, reason} -> {:noreply, put_flash(socket, :error, action_error(reason))}
end
end
def handle_event("load-more-proposals", _params, socket) do
page = Catalog.paginate_proposals(after: socket.assigns.proposals_cursor)
page =
Catalog.paginate_proposals_for(socket.assigns.current_scope,
after: socket.assigns.proposals_cursor
)
existing_ids = MapSet.new(socket.assigns.proposals, & &1.id)
{:noreply,
@ -56,21 +83,28 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
defp action_error(:proposal_closed), do: gettext("This proposal is already closed.")
defp action_error(:not_found), do: gettext("This proposal is no longer available.")
defp action_error(:invalid_parent), do: gettext("Choose a valid parent category.")
defp action_error(%Ecto.Changeset{}), do: gettext("You already voted for this proposal.")
defp action_error(_reason), do: gettext("Could not complete the action. Please try again.")
defp load(socket) do
proposals = Catalog.paginate_proposals()
proposals = Catalog.paginate_proposals_for(socket.assigns.current_scope)
mode = socket.assigns[:proposal_mode] || :help
socket
|> assign(:page_title, gettext("Category proposals"))
|> assign(:proposals, proposals.entries)
|> assign(:proposals_cursor, proposals.next_cursor)
|> assign(:categories, Catalog.list_all_categories())
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{})))
|> assign(:proposal_mode, mode)
|> assign(:parent_categories, Catalog.list_proposal_parents(mode))
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{mode: mode})))
end
defp proposal_mode("activity"), do: :activity
defp proposal_mode(:activity), do: :activity
defp proposal_mode(_mode), do: :help
@impl true
def render(assigns) do
~H"""
@ -89,6 +123,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
<.form
for={@form}
id="category-proposal-form"
phx-change="validate"
phx-submit="propose"
class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6"
>
@ -113,8 +148,8 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
prompt={gettext("Top level")}
options={
Enum.map(
@categories,
&{WhoNeedHelp.Catalog.Category.name(
@parent_categories,
&{WhoNeedHelp.Catalog.category_path(
&1,
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
), &1.id}
@ -153,8 +188,25 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
{gettext("by %{name}", name: proposal.proposer.display_name)}
</p>
</div>
<button phx-click="vote" phx-value-id={proposal.id} class="btn btn-outline btn-sm">
▲ {proposal.vote_count}
<button
:if={!proposal.voted_by_current_user}
id={"proposal-vote-#{proposal.id}"}
phx-click="vote"
phx-value-id={proposal.id}
aria-pressed="false"
class="btn btn-outline btn-sm"
>
{gettext("Vote")} · {proposal.vote_count}
</button>
<button
:if={proposal.voted_by_current_user}
id={"proposal-unvote-#{proposal.id}"}
phx-click="unvote"
phx-value-id={proposal.id}
aria-pressed="true"
class="btn btn-success btn-sm"
>
✓ {gettext("Voted")} · {proposal.vote_count}
</button>
</div>
</article>

View File

@ -68,7 +68,11 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do
</tr>
<tr :for={{entry, index} <- Enum.with_index(@leaders, 1)}>
<td class="font-black">#{index}</td>
<td class="font-semibold">{entry.user.display_name}</td>
<td class="font-semibold">
<.link navigate={~p"/people/#{entry.user.id}"} class="link">
{entry.user.display_name}
</.link>
</td>
<td>{entry.location_supported_people}</td>
<td>{entry.verified_people}</td>
<td>{entry.unique_people}</td>

View File

@ -229,6 +229,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp error_message(:forbidden), do: gettext("Moderator access is required.")
defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.")
defp error_message(:mode_mismatch), do: gettext("Choose a category from the same mode.")
defp error_message(:cannot_restrict_self),
do: gettext("You cannot restrict your own moderator account.")
@ -545,7 +546,9 @@ defmodule WhoNeedHelpWeb.ModerationLive do
type="select"
label={gettext("Merge into")}
options={
Enum.map(@categories, &{WhoNeedHelp.Catalog.Category.name(&1, "en"), &1.id})
@categories
|> Enum.filter(&(&1.mode == proposal.mode))
|> Enum.map(&{WhoNeedHelp.Catalog.category_path(&1, "en"), &1.id})
}
/>
<.input field={merge_form[:note]} placeholder={gettext("Merge note")} />

View File

@ -212,7 +212,15 @@ defmodule WhoNeedHelpWeb.ProfileLive do
{gettext("None revealed yet.")}
</p>
<div :for={review <- @reviews} class="mt-3 border-t border-base-300 pt-3 text-sm">
<div class="font-semibold">{"★" |> String.duplicate(review.rating)}</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold">{"★" |> String.duplicate(review.rating)}</span>
<.link
navigate={~p"/people/#{review.reviewer.id}"}
class="link text-xs text-base-content/60"
>
{review.reviewer.display_name}
</.link>
</div>
<div class="mt-1">{review.comment}</div>
</div>
<button

View File

@ -0,0 +1,181 @@
defmodule WhoNeedHelpWeb.PublicProfileLive do
use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Trust
@impl true
def mount(%{"id" => id}, _session, socket) do
case Accounts.get_public_user(id) do
{:ok, user} ->
reviews = Trust.paginate_visible_reviews(user.id)
{:ok,
socket
|> assign(:page_title, user.display_name)
|> assign(:user, user)
|> assign(:reputation, Trust.reputation(user.id))
|> assign(:reviews, reviews.entries)
|> assign(:reviews_cursor, reviews.next_cursor)}
{:error, :not_found} ->
{:ok,
socket
|> put_flash(:error, gettext("This public profile is not available."))
|> push_navigate(to: ~p"/leaderboard")}
end
end
@impl true
def handle_event("load-more-reviews", _params, socket) do
page =
Trust.paginate_visible_reviews(socket.assigns.user.id,
after: socket.assigns.reviews_cursor
)
existing_ids = MapSet.new(socket.assigns.reviews, & &1.id)
{:noreply,
socket
|> assign(
:reviews,
socket.assigns.reviews ++
Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
)
|> assign(:reviews_cursor, page.next_cursor)}
end
defp provider_label(:github), do: "GitHub"
defp provider_label(:google), do: "Google"
defp provider_label(:instagram), do: "Instagram"
defp provider_label(:facebook), do: "Facebook"
defp provider_label(:telegram), do: "Telegram"
defp provider_label(:other), do: gettext("Other")
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}>
<.link navigate={~p"/leaderboard"} class="btn btn-ghost btn-sm">
{gettext("← Community helpers")}
</.link>
<div class="mt-5 grid gap-6 md:grid-cols-[1.15fr_.85fr]">
<section class="rounded-3xl border border-base-300 p-6">
<div class="text-sm font-semibold text-success">{gettext("COMMUNITY PROFILE")}</div>
<h1 class="mt-2 text-4xl font-black tracking-tight">{@user.display_name}</h1>
<p :if={@user.bio not in [nil, ""]} class="mt-4 whitespace-pre-wrap text-base-content/70">
{@user.bio}
</p>
<p :if={@user.bio in [nil, ""]} class="mt-4 text-sm text-base-content/55">
{gettext("This person has not added a public bio yet.")}
</p>
<div class="mt-7">
<h2 class="text-xl font-bold">{gettext("Public social links")}</h2>
<p class="mt-1 text-sm text-base-content/55">
{gettext(
"A verified badge means the account completed that provider's authorization flow. Manually added links remain unverified."
)}
</p>
<p :if={@user.social_identities == []} class="mt-4 text-sm text-base-content/55">
{gettext("No public social links added.")}
</p>
<div class="mt-4 flex flex-wrap gap-3">
<a
:for={identity <- @user.social_identities}
href={identity.profile_url}
target="_blank"
rel="noopener noreferrer nofollow ugc"
class="btn btn-outline btn-sm"
>
<span>{provider_label(identity.provider)}</span>
<span class={[
"badge badge-sm",
identity.verified_at && "badge-success",
is_nil(identity.verified_at) && "badge-warning"
]}>
{if identity.verified_at, do: gettext("verified"), else: gettext("unverified")}
</span>
</a>
</div>
</div>
<div :if={@user.tip_url not in [nil, ""]} class="mt-7 rounded-2xl bg-base-200 p-5">
<h2 class="font-bold">{gettext("Optional thanks")}</h2>
<p class="mt-1 text-sm text-base-content/60">
{gettext(
"This external link is optional and goes directly to the person. Who Need Help does not process or guarantee a payment."
)}
</p>
<a
href={@user.tip_url}
target="_blank"
rel="noopener noreferrer nofollow ugc"
class="btn btn-outline btn-sm mt-4"
>
{gettext("Open thank-you link")}
</a>
</div>
</section>
<aside class="space-y-6">
<section class="rounded-3xl bg-success p-6 text-success-content">
<h2 class="font-bold">{gettext("Public reputation")}</h2>
<div class="mt-5 grid grid-cols-2 gap-4">
<div>
<div class="text-3xl font-black">{@reputation.completed}</div>
<div class="text-xs">{gettext("completed")}</div>
</div>
<div>
<div class="text-3xl font-black">{@reputation.unique_people}</div>
<div class="text-xs">{gettext("unique people")}</div>
</div>
<div>
<div class="text-3xl font-black">{@reputation.verified_handovers}</div>
<div class="text-xs">{gettext("verified handovers")}</div>
</div>
<div>
<div class="text-3xl font-black">{@reputation.rating || "—"}</div>
<div class="text-xs">{gettext("rating")}</div>
</div>
</div>
</section>
<section class="rounded-3xl border border-base-300 p-6">
<h2 class="text-xl font-bold">{gettext("Revealed reviews")}</h2>
<p class="mt-1 text-sm text-base-content/55">
{gettext("A review becomes public only after both people submit for that match.")}
</p>
<p :if={@reviews == []} class="mt-4 text-sm text-base-content/55">
{gettext("None revealed yet.")}
</p>
<article :for={review <- @reviews} class="mt-4 border-t border-base-300 pt-4 text-sm">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold">{"★" |> String.duplicate(review.rating)}</span>
<.link
navigate={~p"/people/#{review.reviewer.id}"}
class="link text-xs text-base-content/60"
>
{review.reviewer.display_name}
</.link>
</div>
<p :if={review.comment not in [nil, ""]} class="mt-2 whitespace-pre-wrap">
{review.comment}
</p>
</article>
<button
:if={@reviews_cursor}
type="button"
phx-click="load-more-reviews"
class="btn btn-outline btn-sm mt-5 w-full"
>
{gettext("Load more")}
</button>
</section>
</aside>
</div>
</Layouts.app>
"""
end
end

View File

@ -831,7 +831,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
</div>
<div>
<span class="text-base-content/50">{gettext("Requester:")}</span>
{@request.requester.display_name}
<.link
navigate={~p"/people/#{@request.requester.id}"}
class="link font-medium"
>
{@request.requester.display_name}
</.link>
</div>
<div>
<span class="text-base-content/50">{gettext("Expires:")}</span>
@ -1067,7 +1072,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
<% @participant -> %>
<div class="mt-4 space-y-3">
<div class="rounded-xl bg-white/10 p-3 text-sm">
{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}
</.link>
</div>
<div class="grid grid-cols-3 gap-2 rounded-xl bg-white/10 p-3 text-center text-xs">
<div>

View File

@ -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

View File

@ -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(%{})

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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")