From b1777b8297832b7cf12a4c7e20fee58f68f731fb Mon Sep 17 00:00:00 2001 From: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 03:22:05 -0400 Subject: [PATCH] feat(desktop): cancel-and-reopen recovery for failed enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the admin console to the relay's cancel route and align the report detail to the ratified LATERAL semantics. Cancel replaces the composed client-side retry. A failed enforcement is always pre-mutation, so it is always cancellable; cancel returns the report to `open` for a fresh resolution. Composing cancel + resolve would imply an atomicity the relay does not provide, so the console offers a single "Cancel & reopen" button on `failed` only — pending/enforcing are owned by the relay recovery worker — POSTs `{actionId}` to /reports/{id}/cancel, treats 409 as refresh-detail, and reloads rather than consuming the response body. Honest history: the detail LATERAL returns a succeeded action even on a reopened (open) report — the ban actually ran, and a later reopen does not un-happen it. The enforcement block now renders whenever an action is present (live or history) and the resolve form gates on `isOpen` alone, so a reopened-after-enforcement report shows both the executed-enforcement history and the resolve form for re-triage. The old `isOpen && !activeAction` gate wrongly stranded the operator on such a report. Tighten the wire seam to match the relay's no-skip_serializing_if serialization: reason/expiresAt/errorMessage are required-nullable, and resolve/cancel return the `{status, activeAction}` envelope. Feedback communityId/communityHost are nullable — a purged source community severs provenance to NULL while retaining the row as operator evidence; severed rows bucket under a "source community removed" heading and render em-dashes in detail. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/commands/admin/mod.rs | 27 ++ .../src-tauri/src/commands/admin/routes.rs | 13 + desktop/src-tauri/src/lib.rs | 1 + .../admin-console/AdminConsolePanel.tsx | 141 +++---- .../AdminConsolePanelHelpers.tsx | 25 +- .../adminConsolePanelEvents.jsdom-test.mjs | 388 ++++++++++++++++++ desktop/src/features/admin-console/api.ts | 73 +++- 7 files changed, 577 insertions(+), 91 deletions(-) diff --git a/desktop/src-tauri/src/commands/admin/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs index 90b6e8749a..1eb777728c 100644 --- a/desktop/src-tauri/src/commands/admin/mod.rs +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -515,6 +515,33 @@ pub async fn admin_reopen_report( serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) } +/// Cancel a failed enforcement action — POST /api/admin/v1/reports/{id}/cancel. +/// +/// Body: `{actionId}`. Cancel is the only recovery path for a pre-mutation +/// `failed` action: it returns the report to `open` for a fresh resolution. +/// The `actionId` fences the cancel to the failed action the operator observed; +/// a mismatch (already cancelled, superseded, or past the mutation point) is a +/// 409, which the caller treats as "refresh detail". +#[tauri::command] +pub async fn admin_cancel_report( + origin: String, + id: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::ReportCancel { id }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = post_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + /// Update feedback status — PATCH /api/admin/v1/feedback/{id}. /// /// Body: `{status}` where status ∈ {"new","reviewed","archived"}. diff --git a/desktop/src-tauri/src/commands/admin/routes.rs b/desktop/src-tauri/src/commands/admin/routes.rs index 41f49d5c67..33755af531 100644 --- a/desktop/src-tauri/src/commands/admin/routes.rs +++ b/desktop/src-tauri/src/commands/admin/routes.rs @@ -53,6 +53,9 @@ pub enum AdminRoute { ReportReopen { id: uuid::Uuid, }, + ReportCancel { + id: uuid::Uuid, + }, FeedbackList, FeedbackDetail { id: uuid::Uuid, @@ -105,6 +108,7 @@ impl AdminRoute { AdminRoute::ReportDetail { id } => format!("/reports/{id}"), AdminRoute::ReportResolve { id } => format!("/reports/{id}/resolve"), AdminRoute::ReportReopen { id } => format!("/reports/{id}/reopen"), + AdminRoute::ReportCancel { id } => format!("/reports/{id}/cancel"), AdminRoute::FeedbackList => "/feedback".to_string(), AdminRoute::FeedbackDetail { id } => format!("/feedback/{id}"), AdminRoute::FeedbackAttachment { id, sha256 } => { @@ -259,6 +263,15 @@ mod tests { ); } + #[test] + fn report_cancel_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap(); + assert_eq!( + AdminRoute::ReportCancel { id }.path(), + "/reports/00000000-0000-0000-0000-000000000004/cancel" + ); + } + #[test] fn feedback_attachment_path() { let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6ee71eacfa..8a730f9b93 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -842,6 +842,7 @@ pub fn run() { admin_fetch_feedback_attachment, admin_resolve_report, admin_reopen_report, + admin_cancel_report, admin_patch_feedback, admin_list_operators, admin_put_operator, diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx index 11feb1f334..22a4c77655 100644 --- a/desktop/src/features/admin-console/AdminConsolePanel.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -38,6 +38,7 @@ import { cn } from "@/shared/lib/cn"; import { getAdminReport, listAdminReports, + cancelAdminReport, reopenAdminReport, resolveAdminReport, type AdminPrincipalRole, @@ -152,12 +153,20 @@ function actionVariant( /** * Inline enforcement-state block shown on `processing` reports and after a - * failed enforcement action. Shows the action record's state and offers - * retry/cancel where appropriate. + * failed enforcement action. Shows the action record's state and offers cancel + * on a `failed` action. * - * Cancel is only offered when the server permits it (pre-mutation failures). - * A rejected cancel is treated as authoritative — the server knows whether - * the mutation has landed. + * A `failed` action is always pre-mutation (the relay only records `failed` + * before the enforcement side effect lands), so it is always cancellable. + * Cancel is the only recovery path: it returns the report to `open` for a + * fresh resolution. There is no client-side "retry" — composing cancel + a new + * resolve would imply an atomicity the relay does not provide, leaving a window + * where the report is open with no explanation if the second call is lost. + * + * `pending`/`enforcing` actions are NOT cancellable over HTTP — the relay's + * recovery worker owns their convergence — so no button is offered there. + * A rejected cancel (409) is authoritative: the action already advanced or + * someone else cancelled it, so reload detail rather than retrying. */ function EnforcementStateBlock({ activeAction, @@ -184,45 +193,26 @@ function EnforcementStateBlock({ cancelled: "Enforcement cancelled", }; - const handleRetry = async () => { - setError(null); - setIsWorking(true); - try { - // Retry reuses the same requestId so the server returns the existing - // action record rather than creating a new claim. - await resolveAdminReport(origin, reportId, { - action: activeAction.action, - requestId: activeAction.requestId, - reason: activeAction.reason ?? undefined, - }); - toast.success("Enforcement retried"); - onActionComplete(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setIsWorking(false); - } - }; - const handleCancel = async () => { setError(null); setIsWorking(true); try { - // Cancel via the same resolve endpoint with action="dismiss". - // The server rejects cancel if mutation has already landed. - await resolveAdminReport(origin, reportId, { - action: "dismiss", - requestId: crypto.randomUUID(), - reason: "cancelled", + // Fence the cancel to the exact failed action the operator observed. On + // success the report returns to `open`; the detail reload then serves + // `activeAction: null` and re-exposes the resolve form for a fresh attempt. + await cancelAdminReport(origin, reportId, { + actionId: activeAction.id, }); - toast.success("Enforcement cancelled"); + toast.success("Enforcement cancelled — report reopened"); onActionComplete(); } catch (e) { - // A rejected cancel is authoritative — the enforcement may have landed. - // Surface the error but do not retry. + // A 409 means the action is no longer cancellable (already cancelled, + // superseded, or past the mutation point). Reload detail rather than + // retry — the message is informational, the reload shows current state. setError( `Cancel rejected: ${e instanceof Error ? e.message : String(e)}`, ); + onActionComplete(); } finally { setIsWorking(false); } @@ -244,33 +234,26 @@ function EnforcementStateBlock({ {activeAction.action} + {activeAction.errorMessage && ( +

+ {activeAction.errorMessage} +

+ )} {actionStatus === "failed" && ( -
- - -
+ )} {error &&

{error}

} @@ -684,7 +667,6 @@ function ReportDetail({ ); const data = detailState.status === "ok" ? detailState.data : null; - const isProcessing = data?.status === "processing"; const isOpen = data?.status === "open"; const isReopenable = data?.status === "resolved" || @@ -709,21 +691,28 @@ function ReportDetail({ {detailState.status === "ok" && ( <> - {/* Enforcement state: present on processing reports or failed actions */} - {activeAction && - (isProcessing || - activeAction.status === "failed" || - activeAction.status === "pending" || - activeAction.status === "enforcing") && ( - setResolveGen((g) => g + 1)} - /> - )} - {/* Resolve form: only for open (non-processing) reports */} - {isOpen && !activeAction && ( + {/* Enforcement state / history. The detail LATERAL returns an action + whenever one governs the report: a live action (pending/enforcing) + or a cancellable failed action while processing, or a succeeded + action as executed-enforcement history on a terminal or reopened + report (honest history — a later dismissal/reopen does not + un-happen the ban that ran). Cancel is offered only on failed, + inside the block. A cancelled action never reaches this read. */} + {activeAction && ( + setResolveGen((g) => g + 1)} + /> + )} + {/* Resolve form: shown for open reports. A reopened-after-enforcement + report is open yet carries a succeeded activeAction (history); + the form must still show so the operator can re-triage — the + enforcement block above renders that history alongside it. Only a + live/failed action keeps the report `processing` (not open), so + `isOpen` alone never surfaces the form on an in-flight action. */} + {isOpen && ( = { items: T[]; }; +/** React key + heading for rows whose source community has been purged. */ +const SEVERED_COMMUNITY_KEY = "__severed__"; +const SEVERED_COMMUNITY_HOST = "(source community removed)"; + /** * Group deployment-wide rows by community, preserving each community's * first-seen order and the server's row order within it. @@ -194,21 +198,30 @@ export type CommunityGroup = { * deployment; operators triage per community, so rows are bucketed by * `communityId` (stable) and labelled by `communityHost` (display). A blank * host falls back to the id so a group is never headed by an empty string. + * + * Feedback whose source community was purged carries a `null` `communityId` + * (tenant provenance severed, the row retained as operator evidence). Those + * rows bucket into a single "source community removed" group so a null key + * never collapses distinct rows or heads a group with an empty string. */ export function groupByCommunity< - T extends { communityId: string; communityHost: string }, + T extends { communityId: string | null; communityHost: string | null }, >(items: T[]): CommunityGroup[] { const groups: CommunityGroup[] = []; const byId = new Map>(); for (const item of items) { - let group = byId.get(item.communityId); + const key = item.communityId ?? SEVERED_COMMUNITY_KEY; + let group = byId.get(key); if (!group) { group = { - communityId: item.communityId, - communityHost: item.communityHost || item.communityId, + communityId: key, + communityHost: + item.communityId == null + ? SEVERED_COMMUNITY_HOST + : item.communityHost || item.communityId, items: [], }; - byId.set(item.communityId, group); + byId.set(key, group); groups.push(group); } group.items.push(item); @@ -224,7 +237,7 @@ export function groupByCommunity< * entry — the caller owns row markup so navigation/testids are unchanged. */ export function CommunityGroupedList< - T extends { communityId: string; communityHost: string }, + T extends { communityId: string | null; communityHost: string | null }, >({ items, renderItem }: { items: T[]; renderItem: (item: T) => ReactNode }) { const groups = groupByCommunity(items); if (groups.length <= 1) { diff --git a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs index 7ef76b7800..4eadfecd3e 100644 --- a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs +++ b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs @@ -2380,3 +2380,391 @@ test("reopen-409-preserves-requestId: a not-reopenable conflict reuses the same await unmount(); }); + +test("cancel-on-failed: a failed action offers Cancel, POSTs {actionId} to admin_cancel_report, and reloads to open", async () => { + // Cancel-then-resolve is the only recovery from a failed enforcement. The + // block offers Cancel on `status: "failed"`, fences it on the action id, and + // on success the report returns to `open` — the detail reload then serves + // activeAction: null and re-exposes the resolve form for a fresh attempt. + // + // Mutation evidence: revert handleCancel to the old resolve-with-dismiss + // masquerade → admin_cancel_report is never called and cancelArgs stays null. + // Restore the `!activeAction` gate on the resolve form → the reopened report + // still carries no action here, so this test isolates the cancel wiring. + + const origin = "https://admin.example.com"; + const pubkey = "e5".repeat(32); + + const base = { + id: "00000000-0000-0000-0000-0000000000e5", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + createdAt: "2024-06-01T12:00:00Z", + }; + const actionId = "00000000-0000-0000-0000-0000000000f1"; + const failedDetail = { + ...base, + status: "processing", + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + activeAction: { + id: actionId, + requestId: "00000000-0000-0000-0000-0000000000f2", + actorPubkey: + "1111111111111111111111111111111111111111111111111111111111111111", + actorRole: "operator", + action: "ban", + status: "failed", + reason: null, + expiresAt: null, + errorMessage: "adapter timeout", + createdAt: "2024-06-01T12:00:00Z", + updatedAt: "2024-06-01T12:00:05Z", + }, + message: null, + }; + const openDetail = { + ...base, + status: "open", + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + activeAction: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => + Promise.resolve([{ ...base, status: "processing" }]), + ); + let detailCalls = 0; + setIpcHandler("admin_get_report", () => { + detailCalls += 1; + return Promise.resolve(detailCalls === 1 ? failedDetail : openDetail); + }); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + let cancelArgs = null; + setIpcHandler("admin_cancel_report", (args) => { + cancelArgs = args; + return Promise.resolve({ + status: "open", + activeAction: { ...failedDetail.activeAction, status: "cancelled" }, + }); + }); + // The dismiss-masquerade path must be gone: resolve must never be called. + let resolveCalled = false; + setIpcHandler("admin_resolve_report", () => { + resolveCalled = true; + return Promise.reject(new Error("resolve must not be called by cancel")); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + // The failed action surfaces the error message and a single Cancel button. + const block = container.querySelector( + "[data-testid='enforcement-state-block']", + ); + assert.ok(block, "enforcement-state-block must render for a failed action"); + assert.ok( + (block.textContent ?? "").includes("adapter timeout"), + `the failure errorMessage must render; got: ${block.textContent}`, + ); + assert.equal( + container.querySelector("[data-testid='enforcement-retry-btn']"), + null, + "the composed-retry button must be gone (Cancel-only on failed)", + ); + const cancelBtn = container.querySelector( + "[data-testid='enforcement-cancel-btn']", + ); + assert.ok(cancelBtn, "the Cancel button must render on a failed action"); + + await act(async () => { + fireEvent.click(cancelBtn); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + assert.ok(cancelArgs, "admin_cancel_report must be invoked"); + assert.equal(cancelArgs.origin, origin, "origin must be forwarded"); + assert.equal(cancelArgs.id, base.id, "report id must be forwarded"); + assert.equal( + cancelArgs.body?.actionId, + actionId, + "cancel must be fenced on the observed action id", + ); + assert.equal( + resolveCalled, + false, + "cancel must NOT go through the resolve endpoint (no dismiss masquerade)", + ); + assert.ok( + capturedToasts.some((m) => m.toLowerCase().includes("cancel")), + `a cancel success toast must fire; got: ${JSON.stringify(capturedToasts)}`, + ); + // Detail reloaded; the now-open report shows the resolve form for re-triage. + assert.ok(detailCalls >= 2, "detail must reload after cancel"); + assert.ok( + container.querySelector("[data-testid='resolve-report-form']"), + "after cancel the reopened report must show the resolve form", + ); + + await unmount(); +}); + +test("no-cancel-on-in-flight: pending and enforcing actions offer no cancel button", async () => { + // Only a pre-mutation `failed` action is cancellable over HTTP. A stuck + // `pending`/`enforcing` action is owned by the relay's recovery worker; the + // UI must not offer a button that 409s by design. + // + // Mutation evidence: change the button gate from `=== "failed"` to include + // enforcing → the assertion that no cancel button renders goes red. + + const origin = "https://admin.example.com"; + const pubkey = "e6".repeat(32); + + const base = { + id: "00000000-0000-0000-0000-0000000000e6", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + createdAt: "2024-06-01T12:00:00Z", + }; + const enforcingDetail = { + ...base, + status: "processing", + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + activeAction: { + id: "00000000-0000-0000-0000-0000000000f3", + requestId: "00000000-0000-0000-0000-0000000000f4", + actorPubkey: + "1111111111111111111111111111111111111111111111111111111111111111", + actorRole: "operator", + action: "ban", + status: "enforcing", + reason: null, + expiresAt: null, + errorMessage: null, + createdAt: "2024-06-01T12:00:00Z", + updatedAt: "2024-06-01T12:00:01Z", + }, + message: null, + }; + + setIpcHandler("admin_list_reports", () => + Promise.resolve([{ ...base, status: "processing" }]), + ); + setIpcHandler("admin_get_report", () => Promise.resolve(enforcingDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + assert.ok( + container.querySelector("[data-testid='enforcement-state-block']"), + "enforcement-state-block must render for an enforcing action", + ); + assert.equal( + container.querySelector("[data-testid='enforcement-cancel-btn']"), + null, + "no cancel button on an in-flight (enforcing) action", + ); + // And the resolve form must stay suppressed on a processing report. + assert.equal( + container.querySelector("[data-testid='resolve-report-form']"), + null, + "resolve form must not render on a processing report", + ); + + await unmount(); +}); + +test("reopened-after-enforcement: an open report carrying a succeeded action shows both history and the resolve form", async () => { + // Honest history: a report enforced then reopened is `open` yet the detail + // LATERAL still returns the succeeded action (the ban actually ran — a later + // reopen does not un-happen it). The UI must render that action as executed + // history AND still offer the resolve form, because the report is open for + // re-triage. Cancel must NOT appear — cancel is failed-only. + // + // Mutation evidence: restore the `isOpen && !activeAction` gate → the resolve + // form vanishes on this report and the operator is stranded, going red. + + const origin = "https://admin.example.com"; + const pubkey = "e7".repeat(32); + + const reopenedDetail = { + id: "00000000-0000-0000-0000-0000000000e7", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "open", + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + activeAction: { + id: "00000000-0000-0000-0000-0000000000f5", + requestId: "00000000-0000-0000-0000-0000000000f6", + actorPubkey: + "1111111111111111111111111111111111111111111111111111111111111111", + actorRole: "operator", + action: "ban", + status: "succeeded", + reason: "confirmed spam", + expiresAt: null, + errorMessage: null, + createdAt: "2024-06-01T12:00:00Z", + updatedAt: "2024-06-01T12:00:03Z", + }, + message: null, + createdAt: "2024-06-01T11:00:00Z", + }; + + setIpcHandler("admin_list_reports", () => + Promise.resolve([{ ...reopenedDetail, activeAction: undefined }]), + ); + setIpcHandler("admin_get_report", () => Promise.resolve(reopenedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + // Executed-enforcement history renders. + const block = container.querySelector( + "[data-testid='enforcement-state-block']", + ); + assert.ok(block, "the succeeded action must render as enforcement history"); + assert.ok( + (block.textContent ?? "").toLowerCase().includes("succeeded"), + `history must show the succeeded state; got: ${block.textContent}`, + ); + // Cancel is failed-only — never on a succeeded action. + assert.equal( + container.querySelector("[data-testid='enforcement-cancel-btn']"), + null, + "no cancel button on a succeeded action", + ); + // The resolve form must still show — the report is open for re-triage. + assert.ok( + container.querySelector("[data-testid='resolve-report-form']"), + "an open reopened-after-enforcement report must still show the resolve form", + ); + + await unmount(); +}); + +test("feedback-severed-community: a purged-source feedback row renders in list and detail without its community", async () => { + // Item 5 (desktop): feedback whose source community was purged carries a + // null communityId/communityHost (tenant provenance severed, row retained as + // operator evidence). The list must still render it (grouped under a + // "source community removed" bucket) and the detail must show em-dashes for + // the absent community fields — never crash on the null. + // + // Mutation evidence: narrow AdminFeedbackDto.communityId back to `string` → + // typecheck breaks; restore the `communityId: string` grouping constraint → + // the null key throws in groupByCommunity. + + const origin = "https://admin.example.com"; + const pubkey = "e8".repeat(32); + + const severedSummary = { + id: "00000000-0000-0000-0000-0000000000e8", + communityId: null, + communityHost: null, + submitterPubkey: "sub-severed", + category: "bug", + bodySummary: "Feedback from a since-purged community", + status: "new", + receivedAt: "2024-06-01T09:00:00Z", + }; + const severedDetail = { + id: severedSummary.id, + communityId: null, + communityHost: null, + eventId: "sevevent", + submitterPubkey: severedSummary.submitterPubkey, + category: "bug", + body: "Feedback from a since-purged community — full body", + status: "new", + tags: [], + eventCreatedAt: "2024-06-01T09:00:00Z", + receivedAt: "2024-06-01T09:00:00Z", + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([severedSummary])); + setIpcHandler("admin_get_feedback", () => Promise.resolve(severedDetail)); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(30); + + // The severed row still renders in the list (did not throw / vanish). + const listRow = Array.from(container.querySelectorAll("button")).find( + (btn) => + !(btn.getAttribute("data-testid") ?? "").startsWith("admin-tab") && + btn.textContent?.includes("since-purged community"), + ); + assert.ok(listRow, "the severed feedback row must render in the list"); + + await act(async () => { + fireEvent.click(listRow); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(30); + + // Detail renders; the community fields show the em-dash placeholder. + const fields = container.querySelector( + "[data-testid='feedback-detail-fields']", + ); + assert.ok(fields, "feedback detail must render for a severed row"); + assert.ok( + (fields.textContent ?? "").includes("—"), + `absent community fields must render as em-dash; got: ${fields.textContent}`, + ); + + await unmount(); +}); diff --git a/desktop/src/features/admin-console/api.ts b/desktop/src/features/admin-console/api.ts index 3f2ea3c3cb..d2bc7f5c22 100644 --- a/desktop/src/features/admin-console/api.ts +++ b/desktop/src/features/admin-console/api.ts @@ -161,8 +161,13 @@ export type AdminReportDetailDto = AdminReportDto & { /** Deployment-global product feedback entry. */ export type AdminFeedbackDto = { id: string; - communityId: string; - communityHost: string; + /** + * Source community. Both are `null` once the source community has been + * purged: feedback is deployment-global operator evidence whose + * `communityId` is severed to NULL on tenant purge, not cascade-deleted. + */ + communityId: string | null; + communityHost: string | null; eventId: string; submitterPubkey: string; category?: string | null; @@ -185,8 +190,9 @@ export type AdminFeedbackDto = { */ export type AdminFeedbackSummaryDto = { id: string; - communityId: string; - communityHost: string; + /** Source community — `null` on a severed (purged-source) row. */ + communityId: string | null; + communityHost: string | null; submitterPubkey: string; category?: string | null; bodySummary: string; @@ -291,13 +297,27 @@ export type AdminActionRecordDto = { actorRole: AdminPrincipalRole; action: AdminReportAction; status: "pending" | "enforcing" | "succeeded" | "failed" | "cancelled"; - reason?: string | null; - expiresAt?: string | null; - errorMessage?: string | null; + reason: string | null; + expiresAt: string | null; + errorMessage: string | null; createdAt: string; updatedAt: string; }; +/** + * Uniform envelope returned by resolve and cancel: the report's new terminal + * status plus the governing action record. `activeAction` is null for + * decision-only resolutions (`dismiss`/`escalate`, which create no record). + * + * Both endpoints re-read the report so this shape matches a subsequent + * `GET /reports/{id}` — the console reloads detail after a mutation rather than + * consuming this body, so it is a wire contract, not a render source. + */ +export type AdminReportResolution = { + status: string; + activeAction: AdminActionRecordDto | null; +}; + /** * Resolve a report — POST /api/admin/v1/reports/{id}/resolve. * @@ -309,8 +329,43 @@ export async function resolveAdminReport( origin: string, id: string, body: AdminResolveReportBody, -): Promise { - return invokeTauri("admin_resolve_report", { +): Promise { + return invokeTauri("admin_resolve_report", { + origin, + id, + body, + }); +} + +/** + * Body for POST /api/admin/v1/reports/{id}/cancel. + * + * `actionId` fences the cancel to exactly the failed action the operator + * observed. A mismatch — already cancelled, superseded by a newer claim, or + * past the mutation point — resolves to 409. + */ +export type AdminCancelReportBody = { + actionId: string; +}; + +/** + * Cancel a failed enforcement action — POST /api/admin/v1/reports/{id}/cancel. + * + * The only recovery path for a `failed` action: it returns the report to + * `open` for a fresh resolution attempt (there is no composed client-side + * retry — that would imply an atomicity the relay does not provide). A `409` + * means the action is no longer cancellable; treat it as "refresh detail" — + * someone else likely cancelled it or it advanced past the mutation point. + * + * The response embeds the just-cancelled action as a last look; a subsequent + * detail read serves `activeAction: null`. + */ +export async function cancelAdminReport( + origin: string, + id: string, + body: AdminCancelReportBody, +): Promise { + return invokeTauri("admin_cancel_report", { origin, id, body,