Queue bounded support and legal email
This commit is contained in:
parent
346b03695f
commit
d2fb194d56
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
current
|
with {:ok, verified} <-
|
||||||
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))
|
current
|
||||||
|> Repo.update()
|
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))
|
||||||
|> then(fn
|
|> Repo.update(),
|
||||||
{:ok, verified} -> {:ok, {verified, :newly_verified}}
|
{:ok, _jobs} <- enqueue_verified_emails(verified) do
|
||||||
error -> error
|
{:ok, {verified, :newly_verified}}
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
74
lib/who_need_help/mail/content_removal_email_worker.ex
Normal file
74
lib/who_need_help/mail/content_removal_email_worker.ex
Normal 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
|
||||||
34
lib/who_need_help/mail/support_confirmation_worker.ex
Normal file
34
lib/who_need_help/mail/support_confirmation_worker.ex
Normal 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
|
||||||
|
|
@ -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,
|
||||||
%{
|
message_added: match?(%ConversationMessage{}, message),
|
||||||
audit_id: audit.id,
|
status_changed: previous_status != request.status
|
||||||
message_added: match?(%ConversationMessage{}, message),
|
},
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user