mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(relay): add reopen/cancel admin endpoints and activeAction report DTO
The moderation console needs to reopen a terminally-resolved report, cancel a
failed enforcement action, and read the enforcement that ran against a target.
The prior surface exposed none of these: resolve returned an ad-hoc
`{status, actionId}`, report detail carried no action provenance, and feedback
from purged communities was silently dropped by an inner join.
- POST /reports/{id}/reopen returns a terminal report to `open` and records a
durable `reopen` audit row; idempotent on request_id, 409 if not terminal.
- POST /reports/{id}/cancel cancels a pre-mutation `failed` action (the only
recovery path — no composed client-side retry) and embeds the cancelled
action DTO as the last look at a record a later detail read serves as null.
- GET /reports/{id} and /resolve now carry `activeAction`, derived via a LEFT
JOIN LATERAL matching the report's active action or its succeeded enforcement
(`ORDER BY created_at DESC, id DESC`), so a dismissed-after-reopen report
still surfaces the enforcement that actually executed. reopen audit rows are
excluded (`action IN ('delete','kick','ban','timeout')`).
- Feedback list/detail switch to LEFT JOIN communities with nullable
communityId/communityHost so rows survive a tenant purge that severs
provenance (product_feedback.community_id SET NULL); the attachment path
fails closed to 404 on a severed row.
No migration added — activeAction is derived from existing indexes.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -68,6 +68,61 @@ pub struct AdminReportedMessage {
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// The `relay_admin_actions` enforcement record governing a report.
|
||||
///
|
||||
/// Populated on the report detail read and enforcement resolve response. Carries
|
||||
/// the durable state machine so the console can render enforcement progress or
|
||||
/// terminal outcome without inventing a shape the relay never emits.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminActionDto {
|
||||
/// Action row identifier.
|
||||
pub id: Uuid,
|
||||
/// Client-generated idempotency key.
|
||||
pub request_id: Uuid,
|
||||
/// Principal who claimed the report.
|
||||
pub actor_pubkey: String,
|
||||
/// Role of the actor: `"operator"` | `"moderator"`.
|
||||
pub actor_role: String,
|
||||
/// Enforcement action name: `"delete"` | `"kick"` | `"ban"` | `"timeout"`.
|
||||
pub action: String,
|
||||
/// State machine: `"pending"` | `"enforcing"` | `"succeeded"` | `"failed"` | `"cancelled"`.
|
||||
pub status: String,
|
||||
/// Operator reason, if provided.
|
||||
pub reason: Option<String>,
|
||||
/// Absolute timeout expiry for `timeout` actions; null otherwise. Absolute
|
||||
/// (not remaining-seconds) so repeated reads never disagree; the client
|
||||
/// computes remaining time.
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
/// Error from the last failure, if any.
|
||||
pub error_message: Option<String>,
|
||||
/// Action creation time.
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Action last-updated time.
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AdminActionDto {
|
||||
/// Build the wire DTO from a persistence record. Used to embed the
|
||||
/// just-cancelled action in the cancel response — the last look at a record
|
||||
/// that a subsequent detail read (report back to `open`) no longer surfaces.
|
||||
pub fn from_record(record: &crate::relay_admin_actions::AdminActionRecord) -> Self {
|
||||
Self {
|
||||
id: record.id,
|
||||
request_id: record.request_id,
|
||||
actor_pubkey: hex::encode(&record.actor_pubkey),
|
||||
actor_role: record.actor_role.clone(),
|
||||
action: record.action.clone(),
|
||||
status: record.state.clone(),
|
||||
reason: record.reason.clone(),
|
||||
expires_at: record.timeout_until,
|
||||
error_message: record.error_message.clone(),
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deployment-global moderation report detail.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -77,6 +132,9 @@ pub struct AdminReportDetail {
|
||||
pub report: AdminReport,
|
||||
/// Reported message when the report targets a stored event.
|
||||
pub message: Option<AdminReportedMessage>,
|
||||
/// Governing enforcement action, when one exists (live or terminal). Null
|
||||
/// for reports never enforced via the HTTP admin plane.
|
||||
pub active_action: Option<AdminActionDto>,
|
||||
}
|
||||
|
||||
/// Deployment-global product feedback with source-community provenance.
|
||||
@@ -85,10 +143,13 @@ pub struct AdminReportDetail {
|
||||
pub struct AdminFeedback {
|
||||
/// Feedback row identifier.
|
||||
pub id: Uuid,
|
||||
/// Source community identifier.
|
||||
pub community_id: Uuid,
|
||||
/// Source community host.
|
||||
pub community_host: String,
|
||||
/// Source community identifier. `None` once the source community has been
|
||||
/// purged: `product_feedback` is deployment-global operator evidence whose
|
||||
/// `community_id` is severed to NULL on tenant purge, not cascade-deleted.
|
||||
pub community_id: Option<Uuid>,
|
||||
/// Source community host. `None` when `community_id` is severed (no row to
|
||||
/// join) — the feedback is retained without its origin tenant.
|
||||
pub community_host: Option<String>,
|
||||
/// Signed feedback event identifier.
|
||||
pub event_id: String,
|
||||
/// Submitter public key.
|
||||
@@ -99,6 +160,8 @@ pub struct AdminFeedback {
|
||||
pub body: String,
|
||||
/// Full source tags, including attachment metadata.
|
||||
pub tags: serde_json::Value,
|
||||
/// Operator-managed lifecycle status: `"new"` | `"reviewed"` | `"archived"`.
|
||||
pub status: String,
|
||||
/// Timestamp signed into the feedback event.
|
||||
pub event_created_at: DateTime<Utc>,
|
||||
/// Time accepted by this deployment.
|
||||
@@ -165,7 +228,18 @@ pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result<Option<AdminRe
|
||||
target.pubkey AS message_author_pubkey,
|
||||
target.content AS message_content,
|
||||
target.created_at AS message_created_at,
|
||||
target.deleted_at AS message_deleted_at
|
||||
target.deleted_at AS message_deleted_at,
|
||||
act.id AS action_id_admin,
|
||||
act.request_id AS action_request_id,
|
||||
act.actor_pubkey AS action_actor_pubkey,
|
||||
act.actor_role AS action_actor_role,
|
||||
act.action AS action_name,
|
||||
act.state AS action_state,
|
||||
act.reason AS action_reason,
|
||||
act.timeout_until AS action_timeout_until,
|
||||
act.error_message AS action_error_message,
|
||||
act.created_at AS action_created_at,
|
||||
act.updated_at AS action_updated_at
|
||||
FROM moderation_reports r
|
||||
JOIN communities c ON c.id = r.community_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -177,6 +251,18 @@ pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result<Option<AdminRe
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 1
|
||||
) target ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT a.id, a.request_id, a.actor_pubkey, a.actor_role, a.action,
|
||||
a.state, a.reason, a.timeout_until, a.error_message,
|
||||
a.created_at, a.updated_at
|
||||
FROM relay_admin_actions a
|
||||
WHERE a.report_community_id = r.community_id
|
||||
AND a.report_id = r.id
|
||||
AND a.action IN ('delete', 'kick', 'ban', 'timeout')
|
||||
AND (a.id = r.active_action_id OR a.state = 'succeeded')
|
||||
ORDER BY a.created_at DESC, a.id DESC
|
||||
LIMIT 1
|
||||
) act ON TRUE
|
||||
WHERE r.id = $1
|
||||
"#,
|
||||
)
|
||||
@@ -195,14 +281,38 @@ pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result<Option<AdminRe
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let active_action = row_to_action_dto(&row)?;
|
||||
Ok(AdminReportDetail {
|
||||
report: row_to_report(row)?,
|
||||
message,
|
||||
active_action,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Build an [`AdminActionDto`] from the LATERAL-joined `act.*` columns, when a
|
||||
/// governing action was found. Returns `None` when the join produced no row
|
||||
/// (all `act.*` columns null).
|
||||
fn row_to_action_dto(row: &sqlx::postgres::PgRow) -> Result<Option<AdminActionDto>> {
|
||||
let Some(id) = row.try_get::<Option<Uuid>, _>("action_id_admin")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(AdminActionDto {
|
||||
id,
|
||||
request_id: row.try_get("action_request_id")?,
|
||||
actor_pubkey: hex::encode(row.try_get::<Vec<u8>, _>("action_actor_pubkey")?),
|
||||
actor_role: row.try_get("action_actor_role")?,
|
||||
action: row.try_get("action_name")?,
|
||||
status: row.try_get("action_state")?,
|
||||
reason: row.try_get("action_reason")?,
|
||||
expires_at: row.try_get("action_timeout_until")?,
|
||||
error_message: row.try_get("action_error_message")?,
|
||||
created_at: row.try_get("action_created_at")?,
|
||||
updated_at: row.try_get("action_updated_at")?,
|
||||
}))
|
||||
}
|
||||
|
||||
fn row_to_report(row: sqlx::postgres::PgRow) -> Result<AdminReport> {
|
||||
let target_kind: String = row.try_get("target_kind")?;
|
||||
let target = match target_kind.as_str() {
|
||||
@@ -237,10 +347,10 @@ pub async fn list_feedback(pool: &PgPool, limit: i64) -> Result<Vec<AdminFeedbac
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT f.id, f.community_id, c.host AS community_host, f.event_id,
|
||||
f.submitter_pubkey, f.category, f.body, f.tags,
|
||||
f.submitter_pubkey, f.category, f.body, f.tags, f.status,
|
||||
f.event_created_at, f.received_at
|
||||
FROM product_feedback f
|
||||
JOIN communities c ON c.id = f.community_id
|
||||
LEFT JOIN communities c ON c.id = f.community_id
|
||||
ORDER BY f.received_at DESC, f.id DESC
|
||||
LIMIT $1
|
||||
"#,
|
||||
@@ -256,10 +366,10 @@ pub async fn get_feedback(pool: &PgPool, id: Uuid) -> Result<Option<AdminFeedbac
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT f.id, f.community_id, c.host AS community_host, f.event_id,
|
||||
f.submitter_pubkey, f.category, f.body, f.tags,
|
||||
f.submitter_pubkey, f.category, f.body, f.tags, f.status,
|
||||
f.event_created_at, f.received_at
|
||||
FROM product_feedback f
|
||||
JOIN communities c ON c.id = f.community_id
|
||||
LEFT JOIN communities c ON c.id = f.community_id
|
||||
WHERE f.id = $1
|
||||
"#,
|
||||
)
|
||||
@@ -279,6 +389,7 @@ fn row_to_feedback(row: sqlx::postgres::PgRow) -> Result<AdminFeedback> {
|
||||
category: row.try_get("category")?,
|
||||
body: row.try_get("body")?,
|
||||
tags: row.try_get("tags")?,
|
||||
status: row.try_get("status")?,
|
||||
event_created_at: row.try_get("event_created_at")?,
|
||||
received_at: row.try_get("received_at")?,
|
||||
})
|
||||
@@ -384,6 +495,14 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn delete_report_fixture(pool: &PgPool, community_id: Uuid) {
|
||||
// relay_admin_actions FK-references (community_id, report_id), so clear
|
||||
// any enforcement/audit rows before the reports they point at. A no-op
|
||||
// for tests that never insert actions.
|
||||
sqlx::query("DELETE FROM relay_admin_actions WHERE report_community_id = $1")
|
||||
.bind(community_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("delete admin action fixture");
|
||||
sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1")
|
||||
.bind(community_id)
|
||||
.execute(pool)
|
||||
@@ -485,4 +604,288 @@ mod tests {
|
||||
|
||||
delete_report_fixture(&pool, community_id).await;
|
||||
}
|
||||
|
||||
// ── activeAction LATERAL join ─────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn insert_admin_action(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
community_id: Uuid,
|
||||
report_id: Uuid,
|
||||
action: &str,
|
||||
state: &str,
|
||||
created_at: DateTime<Utc>,
|
||||
) {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO relay_admin_actions (
|
||||
id, report_id, report_community_id, request_id, actor_pubkey,
|
||||
actor_role, action, state, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, 'operator', $6, $7, $8, $8)
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(report_id)
|
||||
.bind(community_id)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(vec![2_u8; 32])
|
||||
.bind(action)
|
||||
.bind(state)
|
||||
.bind(created_at)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert admin action");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn report_detail_surfaces_succeeded_enforcement_on_a_dismissed_reopened_report() {
|
||||
// Enforcement succeeded, report was later reopened and re-triaged to
|
||||
// `dismissed`. active_action_id is NULL, but the succeeded enforcement
|
||||
// row still matches `a.state='succeeded'` — the activeAction must surface
|
||||
// that DTO: a later dismissal does not un-happen the executed ban.
|
||||
let pool = setup_pool().await;
|
||||
let community_id = insert_community(&pool, "dismissed-after-enforce").await;
|
||||
let report_id = insert_pubkey_report(&pool, community_id).await;
|
||||
let action_id = Uuid::new_v4();
|
||||
insert_admin_action(
|
||||
&pool,
|
||||
action_id,
|
||||
community_id,
|
||||
report_id,
|
||||
"ban",
|
||||
"succeeded",
|
||||
Utc::now(),
|
||||
)
|
||||
.await;
|
||||
set_report_status(&pool, community_id, report_id, "dismissed").await;
|
||||
|
||||
let detail = get_report(&pool, report_id)
|
||||
.await
|
||||
.expect("query report")
|
||||
.expect("report exists");
|
||||
assert_eq!(detail.report.status, "dismissed");
|
||||
let action = detail
|
||||
.active_action
|
||||
.expect("succeeded enforcement DTO survives dismissal");
|
||||
assert_eq!(action.id, action_id);
|
||||
assert_eq!(action.action, "ban");
|
||||
assert_eq!(action.status, "succeeded");
|
||||
|
||||
delete_report_fixture(&pool, community_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn report_detail_active_action_breaks_equal_timestamp_ties_by_id_desc() {
|
||||
// Two succeeded enforcement rows (possible across reopen cycles) sharing
|
||||
// an identical created_at: the `a.id DESC` tiebreaker must pick the
|
||||
// greater id deterministically, never leave the choice to row order.
|
||||
let pool = setup_pool().await;
|
||||
let community_id = insert_community(&pool, "equal-ts-tiebreak").await;
|
||||
let report_id = insert_pubkey_report(&pool, community_id).await;
|
||||
let ts = Utc::now();
|
||||
let id_a = Uuid::new_v4();
|
||||
let id_b = Uuid::new_v4();
|
||||
insert_admin_action(&pool, id_a, community_id, report_id, "ban", "succeeded", ts).await;
|
||||
insert_admin_action(
|
||||
&pool,
|
||||
id_b,
|
||||
community_id,
|
||||
report_id,
|
||||
"kick",
|
||||
"succeeded",
|
||||
ts,
|
||||
)
|
||||
.await;
|
||||
set_report_status(&pool, community_id, report_id, "resolved").await;
|
||||
|
||||
let detail = get_report(&pool, report_id)
|
||||
.await
|
||||
.expect("query report")
|
||||
.expect("report exists");
|
||||
let action = detail.active_action.expect("an action surfaces");
|
||||
assert_eq!(
|
||||
action.id,
|
||||
id_a.max(id_b),
|
||||
"equal timestamps must resolve to the greater id via a.id DESC"
|
||||
);
|
||||
|
||||
delete_report_fixture(&pool, community_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn report_detail_active_action_excludes_reopen_audit_rows() {
|
||||
// A `reopen` audit row is written state='succeeded'. The enforcement DTO
|
||||
// join filters `action IN (delete,kick,ban,timeout)`, so a report whose
|
||||
// only relay_admin_actions row is a reopen audit must surface no action.
|
||||
let pool = setup_pool().await;
|
||||
let community_id = insert_community(&pool, "reopen-audit-excluded").await;
|
||||
let report_id = insert_pubkey_report(&pool, community_id).await;
|
||||
insert_admin_action(
|
||||
&pool,
|
||||
Uuid::new_v4(),
|
||||
community_id,
|
||||
report_id,
|
||||
"reopen",
|
||||
"succeeded",
|
||||
Utc::now(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let detail = get_report(&pool, report_id)
|
||||
.await
|
||||
.expect("query report")
|
||||
.expect("report exists");
|
||||
assert!(
|
||||
detail.active_action.is_none(),
|
||||
"reopen audit row must not surface as an enforcement action"
|
||||
);
|
||||
|
||||
delete_report_fixture(&pool, community_id).await;
|
||||
}
|
||||
|
||||
async fn set_report_status(pool: &PgPool, community_id: Uuid, report_id: Uuid, status: &str) {
|
||||
sqlx::query(
|
||||
"UPDATE moderation_reports SET status = $3 WHERE community_id = $1 AND id = $2",
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(report_id)
|
||||
.bind(status)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("set report status");
|
||||
}
|
||||
|
||||
// ── Feedback severed-provenance survival ──────────────────────────────────
|
||||
|
||||
async fn insert_feedback(pool: &PgPool, community_id: Uuid, status: &str) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
let event_id: Vec<u8> = id
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.chain(id.as_bytes().iter())
|
||||
.copied()
|
||||
.collect();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO product_feedback (
|
||||
id, community_id, event_id, submitter_pubkey, category, body,
|
||||
tags, status, event_created_at, received_at
|
||||
) VALUES ($1, $2, $3, $4, 'bug', 'reproduces on launch', '[]'::jsonb,
|
||||
$5, now(), now())
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(community_id)
|
||||
.bind(event_id)
|
||||
.bind(vec![9_u8; 32])
|
||||
.bind(status)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert feedback");
|
||||
id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn feedback_survives_community_purge_with_null_provenance_in_list_and_detail() {
|
||||
// purge_postgres severs tenant provenance without deleting the row:
|
||||
// `UPDATE product_feedback SET community_id = NULL`. The LEFT JOIN must
|
||||
// keep the row visible in both list and detail reads with null
|
||||
// community fields and its operator-managed status intact.
|
||||
let pool = setup_pool().await;
|
||||
let community_id = insert_community(&pool, "severed-feedback").await;
|
||||
let feedback_id = insert_feedback(&pool, community_id, "reviewed").await;
|
||||
|
||||
// Sever provenance exactly as the community purge transaction does.
|
||||
sqlx::query("UPDATE product_feedback SET community_id = NULL WHERE community_id = $1")
|
||||
.bind(community_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("sever provenance");
|
||||
|
||||
let detail = get_feedback(&pool, feedback_id)
|
||||
.await
|
||||
.expect("query feedback")
|
||||
.expect("severed feedback still readable in detail");
|
||||
assert_eq!(detail.id, feedback_id);
|
||||
assert!(
|
||||
detail.community_id.is_none(),
|
||||
"community_id severed to None"
|
||||
);
|
||||
assert!(
|
||||
detail.community_host.is_none(),
|
||||
"community_host has no row to join"
|
||||
);
|
||||
assert_eq!(detail.status, "reviewed", "operator status is retained");
|
||||
|
||||
let listed = list_feedback(&pool, MAX_PAGE_SIZE)
|
||||
.await
|
||||
.expect("list feedback");
|
||||
let row = listed
|
||||
.iter()
|
||||
.find(|f| f.id == feedback_id)
|
||||
.expect("severed feedback still appears in the list read");
|
||||
assert!(row.community_id.is_none());
|
||||
assert!(row.community_host.is_none());
|
||||
assert_eq!(row.status, "reviewed");
|
||||
|
||||
sqlx::query("DELETE FROM product_feedback WHERE id = $1")
|
||||
.bind(feedback_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("delete feedback fixture");
|
||||
sqlx::query("DELETE FROM communities WHERE id = $1")
|
||||
.bind(community_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("delete community fixture");
|
||||
}
|
||||
|
||||
// ── Wire contract: nullable action fields are required-nullable ───────────
|
||||
|
||||
#[test]
|
||||
fn action_dto_emits_nullable_fields_as_json_null_not_absent() {
|
||||
// The desktop console types must be required-nullable, not optional:
|
||||
// `reason`, `expiresAt`, `errorMessage` are plain `Option<T>` with no
|
||||
// `skip_serializing_if`, so serde always emits the key (null when None).
|
||||
// This test pins that contract so the seam can't silently drift.
|
||||
let dto = AdminActionDto {
|
||||
id: Uuid::nil(),
|
||||
request_id: Uuid::nil(),
|
||||
actor_pubkey: hex::encode([0_u8; 32]),
|
||||
actor_role: "operator".to_string(),
|
||||
action: "ban".to_string(),
|
||||
status: "succeeded".to_string(),
|
||||
reason: None,
|
||||
expires_at: None,
|
||||
error_message: None,
|
||||
created_at: DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
|
||||
updated_at: DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
|
||||
};
|
||||
let value = serde_json::to_value(&dto).expect("serialize dto");
|
||||
let obj = value.as_object().expect("dto serializes to an object");
|
||||
for key in ["reason", "expiresAt", "errorMessage"] {
|
||||
assert_eq!(
|
||||
obj.get(key),
|
||||
Some(&serde_json::Value::Null),
|
||||
"{key} must be present and null, never absent"
|
||||
);
|
||||
}
|
||||
// Field names are camelCase on the wire.
|
||||
for key in [
|
||||
"requestId",
|
||||
"actorPubkey",
|
||||
"actorRole",
|
||||
"expiresAt",
|
||||
"errorMessage",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] {
|
||||
assert!(obj.contains_key(key), "missing camelCase key {key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4724,6 +4724,29 @@ impl Db {
|
||||
relay_admin_actions::cancel_action(&self.pool, action_id, community_id, report_id).await
|
||||
}
|
||||
|
||||
/// Reopen a terminal report (resolved|dismissed|escalated → open) with a
|
||||
/// durable `reopen` audit row, keyed idempotent on `request_id`.
|
||||
pub async fn reopen_report(
|
||||
&self,
|
||||
community_id: CommunityId,
|
||||
report_id: uuid::Uuid,
|
||||
request_id: uuid::Uuid,
|
||||
actor_pubkey: &[u8],
|
||||
actor_role: &str,
|
||||
reason: Option<&str>,
|
||||
) -> Result<relay_admin_actions::ReopenResult> {
|
||||
relay_admin_actions::reopen_report(
|
||||
&self.pool,
|
||||
community_id,
|
||||
report_id,
|
||||
request_id,
|
||||
actor_pubkey,
|
||||
actor_role,
|
||||
reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch an action record by ID.
|
||||
pub async fn get_admin_action(
|
||||
&self,
|
||||
|
||||
@@ -1009,6 +1009,129 @@ pub async fn cancel_action(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Result of attempting to reopen a terminal report.
|
||||
#[derive(Debug)]
|
||||
pub enum ReopenResult {
|
||||
/// Report was terminal and is now `open`; a `reopen` audit row was inserted.
|
||||
Reopened,
|
||||
/// This exact `request_id` already reopened the report — idempotent replay.
|
||||
/// No state changed; the earlier reopen stands.
|
||||
AlreadyReopened,
|
||||
/// The report is not in a terminal state. Carries its current status.
|
||||
NotReopenable(String),
|
||||
/// The report was not found globally.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Reopen a terminal report (`resolved | dismissed | escalated` → `open`) in a
|
||||
/// single transaction, recording a durable `reopen` audit row.
|
||||
///
|
||||
/// The audit row is written to `relay_admin_actions` with `action = 'reopen'`
|
||||
/// and `state = 'succeeded'`: `succeeded` keeps the stranded-action recovery
|
||||
/// worker (which claims `state IN ('pending','enforcing')`) from ever driving
|
||||
/// it, and the `action` value keeps it out of the enforcement DTO join (which
|
||||
/// filters `action IN ('delete','kick','ban','timeout')`).
|
||||
///
|
||||
/// Idempotency is keyed on `request_id`: a replay after the report has been
|
||||
/// reopened (and possibly re-resolved) returns [`ReopenResult::AlreadyReopened`]
|
||||
/// without mutating, so a client network retry never re-reopens a
|
||||
/// freshly-resolved report.
|
||||
pub async fn reopen_report(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
report_id: Uuid,
|
||||
request_id: Uuid,
|
||||
actor_pubkey: &[u8],
|
||||
actor_role: &str,
|
||||
reason: Option<&str>,
|
||||
) -> Result<ReopenResult> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Lock the report row to serialize concurrent reopen/resolve on it.
|
||||
let report_row = sqlx::query(
|
||||
r#"
|
||||
SELECT status
|
||||
FROM moderation_reports
|
||||
WHERE community_id = $1 AND id = $2
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(report_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let Some(report_row) = report_row else {
|
||||
return Ok(ReopenResult::NotFound);
|
||||
};
|
||||
|
||||
// Idempotent replay: this request_id already reopened the report. Checked
|
||||
// before the terminal-status gate so a retry after a re-resolve still
|
||||
// returns success rather than a spurious NotReopenable.
|
||||
let existing = sqlx::query_scalar::<_, Uuid>(
|
||||
r#"
|
||||
SELECT id FROM relay_admin_actions
|
||||
WHERE report_community_id = $1 AND report_id = $2
|
||||
AND request_id = $3 AND action = 'reopen'
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(report_id)
|
||||
.bind(request_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
tx.rollback().await?;
|
||||
return Ok(ReopenResult::AlreadyReopened);
|
||||
}
|
||||
|
||||
let status: String = report_row.try_get("status")?;
|
||||
if !matches!(status.as_str(), "resolved" | "dismissed" | "escalated") {
|
||||
tx.rollback().await?;
|
||||
return Ok(ReopenResult::NotReopenable(status));
|
||||
}
|
||||
|
||||
// Return the report to the queue. Clear the resolution stamp so an open
|
||||
// report never carries a stale resolver/timestamp; active_action_id is
|
||||
// already NULL on a terminal report but clear it defensively.
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE moderation_reports
|
||||
SET status = 'open', resolved_by = NULL, resolved_at = NULL,
|
||||
active_action_id = NULL
|
||||
WHERE community_id = $1 AND id = $2
|
||||
AND status IN ('resolved', 'dismissed', 'escalated')
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(report_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Durable audit row. Inserted as 'succeeded' so the recovery worker never
|
||||
// claims it; 'reopen' keeps it out of the enforcement DTO join.
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO relay_admin_actions (
|
||||
report_id, report_community_id, request_id, actor_pubkey, actor_role,
|
||||
action, reason, state
|
||||
) VALUES ($1, $2, $3, $4, $5, 'reopen', $6, 'succeeded')
|
||||
"#,
|
||||
)
|
||||
.bind(report_id)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(request_id)
|
||||
.bind(actor_pubkey)
|
||||
.bind(actor_role)
|
||||
.bind(reason)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(ReopenResult::Reopened)
|
||||
}
|
||||
|
||||
/// Fetch an action record by ID.
|
||||
pub async fn get_action(pool: &PgPool, action_id: Uuid) -> Result<Option<AdminActionRecord>> {
|
||||
let row = sqlx::query(
|
||||
@@ -2290,4 +2413,267 @@ mod tests {
|
||||
"second kick must return AlreadyGone"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reopen: terminal → open CAS + durable audit row ───────────────────────
|
||||
|
||||
async fn set_report_status(pool: &PgPool, community_id: Uuid, report_id: Uuid, status: &str) {
|
||||
sqlx::query(
|
||||
"UPDATE moderation_reports SET status = $3 WHERE community_id = $1 AND id = $2",
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(report_id)
|
||||
.bind(status)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("set report status");
|
||||
}
|
||||
|
||||
async fn report_status(pool: &PgPool, community_id: Uuid, report_id: Uuid) -> String {
|
||||
sqlx::query_scalar(
|
||||
"SELECT status FROM moderation_reports WHERE community_id = $1 AND id = $2",
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(report_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("read report status")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn reopen_terminal_report_returns_open_and_records_succeeded_audit_row() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_community(&pool).await;
|
||||
let cid = CommunityId::from_uuid(community_id);
|
||||
|
||||
for terminal in ["resolved", "dismissed", "escalated"] {
|
||||
let report_id = make_report(&pool, community_id).await;
|
||||
set_report_status(&pool, community_id, report_id, terminal).await;
|
||||
|
||||
let request_id = Uuid::new_v4();
|
||||
let result = reopen_report(
|
||||
&pool,
|
||||
cid,
|
||||
report_id,
|
||||
request_id,
|
||||
&actor(),
|
||||
"operator",
|
||||
Some("re-triage"),
|
||||
)
|
||||
.await
|
||||
.expect("reopen_report");
|
||||
assert!(
|
||||
matches!(result, ReopenResult::Reopened),
|
||||
"reopen of {terminal} must return Reopened, got {result:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
report_status(&pool, community_id, report_id).await,
|
||||
"open",
|
||||
"report must be open after reopen from {terminal}"
|
||||
);
|
||||
|
||||
// The audit row is state='succeeded' and action='reopen' so the
|
||||
// recovery worker never claims it and the DTO join never surfaces it.
|
||||
let row = sqlx::query(
|
||||
"SELECT action, state FROM relay_admin_actions WHERE report_id = $1 AND request_id = $2",
|
||||
)
|
||||
.bind(report_id)
|
||||
.bind(request_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("reopen audit row exists");
|
||||
assert_eq!(row.try_get::<String, _>("action").unwrap(), "reopen");
|
||||
assert_eq!(row.try_get::<String, _>("state").unwrap(), "succeeded");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn reopen_open_report_is_rejected_as_not_reopenable() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_community(&pool).await;
|
||||
let report_id = make_report(&pool, community_id).await; // starts 'open'
|
||||
|
||||
let result = reopen_report(
|
||||
&pool,
|
||||
CommunityId::from_uuid(community_id),
|
||||
report_id,
|
||||
Uuid::new_v4(),
|
||||
&actor(),
|
||||
"moderator",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("reopen_report");
|
||||
assert!(
|
||||
matches!(result, ReopenResult::NotReopenable(ref s) if s == "open"),
|
||||
"reopen of an open report must return NotReopenable(open), got {result:?}"
|
||||
);
|
||||
|
||||
// No audit row written on a rejected reopen.
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM relay_admin_actions WHERE report_id = $1")
|
||||
.bind(report_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("count");
|
||||
assert_eq!(count, 0, "rejected reopen must not write an audit row");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn reopen_processing_report_is_rejected_as_not_reopenable() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_community(&pool).await;
|
||||
let report_id = make_report(&pool, community_id).await;
|
||||
// A live enforcement claim moves the report to 'processing'.
|
||||
let _ = do_claim(&pool, community_id, report_id, Uuid::new_v4()).await;
|
||||
|
||||
let result = reopen_report(
|
||||
&pool,
|
||||
CommunityId::from_uuid(community_id),
|
||||
report_id,
|
||||
Uuid::new_v4(),
|
||||
&actor(),
|
||||
"operator",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("reopen_report");
|
||||
assert!(
|
||||
matches!(result, ReopenResult::NotReopenable(ref s) if s == "processing"),
|
||||
"reopen of a processing report must return NotReopenable(processing), got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn reopen_is_idempotent_on_request_id_even_after_reresolve() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_community(&pool).await;
|
||||
let cid = CommunityId::from_uuid(community_id);
|
||||
let report_id = make_report(&pool, community_id).await;
|
||||
set_report_status(&pool, community_id, report_id, "resolved").await;
|
||||
|
||||
let request_id = Uuid::new_v4();
|
||||
let first = reopen_report(
|
||||
&pool,
|
||||
cid,
|
||||
report_id,
|
||||
request_id,
|
||||
&actor(),
|
||||
"operator",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("first reopen");
|
||||
assert!(matches!(first, ReopenResult::Reopened));
|
||||
|
||||
// The report gets re-resolved (a fresh terminal cycle) before the client's
|
||||
// network retry of the SAME reopen request lands.
|
||||
set_report_status(&pool, community_id, report_id, "resolved").await;
|
||||
|
||||
let replay = reopen_report(
|
||||
&pool,
|
||||
cid,
|
||||
report_id,
|
||||
request_id,
|
||||
&actor(),
|
||||
"operator",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("reopen replay");
|
||||
assert!(
|
||||
matches!(replay, ReopenResult::AlreadyReopened),
|
||||
"same request_id replay must return AlreadyReopened, got {replay:?}"
|
||||
);
|
||||
|
||||
// The replay must NOT have re-reopened the freshly re-resolved report.
|
||||
assert_eq!(
|
||||
report_status(&pool, community_id, report_id).await,
|
||||
"resolved",
|
||||
"idempotent replay must not re-reopen a re-resolved report"
|
||||
);
|
||||
|
||||
// Exactly one reopen audit row exists for this request_id.
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM relay_admin_actions WHERE report_id = $1 AND request_id = $2 AND action = 'reopen'",
|
||||
)
|
||||
.bind(report_id)
|
||||
.bind(request_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("count");
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"idempotent replay must not write a second audit row"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn reopen_missing_report_returns_not_found() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_community(&pool).await;
|
||||
|
||||
let result = reopen_report(
|
||||
&pool,
|
||||
CommunityId::from_uuid(community_id),
|
||||
Uuid::new_v4(), // no such report
|
||||
Uuid::new_v4(),
|
||||
&actor(),
|
||||
"operator",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("reopen_report");
|
||||
assert!(
|
||||
matches!(result, ReopenResult::NotFound),
|
||||
"reopen of a missing report must return NotFound, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn reopen_audit_row_is_never_claimed_by_recovery_worker() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_community(&pool).await;
|
||||
let cid = CommunityId::from_uuid(community_id);
|
||||
let report_id = make_report(&pool, community_id).await;
|
||||
set_report_status(&pool, community_id, report_id, "dismissed").await;
|
||||
|
||||
let request_id = Uuid::new_v4();
|
||||
reopen_report(
|
||||
&pool,
|
||||
cid,
|
||||
report_id,
|
||||
request_id,
|
||||
&actor(),
|
||||
"operator",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("reopen");
|
||||
|
||||
// The stranded-action recovery worker claims state IN ('pending','enforcing').
|
||||
// A 'succeeded' reopen row must never appear in its batch — otherwise the
|
||||
// worker would try to drive an enforcement mutation for a reopen.
|
||||
let lease_until = chrono::Utc::now() + chrono::Duration::seconds(30);
|
||||
let batch = claim_stranded_action_batch(&pool, "recovery-worker", lease_until, 1000)
|
||||
.await
|
||||
.expect("claim batch");
|
||||
let reopen_action_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT id FROM relay_admin_actions WHERE report_id = $1 AND request_id = $2",
|
||||
)
|
||||
.bind(report_id)
|
||||
.bind(request_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("reopen action id");
|
||||
assert!(
|
||||
!batch.iter().any(|c| c.record.id == reopen_action_id),
|
||||
"reopen audit row (state=succeeded) must never be claimed by the recovery worker"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ pub fn router(state: Arc<crate::state::AppState>) -> Router {
|
||||
.route("/reports", get(reports))
|
||||
.route("/reports/{id}", get(report_detail))
|
||||
.route("/reports/{id}/resolve", axum::routing::post(resolve_report))
|
||||
.route("/reports/{id}/reopen", axum::routing::post(reopen_report))
|
||||
.route("/reports/{id}/cancel", axum::routing::post(cancel_report))
|
||||
.route("/feedback", get(feedback))
|
||||
.route("/feedback/{id}", get(feedback_detail))
|
||||
.route("/feedback/{id}", patch(update_feedback_status))
|
||||
@@ -256,11 +258,15 @@ async fn report_detail(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FeedbackSummary {
|
||||
id: Uuid,
|
||||
community_id: Uuid,
|
||||
community_host: String,
|
||||
/// `None` once the source community has been purged (provenance severed).
|
||||
community_id: Option<Uuid>,
|
||||
/// `None` when `community_id` is severed — feedback retained without origin.
|
||||
community_host: Option<String>,
|
||||
submitter_pubkey: String,
|
||||
category: Option<String>,
|
||||
body_summary: String,
|
||||
/// Operator-managed lifecycle status: `"new"` | `"reviewed"` | `"archived"`.
|
||||
status: String,
|
||||
received_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -292,6 +298,7 @@ async fn feedback(
|
||||
submitter_pubkey: item.submitter_pubkey,
|
||||
category: item.category,
|
||||
body_summary,
|
||||
status: item.status,
|
||||
received_at: item.received_at,
|
||||
}
|
||||
})
|
||||
@@ -346,20 +353,30 @@ async fn feedback_attachment(
|
||||
.admin_get_feedback(id)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found)?;
|
||||
if !feedback_references_hash(&feedback.tags, &feedback.community_host, &sha256) {
|
||||
|
||||
// A severed feedback row (source community purged, community_id NULL) has no
|
||||
// tenant to bind and no tenant-scoped media to serve — its attachment bytes
|
||||
// were purged with the community. Fail closed to 404.
|
||||
let (Some(community_host), Some(community_id)) =
|
||||
(feedback.community_host.as_deref(), feedback.community_id)
|
||||
else {
|
||||
return Err(ApiError::not_found());
|
||||
};
|
||||
|
||||
if !feedback_references_hash(&feedback.tags, community_host, &sha256) {
|
||||
return Err(ApiError::not_found());
|
||||
}
|
||||
|
||||
// Resolve the tenant from server-owned feedback provenance, then assert the
|
||||
// resolved row still agrees with the feedback FK. Client input never names
|
||||
// a community, host, object key, extension, or upstream URL.
|
||||
let tenant = crate::tenant::bind_community(&state.db, &feedback.community_host)
|
||||
let tenant = crate::tenant::bind_community(&state.db, community_host)
|
||||
.await
|
||||
.map_err(|_| ApiError::not_found())?;
|
||||
if tenant.community().as_uuid() != &feedback.community_id {
|
||||
if tenant.community().as_uuid() != &community_id {
|
||||
tracing::warn!(
|
||||
feedback_id = %feedback.id,
|
||||
feedback_community_id = %feedback.community_id,
|
||||
feedback_community_id = %community_id,
|
||||
resolved_community_id = %tenant.community(),
|
||||
"admin feedback attachment tenant provenance mismatch"
|
||||
);
|
||||
@@ -374,7 +391,7 @@ async fn feedback_attachment(
|
||||
})?;
|
||||
tracing::info!(
|
||||
feedback_id = %feedback.id,
|
||||
community_id = %feedback.community_id,
|
||||
community_id = %community_id,
|
||||
attachment_sha256 = %sha256,
|
||||
"admin feedback attachment read"
|
||||
);
|
||||
@@ -522,7 +539,11 @@ async fn resolve_report(
|
||||
.status(200)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(
|
||||
serde_json::json!({"status": terminal_status}).to_string(),
|
||||
serde_json::json!({
|
||||
"status": terminal_status,
|
||||
"activeAction": serde_json::Value::Null,
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap())
|
||||
}
|
||||
@@ -535,7 +556,7 @@ async fn resolve_report(
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = resolve_report_with_enforcement(
|
||||
resolve_report_with_enforcement(
|
||||
&state,
|
||||
&tenant,
|
||||
&report_detail,
|
||||
@@ -565,13 +586,22 @@ async fn resolve_report(
|
||||
}
|
||||
})?;
|
||||
|
||||
// Re-read the report so the resolve response carries the same
|
||||
// `status` + `activeAction` shape a later GET /reports/{id} returns —
|
||||
// single source of truth for the enforcement DTO.
|
||||
let detail = state
|
||||
.db
|
||||
.admin_get_report(report_id)
|
||||
.await?
|
||||
.ok_or_else(ApiError::internal)?;
|
||||
|
||||
Ok(axum::http::Response::builder()
|
||||
.status(200)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(
|
||||
serde_json::json!({
|
||||
"status": "resolved",
|
||||
"actionId": result.action_id.to_string(),
|
||||
"status": detail.report.status,
|
||||
"activeAction": detail.active_action,
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
@@ -580,6 +610,180 @@ async fn resolve_report(
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for POST /reports/{id}/reopen.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ReopenReportBody {
|
||||
/// Client-generated idempotency key. A retry with the same key returns the
|
||||
/// same success without re-reopening a report that has since been re-resolved.
|
||||
request_id: Uuid,
|
||||
/// Optional operator reason, recorded on the reopen audit row.
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
/// POST /reports/{id}/reopen
|
||||
///
|
||||
/// Requires nip98 auth. Both Operator and Moderator may act.
|
||||
///
|
||||
/// Returns a terminal report (`resolved | dismissed | escalated`) to `open` and
|
||||
/// records a durable `reopen` audit row. `409` if the report is not terminal.
|
||||
async fn reopen_report(
|
||||
State(state): State<Arc<crate::state::AppState>>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(report_id): Path<Uuid>,
|
||||
body_bytes: Bytes,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
use buzz_db::relay_admin_actions::ReopenResult;
|
||||
|
||||
let principal_opt = authorize(
|
||||
&state,
|
||||
&headers,
|
||||
uri.path_and_query()
|
||||
.map_or_else(|| uri.path(), |pq| pq.as_str()),
|
||||
"POST",
|
||||
Some(&body_bytes),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let principal = require_mutation_principal(principal_opt)?;
|
||||
|
||||
let body: ReopenReportBody = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?;
|
||||
|
||||
// Load report globally to derive tenant provenance.
|
||||
let report_detail = state
|
||||
.db
|
||||
.admin_get_report(report_id)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found)?;
|
||||
|
||||
// Bind tenant from server-owned report provenance (never from client input).
|
||||
let tenant = crate::tenant::bind_community(&state.db, &report_detail.report.community_host)
|
||||
.await
|
||||
.map_err(|_| ApiError::internal())?;
|
||||
|
||||
let actor_pubkey: Vec<u8> = principal.pubkey.to_vec();
|
||||
let actor_role_str = match principal.role {
|
||||
AdminRole::Operator => "operator",
|
||||
AdminRole::Moderator => "moderator",
|
||||
};
|
||||
|
||||
let result = state
|
||||
.db
|
||||
.reopen_report(
|
||||
tenant.community(),
|
||||
report_id,
|
||||
body.request_id,
|
||||
&actor_pubkey,
|
||||
actor_role_str,
|
||||
body.reason.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
match result {
|
||||
// AlreadyReopened returns the same success as the original reopen: the
|
||||
// request_id identifies the reopen outcome, not a fresh status read.
|
||||
ReopenResult::Reopened | ReopenResult::AlreadyReopened => {
|
||||
Ok(Json(serde_json::json!({"status": "open"})))
|
||||
}
|
||||
ReopenResult::NotReopenable(status) => Err(ApiError::conflict(&format!(
|
||||
"report is not reopenable (current status: {status})"
|
||||
))),
|
||||
ReopenResult::NotFound => Err(ApiError::not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for POST /reports/{id}/cancel.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CancelReportBody {
|
||||
/// The failed action to cancel — the `activeAction.id` the client observed.
|
||||
/// Fences the cancel to exactly that action: a mismatch (already cancelled,
|
||||
/// superseded by a newer claim, or past the mutation point) resolves to 409.
|
||||
action_id: Uuid,
|
||||
}
|
||||
|
||||
/// POST /reports/{id}/cancel
|
||||
///
|
||||
/// Requires nip98 auth. Both Operator and Moderator may act.
|
||||
///
|
||||
/// Cancels a pre-mutation `failed` enforcement action, returning the report to
|
||||
/// `open`. Cancel is the only recovery path for a failed action (no composed
|
||||
/// client-side retry). `409` if the action is not cancellable — treat as
|
||||
/// "refresh detail" (someone else likely cancelled or the action advanced).
|
||||
///
|
||||
/// The response embeds the just-cancelled action DTO: this is the last look at
|
||||
/// that record, since a subsequent detail read (report back to `open`) serves
|
||||
/// `activeAction: null`.
|
||||
async fn cancel_report(
|
||||
State(state): State<Arc<crate::state::AppState>>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(report_id): Path<Uuid>,
|
||||
body_bytes: Bytes,
|
||||
) -> Result<axum::http::Response<axum::body::Body>, ApiError> {
|
||||
let principal_opt = authorize(
|
||||
&state,
|
||||
&headers,
|
||||
uri.path_and_query()
|
||||
.map_or_else(|| uri.path(), |pq| pq.as_str()),
|
||||
"POST",
|
||||
Some(&body_bytes),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _principal = require_mutation_principal(principal_opt)?;
|
||||
|
||||
let body: CancelReportBody = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?;
|
||||
|
||||
// Load report globally to derive tenant provenance.
|
||||
let report_detail = state
|
||||
.db
|
||||
.admin_get_report(report_id)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found)?;
|
||||
|
||||
// Bind tenant from server-owned report provenance (never from client input).
|
||||
let tenant = crate::tenant::bind_community(&state.db, &report_detail.report.community_host)
|
||||
.await
|
||||
.map_err(|_| ApiError::internal())?;
|
||||
|
||||
let cancelled = state
|
||||
.db
|
||||
.cancel_admin_action(body.action_id, tenant.community(), report_id)
|
||||
.await?;
|
||||
|
||||
if !cancelled {
|
||||
return Err(ApiError::conflict(
|
||||
"action is not cancellable (already cancelled, superseded, or past the mutation point)",
|
||||
));
|
||||
}
|
||||
|
||||
// Re-read the just-cancelled action for the last-look DTO. The report is now
|
||||
// `open`, so a detail read serves activeAction: null — this response is the
|
||||
// only place the cancelled record surfaces.
|
||||
let record = state
|
||||
.db
|
||||
.get_admin_action(body.action_id)
|
||||
.await?
|
||||
.ok_or_else(ApiError::internal)?;
|
||||
let dto = buzz_db::admin_moderation::AdminActionDto::from_record(&record);
|
||||
|
||||
Ok(axum::http::Response::builder()
|
||||
.status(200)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(
|
||||
serde_json::json!({
|
||||
"status": "open",
|
||||
"activeAction": dto,
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// PATCH /feedback/{id}
|
||||
///
|
||||
/// Update product_feedback status. Requires nip98 auth.
|
||||
@@ -2930,6 +3134,262 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Item 9: HTTP → DB wiring for the reopen and cancel routes ─────────────
|
||||
//
|
||||
// reopen and cancel touch only `state.db` (no enforcement stack, Redis, or
|
||||
// media), so a full `router().oneshot()` drive with nip98 auth exercises the
|
||||
// real HTTP → handler → tenant-bind → DB path and reads the durable evidence
|
||||
// back. This is the seeded action→HTTP→DB matrix for the two new routes.
|
||||
|
||||
/// Seed a community whose host is `admin.example` (the nip98 test host) plus
|
||||
/// one report in the given status. Returns the report id.
|
||||
async fn seed_admin_host_report(pool: &sqlx::PgPool, status: &str) -> Uuid {
|
||||
// The nip98 test host must resolve to a community, so bind_community in
|
||||
// the handler succeeds. `communities.host` is uniquely indexed on
|
||||
// lower(host), so reuse an existing row rather than racing an insert.
|
||||
let existing: Option<Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM communities WHERE lower(host) = 'admin.example'")
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.expect("lookup admin.example community");
|
||||
let community_id = match existing {
|
||||
Some(id) => id,
|
||||
None => sqlx::query_scalar(
|
||||
"INSERT INTO communities (id, host) VALUES (gen_random_uuid(), 'admin.example') RETURNING id",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed admin.example community"),
|
||||
};
|
||||
|
||||
let uid = Uuid::new_v4();
|
||||
let event_id: Vec<u8> = uid
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.chain(uid.as_bytes().iter())
|
||||
.copied()
|
||||
.collect();
|
||||
let report_id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO moderation_reports (
|
||||
community_id, report_event_id, reporter_pubkey, target_kind,
|
||||
target_pubkey, report_type, status
|
||||
) VALUES ($1, $2, $3, 'pubkey', $4, 'harassment', $5)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(event_id)
|
||||
.bind(vec![0u8; 32])
|
||||
.bind(vec![1u8; 32])
|
||||
.bind(status)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed report");
|
||||
report_id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres — reopen HTTP route drives the DB"]
|
||||
async fn reopen_route_returns_report_to_open_and_writes_audit_row() {
|
||||
let keys = nostr::Keys::generate();
|
||||
let state = nip98_state(vec![keys.public_key().to_hex()]).await;
|
||||
let pool = sqlx::PgPool::connect(
|
||||
&std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("connect to test DB");
|
||||
let report_id = seed_admin_host_report(&pool, "resolved").await;
|
||||
|
||||
let request_id = Uuid::new_v4();
|
||||
let body = serde_json::json!({ "requestId": request_id }).to_string();
|
||||
let path = format!("/reports/{report_id}/reopen");
|
||||
let auth = make_nostr_auth_post(&keys, &path, body.as_bytes());
|
||||
let response = status_for(
|
||||
state,
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&path)
|
||||
.header(header::HOST, "admin.example")
|
||||
.header(header::AUTHORIZATION, auth)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 4096)
|
||||
.await
|
||||
.expect("body");
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
|
||||
assert_eq!(json["status"], "open");
|
||||
|
||||
// DB evidence: report is open and a succeeded reopen audit row exists.
|
||||
let status: String =
|
||||
sqlx::query_scalar("SELECT status FROM moderation_reports WHERE id = $1")
|
||||
.bind(report_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read status");
|
||||
assert_eq!(status, "open");
|
||||
let (action, state_col): (String, String) = sqlx::query_as(
|
||||
"SELECT action, state FROM relay_admin_actions WHERE report_id = $1 AND request_id = $2",
|
||||
)
|
||||
.bind(report_id)
|
||||
.bind(request_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("reopen audit row");
|
||||
assert_eq!(
|
||||
(action.as_str(), state_col.as_str()),
|
||||
("reopen", "succeeded")
|
||||
);
|
||||
|
||||
cleanup_admin_host_report(&pool, report_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres — reopen of an open report is 409"]
|
||||
async fn reopen_route_rejects_non_terminal_report_with_409() {
|
||||
let keys = nostr::Keys::generate();
|
||||
let state = nip98_state(vec![keys.public_key().to_hex()]).await;
|
||||
let pool = sqlx::PgPool::connect(
|
||||
&std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("connect to test DB");
|
||||
let report_id = seed_admin_host_report(&pool, "open").await;
|
||||
|
||||
let body = serde_json::json!({ "requestId": Uuid::new_v4() }).to_string();
|
||||
let path = format!("/reports/{report_id}/reopen");
|
||||
let auth = make_nostr_auth_post(&keys, &path, body.as_bytes());
|
||||
let response = status_for(
|
||||
state,
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&path)
|
||||
.header(header::HOST, "admin.example")
|
||||
.header(header::AUTHORIZATION, auth)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
|
||||
cleanup_admin_host_report(&pool, report_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres — cancel HTTP route drives the DB"]
|
||||
async fn cancel_route_returns_open_and_embeds_the_cancelled_action_dto() {
|
||||
let keys = nostr::Keys::generate();
|
||||
let state = nip98_state(vec![keys.public_key().to_hex()]).await;
|
||||
let pool = sqlx::PgPool::connect(
|
||||
&std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("connect to test DB");
|
||||
let report_id = seed_admin_host_report(&pool, "open").await;
|
||||
let community_id: Uuid =
|
||||
sqlx::query_scalar("SELECT community_id FROM moderation_reports WHERE id = $1")
|
||||
.bind(report_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("community id");
|
||||
let cid = buzz_core::CommunityId::from_uuid(community_id);
|
||||
|
||||
// Claim → fail (pre-mutation) leaves a cancellable failed action.
|
||||
let action_id = match buzz_db::relay_admin_actions::claim_report(
|
||||
&pool,
|
||||
cid,
|
||||
report_id,
|
||||
Uuid::new_v4(),
|
||||
&[2u8; 32],
|
||||
"operator",
|
||||
"ban",
|
||||
None,
|
||||
None,
|
||||
"resolve:ban",
|
||||
"relay_operator",
|
||||
Some(&[1u8; 32]),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("claim")
|
||||
{
|
||||
buzz_db::relay_admin_actions::ClaimResult::Claimed(a) => a.id,
|
||||
other => panic!("expected Claimed, got {other:?}"),
|
||||
};
|
||||
// pending → enforcing → failed (pre-mutation): the only cancellable state.
|
||||
buzz_db::relay_admin_actions::begin_enforcing(&pool, action_id)
|
||||
.await
|
||||
.expect("begin_enforcing");
|
||||
buzz_db::relay_admin_actions::record_failure(&pool, action_id, "boom")
|
||||
.await
|
||||
.expect("record_failure");
|
||||
|
||||
let body = serde_json::json!({ "actionId": action_id }).to_string();
|
||||
let path = format!("/reports/{report_id}/cancel");
|
||||
let auth = make_nostr_auth_post(&keys, &path, body.as_bytes());
|
||||
let response = status_for(
|
||||
state,
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&path)
|
||||
.header(header::HOST, "admin.example")
|
||||
.header(header::AUTHORIZATION, auth)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 4096)
|
||||
.await
|
||||
.expect("body");
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
|
||||
assert_eq!(json["status"], "open");
|
||||
// The last-look DTO embeds the just-cancelled action with status cancelled.
|
||||
assert_eq!(json["activeAction"]["id"], action_id.to_string());
|
||||
assert_eq!(json["activeAction"]["status"], "cancelled");
|
||||
|
||||
// DB evidence: action is cancelled and the report is back to open.
|
||||
let (state_col, report_status): (String, String) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT a.state, r.status
|
||||
FROM relay_admin_actions a
|
||||
JOIN moderation_reports r ON r.id = a.report_id
|
||||
WHERE a.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(action_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read action + report");
|
||||
assert_eq!(state_col, "cancelled");
|
||||
assert_eq!(report_status, "open");
|
||||
|
||||
cleanup_admin_host_report(&pool, report_id).await;
|
||||
}
|
||||
|
||||
async fn cleanup_admin_host_report(pool: &sqlx::PgPool, report_id: Uuid) {
|
||||
sqlx::query("DELETE FROM relay_admin_actions WHERE report_id = $1")
|
||||
.bind(report_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("delete actions");
|
||||
sqlx::query("DELETE FROM moderation_reports WHERE id = $1")
|
||||
.bind(report_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("delete report");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres — worker crash re-drive convergence"]
|
||||
async fn worker_crash_redrive_converges_to_exactly_one_enforcement() {
|
||||
@@ -3234,6 +3694,7 @@ mod tests {
|
||||
created_at: chrono::Utc::now(),
|
||||
},
|
||||
message: None,
|
||||
active_action: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -326,6 +326,24 @@ Feedback search and filters run over the bounded browser result set; the
|
||||
Body: `{"action": "delete|kick|ban|timeout|dismiss|escalate", "expirationSecs": <number>, "reason": "<string>", "requestId": "<uuid>"}`
|
||||
`expirationSecs` required for `timeout`, rejected for all others.
|
||||
Target/channel are always derived from server-owned report provenance.
|
||||
Response: `{"status": "<terminal>", "activeAction": <action | null>}`. Enforcement
|
||||
actions return the governing action record; `dismiss`/`escalate` return `null`.
|
||||
- `POST /api/admin/v1/reports/:id/reopen`
|
||||
Body: `{"requestId": "<uuid>", "reason": "<string>"}`
|
||||
Returns a terminal report (`resolved`, `dismissed`, or `escalated`) to `open` and
|
||||
records a durable `reopen` audit row. Idempotent on `requestId`: a retry after the
|
||||
report has been reopened (and possibly re-resolved) returns the same `200` without
|
||||
re-reopening. Returns `{"status": "open"}` on success, `409` if the report is not
|
||||
in a terminal state, `404` if it does not exist.
|
||||
- `POST /api/admin/v1/reports/:id/cancel`
|
||||
Body: `{"actionId": "<uuid>"}`
|
||||
Cancels a pre-mutation `failed` enforcement action, returning the report to `open`.
|
||||
Cancel is the only recovery path for a failed action. `actionId` fences the cancel
|
||||
to exactly the action the client observed. Returns `{"status": "open", "activeAction":
|
||||
<cancelled action>}` — the embedded record is the last look at that action, since a
|
||||
subsequent detail read (report back to `open`) serves `activeAction: null`. Returns
|
||||
`409` if the action is not cancellable (already cancelled, superseded, or past the
|
||||
mutation point) — treat as "refresh detail".
|
||||
- `PATCH /api/admin/v1/feedback/:id`
|
||||
Body: `{"status": "new|reviewed|archived"}`
|
||||
|
||||
@@ -358,6 +376,13 @@ sidecar before accessing the shared content-addressed blob. Unknown feedback,
|
||||
unreferenced hashes, malformed paths, and cross-community substitutions all
|
||||
collapse to `404`.
|
||||
|
||||
Product feedback is deployment-global operator evidence: when its source community
|
||||
is purged, the row's `community_id` is severed to `NULL` (the row survives, its
|
||||
provenance does not). List and detail reads still return such rows with
|
||||
`communityId` and `communityHost` as `null`. Their attachments, however, were
|
||||
purged with the tenant, so the attachment route fails closed to `404` for any
|
||||
severed feedback — there is no tenant to bind and no tenant-scoped media to serve.
|
||||
|
||||
Only `GET` and `HEAD` are routed. Community `/media/*` reads always require
|
||||
Blossom authorization and relay membership; the browser receives no reusable
|
||||
signed URL. Responses are uncached, `nosniff`,
|
||||
|
||||
Reference in New Issue
Block a user