feat(desktop): cancel-and-reopen recovery for failed enforcement

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Hayt
2026-08-13 03:22:05 -04:00
parent 6e7242dd26
commit b1777b8297
7 changed files with 577 additions and 91 deletions

View File

@@ -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<serde_json::Value, String> {
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"}.

View File

@@ -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();

View File

@@ -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,

View File

@@ -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}
</span>
</div>
{activeAction.errorMessage && (
<p className="text-xs text-muted-foreground break-words">
{activeAction.errorMessage}
</p>
)}
{actionStatus === "failed" && (
<div className="flex gap-2">
<Button
data-testid="enforcement-retry-btn"
disabled={isWorking}
onClick={() => void handleRetry()}
size="sm"
type="button"
variant="outline"
>
{isWorking ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
"Retry"
)}
</Button>
<Button
data-testid="enforcement-cancel-btn"
disabled={isWorking}
onClick={() => void handleCancel()}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</div>
<Button
data-testid="enforcement-cancel-btn"
disabled={isWorking}
onClick={() => void handleCancel()}
size="sm"
type="button"
variant="outline"
>
{isWorking ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
"Cancel & reopen"
)}
</Button>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
@@ -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" && (
<>
<ReportFields data={detailState.data} />
{/* Enforcement state: present on processing reports or failed actions */}
{activeAction &&
(isProcessing ||
activeAction.status === "failed" ||
activeAction.status === "pending" ||
activeAction.status === "enforcing") && (
<EnforcementStateBlock
activeAction={activeAction}
origin={origin}
reportId={reportId}
onActionComplete={() => 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 && (
<EnforcementStateBlock
activeAction={activeAction}
origin={origin}
reportId={reportId}
onActionComplete={() => 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 && (
<ResolveReportForm
report={detailState.data}
origin={origin}

View File

@@ -186,6 +186,10 @@ export type CommunityGroup<T> = {
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<T> = {
* 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<T>[] {
const groups: CommunityGroup<T>[] = [];
const byId = new Map<string, CommunityGroup<T>>();
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) {

View File

@@ -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();
});

View File

@@ -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<AdminActionRecordDto> {
return invokeTauri<AdminActionRecordDto>("admin_resolve_report", {
): Promise<AdminReportResolution> {
return invokeTauri<AdminReportResolution>("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<AdminReportResolution> {
return invokeTauri<AdminReportResolution>("admin_cancel_report", {
origin,
id,
body,