use nostr_sdk::Event;
use serde_json::json;
use tracing::{debug, error, info, instrument, warn};
use crate::db;
use crate::db::mediation_events::MediationEventKind;
use crate::error::Result;
use crate::handlers::dispute_detected::{current_timestamp, HandlerContext};
use crate::mediation::report;
use crate::models::mediation::MediationSessionState;
use crate::models::LifecycleState;
#[instrument(skip(ctx, event), fields(dispute_id, event_id, resolution_status))]
pub async fn handle(ctx: &HandlerContext, event: &Event) -> Result<()> {
let event_id_hex = event.id.to_string();
let Some(dispute_id) = event.tags.identifier().map(|v| v.to_string()) else {
error!(
event_id = %event_id_hex,
event_kind = ?event.kind,
tags = ?event.tags,
"dispute_resolved: missing `d` tag on resolved dispute event"
);
return Ok(());
};
let Some(resolution_status) = status_tag(event) else {
error!(
dispute_id = %dispute_id,
event_id = %event_id_hex,
event_kind = ?event.kind,
tags = ?event.tags,
"dispute_resolved: missing `s` tag on resolved dispute event"
);
return Ok(());
};
tracing::Span::current().record("dispute_id", dispute_id.as_str());
tracing::Span::current().record("event_id", event_id_hex.as_str());
tracing::Span::current().record("resolution_status", resolution_status.as_str());
info!(
dispute_id = %dispute_id,
resolution_status = %resolution_status,
"dispute_resolved_externally"
);
let now = current_timestamp();
let mut closed_session_id: Option<String> = None;
let existing = {
let guard = ctx.conn.lock().await;
match db::disputes::get_dispute(&guard, &dispute_id) {
Ok(opt) => opt,
Err(e) => {
error!(
dispute_id = %dispute_id,
event_id = %event_id_hex,
error = %e,
"dispute_resolved: dispute lookup failed"
);
return Ok(());
}
}
};
let Some(existing) = existing else {
debug!("resolution event for unknown dispute; ignoring");
return Ok(());
};
if existing.lifecycle_state == LifecycleState::Resolved {
debug!(state = %existing.lifecycle_state, "dispute already resolved; idempotent no-op");
return Ok(());
}
{
let mut guard = ctx.conn.lock().await;
let tx = match guard.transaction() {
Ok(tx) => tx,
Err(e) => {
error!(
dispute_id = %dispute_id,
event_id = %event_id_hex,
error = %e,
"dispute_resolved: failed to open transaction"
);
return Ok(());
}
};
if let Err(e) = tx.execute(
"UPDATE disputes
SET lifecycle_state = ?1, last_state_change = ?2
WHERE dispute_id = ?3",
rusqlite::params![LifecycleState::Resolved.to_string(), now, dispute_id],
) {
error!(
dispute_id = %dispute_id,
event_id = %event_id_hex,
sql = "UPDATE disputes SET lifecycle_state = ?1, last_state_change = ?2 WHERE dispute_id = ?3",
error = %e,
"dispute_resolved: failed to update dispute lifecycle_state"
);
return Ok(());
}
if let Err(e) = tx.execute(
"INSERT INTO dispute_state_transitions
(dispute_id, from_state, to_state, transitioned_at, trigger)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
dispute_id,
existing.lifecycle_state.to_string(),
LifecycleState::Resolved.to_string(),
now,
"dispute_resolved_externally",
],
) {
error!(
dispute_id = %dispute_id,
event_id = %event_id_hex,
sql = "INSERT INTO dispute_state_transitions (dispute_id, from_state, to_state, transitioned_at, trigger) VALUES (?1, ?2, ?3, ?4, ?5)",
error = %e,
"dispute_resolved: failed to insert dispute_state_transitions row"
);
return Ok(());
}
let open_session = match db::mediation::latest_open_session_for(&tx, &dispute_id) {
Ok(v) => v,
Err(e) => {
error!(
dispute_id = %dispute_id,
event_id = %event_id_hex,
error = %e,
"dispute_resolved: latest_open_session_for lookup failed"
);
return Ok(());
}
};
if let Some((session_id, _current_state)) = open_session {
let (pinned_bundle_id, pinned_policy_hash): (String, String) = match tx.query_row(
"SELECT prompt_bundle_id, policy_hash
FROM mediation_sessions WHERE session_id = ?1",
rusqlite::params![session_id],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
) {
Ok(pair) => pair,
Err(e) => {
error!(
dispute_id = %dispute_id,
session_id = %session_id,
event_id = %event_id_hex,
error = %e,
"dispute_resolved: failed to read pinned bundle for session"
);
return Ok(());
}
};
let supersede_payload = json!({
"reason": "dispute_resolved_externally",
"resolution_status": resolution_status,
"dispute_id": dispute_id,
"event_id": event_id_hex,
})
.to_string();
let closed_payload = json!({
"reason": "dispute_resolved_externally",
"dispute_id": dispute_id,
})
.to_string();
if let Err(e) = db::mediation::set_session_state(
&tx,
&session_id,
MediationSessionState::SupersededByHuman,
now,
) {
error!(
session_id = %session_id,
event_id = %event_id_hex,
error = %e,
"dispute_resolved: set_session_state(SupersededByHuman) failed"
);
return Ok(());
}
if let Err(e) = db::mediation_events::record_event(
&tx,
MediationEventKind::SupersededByHuman,
Some(&session_id),
&supersede_payload,
None,
Some(&pinned_bundle_id),
Some(&pinned_policy_hash),
now,
) {
error!(
session_id = %session_id,
event_id = %event_id_hex,
error = %e,
"dispute_resolved: record_event(SupersededByHuman) failed"
);
return Ok(());
}
if let Err(e) = db::mediation::set_session_state(
&tx,
&session_id,
MediationSessionState::Closed,
now,
) {
error!(
session_id = %session_id,
event_id = %event_id_hex,
error = %e,
"dispute_resolved: set_session_state(Closed) failed"
);
return Ok(());
}
if let Err(e) = db::mediation_events::record_event(
&tx,
MediationEventKind::SessionClosed,
Some(&session_id),
&closed_payload,
None,
Some(&pinned_bundle_id),
Some(&pinned_policy_hash),
now,
) {
error!(
session_id = %session_id,
event_id = %event_id_hex,
error = %e,
"dispute_resolved: record_event(SessionClosed) failed"
);
return Ok(());
}
closed_session_id = Some(session_id);
}
if let Err(e) = tx.commit() {
error!(
dispute_id = %dispute_id,
event_id = %event_id_hex,
error = %e,
"dispute_resolved: transaction commit failed"
);
return Ok(());
}
}
if let Some(session_id) = closed_session_id.as_deref() {
info!(
dispute_id = %dispute_id,
session_id = %session_id,
"mediation_session_superseded"
);
}
let has_context = match report::has_any_mediation_context(&ctx.conn, &dispute_id).await {
Ok(v) => v,
Err(e) => {
warn!(
dispute_id = %dispute_id,
error = %e,
"dispute_resolved: has_any_mediation_context query failed; skipping FR-124 DM"
);
return Ok(());
}
};
if !has_context {
debug!(
dispute_id = %dispute_id,
"dispute_resolved: no Phase 3 context for dispute; FR-124 report skipped"
);
return Ok(());
}
if let Err(e) = report::emit_final_report(
&ctx.conn,
&ctx.client,
&ctx.solvers,
&dispute_id,
&resolution_status,
)
.await
{
warn!(
dispute_id = %dispute_id,
error = %e,
"dispute_resolved: emit_final_report failed"
);
}
Ok(())
}
fn status_tag(event: &Event) -> Option<String> {
use nostr_sdk::TagKind;
event
.tags
.iter()
.find(|t| match t.kind() {
TagKind::SingleLetter(slt) => slt.as_char() == 's',
_ => false,
})
.and_then(|t| t.content().map(|s| s.to_string()))
}