Improve trust profiles and account case history
This commit is contained in:
parent
f97b00bda8
commit
c634c136c9
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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} <-
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<.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">
|
||||
<.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">
|
||||
<.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)}>
|
||||
<.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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
181
lib/who_need_help_web/live/public_profile_live.ex
Normal file
181
lib/who_need_help_web/live/public_profile_live.ex
Normal 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
|
||||
|
|
@ -831,7 +831,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/50">{gettext("Requester:")}</span>
|
||||
<.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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(%{})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user