diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex
index 2139de2..0eaecf8 100644
--- a/lib/who_need_help/accounts.ex
+++ b/lib/who_need_help/accounts.ex
@@ -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.
diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex
index dd4f4b1..1054694 100644
--- a/lib/who_need_help/catalog.ex
+++ b/lib/who_need_help/catalog.ex
@@ -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
diff --git a/lib/who_need_help/catalog/category_proposal.ex b/lib/who_need_help/catalog/category_proposal.ex
index abeaac1..550036d 100644
--- a/lib/who_need_help/catalog/category_proposal.ex
+++ b/lib/who_need_help/catalog/category_proposal.ex
@@ -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)
diff --git a/lib/who_need_help/content_removal.ex b/lib/who_need_help/content_removal.ex
index 86f1d7d..f07c462 100644
--- a/lib/who_need_help/content_removal.ex
+++ b/lib/who_need_help/content_removal.ex
@@ -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} <-
diff --git a/lib/who_need_help_web/controllers/content_removal_controller.ex b/lib/who_need_help_web/controllers/content_removal_controller.ex
index dc2c1a0..8243e47 100644
--- a/lib/who_need_help_web/controllers/content_removal_controller.ex
+++ b/lib/who_need_help_web/controllers/content_removal_controller.ex
@@ -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} ->
diff --git a/lib/who_need_help_web/controllers/content_removal_html/index.html.heex b/lib/who_need_help_web/controllers/content_removal_html/index.html.heex
new file mode 100644
index 0000000..68f5d2b
--- /dev/null
+++ b/lib/who_need_help_web/controllers/content_removal_html/index.html.heex
@@ -0,0 +1,44 @@
+
+ {gettext(
+ "Only notices submitted while signed in appear here. Notices submitted without an account remain accessible through their private email link."
+ )}
+ {notice.explanation}
+ {gettext("You have not submitted any content-removal notices while signed in.")}
+ {gettext("Your notices")}
+
+ {gettext("Status updates go to the email address of your signed-in account.")} +
<.input field={@form[:relationship]} type="select" diff --git a/lib/who_need_help_web/controllers/content_removal_html/received.html.heex b/lib/who_need_help_web/controllers/content_removal_html/received.html.heex index 143505c..b6acfaa 100644 --- a/lib/who_need_help_web/controllers/content_removal_html/received.html.heex +++ b/lib/who_need_help_web/controllers/content_removal_html/received.html.heex @@ -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." )} - <.link navigate={~p"/legal/content-removal"} class="btn btn-primary mt-7"> - {gettext("Back to removal form")} - ++ {gettext("Status updates go to the email address of your signed-in account.")} +
<.input field={@form[:relationship]} type="select" diff --git a/lib/who_need_help_web/controllers/support_html/delete_account.html.heex b/lib/who_need_help_web/controllers/support_html/delete_account.html.heex index ab1e2f3..456b752 100644 --- a/lib/who_need_help_web/controllers/support_html/delete_account.html.heex +++ b/lib/who_need_help_web/controllers/support_html/delete_account.html.heex @@ -28,6 +28,9 @@ readonly={not is_nil(@contact_email)} required /> ++ {gettext("This request is linked to your signed-in account and its email address.")} +
<.input field={@form[:details]} type="textarea" diff --git a/lib/who_need_help_web/controllers/support_html/new.html.heex b/lib/who_need_help_web/controllers/support_html/new.html.heex index 8db4031..11598e4 100644 --- a/lib/who_need_help_web/controllers/support_html/new.html.heex +++ b/lib/who_need_help_web/controllers/support_html/new.html.heex @@ -9,6 +9,9 @@+ {gettext( + "This request is linked to your signed-in account. Replies go to its email address." + )} +
<.input field={@form[:subject]} label={gettext("Subject")} required /> <.input field={@form[:details]} diff --git a/lib/who_need_help_web/controllers/support_html/received.html.heex b/lib/who_need_help_web/controllers/support_html/received.html.heex index 7418261..b95756c 100644 --- a/lib/who_need_help_web/controllers/support_html/received.html.heex +++ b/lib/who_need_help_web/controllers/support_html/received.html.heex @@ -13,6 +13,11 @@ reference: @reference )} - <.link navigate={~p"/support"} class="btn btn-primary mt-7">{gettext("Back to support")} ++ {@user.bio} +
++ {gettext("This person has not added a public bio yet.")} +
+ ++ {gettext( + "A verified badge means the account completed that provider's authorization flow. Manually added links remain unverified." + )} +
++ {gettext("No public social links added.")} +
+ ++ {gettext( + "This external link is optional and goes directly to the person. Who Need Help does not process or guarantee a payment." + )} +
+ + {gettext("Open thank-you link")} + +