Queue bounded support and legal email

This commit is contained in:
SimpleTest 2026-08-12 18:53:25 +03:00
parent 346b03695f
commit d2fb194d56
12 changed files with 390 additions and 110 deletions

View File

@ -77,7 +77,9 @@ local Codex CLI authenticated with their ChatGPT subscription.
verified-contact status links, verification-gated support alerts, and audited verified-contact status links, verification-gated support alerts, and audited
staff queues. Support sends one initial response email and later public-status staff queues. Support sends one initial response email and later public-status
changes through the dedicated Oban `mail` queue; ordinary conversation changes through the dedicated Oban `mail` queue; ordinary conversation
messages stay in the private inbox and optional push. messages stay in the private inbox and optional push. Content-removal
confirmation, receipt, and public decision updates use the same bounded queue,
while internal assignment-only changes do not email the submitter.
Authenticated users can download an allow-listed JSON data export that omits Authenticated users can download an allow-listed JSON data export that omits
password/session/push credentials and counterpart message bodies. A password/session/push credentials and counterpart message bodies. A
moderator-only deletion preflight reports active workflows without performing moderator-only deletion preflight reports active workflows without performing

View File

@ -1047,11 +1047,13 @@ The worker role consumes `maintenance`, `push`, and `mail`. Configure their
per-worker limits with `OBAN_MAINTENANCE_CONCURRENCY`, per-worker limits with `OBAN_MAINTENANCE_CONCURRENCY`,
`OBAN_PUSH_CONCURRENCY`, and `OBAN_MAIL_CONCURRENCY`; multiplying a value by `OBAN_PUSH_CONCURRENCY`, and `OBAN_MAIL_CONCURRENCY`; multiplying a value by
the number of worker replicas gives the configured cluster-wide concurrency the number of worker replicas gives the configured cluster-wide concurrency
for that queue. Authentication and mandatory legal-intake confirmations remain for that queue. Authentication links remain synchronous because their database
synchronous. Ordinary support-update email runs in `mail`, so SMTP latency does token is committed only when delivery succeeds. Support-contact verification,
not occupy a web request or the `push` queue. Optional new-case operator alerts support updates, content-removal confirmation/receipt/decision messages, and
also run in `mail`; requester conversation messages do not generate operator optional new-case operator alerts run in `mail`, so their SMTP latency does not
email. occupy a web request or the `push` queue. Requester and staff conversation
messages do not each generate email, and internal assignment-only legal updates
do not notify the submitter.
After starting the isolated load project, run an explicitly sized experiment: After starting the isolated load project, run an explicitly sized experiment:

View File

@ -115,6 +115,14 @@ specific review.
public support contact is verified. Content-removal notification behavior is public support contact is verified. Content-removal notification behavior is
kept separate because those notices can require prompt legal or safety review. kept separate because those notices can require prompt legal or safety review.
All support-contact and content-removal confirmation, receipt, decision, and
optional operator-alert messages run through the dedicated Oban `mail` queue.
Authentication email remains a separate immediate path. A legal operator's
assignment or other internal-only change does not email the submitter; a public
status or decision-text change does. Queue uniqueness coalesces an undelivered
update to the latest persisted case state instead of sending one message for
each rapid edit.
The alert intentionally excludes the free-text report and reported URLs. The The alert intentionally excludes the free-text report and reported URLs. The
full record remains in the protected database queue. If the variable is empty, full record remains in the protected database queue. If the variable is empty,
intake and reporter acknowledgement still work, but no operator inbox alert is intake and reporter acknowledgement still work, but no operator inbox alert is

View File

@ -30,11 +30,14 @@ results from product limits and unknown production properties.
response or a later public-status change, while authenticated requesters also response or a later public-status change, while authenticated requesters also
receive an in-app support update. Ordinary requester and staff conversation receive an in-app support update. Ordinary requester and staff conversation
messages do not each generate email. messages do not each generate email.
- Optional support email runs through the dedicated Oban `mail` queue, whose - Support-contact verification, optional support updates, content-removal
default concurrency is one per worker. Fixed-purpose delivery telemetry confirmation/receipt/decision messages, and optional operator alerts run
records only the allow-listed purpose and outcome, without email addresses or through the dedicated Oban `mail` queue, whose default concurrency is one per
message content. Authentication and content-removal confirmations remain worker. Internal legal assignment-only changes do not email the submitter.
transactional email paths by design. Fixed-purpose delivery telemetry records only the allow-listed purpose and
outcome, without email addresses or message content. Authentication links
remain synchronous by design because their database token is committed only
when delivery succeeds.
- Exact-name inspection after completion found no container or temporary image - Exact-name inspection after completion found no container or temporary image
belonging to quality scope `20260812133255-3403320` / belonging to quality scope `20260812133255-3403320` /
`wnh_quality_202608121332553403320`. `wnh_quality_202608121332553403320`.

View File

@ -6,6 +6,7 @@ defmodule WhoNeedHelp.ContentRemoval do
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.{Scope, User} alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.ContentRemoval.{Notice, Notifier} alias WhoNeedHelp.ContentRemoval.{Notice, Notifier}
alias WhoNeedHelp.Mail.ContentRemovalEmailWorker
alias WhoNeedHelp.Pagination alias WhoNeedHelp.Pagination
alias WhoNeedHelp.PublicAccess alias WhoNeedHelp.PublicAccess
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
@ -63,11 +64,11 @@ defmodule WhoNeedHelp.ContentRemoval do
"regime" => to_string(notice.regime), "regime" => to_string(notice.regime),
"status" => to_string(notice.status) "status" => to_string(notice.status)
} }
) do ),
{:ok, _jobs} <- enqueue_created_emails(notice) do
{:ok, notice} {:ok, notice}
end end
end) end)
|> notify_received()
end end
end end
@ -198,7 +199,13 @@ defmodule WhoNeedHelp.ContentRemoval do
|> Repo.one() |> Repo.one()
if notice do if notice do
with {:ok, notice} <- notice |> Notice.moderation_changeset(attrs) |> Repo.update(), changeset = Notice.moderation_changeset(notice, attrs)
requester_update? =
Ecto.Changeset.changed?(changeset, :status) or
Ecto.Changeset.changed?(changeset, :resolution_note)
with {:ok, notice} <- Repo.update(changeset),
{:ok, _audit} <- {:ok, _audit} <-
Trust.audit( Trust.audit(
moderator.id, moderator.id,
@ -209,14 +216,15 @@ defmodule WhoNeedHelp.ContentRemoval do
"status" => to_string(notice.status), "status" => to_string(notice.status),
"assigned_to_id" => notice.assigned_to_id "assigned_to_id" => notice.assigned_to_id
} }
) do ),
{:ok, Repo.preload(notice, :assigned_to, force: true)} {:ok, _job} <- maybe_enqueue_update(notice, requester_update?) do
{:ok, {Repo.preload(notice, :assigned_to, force: true), requester_update?}}
end end
else else
{:error, :not_found} {:error, :not_found}
end end
end) end)
|> notify_decision() |> normalize_moderation_result()
end end
else else
false -> {:error, :forbidden} false -> {:error, :forbidden}
@ -224,52 +232,8 @@ defmodule WhoNeedHelp.ContentRemoval do
end end
end end
defp notify_received({:ok, %Notice{contact_email: email, contact_verified_at: nil} = notice}) defp normalize_moderation_result({:ok, {notice, _requester_update?}}), do: {:ok, notice}
when is_binary(email) and email != "" do defp normalize_moderation_result(result), do: result
case Notifier.deliver_confirmation(notice, confirmation_url(notice)) do
{:ok, _metadata} -> mark_acknowledgement_sent(notice)
_error -> {:ok, notice}
end
end
defp notify_received({:ok, %Notice{contact_email: email} = notice}) when email in [nil, ""],
do: notify_operator(notice)
defp notify_received({:ok, notice}) do
result =
case Notifier.deliver_received(notice, status_url(notice)) do
{:ok, _metadata} -> mark_acknowledgement_sent(notice)
_error -> {:ok, notice}
end
case result do
{:ok, current} -> notify_operator(current)
error -> error
end
end
defp notify_received(result), do: result
defp notify_operator(notice) do
_ = Notifier.deliver_operator_alert(notice)
{:ok, notice}
end
defp notify_decision({:ok, %Notice{contact_verified_at: nil} = notice}), do: {:ok, notice}
defp notify_decision({:ok, notice}) do
case Notifier.deliver_decision(notice, status_url(notice)) do
{:ok, _metadata} ->
notice
|> Ecto.Changeset.change(decision_sent_at: DateTime.utc_now(:second))
|> Repo.update()
_error ->
{:ok, notice}
end
end
defp notify_decision(result), do: result
defp verify_contact(%Notice{contact_verified_at: nil} = notice) do defp verify_contact(%Notice{contact_verified_at: nil} = notice) do
Repo.transact(fn -> Repo.transact(fn ->
@ -287,13 +251,13 @@ defmodule WhoNeedHelp.ContentRemoval do
{:ok, {current, :already_verified}} {:ok, {current, :already_verified}}
true -> true ->
with {:ok, verified} <-
current current
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second)) |> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))
|> Repo.update() |> Repo.update(),
|> then(fn {:ok, _jobs} <- enqueue_verified_emails(verified) do
{:ok, verified} -> {:ok, {verified, :newly_verified}} {:ok, {verified, :newly_verified}}
error -> error end
end)
end end
end) end)
|> notify_verified_notice() |> notify_verified_notice()
@ -302,8 +266,6 @@ defmodule WhoNeedHelp.ContentRemoval do
defp verify_contact(%Notice{} = notice), do: {:ok, notice} defp verify_contact(%Notice{} = notice), do: {:ok, notice}
defp notify_verified_notice({:ok, {notice, :newly_verified}}) do defp notify_verified_notice({:ok, {notice, :newly_verified}}) do
_ = Notifier.deliver_received(notice, status_url(notice))
_ = Notifier.deliver_operator_alert(notice)
{:ok, notice} {:ok, notice}
end end
@ -325,12 +287,46 @@ defmodule WhoNeedHelp.ContentRemoval do
Application.fetch_env!(:who_need_help, :public_case_access_max_age_seconds) Application.fetch_env!(:who_need_help, :public_case_access_max_age_seconds)
end end
defp mark_acknowledgement_sent(notice) do defp enqueue_email(notice, kind) do
notice %{notice_id: notice.id, kind: to_string(kind)}
|> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second)) |> ContentRemovalEmailWorker.new()
|> Repo.update() |> Oban.insert()
end end
defp enqueue_created_emails(%Notice{contact_email: email, contact_verified_at: nil} = notice)
when is_binary(email) and email != "" do
with {:ok, confirmation} <- enqueue_email(notice, :confirmation) do
{:ok, [confirmation]}
end
end
defp enqueue_created_emails(%Notice{} = notice), do: enqueue_verified_emails(notice)
defp enqueue_verified_emails(%Notice{} = notice) do
with {:ok, received} <- maybe_enqueue_received(notice),
{:ok, operator} <- maybe_enqueue_operator(notice) do
{:ok, [received, operator]}
end
end
defp maybe_enqueue_received(%Notice{contact_email: email} = notice)
when is_binary(email) and email != "",
do: enqueue_email(notice, :received)
defp maybe_enqueue_received(%Notice{}), do: {:ok, :no_contact_email}
defp maybe_enqueue_operator(%Notice{} = notice) do
if Notifier.operator_alerts_enabled?(),
do: enqueue_email(notice, :operator),
else: {:ok, :disabled}
end
defp maybe_enqueue_update(%Notice{contact_verified_at: nil}, _requester_update?),
do: {:ok, :contact_not_verified}
defp maybe_enqueue_update(%Notice{} = notice, true), do: enqueue_email(notice, :update)
defp maybe_enqueue_update(%Notice{}, false), do: {:ok, :not_needed}
defp visible_to_staff(query) do defp visible_to_staff(query) do
where( where(
query, query,

View File

@ -93,6 +93,8 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
end end
end end
def operator_alerts_enabled?, do: match?({:ok, _address}, operator_alert_address())
defp deliver(recipient, subject, body, kind) do defp deliver(recipient, subject, body, kind) do
from = Application.fetch_env!(:who_need_help, :mailer_from) from = Application.fetch_env!(:who_need_help, :mailer_from)

View File

@ -17,7 +17,6 @@ defmodule WhoNeedHelp.EmailDelivery do
:content_removal_operator, :content_removal_operator,
:content_removal_received, :content_removal_received,
:content_removal_update, :content_removal_update,
:nearby_alert,
:support_confirmation, :support_confirmation,
:support_operator, :support_operator,
:support_update :support_update

View File

@ -0,0 +1,74 @@
defmodule WhoNeedHelp.Mail.ContentRemovalEmailWorker do
@moduledoc false
use Oban.Worker,
queue: :mail,
max_attempts: 8,
unique: [
period: :infinity,
fields: [:args, :worker],
keys: [:notice_id, :kind],
states: :incomplete
]
alias WhoNeedHelp.ContentRemoval
alias WhoNeedHelp.ContentRemoval.{Notice, Notifier}
alias WhoNeedHelp.Repo
@impl Oban.Worker
def perform(%Oban.Job{args: %{"notice_id" => notice_id, "kind" => kind}}) do
case Repo.get(Notice, notice_id) do
nil ->
{:cancel, :content_removal_notice_missing}
%Notice{} = notice ->
deliver(notice, kind)
end
end
defp deliver(%Notice{contact_verified_at: verified_at}, "confirmation")
when not is_nil(verified_at),
do: {:cancel, :contact_already_verified}
defp deliver(%Notice{} = notice, "confirmation") do
notice
|> Notifier.deliver_confirmation(ContentRemoval.confirmation_url(notice))
|> mark_sent(notice, :acknowledgement_sent_at)
end
defp deliver(%Notice{contact_verified_at: nil}, kind) when kind in ["received", "update"],
do: {:cancel, :contact_not_verified}
defp deliver(%Notice{} = notice, "received") do
notice
|> Notifier.deliver_received(ContentRemoval.status_url(notice))
|> mark_sent(notice, :acknowledgement_sent_at)
end
defp deliver(%Notice{} = notice, "update") do
notice
|> Notifier.deliver_decision(ContentRemoval.status_url(notice))
|> mark_sent(notice, :decision_sent_at)
end
defp deliver(%Notice{} = notice, "operator") do
case Notifier.deliver_operator_alert(notice) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp deliver(%Notice{}, _kind), do: {:cancel, :unknown_content_removal_email_kind}
defp mark_sent({:ok, _metadata}, notice, field) do
notice
|> Ecto.Changeset.change([{field, DateTime.utc_now(:second)}])
|> Repo.update()
|> case do
{:ok, _notice} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp mark_sent({:error, reason}, _notice, _field), do: {:error, reason}
end

View File

@ -0,0 +1,34 @@
defmodule WhoNeedHelp.Mail.SupportConfirmationWorker do
@moduledoc false
use Oban.Worker,
queue: :mail,
max_attempts: 8,
unique: [
period: :infinity,
fields: [:args, :worker],
keys: [:request_id],
states: :incomplete
]
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Support
alias WhoNeedHelp.Support.{Notifier, SupportRequest}
@impl Oban.Worker
def perform(%Oban.Job{args: %{"request_id" => request_id}}) do
case Repo.get(SupportRequest, request_id) do
nil ->
{:cancel, :support_request_missing}
%SupportRequest{contact_verified_at: verified_at} when not is_nil(verified_at) ->
{:cancel, :contact_already_verified}
%SupportRequest{} = request ->
case Notifier.deliver_confirmation(request, Support.confirmation_url(request)) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
end

View File

@ -6,7 +6,13 @@ defmodule WhoNeedHelp.Support do
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.DataLifecycle alias WhoNeedHelp.Accounts.DataLifecycle
alias WhoNeedHelp.Accounts.{Scope, User} alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.Mail.{SupportOperatorAlertWorker, SupportUpdateWorker}
alias WhoNeedHelp.Mail.{
SupportConfirmationWorker,
SupportOperatorAlertWorker,
SupportUpdateWorker
}
alias WhoNeedHelp.Notifications alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Pagination alias WhoNeedHelp.Pagination
alias WhoNeedHelp.PublicAccess alias WhoNeedHelp.PublicAccess
@ -70,14 +76,14 @@ defmodule WhoNeedHelp.Support do
request.status, request.status,
user && user.id, user && user.id,
:requester :requester
) do ),
{:ok, _job} <- enqueue_created_email(request) do
{:ok, request} {:ok, request}
end end
end) end)
result result
|> resolve_duplicate_submission(fingerprint) |> resolve_duplicate_submission(fingerprint)
|> notify_created()
|> broadcast_request_update() |> broadcast_request_update()
end end
end end
@ -257,14 +263,14 @@ defmodule WhoNeedHelp.Support do
"status" => to_string(request.status), "status" => to_string(request.status),
"assigned_to_id" => request.assigned_to_id "assigned_to_id" => request.assigned_to_id
} }
) do ),
{:ok, update = %{
{preload_conversation(request),
%{
audit_id: audit.id, audit_id: audit.id,
message_added: match?(%ConversationMessage{}, message), message_added: match?(%ConversationMessage{}, message),
status_changed: previous_status != request.status status_changed: previous_status != request.status
}}} },
{:ok, _job} <- maybe_enqueue_support_email(request, update) do
{:ok, {preload_conversation(request), update}}
end end
else else
{:error, :not_found} {:error, :not_found}
@ -406,18 +412,13 @@ defmodule WhoNeedHelp.Support do
end end
end end
defp notify_created({:ok, {request, :pending_verification}}) do defp enqueue_created_email(%SupportRequest{contact_verified_at: nil} = request) do
_ = Notifier.deliver_confirmation(request, confirmation_url(request)) request.id
{:ok, request} |> then(&SupportConfirmationWorker.new(%{request_id: &1}))
|> Oban.insert()
end end
defp notify_created({:ok, {request, :verified}}) do defp enqueue_created_email(%SupportRequest{} = request), do: enqueue_operator_alert(request)
_ = enqueue_operator_alert(request)
{:ok, request}
end
defp notify_created({:ok, {request, :duplicate}}), do: {:ok, request}
defp notify_created(result), do: result
defp notify_decision({:ok, {%SupportRequest{contact_verified_at: nil} = request, _update}}), defp notify_decision({:ok, {%SupportRequest{contact_verified_at: nil} = request, _update}}),
do: {:ok, request} do: {:ok, request}
@ -427,10 +428,6 @@ defmodule WhoNeedHelp.Support do
notify_requester_in_app(request, update) notify_requester_in_app(request, update)
end end
if support_email_needed?(request, update) do
_ = enqueue_support_email(request)
end
{:ok, request} {:ok, request}
end end
@ -493,7 +490,8 @@ defmodule WhoNeedHelp.Support do
"support_request.contact_verified", "support_request.contact_verified",
"support_request", "support_request",
verified.id verified.id
) do ),
{:ok, _job} <- enqueue_operator_alert(verified) do
{:ok, {verified, :newly_verified}} {:ok, {verified, :newly_verified}}
end end
end end
@ -504,8 +502,6 @@ defmodule WhoNeedHelp.Support do
defp verify_contact(%SupportRequest{} = request), do: {:ok, request} defp verify_contact(%SupportRequest{} = request), do: {:ok, request}
defp notify_verified_request({:ok, {request, :newly_verified}}) do defp notify_verified_request({:ok, {request, :newly_verified}}) do
_ = enqueue_operator_alert(request)
event = {:support_request_updated, request.id} event = {:support_request_updated, request.id}
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, request_topic(request.id), event) Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, request_topic(request.id), event)
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @staff_topic, event) Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @staff_topic, event)
@ -538,6 +534,14 @@ defmodule WhoNeedHelp.Support do
update.status_changed or (update.message_added and is_nil(request.response_sent_at)) update.status_changed or (update.message_added and is_nil(request.response_sent_at))
end end
defp maybe_enqueue_support_email(request, update) do
if support_email_needed?(request, update) do
enqueue_support_email(request)
else
{:ok, :not_needed}
end
end
defp enqueue_support_email(request) do defp enqueue_support_email(request) do
request.id request.id
|> then(&SupportUpdateWorker.new(%{request_id: &1})) |> then(&SupportUpdateWorker.new(%{request_id: &1}))
@ -757,15 +761,14 @@ defmodule WhoNeedHelp.Support do
end end
defp resolve_duplicate_submission({:ok, request}, _fingerprint) do defp resolve_duplicate_submission({:ok, request}, _fingerprint) do
outcome = if request.contact_verified_at, do: :verified, else: :pending_verification {:ok, request}
{:ok, {request, outcome}}
end end
defp resolve_duplicate_submission({:error, %Ecto.Changeset{} = changeset} = error, fingerprint) defp resolve_duplicate_submission({:error, %Ecto.Changeset{} = changeset} = error, fingerprint)
when is_binary(fingerprint) do when is_binary(fingerprint) do
if duplicate_fingerprint_error?(changeset) do if duplicate_fingerprint_error?(changeset) do
case Repo.get_by(SupportRequest, public_submission_fingerprint: fingerprint) do case Repo.get_by(SupportRequest, public_submission_fingerprint: fingerprint) do
%SupportRequest{} = request -> {:ok, {request, :duplicate}} %SupportRequest{} = request -> {:ok, request}
nil -> error nil -> error
end end
else else

View File

@ -7,7 +7,14 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support} alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support}
alias WhoNeedHelp.ContentRemoval.Notice alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Mail.{SupportOperatorAlertWorker, SupportUpdateWorker}
alias WhoNeedHelp.Mail.{
ContentRemovalEmailWorker,
SupportConfirmationWorker,
SupportOperatorAlertWorker,
SupportUpdateWorker
}
alias WhoNeedHelp.Notifications.Notification alias WhoNeedHelp.Notifications.Notification
alias WhoNeedHelp.Support.{ConversationMessage, StatusEvent, SupportRequest} alias WhoNeedHelp.Support.{ConversationMessage, StatusEvent, SupportRequest}
alias WhoNeedHelp.Trust.AuditEvent alias WhoNeedHelp.Trust.AuditEvent
@ -27,6 +34,14 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert is_binary(request.public_submission_fingerprint) assert is_binary(request.public_submission_fingerprint)
assert is_nil(request.contact_verified_at) assert is_nil(request.contact_verified_at)
assert_enqueued(
worker: SupportConfirmationWorker,
queue: :mail,
args: %{"request_id" => request.id}
)
assert :ok = perform_job(SupportConfirmationWorker, %{"request_id" => request.id})
test_pid = self() test_pid = self()
assert_email_sent(fn email -> assert_email_sent(fn email ->
@ -103,6 +118,8 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
"Please review the moderation decision identified in my account notice." "Please review the moderation decision identified in my account notice."
}) })
assert :ok = perform_job(SupportConfirmationWorker, %{"request_id" => request.id})
assert_email_sent(fn email -> assert_email_sent(fn email ->
email.to == [{"", "appeal@example.com"}] and email.to == [{"", "appeal@example.com"}] and
email.reply_to == {"", "support@example.com"} and email.reply_to == {"", "support@example.com"} and
@ -179,6 +196,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
} }
assert {:ok, first} = Support.create_request(nil, attrs) assert {:ok, first} = Support.create_request(nil, attrs)
assert :ok = perform_job(SupportConfirmationWorker, %{"request_id" => first.id})
assert_email_sent() assert_email_sent()
assert {:ok, duplicate} = assert {:ok, duplicate} =
@ -386,11 +404,98 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert {:ok, assigned_notice} = assert {:ok, assigned_notice} =
ContentRemoval.moderate(operator_scope, notice.id, %{ ContentRemoval.moderate(operator_scope, notice.id, %{
"status" => "reviewing", "status" => "open",
"assigned_to_id" => legal_assignee.id "assigned_to_id" => legal_assignee.id
}) })
assert assigned_notice.assigned_to_id == legal_assignee.id assert assigned_notice.assigned_to_id == legal_assignee.id
refute_enqueued(
worker: ContentRemovalEmailWorker,
args: %{"notice_id" => notice.id, "kind" => "update"}
)
end
test "content-removal mail is queued and internal assignment does not notify the submitter" do
requester = user_fixture()
assert_email_sent()
legal_operator = staff_user_fixture([:legal], display_name: "Legal operator")
assert_email_sent()
operator_scope = user_scope_fixture(legal_operator)
assert {:ok, notice} =
ContentRemoval.create_notice(user_scope_fixture(requester), :general, %{
"category" => "privacy_violation",
"submitter_name" => "Verified submitter",
"relationship" => "self",
"content_locations" => "https://example.test/requests/queued-legal-mail",
"explanation" =>
"This notice verifies bounded asynchronous legal-notice email delivery.",
"electronic_signature" => "Verified submitter",
"good_faith" => "true",
"accurate_complete" => "true"
})
assert_enqueued(
worker: ContentRemovalEmailWorker,
queue: :mail,
args: %{"notice_id" => notice.id, "kind" => "received"}
)
refute_receive {:email, _synchronous_received_email}, 50
assert :ok =
perform_job(ContentRemovalEmailWorker, %{
"notice_id" => notice.id,
"kind" => "received"
})
assert Repo.reload!(notice).acknowledgement_sent_at
assert_email_sent(fn email ->
email.subject =~ notice.reference and email.text_body =~ "We received removal notice"
end)
assert {:ok, assigned} =
ContentRemoval.moderate(operator_scope, notice.id, %{
"status" => "open",
"assigned_to_id" => legal_operator.id
})
assert assigned.assigned_to_id == legal_operator.id
refute_enqueued(
worker: ContentRemovalEmailWorker,
args: %{"notice_id" => notice.id, "kind" => "update"}
)
assert {:ok, updated} =
ContentRemoval.moderate(operator_scope, notice.id, %{
"status" => "needs_information",
"assigned_to_id" => legal_operator.id,
"resolution_note" => "Please provide one exact public URL for review."
})
assert updated.status == :needs_information
assert_enqueued(
worker: ContentRemovalEmailWorker,
queue: :mail,
args: %{"notice_id" => notice.id, "kind" => "update"}
)
assert :ok =
perform_job(ContentRemovalEmailWorker, %{
"notice_id" => notice.id,
"kind" => "update"
})
assert Repo.reload!(updated).decision_sent_at
assert_email_sent(fn email ->
email.subject =~ "removal notice update" and
email.text_body =~ "Please provide one exact public URL for review."
end)
end end
test "requester replies reopen a finished case and preserve the full conversation history" do test "requester replies reopen a finished case and preserve the full conversation history" do
@ -515,6 +620,18 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert notice.response_due_at assert notice.response_due_at
assert is_nil(notice.contact_verified_at) assert is_nil(notice.contact_verified_at)
assert_enqueued(
worker: ContentRemovalEmailWorker,
queue: :mail,
args: %{"notice_id" => notice.id, "kind" => "confirmation"}
)
assert :ok =
perform_job(ContentRemovalEmailWorker, %{
"notice_id" => notice.id,
"kind" => "confirmation"
})
assert_email_sent(fn email -> assert_email_sent(fn email ->
email.subject =~ "Confirm Who Need Help removal notice" and email.subject =~ "Confirm Who Need Help removal notice" and
email.text_body =~ notice.reference and email.text_body =~ notice.reference and
@ -538,6 +655,12 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert verified.contact_verified_at assert verified.contact_verified_at
assert_enqueued(
worker: ContentRemovalEmailWorker,
queue: :mail,
args: %{"notice_id" => notice.id, "kind" => "received"}
)
assert Enum.map(ContentRemoval.paginate_for_staff(moderator_scope).entries, & &1.id) == [ assert Enum.map(ContentRemoval.paginate_for_staff(moderator_scope).entries, & &1.id) == [
notice.id notice.id
] ]

View File

@ -1,11 +1,13 @@
defmodule WhoNeedHelpWeb.SupportControllerTest do defmodule WhoNeedHelpWeb.SupportControllerTest do
use WhoNeedHelpWeb.ConnCase, async: false use WhoNeedHelpWeb.ConnCase, async: false
use Oban.Testing, repo: WhoNeedHelp.Repo
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions import Swoosh.TestAssertions
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Mail.{ContentRemovalEmailWorker, SupportConfirmationWorker}
alias WhoNeedHelp.Support.SupportRequest alias WhoNeedHelp.Support.SupportRequest
describe "public support and removal intake" do describe "public support and removal intake" do
@ -66,6 +68,18 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
assert location =~ "/support/received?reference=SUP-" assert location =~ "/support/received?reference=SUP-"
refute location =~ "token=" refute location =~ "token="
request = Repo.get_by!(SupportRequest, contact_email: "person@example.com")
assert_enqueued(
worker: SupportConfirmationWorker,
queue: :mail,
args: %{"request_id" => request.id}
)
refute_receive {:email, _synchronous_confirmation}, 50
assert :ok = perform_job(SupportConfirmationWorker, %{"request_id" => request.id})
assert_email_sent(fn email -> assert_email_sent(fn email ->
email.to == [{"", "person@example.com"}] and email.to == [{"", "person@example.com"}] and
email.subject =~ "Confirm Who Need Help support request" email.subject =~ "Confirm Who Need Help support request"
@ -134,6 +148,26 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
location = redirected_to(conn) location = redirected_to(conn)
assert location =~ "/legal/content-removal/received?reference=REM-" assert location =~ "/legal/content-removal/received?reference=REM-"
refute location =~ "token=" refute location =~ "token="
notice =
Repo.get_by!(WhoNeedHelp.ContentRemoval.Notice,
contact_email: "affected@example.com"
)
assert_enqueued(
worker: ContentRemovalEmailWorker,
queue: :mail,
args: %{"notice_id" => notice.id, "kind" => "confirmation"}
)
refute_receive {:email, _synchronous_confirmation}, 50
assert :ok =
perform_job(ContentRemovalEmailWorker, %{
"notice_id" => notice.id,
"kind" => "confirmation"
})
assert_email_sent() assert_email_sent()
end end