mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
feat(desktop): reopen resolved reports from the admin console
Add a reopen affordance to the report detail: terminal reports
(resolved | dismissed | escalated) show a form that moves the report
back to open for re-triage via POST /reports/{id}/reopen.
Reopen is re-triage only — it does not reverse any enforcement already
applied. The copy says so, and warns more emphatically when the report
carries an actionId (an enforcement action ran while it was resolved).
A per-attempt requestId is generated once and reused on a 409 retry so a
report that has moved to processing dedupes on the relay, mirroring the
resolve flow's idempotency.
Surface confirmation on the report and feedback actions with sonner
success toasts (resolve, retry, cancel, reopen, feedback status).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -488,6 +488,33 @@ pub async fn admin_resolve_report(
|
||||
serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}"))
|
||||
}
|
||||
|
||||
/// Reopen a resolved report — POST /api/admin/v1/reports/{id}/reopen.
|
||||
///
|
||||
/// Body: `{request_id, reason?}`. The `request_id` is a client-generated UUID
|
||||
/// for idempotency; the caller must generate once per reopen attempt and reuse
|
||||
/// on retry. Reopen is re-triage only — it moves a `resolved`/`dismissed`/
|
||||
/// `escalated` report back to `open` and does not reverse any enforcement
|
||||
/// action (bans, deletions) taken while it was resolved.
|
||||
#[tauri::command]
|
||||
pub async fn admin_reopen_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::ReportReopen { 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"}.
|
||||
|
||||
@@ -50,6 +50,9 @@ pub enum AdminRoute {
|
||||
ReportResolve {
|
||||
id: uuid::Uuid,
|
||||
},
|
||||
ReportReopen {
|
||||
id: uuid::Uuid,
|
||||
},
|
||||
FeedbackList,
|
||||
FeedbackDetail {
|
||||
id: uuid::Uuid,
|
||||
@@ -101,6 +104,7 @@ impl AdminRoute {
|
||||
AdminRoute::ReportsList => "/reports".to_string(),
|
||||
AdminRoute::ReportDetail { id } => format!("/reports/{id}"),
|
||||
AdminRoute::ReportResolve { id } => format!("/reports/{id}/resolve"),
|
||||
AdminRoute::ReportReopen { id } => format!("/reports/{id}/reopen"),
|
||||
AdminRoute::FeedbackList => "/feedback".to_string(),
|
||||
AdminRoute::FeedbackDetail { id } => format!("/feedback/{id}"),
|
||||
AdminRoute::FeedbackAttachment { id, sha256 } => {
|
||||
@@ -246,6 +250,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_reopen_path() {
|
||||
let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap();
|
||||
assert_eq!(
|
||||
AdminRoute::ReportReopen { id }.path(),
|
||||
"/reports/00000000-0000-0000-0000-000000000003/reopen"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_attachment_path() {
|
||||
let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
|
||||
|
||||
@@ -841,6 +841,7 @@ pub fn run() {
|
||||
admin_get_feedback,
|
||||
admin_fetch_feedback_attachment,
|
||||
admin_resolve_report,
|
||||
admin_reopen_report,
|
||||
admin_patch_feedback,
|
||||
admin_list_operators,
|
||||
admin_put_operator,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AlertCircle, ChevronLeft, Download, LoaderCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -335,6 +336,7 @@ function FeedbackStatusControl({
|
||||
setIsWorking(true);
|
||||
try {
|
||||
await patchAdminFeedback(origin, feedbackId, newStatus);
|
||||
toast.success(`Feedback marked ${newStatus}`);
|
||||
onStatusChanged(newStatus);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
|
||||
@@ -31,12 +31,14 @@ import {
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
getAdminReport,
|
||||
listAdminReports,
|
||||
reopenAdminReport,
|
||||
resolveAdminReport,
|
||||
type AdminPrincipalRole,
|
||||
type AdminPrincipalSource,
|
||||
@@ -194,6 +196,7 @@ function EnforcementStateBlock({
|
||||
expirationSecs: activeAction.expirationSecs ?? undefined,
|
||||
reason: activeAction.reason ?? undefined,
|
||||
});
|
||||
toast.success("Enforcement retried");
|
||||
onActionComplete();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -213,6 +216,7 @@ function EnforcementStateBlock({
|
||||
requestId: crypto.randomUUID(),
|
||||
reason: "cancelled",
|
||||
});
|
||||
toast.success("Enforcement cancelled");
|
||||
onActionComplete();
|
||||
} catch (e) {
|
||||
// A rejected cancel is authoritative — the enforcement may have landed.
|
||||
@@ -325,6 +329,7 @@ function ResolveReportForm({
|
||||
: undefined,
|
||||
reason: reason.trim() || undefined,
|
||||
});
|
||||
toast.success(`Report resolved: ${actionLabel(selectedAction)}`);
|
||||
onResolved();
|
||||
} catch (e) {
|
||||
// On error, reset requestId so the next submit generates a new one.
|
||||
@@ -424,6 +429,114 @@ function ResolveReportForm({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reopen report form ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reopen form — shown on terminal reports (`resolved` | `dismissed` |
|
||||
* `escalated`). Moves the report back to `open` for re-triage.
|
||||
*
|
||||
* Reopen is re-triage only: it does NOT reverse any enforcement already taken
|
||||
* (no un-ban, no un-timeout, no message restore). The copy states this
|
||||
* explicitly, and more emphatically when the report carries an `actionId`
|
||||
* (an enforcement action was applied while it was resolved).
|
||||
*
|
||||
* A `requestId` is generated per attempt and reused on retry so a lost
|
||||
* response is idempotent, mirroring the resolve flow. A 409 (report not
|
||||
* reopenable — e.g. it moved to `processing`) preserves the `requestId`.
|
||||
*/
|
||||
function ReopenReportForm({
|
||||
report,
|
||||
origin,
|
||||
onReopened,
|
||||
}: {
|
||||
report: AdminReportDto;
|
||||
origin: string;
|
||||
onReopened: () => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Stable requestId per attempt; reused on retry after a lost response.
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
|
||||
// An enforcement action was applied while this report was resolved.
|
||||
const wasEnforced = report.actionId != null;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (!requestIdRef.current) {
|
||||
requestIdRef.current = crypto.randomUUID();
|
||||
}
|
||||
|
||||
try {
|
||||
await reopenAdminReport(origin, report.id, {
|
||||
requestId: requestIdRef.current,
|
||||
reason: reason.trim() || undefined,
|
||||
});
|
||||
toast.success("Report reopened");
|
||||
onReopened();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// A 409 means the report is not reopenable (e.g. it moved to
|
||||
// `processing`). Preserve the requestId so a genuine retry reuses it;
|
||||
// reset otherwise so the next attempt generates a fresh one.
|
||||
if (!msg.includes("409") && !msg.includes("processing")) {
|
||||
requestIdRef.current = null;
|
||||
}
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-border/60 px-3 py-2.5 space-y-3"
|
||||
data-testid="reopen-report-form"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Reopen report
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Moves this report back to the open queue for re-triage.{" "}
|
||||
{wasEnforced
|
||||
? "The enforcement action already taken is not reversed — reopening does not un-ban, un-timeout, or restore a deleted message."
|
||||
: "Reopening does not reverse any enforcement action."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="w-full rounded-md border border-border/60 bg-background px-2 py-1 text-xs"
|
||||
data-testid="reopen-reason-input"
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Reason (optional)"
|
||||
type="text"
|
||||
value={reason}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-testid="reopen-submit-btn"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleSubmit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
"Reopen report"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reports tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function ReportsTab({
|
||||
@@ -575,6 +688,10 @@ 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" ||
|
||||
data?.status === "dismissed" ||
|
||||
data?.status === "escalated";
|
||||
const activeAction = data?.activeAction ?? null;
|
||||
|
||||
return (
|
||||
@@ -615,6 +732,14 @@ function ReportDetail({
|
||||
onResolved={() => setResolveGen((g) => g + 1)}
|
||||
/>
|
||||
)}
|
||||
{/* Reopen form: only for terminal (resolved/dismissed/escalated) reports */}
|
||||
{isReopenable && (
|
||||
<ReopenReportForm
|
||||
report={detailState.data}
|
||||
origin={origin}
|
||||
onReopened={() => setResolveGen((g) => g + 1)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -75,10 +75,24 @@ import { createRoot } from "react-dom/client";
|
||||
import { act } from "react";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AdminConsoleSettingsCard } from "./AdminConsoleSettingsCard.tsx";
|
||||
import { AdminConsolePanel } from "./AdminConsolePanel.tsx";
|
||||
|
||||
// ── Success-toast capture ────────────────────────────────────────────────────
|
||||
//
|
||||
// sonner's `toast` is a shared singleton object across import paths (verified),
|
||||
// so replacing `toast.success` here is observed by the production components.
|
||||
// Captured messages are asserted by the toast tests and cleared in afterEach.
|
||||
|
||||
/** @type {string[]} */
|
||||
const capturedToasts = [];
|
||||
toast.success = (msg) => {
|
||||
capturedToasts.push(String(msg));
|
||||
return 0;
|
||||
};
|
||||
|
||||
// ── Deferred promise helper ──────────────────────────────────────────────────
|
||||
|
||||
function deferred() {
|
||||
@@ -159,6 +173,7 @@ async function settle(ms = 20) {
|
||||
|
||||
afterEach(() => {
|
||||
clearIpcHandlers();
|
||||
capturedToasts.length = 0;
|
||||
});
|
||||
|
||||
// ── origin-edit ──────────────────────────────────────────────────────────────
|
||||
@@ -1822,3 +1837,338 @@ test("feedback-grouped-by-community: multi-community feedback renders per-commun
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
// ── reopen ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mount the panel, wait for the list, then click the first non-tab report row
|
||||
* to open its detail. Returns after the detail has settled.
|
||||
*/
|
||||
async function openFirstReportDetail(container) {
|
||||
const allButtons = container.querySelectorAll("button");
|
||||
for (const btn of allButtons) {
|
||||
const testid = btn.getAttribute("data-testid") ?? "";
|
||||
if (testid.startsWith("admin-tab")) continue;
|
||||
await act(async () => {
|
||||
fireEvent.click(btn);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error("no navigable report row found");
|
||||
}
|
||||
|
||||
test("reopen-form-gated-by-status: resolved report shows the reopen form, open report does not", async () => {
|
||||
// The reopen form must render only for terminal reports
|
||||
// (resolved | dismissed | escalated) and never for an open report — an open
|
||||
// report shows the resolve form instead.
|
||||
//
|
||||
// Mutation evidence: drop the `isReopenable` gate → the form renders for
|
||||
// open reports too and the second assertion goes red.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "c1".repeat(32);
|
||||
|
||||
const resolvedItem = {
|
||||
id: "00000000-0000-0000-0000-0000000000c1",
|
||||
communityId: "comm-1",
|
||||
communityHost: "alpha.example.com",
|
||||
reportEventId: "aa",
|
||||
reporterPubkey: "bb",
|
||||
targetKind: "event",
|
||||
target: "cc",
|
||||
reportType: "spam",
|
||||
status: "resolved",
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
};
|
||||
const resolvedDetail = {
|
||||
...resolvedItem,
|
||||
channelId: null,
|
||||
note: null,
|
||||
resolvedBy: "mod_pubkey",
|
||||
resolvedAt: "2024-06-02T08:00:00Z",
|
||||
actionId: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem]));
|
||||
setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail));
|
||||
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='reopen-report-form']"),
|
||||
"reopen form must render for a resolved report",
|
||||
);
|
||||
assert.equal(
|
||||
container.querySelector("[data-testid='resolve-report-form']"),
|
||||
null,
|
||||
"resolve form must NOT render for a resolved report",
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
test("reopen-submit: calls admin_reopen_report with requestId+reason, toasts, and refreshes", async () => {
|
||||
// The reopen submit must POST {requestId, reason} to admin_reopen_report,
|
||||
// fire a success toast, and bump the resolve generation so the detail
|
||||
// reloads (verified here by a second admin_get_report call returning the
|
||||
// now-open report, which flips the UI to the resolve form).
|
||||
//
|
||||
// Mutation evidence: remove `onReopened()` → no reload, detail stays
|
||||
// resolved, and the resolve-form assertion goes red. Remove the toast →
|
||||
// capturedToasts assertion goes red.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "c2".repeat(32);
|
||||
|
||||
const base = {
|
||||
id: "00000000-0000-0000-0000-0000000000c2",
|
||||
communityId: "comm-1",
|
||||
communityHost: "alpha.example.com",
|
||||
reportEventId: "aa",
|
||||
reporterPubkey: "bb",
|
||||
targetKind: "event",
|
||||
target: "cc",
|
||||
reportType: "spam",
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
};
|
||||
const dismissedItem = { ...base, status: "dismissed" };
|
||||
const dismissedDetail = {
|
||||
...dismissedItem,
|
||||
channelId: null,
|
||||
note: null,
|
||||
resolvedBy: "mod_pubkey",
|
||||
resolvedAt: "2024-06-02T08:00:00Z",
|
||||
actionId: null,
|
||||
message: null,
|
||||
};
|
||||
const openDetail = {
|
||||
...base,
|
||||
status: "open",
|
||||
channelId: null,
|
||||
note: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
actionId: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
setIpcHandler("admin_list_reports", () => Promise.resolve([dismissedItem]));
|
||||
// First detail load: dismissed. After reopen, the generation bump reloads
|
||||
// and the report is now open.
|
||||
let detailCalls = 0;
|
||||
setIpcHandler("admin_get_report", () => {
|
||||
detailCalls += 1;
|
||||
return Promise.resolve(detailCalls === 1 ? dismissedDetail : openDetail);
|
||||
});
|
||||
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
|
||||
|
||||
let reopenArgs = null;
|
||||
setIpcHandler("admin_reopen_report", (args) => {
|
||||
reopenArgs = args;
|
||||
return Promise.resolve({ status: "open" });
|
||||
});
|
||||
|
||||
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
||||
await doRender();
|
||||
await settle(30);
|
||||
await openFirstReportDetail(container);
|
||||
await settle(20);
|
||||
|
||||
// Type a reason.
|
||||
const reasonInput = container.querySelector(
|
||||
"[data-testid='reopen-reason-input']",
|
||||
);
|
||||
assert.ok(reasonInput, "reopen reason input must be present");
|
||||
await act(async () => {
|
||||
fireEvent.change(reasonInput, { target: { value: "new evidence" } });
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
});
|
||||
|
||||
// Submit.
|
||||
const submit = container.querySelector("[data-testid='reopen-submit-btn']");
|
||||
assert.ok(submit, "reopen submit button must be present");
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
await settle(20);
|
||||
|
||||
assert.ok(reopenArgs, "admin_reopen_report must be invoked");
|
||||
assert.equal(reopenArgs.origin, origin, "origin must be forwarded");
|
||||
assert.equal(reopenArgs.id, base.id, "report id must be forwarded");
|
||||
assert.equal(
|
||||
reopenArgs.body?.reason,
|
||||
"new evidence",
|
||||
"reason must be forwarded in the body",
|
||||
);
|
||||
assert.ok(
|
||||
typeof reopenArgs.body?.requestId === "string" &&
|
||||
reopenArgs.body.requestId.length > 0,
|
||||
`requestId must be a non-empty string; got: ${JSON.stringify(reopenArgs.body?.requestId)}`,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
capturedToasts.some((m) => m.toLowerCase().includes("reopen")),
|
||||
`a reopen success toast must fire; got: ${JSON.stringify(capturedToasts)}`,
|
||||
);
|
||||
|
||||
// Refresh: detail reloaded (call 2) and the report is now open → resolve form.
|
||||
assert.ok(
|
||||
detailCalls >= 2,
|
||||
"detail must reload after reopen (generation bump)",
|
||||
);
|
||||
assert.ok(
|
||||
container.querySelector("[data-testid='resolve-report-form']"),
|
||||
"after reopen, the now-open report must show the resolve form",
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
test("reopen-enforced-copy: a report with an actionId warns enforcement is not reversed", async () => {
|
||||
// Reopen is re-triage only. When the report carries an actionId (enforcement
|
||||
// was applied), the copy must say the enforcement is not reversed.
|
||||
//
|
||||
// Mutation evidence: collapse the `wasEnforced` branch to the generic copy →
|
||||
// the "not reversed" wording for un-ban/un-timeout/restore disappears.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "c3".repeat(32);
|
||||
|
||||
const escalatedItem = {
|
||||
id: "00000000-0000-0000-0000-0000000000c3",
|
||||
communityId: "comm-1",
|
||||
communityHost: "alpha.example.com",
|
||||
reportEventId: "aa",
|
||||
reporterPubkey: "bb",
|
||||
targetKind: "pubkey",
|
||||
target: "cc",
|
||||
reportType: "abuse",
|
||||
status: "escalated",
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
};
|
||||
const escalatedDetail = {
|
||||
...escalatedItem,
|
||||
channelId: null,
|
||||
note: null,
|
||||
resolvedBy: "mod_pubkey",
|
||||
resolvedAt: "2024-06-02T08:00:00Z",
|
||||
actionId: "00000000-0000-0000-0000-0000000000ff",
|
||||
message: null,
|
||||
};
|
||||
|
||||
setIpcHandler("admin_list_reports", () => Promise.resolve([escalatedItem]));
|
||||
setIpcHandler("admin_get_report", () => Promise.resolve(escalatedDetail));
|
||||
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
|
||||
|
||||
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
||||
await doRender();
|
||||
await settle(30);
|
||||
await openFirstReportDetail(container);
|
||||
await settle(20);
|
||||
|
||||
const form = container.querySelector("[data-testid='reopen-report-form']");
|
||||
assert.ok(form, "reopen form must render for an escalated report");
|
||||
const text = form.textContent ?? "";
|
||||
assert.ok(
|
||||
text.toLowerCase().includes("not reversed"),
|
||||
`enforced-report copy must state the action is not reversed; got: ${text}`,
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
test("reopen-409-preserves-requestId: a not-reopenable conflict reuses the same requestId on retry", async () => {
|
||||
// A 409 (report is not reopenable — e.g. it moved to processing) is an
|
||||
// idempotency-relevant failure: the same requestId must be reused on retry
|
||||
// so the relay can dedupe. A non-409 failure resets it.
|
||||
//
|
||||
// Mutation evidence: drop the 409 branch in the catch → requestId resets and
|
||||
// the two attempts carry different ids, going red.
|
||||
|
||||
const origin = "https://admin.example.com";
|
||||
const pubkey = "c4".repeat(32);
|
||||
|
||||
const resolvedItem = {
|
||||
id: "00000000-0000-0000-0000-0000000000c4",
|
||||
communityId: "comm-1",
|
||||
communityHost: "alpha.example.com",
|
||||
reportEventId: "aa",
|
||||
reporterPubkey: "bb",
|
||||
targetKind: "event",
|
||||
target: "cc",
|
||||
reportType: "spam",
|
||||
status: "resolved",
|
||||
createdAt: "2024-06-01T12:00:00Z",
|
||||
};
|
||||
const resolvedDetail = {
|
||||
...resolvedItem,
|
||||
channelId: null,
|
||||
note: null,
|
||||
resolvedBy: "mod_pubkey",
|
||||
resolvedAt: "2024-06-02T08:00:00Z",
|
||||
actionId: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem]));
|
||||
setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail));
|
||||
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
|
||||
|
||||
const requestIds = [];
|
||||
setIpcHandler("admin_reopen_report", (args) => {
|
||||
requestIds.push(args?.body?.requestId);
|
||||
return Promise.reject(
|
||||
new Error("409 report is not reopenable (current status: processing)"),
|
||||
);
|
||||
});
|
||||
|
||||
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
||||
await doRender();
|
||||
await settle(30);
|
||||
await openFirstReportDetail(container);
|
||||
await settle(20);
|
||||
|
||||
const submit = container.querySelector("[data-testid='reopen-submit-btn']");
|
||||
assert.ok(submit, "reopen submit button must be present");
|
||||
|
||||
// First attempt → 409.
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
// Second attempt → 409 again; requestId must be identical.
|
||||
await act(async () => {
|
||||
fireEvent.click(submit);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
|
||||
assert.equal(requestIds.length, 2, "two reopen attempts must have been made");
|
||||
assert.equal(
|
||||
requestIds[0],
|
||||
requestIds[1],
|
||||
`requestId must be preserved across a 409 retry; got: ${JSON.stringify(requestIds)}`,
|
||||
);
|
||||
|
||||
// No success toast on a 409.
|
||||
assert.deepEqual(
|
||||
capturedToasts,
|
||||
[],
|
||||
`no success toast on a 409; got: ${JSON.stringify(capturedToasts)}`,
|
||||
);
|
||||
// The error is surfaced.
|
||||
const text = container.textContent ?? "";
|
||||
assert.ok(
|
||||
text.includes("not reopenable"),
|
||||
`the 409 error message must surface; got: ${text.slice(0, 400)}`,
|
||||
);
|
||||
|
||||
await unmount();
|
||||
});
|
||||
|
||||
@@ -305,6 +305,46 @@ export async function resolveAdminReport(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Body for POST /api/admin/v1/reports/{id}/reopen.
|
||||
*
|
||||
* `requestId` is a client-generated UUID; generate once per reopen attempt and
|
||||
* reuse the **same** UUID on retry after a lost response, mirroring resolve
|
||||
* idempotency.
|
||||
*/
|
||||
export type AdminReopenReportBody = {
|
||||
requestId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/** The status returned by a successful reopen — always `"open"`. */
|
||||
export type AdminReopenReportResult = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reopen a resolved report — POST /api/admin/v1/reports/{id}/reopen.
|
||||
*
|
||||
* Moves a `resolved` | `dismissed` | `escalated` report back to `open` for
|
||||
* re-triage. A `processing` report is not reopenable and yields 409. Reopen
|
||||
* does **not** reverse enforcement (no un-ban, no un-delete) — it only
|
||||
* re-queues the report.
|
||||
*
|
||||
* The caller must generate a UUID `requestId` per reopen attempt and reuse the
|
||||
* **same** UUID on retry after a lost response.
|
||||
*/
|
||||
export async function reopenAdminReport(
|
||||
origin: string,
|
||||
id: string,
|
||||
body: AdminReopenReportBody,
|
||||
): Promise<AdminReopenReportResult> {
|
||||
return invokeTauri<AdminReopenReportResult>("admin_reopen_report", {
|
||||
origin,
|
||||
id,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Feedback status ───────────────────────────────────────────────────────
|
||||
|
||||
export type AdminFeedbackStatus = "new" | "reviewed" | "archived";
|
||||
|
||||
Reference in New Issue
Block a user