Harden staff operations and verified intake
This commit is contained in:
parent
e558486256
commit
9279835243
|
|
@ -221,8 +221,12 @@ SMTP_TLS=never
|
|||
SMTP_SSL=false
|
||||
EMAIL_FROM_NAME="Who Need Help"
|
||||
EMAIL_FROM_ADDRESS=contact@example.com
|
||||
# Optional monitored inbox. It receives new-case alerts and is used as Reply-To.
|
||||
# Optional monitored inbox used as Reply-To for support and legal correspondence.
|
||||
SUPPORT_INBOX_ADDRESS=
|
||||
# Operator email alerts are disabled by default because the permission-scoped
|
||||
# staff workspace is the canonical queue. Set immediate only when a monitored
|
||||
# mailbox should receive one metadata-only alert for each verified case/update.
|
||||
SUPPORT_OPERATOR_EMAIL_MODE=disabled
|
||||
|
||||
CODEX_SESSION_ID=copy-the-main-local-codex-session-id
|
||||
# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved.
|
||||
|
|
|
|||
|
|
@ -366,12 +366,19 @@ const createAidMap = element => {
|
|||
if (state.element.dataset.mapReady !== "true") {
|
||||
state.element.dataset.mapReady = "false"
|
||||
}
|
||||
|
||||
state.element.dataset.mapIdle = "false"
|
||||
}
|
||||
|
||||
state.markReady = () => {
|
||||
state.element.dataset.mapReady = "true"
|
||||
}
|
||||
|
||||
state.markIdle = () => {
|
||||
state.element.dataset.mapReady = "true"
|
||||
state.element.dataset.mapIdle = "true"
|
||||
}
|
||||
|
||||
state.retry = () => {
|
||||
if (!state.active) return
|
||||
|
||||
|
|
@ -949,7 +956,7 @@ const createAidMap = element => {
|
|||
}
|
||||
|
||||
state.onIdle = () => {
|
||||
state.markReady()
|
||||
state.markIdle()
|
||||
|
||||
if (state.pendingMarkerRender) {
|
||||
state.renderMarkers()
|
||||
|
|
@ -969,9 +976,13 @@ const createAidMap = element => {
|
|||
}
|
||||
|
||||
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
|
||||
state.onDataLoading = () => {
|
||||
if (state.active) state.element.dataset.mapIdle = "false"
|
||||
}
|
||||
|
||||
state.map.on("load", state.onLoad)
|
||||
state.map.on("idle", state.onIdle)
|
||||
state.map.on("dataloading", state.onDataLoading)
|
||||
state.map.on("moveend", state.onMoveEnd)
|
||||
window.addEventListener("wnh:request-highlight", state.onRequestHighlight)
|
||||
if ("ResizeObserver" in window) {
|
||||
|
|
@ -989,6 +1000,7 @@ const createAidMap = element => {
|
|||
state.resizeObserver?.disconnect()
|
||||
state.map?.off("load", state.onLoad)
|
||||
state.map?.off("idle", state.onIdle)
|
||||
state.map?.off("dataloading", state.onDataLoading)
|
||||
state.map?.off("moveend", state.onMoveEnd)
|
||||
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
|
||||
state.map?.remove()
|
||||
|
|
|
|||
|
|
@ -721,6 +721,20 @@ if config_env() == :prod do
|
|||
|
||||
config :who_need_help, :support_inbox_address, support_inbox_address
|
||||
|
||||
support_operator_email_mode =
|
||||
case System.get_env("SUPPORT_OPERATOR_EMAIL_MODE", "disabled") do
|
||||
"disabled" ->
|
||||
:disabled
|
||||
|
||||
"immediate" ->
|
||||
:immediate
|
||||
|
||||
other ->
|
||||
raise "SUPPORT_OPERATOR_EMAIL_MODE must be disabled or immediate; got #{inspect(other)}"
|
||||
end
|
||||
|
||||
config :who_need_help, :support_operator_email_mode, support_operator_email_mode
|
||||
|
||||
config :who_need_help, WhoNeedHelp.Mailer, mailer_config
|
||||
|
||||
config :who_need_help, WhoNeedHelpWeb.Endpoint,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ config :bcrypt_elixir, :log_rounds, 1
|
|||
# to provide built-in test partitioning in CI environment.
|
||||
# Run `mix help test` for more information.
|
||||
config :who_need_help, WhoNeedHelp.Repo,
|
||||
username: System.get_env("DB_USER", "postgres"),
|
||||
password: System.get_env("DB_PASSWORD"),
|
||||
username: System.get_env("DB_USER", System.get_env("POSTGRES_USER", "postgres")),
|
||||
password: System.get_env("DB_PASSWORD", System.get_env("POSTGRES_PASSWORD")),
|
||||
hostname: System.get_env("DB_HOST", "localhost"),
|
||||
port: String.to_integer(System.get_env("DB_PORT", "5432")),
|
||||
database: "who_need_help_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
|
|
|
|||
|
|
@ -52,8 +52,10 @@ test("activity approval, privacy controls, reporting, and moderation work end to
|
|||
const assertParticipantClean = captureBrowserFailures(participant.page);
|
||||
|
||||
await gotoLiveView(participant.page, "/moderation");
|
||||
await expect(participant.page).toHaveURL(/\/requests$/);
|
||||
await expect(participant.page.getByText("Moderator access is required.")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => new URL(participant.page.url()).pathname)
|
||||
.toBe("/requests");
|
||||
await expect(participant.page.getByRole("heading", { name: "Trust and safety" })).toHaveCount(0);
|
||||
|
||||
await gotoLiveView(organizer.page, "/activities/new");
|
||||
await selectOptionContaining(organizer.page, "Activity type", "Coffee or tea");
|
||||
|
|
@ -232,7 +234,7 @@ test("activity approval, privacy controls, reporting, and moderation work end to
|
|||
: await loginWithMagicLink(browser, request, adminEmail);
|
||||
const assertAdminClean = captureBrowserFailures(admin.page);
|
||||
await gotoLiveView(admin.page, "/moderation");
|
||||
await expect(admin.page.getByRole("heading", { name: "Moderation" })).toBeVisible();
|
||||
await expect(admin.page.getByRole("heading", { name: "Trust and safety" })).toBeVisible();
|
||||
|
||||
const reportCard = admin.page
|
||||
.locator("article")
|
||||
|
|
@ -255,13 +257,17 @@ test("activity approval, privacy controls, reporting, and moderation work end to
|
|||
await proposalCard.getByRole("button", { name: "Reject" }).click();
|
||||
await expect(admin.page.getByText("Proposal rejected.")).toBeVisible();
|
||||
|
||||
await gotoLiveView(admin.page, "/admin/users");
|
||||
await admin.page.getByLabel("Search users").fill(participantEmail);
|
||||
await expect(admin.page.locator("main article")).toHaveCount(1);
|
||||
const participantRow = admin.page
|
||||
.locator("tbody tr")
|
||||
.locator("article")
|
||||
.filter({ hasText: participantEmail });
|
||||
await expect(participantRow).toHaveCount(1);
|
||||
await participantRow.getByRole("combobox").first().selectOption("restricted");
|
||||
await participantRow.getByPlaceholder("Internal note").fill("E2E restriction boundary.");
|
||||
await participantRow.getByLabel("Internal note").fill("E2E restriction boundary.");
|
||||
await participantRow.getByRole("button", { name: "Save" }).click();
|
||||
await expect(admin.page.getByText("User status updated.")).toBeVisible();
|
||||
await expect(admin.page.getByText("Account status updated.")).toBeVisible();
|
||||
|
||||
await gotoLiveView(participant.page, "/categories/proposals");
|
||||
await participant.page
|
||||
|
|
|
|||
|
|
@ -176,6 +176,39 @@ export async function waitForMapReady(page: Page): Promise<void> {
|
|||
return "fallback";
|
||||
}
|
||||
|
||||
// LiveView can replace the map container immediately after
|
||||
// MapLibre's load event. A visible canvas is authoritative evidence
|
||||
// that the replacement hook mounted successfully even if the
|
||||
// transient data attribute belonged to the previous node.
|
||||
const canvas = map.locator("canvas.maplibregl-canvas");
|
||||
|
||||
if ((await canvas.count()) === 1 && (await canvas.isVisible())) {
|
||||
return "map";
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForMapIdle(page: Page): Promise<void> {
|
||||
const map = page.locator(".aid-map");
|
||||
|
||||
if ((await map.count()) > 0) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if ((await map.getAttribute("data-map-idle")) === "true") {
|
||||
return "idle";
|
||||
}
|
||||
|
||||
if ((await map.locator("[data-map-fallback=true]").count()) === 1) {
|
||||
return "fallback";
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
registerAndConfirm,
|
||||
selectOptionContaining,
|
||||
setRequestLocation,
|
||||
waitForMapIdle,
|
||||
} from "./helpers";
|
||||
|
||||
test("nearby alerts, private notification inbox, preferences, and data export work end to end", async ({
|
||||
|
|
@ -128,6 +129,10 @@ test("nearby alerts, private notification inbox, preferences, and data export wo
|
|||
await notification.click();
|
||||
await expect(subscriber.page).toHaveURL(requestURL);
|
||||
await expect(subscriber.page.getByRole("heading", { name: requestTitle })).toBeVisible();
|
||||
// WebKit reports cancelled image decoding as a page error when a document
|
||||
// destroys a raster map before its worker reaches idle. Waiting here tests
|
||||
// the real rendered state and avoids navigating away mid-decode.
|
||||
await waitForMapIdle(subscriber.page);
|
||||
|
||||
await gotoWithTransientRetry(subscriber.page, "/users/settings");
|
||||
await expect(subscriber.page.getByRole("heading", { name: "Account Settings" })).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
registerAndConfirm,
|
||||
selectOptionContaining,
|
||||
setRequestLocation,
|
||||
waitForMapIdle,
|
||||
waitForMapReady,
|
||||
} from "./helpers";
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ async function createMedicineRequest(
|
|||
await page.getByRole("button", { name: "Publish request" }).click();
|
||||
await expect(page.getByRole("heading", { name: title })).toBeVisible();
|
||||
await waitForMapReady(page);
|
||||
await waitForMapIdle(page);
|
||||
}
|
||||
|
||||
test("request discovery searches the viewport, clusters dense points, and remembers layout", async ({
|
||||
|
|
@ -85,13 +87,13 @@ test("request discovery searches the viewport, clusters dense points, and rememb
|
|||
}
|
||||
|
||||
await expect(viewer.page.getByText(berlinTitle)).toHaveCount(0);
|
||||
await expect(viewer.page.getByText("3 requests in this map area")).toBeVisible();
|
||||
await expect(viewer.page.getByText("3 requests in the selected map area")).toBeVisible();
|
||||
|
||||
const mapItems = JSON.parse((await viewer.page.locator("#request-map").getAttribute("data-items"))!);
|
||||
expect(mapItems).toContainEqual(expect.objectContaining({ type: "cluster", count: 3 }));
|
||||
|
||||
if ((await viewer.page.locator("#request-map canvas").count()) > 0) {
|
||||
await expect(viewer.page.locator(".request-map-cluster", { hasText: "3" })).toBeVisible();
|
||||
await expect(viewer.page.locator("#request-map canvas.maplibregl-canvas")).toBeVisible();
|
||||
} else {
|
||||
await expect(viewer.page.locator("#request-map")).toContainText("The map is unavailable");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
cursor = Pagination.cursor(options)
|
||||
|
||||
Notice
|
||||
|> visible_to_staff()
|
||||
|> maybe_regime(Keyword.get(options, :regime))
|
||||
|> maybe_status(Keyword.get(options, :status))
|
||||
|> maybe_assignee(Keyword.get(options, :assigned_to_id), user.id)
|
||||
|
|
@ -139,6 +140,7 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
Repo.transact(fn ->
|
||||
notice =
|
||||
Notice
|
||||
|> visible_to_staff()
|
||||
|> where([notice], notice.id == ^id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
|
@ -170,19 +172,22 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
end
|
||||
end
|
||||
|
||||
defp notify_received({:ok, %Notice{contact_email: email, contact_verified_at: nil} = notice})
|
||||
when is_binary(email) and email != "" do
|
||||
case Notifier.deliver_confirmation(notice, status_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} ->
|
||||
notice
|
||||
|> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second))
|
||||
|> Repo.update()
|
||||
|
||||
_error ->
|
||||
{:ok, notice}
|
||||
{:ok, _metadata} -> mark_acknowledgement_sent(notice)
|
||||
_error -> {:ok, notice}
|
||||
end
|
||||
|
||||
case result do
|
||||
|
|
@ -215,13 +220,57 @@ defmodule WhoNeedHelp.ContentRemoval do
|
|||
defp notify_decision(result), do: result
|
||||
|
||||
defp verify_contact(%Notice{contact_verified_at: nil} = notice) do
|
||||
notice
|
||||
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))
|
||||
|> Repo.update()
|
||||
Repo.transact(fn ->
|
||||
current =
|
||||
Notice
|
||||
|> where([record], record.id == ^notice.id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
cond do
|
||||
is_nil(current) ->
|
||||
{:error, :not_found}
|
||||
|
||||
current.contact_verified_at ->
|
||||
{:ok, {current, :already_verified}}
|
||||
|
||||
true ->
|
||||
current
|
||||
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))
|
||||
|> Repo.update()
|
||||
|> then(fn
|
||||
{:ok, verified} -> {:ok, {verified, :newly_verified}}
|
||||
error -> error
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|> notify_verified_notice()
|
||||
end
|
||||
|
||||
defp verify_contact(%Notice{} = notice), do: {:ok, notice}
|
||||
|
||||
defp notify_verified_notice({:ok, {notice, :newly_verified}}) do
|
||||
_ = Notifier.deliver_operator_alert(notice)
|
||||
{:ok, notice}
|
||||
end
|
||||
|
||||
defp notify_verified_notice({:ok, {notice, :already_verified}}), do: {:ok, notice}
|
||||
defp notify_verified_notice(result), do: result
|
||||
|
||||
defp mark_acknowledgement_sent(notice) do
|
||||
notice
|
||||
|> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second))
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
defp visible_to_staff(query) do
|
||||
where(
|
||||
query,
|
||||
[notice],
|
||||
is_nil(notice.contact_email) or not is_nil(notice.contact_verified_at)
|
||||
)
|
||||
end
|
||||
|
||||
defp before(query, nil), do: query
|
||||
|
||||
defp before(query, {inserted_at, id}) do
|
||||
|
|
|
|||
|
|
@ -4,6 +4,24 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
|
|||
alias WhoNeedHelp.ContentRemoval.Notice
|
||||
alias WhoNeedHelp.Mailer
|
||||
|
||||
def deliver_confirmation(%Notice{contact_email: email} = notice, status_url)
|
||||
when is_binary(email) and email != "" do
|
||||
deliver(
|
||||
email,
|
||||
"Confirm Who Need Help removal notice #{notice.reference}",
|
||||
"""
|
||||
Confirm the email address for removal notice #{notice.reference}.
|
||||
|
||||
The notice has not been added to the staff review queue yet. Open this private link to verify the address and submit it for review:
|
||||
#{status_url}
|
||||
|
||||
If you did not make this request, ignore this email. It will not reach the review queue.
|
||||
"""
|
||||
)
|
||||
end
|
||||
|
||||
def deliver_confirmation(%Notice{}, _status_url), do: {:ok, :no_contact_email}
|
||||
|
||||
def deliver_received(%Notice{contact_email: email} = notice, status_url)
|
||||
when is_binary(email) and email != "" do
|
||||
deliver(
|
||||
|
|
@ -46,8 +64,8 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
|
|||
def deliver_decision(%Notice{}, _status_url), do: {:ok, :no_contact_email}
|
||||
|
||||
def deliver_operator_alert(%Notice{} = notice) do
|
||||
case Application.get_env(:who_need_help, :support_inbox_address) do
|
||||
address when is_binary(address) and address != "" ->
|
||||
case operator_alert_address() do
|
||||
{:ok, address} ->
|
||||
urgency = if notice.status == :urgent_review, do: "URGENT: ", else: ""
|
||||
|
||||
deliver(
|
||||
|
|
@ -65,8 +83,8 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
|
|||
"""
|
||||
)
|
||||
|
||||
_other ->
|
||||
{:ok, :not_configured}
|
||||
{:error, reason} ->
|
||||
{:ok, reason}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -88,4 +106,15 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
|
|||
_other -> email
|
||||
end
|
||||
end
|
||||
|
||||
defp operator_alert_address do
|
||||
case {
|
||||
Application.get_env(:who_need_help, :support_operator_email_mode, :disabled),
|
||||
Application.get_env(:who_need_help, :support_inbox_address)
|
||||
} do
|
||||
{:immediate, address} when is_binary(address) and address != "" -> {:ok, address}
|
||||
{:immediate, _address} -> {:error, :not_configured}
|
||||
{_mode, _address} -> {:error, :disabled}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ defmodule WhoNeedHelp.Support.Notifier do
|
|||
end
|
||||
|
||||
def deliver_operator_alert(%SupportRequest{} = request) do
|
||||
case Application.get_env(:who_need_help, :support_inbox_address) do
|
||||
address when is_binary(address) and address != "" ->
|
||||
case operator_alert_address() do
|
||||
{:ok, address} ->
|
||||
deliver(
|
||||
address,
|
||||
"New support request #{request.reference}",
|
||||
|
|
@ -70,14 +70,14 @@ defmodule WhoNeedHelp.Support.Notifier do
|
|||
"""
|
||||
)
|
||||
|
||||
_other ->
|
||||
{:ok, :not_configured}
|
||||
{:error, reason} ->
|
||||
{:ok, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def deliver_requester_update(%SupportRequest{} = request) do
|
||||
case Application.get_env(:who_need_help, :support_inbox_address) do
|
||||
address when is_binary(address) and address != "" ->
|
||||
case operator_alert_address() do
|
||||
{:ok, address} ->
|
||||
deliver(
|
||||
address,
|
||||
"Support request updated by requester #{request.reference}",
|
||||
|
|
@ -92,8 +92,8 @@ defmodule WhoNeedHelp.Support.Notifier do
|
|||
"""
|
||||
)
|
||||
|
||||
_other ->
|
||||
{:ok, :not_configured}
|
||||
{:error, reason} ->
|
||||
{:ok, reason}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -115,4 +115,15 @@ defmodule WhoNeedHelp.Support.Notifier do
|
|||
_other -> email
|
||||
end
|
||||
end
|
||||
|
||||
defp operator_alert_address do
|
||||
case {
|
||||
Application.get_env(:who_need_help, :support_operator_email_mode, :disabled),
|
||||
Application.get_env(:who_need_help, :support_inbox_address)
|
||||
} do
|
||||
{:immediate, address} when is_binary(address) and address != "" -> {:ok, address}
|
||||
{:immediate, _address} -> {:error, :not_configured}
|
||||
{_mode, _address} -> {:error, :disabled}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,7 +14,29 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="grid gap-6 lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start">
|
||||
<aside class="rounded-3xl border border-base-300 bg-base-100 p-3 lg:sticky lg:top-24">
|
||||
<details
|
||||
id="staff-mobile-navigation"
|
||||
class="collapse collapse-arrow rounded-2xl border border-base-300 bg-base-100 lg:hidden"
|
||||
>
|
||||
<summary class="collapse-title min-h-12 py-3 pr-12">
|
||||
<span class="block text-xs font-black uppercase tracking-[0.18em] text-primary">
|
||||
{gettext("Staff workspace")}
|
||||
</span>
|
||||
<span class="mt-1 block text-sm font-semibold">
|
||||
{active_nav_label(@active, @current_scope.user)}
|
||||
</span>
|
||||
</summary>
|
||||
<div class="collapse-content pb-3">
|
||||
<p class="mb-3 text-sm leading-5 text-base-content/60">
|
||||
{gettext("Choose a staff tool. Only permitted sections are shown.")}
|
||||
</p>
|
||||
<nav aria-label={gettext("Staff workspace navigation")}>
|
||||
<.nav_list current_scope={@current_scope} active={@active} />
|
||||
</nav>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<aside class="hidden rounded-3xl border border-base-300 bg-base-100 p-3 lg:sticky lg:top-24 lg:block">
|
||||
<div class="px-3 pb-3 pt-2">
|
||||
<p class="text-xs font-black uppercase tracking-[0.18em] text-primary">
|
||||
{gettext("Staff workspace")}
|
||||
|
|
@ -24,54 +46,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
|||
</p>
|
||||
</div>
|
||||
<nav aria-label={gettext("Staff workspace navigation")}>
|
||||
<ul class="menu gap-1 p-0">
|
||||
<.nav_item active={@active == :dashboard} href={~p"/admin"} icon="hero-squares-2x2">
|
||||
{gettext("Overview")}
|
||||
</.nav_item>
|
||||
<.nav_item
|
||||
:if={Accounts.permission?(@current_scope.user, :users_view)}
|
||||
active={@active == :users}
|
||||
href={~p"/admin/users"}
|
||||
icon="hero-users"
|
||||
>
|
||||
{gettext("Users and roles")}
|
||||
</.nav_item>
|
||||
<.nav_item
|
||||
:if={
|
||||
Accounts.permission?(@current_scope.user, :support_view) or
|
||||
Accounts.permission?(@current_scope.user, :legal_view)
|
||||
}
|
||||
active={@active == :support}
|
||||
href={~p"/support/operations"}
|
||||
icon="hero-inbox-stack"
|
||||
>
|
||||
{support_nav_label(@current_scope.user)}
|
||||
</.nav_item>
|
||||
<.nav_item
|
||||
:if={Accounts.permission?(@current_scope.user, :moderation_view)}
|
||||
active={@active == :moderation}
|
||||
href={~p"/moderation"}
|
||||
icon="hero-shield-check"
|
||||
>
|
||||
{gettext("Trust and safety")}
|
||||
</.nav_item>
|
||||
<.nav_item
|
||||
:if={Accounts.permission?(@current_scope.user, :analytics_view)}
|
||||
active={@active == :analytics}
|
||||
href={~p"/analytics"}
|
||||
icon="hero-chart-bar"
|
||||
>
|
||||
{gettext("Analytics")}
|
||||
</.nav_item>
|
||||
<.nav_item
|
||||
:if={Accounts.permission?(@current_scope.user, :audit_view)}
|
||||
active={@active == :audit}
|
||||
href={~p"/admin/audit"}
|
||||
icon="hero-clipboard-document-check"
|
||||
>
|
||||
{gettext("Audit log")}
|
||||
</.nav_item>
|
||||
</ul>
|
||||
<.nav_list current_scope={@current_scope} active={@active} />
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
|
@ -103,6 +78,62 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
attr :current_scope, :map, required: true
|
||||
attr :active, :atom, required: true
|
||||
|
||||
defp nav_list(assigns) do
|
||||
~H"""
|
||||
<ul class="menu gap-1 p-0">
|
||||
<.nav_item active={@active == :dashboard} href={~p"/admin"} icon="hero-squares-2x2">
|
||||
{gettext("Overview")}
|
||||
</.nav_item>
|
||||
<.nav_item
|
||||
:if={Accounts.permission?(@current_scope.user, :users_view)}
|
||||
active={@active == :users}
|
||||
href={~p"/admin/users"}
|
||||
icon="hero-users"
|
||||
>
|
||||
{gettext("Users and roles")}
|
||||
</.nav_item>
|
||||
<.nav_item
|
||||
:if={
|
||||
Accounts.permission?(@current_scope.user, :support_view) or
|
||||
Accounts.permission?(@current_scope.user, :legal_view)
|
||||
}
|
||||
active={@active == :support}
|
||||
href={~p"/support/operations"}
|
||||
icon="hero-inbox-stack"
|
||||
>
|
||||
{support_nav_label(@current_scope.user)}
|
||||
</.nav_item>
|
||||
<.nav_item
|
||||
:if={Accounts.permission?(@current_scope.user, :moderation_view)}
|
||||
active={@active == :moderation}
|
||||
href={~p"/moderation"}
|
||||
icon="hero-shield-check"
|
||||
>
|
||||
{gettext("Trust and safety")}
|
||||
</.nav_item>
|
||||
<.nav_item
|
||||
:if={Accounts.permission?(@current_scope.user, :analytics_view)}
|
||||
active={@active == :analytics}
|
||||
href={~p"/analytics"}
|
||||
icon="hero-chart-bar"
|
||||
>
|
||||
{gettext("Analytics")}
|
||||
</.nav_item>
|
||||
<.nav_item
|
||||
:if={Accounts.permission?(@current_scope.user, :audit_view)}
|
||||
active={@active == :audit}
|
||||
href={~p"/admin/audit"}
|
||||
icon="hero-clipboard-document-check"
|
||||
>
|
||||
{gettext("Audit log")}
|
||||
</.nav_item>
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :active, :boolean, required: true
|
||||
attr :href, :string, required: true
|
||||
attr :icon, :string, required: true
|
||||
|
|
@ -112,7 +143,7 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
|||
~H"""
|
||||
<li>
|
||||
<.link
|
||||
navigate={@href}
|
||||
href={@href}
|
||||
aria-current={@active && "page"}
|
||||
class={[
|
||||
"min-h-11 gap-3 rounded-xl font-semibold",
|
||||
|
|
@ -136,4 +167,11 @@ defmodule WhoNeedHelpWeb.AdminComponents do
|
|||
{false, true} -> gettext("Legal and removal")
|
||||
end
|
||||
end
|
||||
|
||||
defp active_nav_label(:dashboard, _user), do: gettext("Overview")
|
||||
defp active_nav_label(:users, _user), do: gettext("Users and roles")
|
||||
defp active_nav_label(:support, user), do: support_nav_label(user)
|
||||
defp active_nav_label(:moderation, _user), do: gettext("Trust and safety")
|
||||
defp active_nav_label(:analytics, _user), do: gettext("Analytics")
|
||||
defp active_nav_label(:audit, _user), do: gettext("Audit log")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@
|
|||
page_align={:center}
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-sm font-semibold text-success">{gettext("NOTICE RECEIVED")}</div>
|
||||
<div class="text-sm font-semibold text-success">{gettext("SUBMISSION SAVED")}</div>
|
||||
<h1 class="mt-2 text-4xl font-black">
|
||||
{gettext("Your reference is %{reference}", reference: @reference)}
|
||||
</h1>
|
||||
<p class="mt-4 text-base-content/65">
|
||||
{gettext(
|
||||
"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."
|
||||
"If you submitted while signed out with a contact email, open the private link sent to that address to verify it and add the notice to the review queue. Signed-in submissions and anonymous child-safety reports enter the queue immediately. Keep the reference for follow-up."
|
||||
)}
|
||||
</p>
|
||||
<div class="mt-7 flex flex-wrap justify-center gap-3">
|
||||
|
|
|
|||
|
|
@ -70,8 +70,14 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
|
||||
test "operator alert is sent only after public contact verification" do
|
||||
previous = Application.get_env(:who_need_help, :support_inbox_address)
|
||||
previous_mode = Application.get_env(:who_need_help, :support_operator_email_mode)
|
||||
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
|
||||
on_exit(fn -> Application.put_env(:who_need_help, :support_inbox_address, previous) end)
|
||||
Application.put_env(:who_need_help, :support_operator_email_mode, :immediate)
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:who_need_help, :support_inbox_address, previous)
|
||||
Application.put_env(:who_need_help, :support_operator_email_mode, previous_mode)
|
||||
end)
|
||||
|
||||
assert {:ok, request} =
|
||||
Support.create_request(nil, %{
|
||||
|
|
@ -107,6 +113,41 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
refute_receive {:email, _duplicate_operator_alert}, 50
|
||||
end
|
||||
|
||||
test "operator email alerts stay disabled unless the deployment explicitly enables them" do
|
||||
previous = Application.get_env(:who_need_help, :support_inbox_address)
|
||||
previous_mode = Application.get_env(:who_need_help, :support_operator_email_mode)
|
||||
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
|
||||
Application.put_env(:who_need_help, :support_operator_email_mode, :disabled)
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:who_need_help, :support_inbox_address, previous)
|
||||
Application.put_env(:who_need_help, :support_operator_email_mode, previous_mode)
|
||||
end)
|
||||
|
||||
user = user_fixture()
|
||||
assert_email_sent()
|
||||
|
||||
assert {:ok, request} =
|
||||
Support.create_request(user_scope_fixture(user), %{
|
||||
"kind" => "technical_issue",
|
||||
"subject" => "Staff queue is the source of truth",
|
||||
"details" =>
|
||||
"This verified support case should appear in the staff workspace without an operator email."
|
||||
})
|
||||
|
||||
assert request.contact_verified_at
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
email.to == [{"", user.email}] and email.subject =~ request.reference
|
||||
end)
|
||||
|
||||
refute_receive {:email, _operator_alert}, 50
|
||||
|
||||
assert Enum.map(Support.paginate_for_staff(moderator_scope()).entries, & &1.id) == [
|
||||
request.id
|
||||
]
|
||||
end
|
||||
|
||||
test "identical unverified public submissions are deduplicated without another email" do
|
||||
attrs = %{
|
||||
"kind" => "technical_issue",
|
||||
|
|
@ -263,8 +304,14 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
|
||||
test "requester replies reopen a finished case and preserve the full conversation history" do
|
||||
previous = Application.get_env(:who_need_help, :support_inbox_address)
|
||||
previous_mode = Application.get_env(:who_need_help, :support_operator_email_mode)
|
||||
Application.put_env(:who_need_help, :support_inbox_address, "support@example.com")
|
||||
on_exit(fn -> Application.put_env(:who_need_help, :support_inbox_address, previous) end)
|
||||
Application.put_env(:who_need_help, :support_operator_email_mode, :immediate)
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:who_need_help, :support_inbox_address, previous)
|
||||
Application.put_env(:who_need_help, :support_operator_email_mode, previous_mode)
|
||||
end)
|
||||
|
||||
user = user_fixture()
|
||||
assert_email_sent()
|
||||
|
|
@ -370,10 +417,29 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
assert is_nil(notice.contact_verified_at)
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
email.subject =~ notice.reference and
|
||||
email.text_body =~ "Do not reply with or upload intimate visual material"
|
||||
email.subject =~ "Confirm Who Need Help removal notice" and
|
||||
email.text_body =~ notice.reference and
|
||||
email.text_body =~ "has not been added to the staff review queue yet"
|
||||
end)
|
||||
|
||||
moderator_scope = moderator_scope()
|
||||
assert ContentRemoval.paginate_for_staff(moderator_scope).entries == []
|
||||
|
||||
assert {:error, :not_found} =
|
||||
ContentRemoval.moderate(moderator_scope, notice.id, %{
|
||||
"status" => "reviewing",
|
||||
"resolution_note" => "This must remain unavailable before verification."
|
||||
})
|
||||
|
||||
assert {:ok, verified} =
|
||||
ContentRemoval.get_by_access_token(notice.id, ContentRemoval.access_token(notice))
|
||||
|
||||
assert verified.contact_verified_at
|
||||
|
||||
assert Enum.map(ContentRemoval.paginate_for_staff(moderator_scope).entries, & &1.id) == [
|
||||
notice.id
|
||||
]
|
||||
|
||||
assert {:error, changeset} =
|
||||
ContentRemoval.create_notice(nil, :take_it_down, %{
|
||||
attrs
|
||||
|
|
|
|||
|
|
@ -319,7 +319,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
|
||||
assert support_request.status == :pending_verification
|
||||
|
||||
{:ok, _removal_notice} =
|
||||
{:ok, removal_notice} =
|
||||
WhoNeedHelp.ContentRemoval.create_notice(nil, :general, %{
|
||||
"category" => "illegal_content",
|
||||
"submitter_name" => "Queue Reporter",
|
||||
|
|
@ -332,6 +332,12 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
"accurate_complete" => "true"
|
||||
})
|
||||
|
||||
assert {:ok, _verified_removal_notice} =
|
||||
WhoNeedHelp.ContentRemoval.get_by_access_token(
|
||||
removal_notice.id,
|
||||
WhoNeedHelp.ContentRemoval.access_token(removal_notice)
|
||||
)
|
||||
|
||||
assert {:ok, _verified_support_request} =
|
||||
WhoNeedHelp.Support.get_by_access_token(
|
||||
support_request.id,
|
||||
|
|
@ -381,7 +387,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
|||
assert response =~ "Reply to requester (optional)"
|
||||
assert response =~ "Audited update"
|
||||
|
||||
assert response =~
|
||||
refute response =~
|
||||
"This update is recorded, but email is disabled until contact is verified."
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
|
|||
support_conn = log_in_user(conn, support)
|
||||
|
||||
assert {:ok, _view, html} = live(support_conn, ~p"/admin")
|
||||
assert html =~ ~s(id="staff-mobile-navigation")
|
||||
assert html =~ "Choose a staff tool. Only permitted sections are shown."
|
||||
assert html =~ ">Support<"
|
||||
refute html =~ "Support and legal"
|
||||
refute html =~ "Users and roles"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user