From 9279835243df24c788e8e93f0d39ded690dfaf60 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sat, 1 Aug 2026 13:41:05 +0300 Subject: [PATCH] Harden staff operations and verified intake --- .env.example | 6 +- assets/js/hooks.js | 14 +- config/runtime.exs | 14 ++ config/test.exs | 4 +- e2e/tests/activity-moderation.spec.ts | 18 ++- e2e/tests/helpers.ts | 33 +++++ e2e/tests/notifications-data.spec.ts | 5 + e2e/tests/request-discovery.spec.ts | 6 +- lib/who_need_help/content_removal.ex | 69 +++++++-- lib/who_need_help/content_removal/notifier.ex | 37 ++++- lib/who_need_help/support/notifier.ex | 27 +++- .../components/admin_components.ex | 138 +++++++++++------- .../content_removal_html/received.html.heex | 4 +- .../support_and_content_removal_test.exs | 74 +++++++++- .../controllers/support_controller_test.exs | 10 +- .../live/admin_live_test.exs | 2 + 16 files changed, 369 insertions(+), 92 deletions(-) diff --git a/.env.example b/.env.example index 77c1ace..bfb5e1d 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/assets/js/hooks.js b/assets/js/hooks.js index d93d912..8ab0e5b 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -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() diff --git a/config/runtime.exs b/config/runtime.exs index 30aabb8..32330fd 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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, diff --git a/config/test.exs b/config/test.exs index 6a04186..c692d17 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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")}", diff --git a/e2e/tests/activity-moderation.spec.ts b/e2e/tests/activity-moderation.spec.ts index b72ad8d..caa9e57 100644 --- a/e2e/tests/activity-moderation.spec.ts +++ b/e2e/tests/activity-moderation.spec.ts @@ -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 diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 0b5ddbf..838d456 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -176,6 +176,39 @@ export async function waitForMapReady(page: Page): Promise { 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 { + 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 }, diff --git a/e2e/tests/notifications-data.spec.ts b/e2e/tests/notifications-data.spec.ts index 5bc1de4..5a630c9 100644 --- a/e2e/tests/notifications-data.spec.ts +++ b/e2e/tests/notifications-data.spec.ts @@ -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(); diff --git a/e2e/tests/request-discovery.spec.ts b/e2e/tests/request-discovery.spec.ts index 653dc8f..de5b085 100644 --- a/e2e/tests/request-discovery.spec.ts +++ b/e2e/tests/request-discovery.spec.ts @@ -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"); } diff --git a/lib/who_need_help/content_removal.ex b/lib/who_need_help/content_removal.ex index 9049bc1..1f0a0f8 100644 --- a/lib/who_need_help/content_removal.ex +++ b/lib/who_need_help/content_removal.ex @@ -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 diff --git a/lib/who_need_help/content_removal/notifier.ex b/lib/who_need_help/content_removal/notifier.ex index ded33fe..3f8ef05 100644 --- a/lib/who_need_help/content_removal/notifier.ex +++ b/lib/who_need_help/content_removal/notifier.ex @@ -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 diff --git a/lib/who_need_help/support/notifier.ex b/lib/who_need_help/support/notifier.ex index 6aaf66c..07d207b 100644 --- a/lib/who_need_help/support/notifier.ex +++ b/lib/who_need_help/support/notifier.ex @@ -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 diff --git a/lib/who_need_help_web/components/admin_components.ex b/lib/who_need_help_web/components/admin_components.ex index 49add7e..5835f7c 100644 --- a/lib/who_need_help_web/components/admin_components.ex +++ b/lib/who_need_help_web/components/admin_components.ex @@ -14,7 +14,29 @@ defmodule WhoNeedHelpWeb.AdminComponents do ~H"""
-