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) select: struct(identity, ^@public_social_identity_fields)
end 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 """ @doc """
Gets a user by email. Gets a user by email.

View File

@ -25,6 +25,14 @@ defmodule WhoNeedHelp.Catalog do
|> Repo.all() |> Repo.all()
end 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 get_category!(id), do: Repo.get!(Category, id)
def category_path(%Category{parent: %Category{} = parent} = category, locale) do def category_path(%Category{parent: %Category{} = parent} = category, locale) do
@ -38,6 +46,14 @@ defmodule WhoNeedHelp.Catalog do
end end
def paginate_proposals(options \\ []) do 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) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query() public_user = Accounts.public_user_query()
@ -48,6 +64,7 @@ defmodule WhoNeedHelp.Catalog do
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id) |> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> with_vote_count() |> with_vote_count()
|> with_current_vote(user_id)
|> preload([proposal], proposer: ^public_user, parent: []) |> preload([proposal], proposer: ^public_user, parent: [])
|> Repo.all() |> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id}) |> Pagination.page(limit, &{&1.inserted_at, &1.id})
@ -201,8 +218,8 @@ defmodule WhoNeedHelp.Catalog do
category_attrs category_attrs
|> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.new(fn {key, value} -> {to_string(key), value} end)
|> normalize_descriptions() |> normalize_descriptions()
|> Map.put_new("parent_id", proposal.parent_id) |> Map.put("parent_id", proposal.parent_id)
|> Map.put_new("mode", to_string(proposal.mode)) |> Map.put("mode", to_string(proposal.mode))
with {:ok, category} <- with {:ok, category} <-
%Category{} |> Category.changeset(category_attrs) |> Repo.insert(), %Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
@ -242,12 +259,10 @@ defmodule WhoNeedHelp.Catalog do
end end
def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do
with {:ok, category_id} <- cast_id(category_id), with {:ok, category_id} <- cast_id(category_id) do
%Category{} <- Repo.get(Category, category_id) do
moderate_proposal(moderator, proposal_id, :merged, category_id, note) moderate_proposal(moderator, proposal_id, :merged, category_id, note)
else else
{:error, :not_found} = error -> error {:error, :not_found} = error -> error
nil -> {:error, :not_found}
end end
end end
@ -257,10 +272,24 @@ defmodule WhoNeedHelp.Catalog do
Repo.transact(fn -> Repo.transact(fn ->
proposal = locked_proposal(proposal_id) 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 cond do
is_nil(proposal) -> is_nil(proposal) ->
{:error, :not_found} {: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 -> proposal.status == :open ->
with {:ok, proposal} <- with {:ok, proposal} <-
proposal proposal
@ -889,6 +918,21 @@ defmodule WhoNeedHelp.Catalog do
}) })
end 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, nil), do: query
defp before_proposal(query, {inserted_at, id}) do 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 :reviewed_at, :utc_datetime
field :moderation_note, :string field :moderation_note, :string
field :vote_count, :integer, virtual: true, default: 0 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 has_many :votes, WhoNeedHelp.Catalog.CategoryVote, foreign_key: :proposal_id
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)

View File

@ -72,6 +72,15 @@ defmodule WhoNeedHelp.ContentRemoval do
|> Repo.all() |> Repo.all()
end 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 def get_by_access_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id), with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <- {:ok, ^id} <-

View File

@ -4,6 +4,11 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
alias WhoNeedHelp.ContentRemoval alias WhoNeedHelp.ContentRemoval
alias WhoNeedHelp.ContentRemoval.Notice 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 def new(conn, params) do
attrs = attrs =
case internal_location(params["location"]) do case internal_location(params["location"]) do
@ -27,15 +32,25 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
def create_take_it_down(conn, _params), def create_take_it_down(conn, _params),
do: send_resp(conn, :bad_request, "Bad Request") do: send_resp(conn, :bad_request, "Bad Request")
def show(conn, %{"id" => id, "token" => token}) do def show(conn, %{"id" => id} = params) do
case ContentRemoval.get_by_access_token(id, token) 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) {:ok, notice} -> render(conn, :show, notice: notice)
{:error, :not_found} -> send_resp(conn, :not_found, "Not found") {:error, :not_found} -> send_resp(conn, :not_found, "Not found")
end end
end end
def show(conn, _params), do: send_resp(conn, :not_found, "Not found")
defp create_notice(conn, regime, params) do defp create_notice(conn, regime, params) do
case ContentRemoval.create_notice(conn.assigns.current_scope, regime, params) do case ContentRemoval.create_notice(conn.assigns.current_scope, regime, params) do
{:ok, notice} -> {: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." "Provide exact Who Need Help URLs. Do not upload or paste intimate imagery, identity documents, passwords, access codes, or unnecessary medical information."
)} )}
</div> </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"> <.form for={@form} action={~p"/legal/content-removal"} class="mt-8 space-y-4">
<.input <.input
@ -39,6 +46,9 @@
value={@contact_email || @form[:contact_email].value} value={@contact_email || @form[:contact_email].value}
readonly={not is_nil(@contact_email)} 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 <.input
field={@form[:relationship]} field={@form[:relationship]}
type="select" 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." "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> </p>
<.link navigate={~p"/legal/content-removal"} class="btn btn-primary mt-7"> <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")} {gettext("Back to removal form")}
</.link> </.link>
</div> </div>
</div>
</Layouts.app> </Layouts.app>

View File

@ -1,5 +1,12 @@
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:compact}> <Layouts.app flash={@flash} current_scope={@current_scope} page_width={:compact}>
<div> <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"> <div class="flex flex-wrap items-center gap-2">
<span class="badge badge-outline">{@notice.reference}</span> <span class="badge badge-outline">{@notice.reference}</span>
<span class={[ <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." "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> </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"> <.form for={@form} action={~p"/legal/take-it-down"} class="mt-8 space-y-4">
<.input <.input
@ -36,6 +43,9 @@
readonly={not is_nil(@contact_email)} readonly={not is_nil(@contact_email)}
required 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 <.input
field={@form[:relationship]} field={@form[:relationship]}
type="select" type="select"

View File

@ -28,6 +28,9 @@
readonly={not is_nil(@contact_email)} readonly={not is_nil(@contact_email)}
required 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 <.input
field={@form[:details]} field={@form[:details]}
type="textarea" type="textarea"

View File

@ -9,6 +9,9 @@
</p> </p>
<div class="mt-5 flex flex-wrap gap-2"> <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"> <.link navigate={~p"/legal/content-removal"} class="btn btn-outline btn-sm">
{gettext("Report content")} {gettext("Report content")}
</.link> </.link>
@ -45,6 +48,11 @@
readonly={not is_nil(@contact_email)} readonly={not is_nil(@contact_email)}
required 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[:subject]} label={gettext("Subject")} required />
<.input <.input
field={@form[:details]} field={@form[:details]}

View File

@ -13,6 +13,11 @@
reference: @reference reference: @reference
)} )}
</p> </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> </div>
</Layouts.app> </Layouts.app>

View File

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

View File

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

View File

@ -27,6 +27,22 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
end end
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 def handle_event("vote", %{"id" => id}, socket) do
case Catalog.vote(socket.assigns.current_scope, id) do case Catalog.vote(socket.assigns.current_scope, id) do
{:ok, _} -> {:noreply, load(socket)} {:ok, _} -> {:noreply, load(socket)}
@ -34,8 +50,19 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
end end
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 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) existing_ids = MapSet.new(socket.assigns.proposals, & &1.id)
{:noreply, {:noreply,
@ -56,21 +83,28 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
defp action_error(:proposal_closed), do: gettext("This proposal is already closed.") 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(: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(%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 action_error(_reason), do: gettext("Could not complete the action. Please try again.")
defp load(socket) do defp load(socket) do
proposals = Catalog.paginate_proposals() proposals = Catalog.paginate_proposals_for(socket.assigns.current_scope)
mode = socket.assigns[:proposal_mode] || :help
socket socket
|> assign(:page_title, gettext("Category proposals")) |> assign(:page_title, gettext("Category proposals"))
|> assign(:proposals, proposals.entries) |> assign(:proposals, proposals.entries)
|> assign(:proposals_cursor, proposals.next_cursor) |> assign(:proposals_cursor, proposals.next_cursor)
|> assign(:categories, Catalog.list_all_categories()) |> assign(:proposal_mode, mode)
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{}))) |> assign(:parent_categories, Catalog.list_proposal_parents(mode))
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{mode: mode})))
end end
defp proposal_mode("activity"), do: :activity
defp proposal_mode(:activity), do: :activity
defp proposal_mode(_mode), do: :help
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -89,6 +123,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
<.form <.form
for={@form} for={@form}
id="category-proposal-form" id="category-proposal-form"
phx-change="validate"
phx-submit="propose" phx-submit="propose"
class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6" 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")} prompt={gettext("Top level")}
options={ options={
Enum.map( Enum.map(
@categories, @parent_categories,
&{WhoNeedHelp.Catalog.Category.name( &{WhoNeedHelp.Catalog.category_path(
&1, &1,
Gettext.get_locale(WhoNeedHelpWeb.Gettext) Gettext.get_locale(WhoNeedHelpWeb.Gettext)
), &1.id} ), &1.id}
@ -153,8 +188,25 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
{gettext("by %{name}", name: proposal.proposer.display_name)} {gettext("by %{name}", name: proposal.proposer.display_name)}
</p> </p>
</div> </div>
<button phx-click="vote" phx-value-id={proposal.id} class="btn btn-outline btn-sm"> <button
▲ {proposal.vote_count} :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> </button>
</div> </div>
</article> </article>

View File

@ -68,7 +68,11 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do
</tr> </tr>
<tr :for={{entry, index} <- Enum.with_index(@leaders, 1)}> <tr :for={{entry, index} <- Enum.with_index(@leaders, 1)}>
<td class="font-black">#{index}</td> <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.location_supported_people}</td>
<td>{entry.verified_people}</td> <td>{entry.verified_people}</td>
<td>{entry.unique_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(:forbidden), do: gettext("Moderator access is required.")
defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.") 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), defp error_message(:cannot_restrict_self),
do: gettext("You cannot restrict your own moderator account.") do: gettext("You cannot restrict your own moderator account.")
@ -545,7 +546,9 @@ defmodule WhoNeedHelpWeb.ModerationLive do
type="select" type="select"
label={gettext("Merge into")} label={gettext("Merge into")}
options={ 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")} /> <.input field={merge_form[:note]} placeholder={gettext("Merge note")} />

View File

@ -212,7 +212,15 @@ defmodule WhoNeedHelpWeb.ProfileLive do
{gettext("None revealed yet.")} {gettext("None revealed yet.")}
</p> </p>
<div :for={review <- @reviews} class="mt-3 border-t border-base-300 pt-3 text-sm"> <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 class="mt-1">{review.comment}</div>
</div> </div>
<button <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>
<div> <div>
<span class="text-base-content/50">{gettext("Requester:")}</span> <span class="text-base-content/50">{gettext("Requester:")}</span>
<.link
navigate={~p"/people/#{@request.requester.id}"}
class="link font-medium"
>
{@request.requester.display_name} {@request.requester.display_name}
</.link>
</div> </div>
<div> <div>
<span class="text-base-content/50">{gettext("Expires:")}</span> <span class="text-base-content/50">{gettext("Expires:")}</span>
@ -1067,7 +1072,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
<% @participant -> %> <% @participant -> %>
<div class="mt-4 space-y-3"> <div class="mt-4 space-y-3">
<div class="rounded-xl bg-white/10 p-3 text-sm"> <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>
<div class="grid grid-cols-3 gap-2 rounded-xl bg-white/10 p-3 text-center text-xs"> <div class="grid grid-cols-3 gap-2 rounded-xl bg-white/10 p-3 text-center text-xs">
<div> <div>

View File

@ -84,7 +84,6 @@ defmodule WhoNeedHelpWeb.Router do
get "/legal/content-removal", ContentRemovalController, :new get "/legal/content-removal", ContentRemovalController, :new
post "/legal/content-removal", ContentRemovalController, :create post "/legal/content-removal", ContentRemovalController, :create
get "/legal/content-removal/received", ContentRemovalController, :received get "/legal/content-removal/received", ContentRemovalController, :received
get "/legal/content-removal/:id", ContentRemovalController, :show
get "/legal/take-it-down", ContentRemovalController, :take_it_down get "/legal/take-it-down", ContentRemovalController, :take_it_down
post "/legal/take-it-down", ContentRemovalController, :create_take_it_down post "/legal/take-it-down", ContentRemovalController, :create_take_it_down
end end
@ -138,6 +137,13 @@ defmodule WhoNeedHelpWeb.Router do
get "/auth/social/:provider", SocialOAuthController, :request get "/auth/social/:provider", SocialOAuthController, :request
get "/auth/social/:provider/callback", SocialOAuthController, :callback get "/auth/social/:provider/callback", SocialOAuthController, :callback
get "/support/requests", SupportController, :index 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 end
scope "/", WhoNeedHelpWeb do scope "/", WhoNeedHelpWeb do
@ -153,6 +159,7 @@ defmodule WhoNeedHelpWeb.Router do
live "/activities/:id", ActivityLive.Show, :show live "/activities/:id", ActivityLive.Show, :show
live "/categories/proposals", CategoryProposalLive, :index live "/categories/proposals", CategoryProposalLive, :index
live "/profile", ProfileLive, :edit live "/profile", ProfileLive, :edit
live "/people/:id", PublicProfileLive, :show
live "/leaderboard", LeaderboardLive, :index live "/leaderboard", LeaderboardLive, :index
end end

View File

@ -89,6 +89,56 @@ defmodule WhoNeedHelp.AccountsTest do
end end
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 describe "register_user/1" do
test "requires email to be set" do test "requires email to be set" do
{:error, changeset} = Accounts.register_user(%{}) {: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 [%{id: listed_id, vote_count: 1}] = Catalog.list_proposals()
assert listed_id == proposal.id 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.email
refute export =~ context.requester.display_name refute export =~ context.requester.display_name
end 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 test "default urgent and roadside categories are hierarchical and idempotent", context do
first_ids = first_ids =
Catalog.list_categories() Catalog.list_categories()

View File

@ -211,6 +211,32 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert request.contact_verified_at assert request.contact_verified_at
end 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 defp moderator_scope do
user_fixture() user_fixture()
|> Ecto.Changeset.change(role: :moderator) |> Ecto.Changeset.change(role: :moderator)

View File

@ -114,6 +114,67 @@ defmodule WhoNeedHelp.TrustSafetyTest do
) )
end 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 test "tracking derives movement and proximity from browser accuracy envelopes", context do
{:ok, request} = Help.create_request(context.requester_scope, context.attrs) {:ok, request} = Help.create_request(context.requester_scope, context.attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)

View File

@ -80,7 +80,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
describe "authenticated support" do describe "authenticated support" do
setup :register_and_log_in_user 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, conn: conn,
user: user user: user
} do } do
@ -91,9 +91,50 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
"details" => "I need assistance understanding an account setting." "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"/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" assert html_response(get(conn, ~p"/users/settings"), 200) =~ ~p"/account/delete"
end 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 end
describe "operator queue authorization" do describe "operator queue authorization" do

View File

@ -15,6 +15,71 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Repeated help between the same pair" assert html =~ "Repeated help between the same pair"
end 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 test "authenticated LiveView honors the locale stored by the browser pipeline", %{conn: conn} do
conn = get(conn, ~p"/?locale=uk") conn = get(conn, ~p"/?locale=uk")
{:ok, _view, html} = live(conn, ~p"/requests") {:ok, _view, html} = live(conn, ~p"/requests")
@ -860,6 +925,55 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert render(view) =~ "Chain repair" assert render(view) =~ "Chain repair"
end 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 test "review submission updates both participant pages and hides the submitted form" do
category = Catalog.seed_defaults() category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Review requester") requester = user_fixture(display_name: "Review requester")