//! Decisions: list, detail, propose, attest.
use core::str::FromStr as _;
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use jac_core::HumanId;
use jac_decision::Statement;
use jac_telemetry::Envelope;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::dto::{self, AttestationDto, DecisionDto, ProposeDecisionRequest};
use crate::error::ApiError;
use crate::routes::{with_repo, with_repo_mut};
use crate::state::SharedState;
#[derive(Debug, Deserialize)]
pub(crate) struct ListQuery {
/// `unsettled`, `settled`, or absent for all.
#[serde(default)]
pub state: Option<String>,
}
/// `GET /api/repos/{slug}/decisions?state=`.
pub(crate) async fn list(
State(state): State<SharedState>,
Path(slug): Path<String>,
Query(query): Query<ListQuery>,
) -> Result<Json<Value>, ApiError> {
with_repo(&state, &slug, |entry| {
let want = query.state.as_deref();
let mut decisions: Vec<DecisionDto> = Vec::new();
if want.is_none() || want == Some("unsettled") {
decisions.extend(
entry
.repo
.pending_decisions()
.map(|r| DecisionDto::of(r, entry)),
);
}
if want.is_none() || want == Some("settled") {
decisions.extend(entry.repo.settled_decisions().map(|r| {
let mut d = DecisionDto::of(r, entry);
d.attestation = r.attestation().map(|a| AttestationDto::of(a, entry));
d
}));
}
if let Some(other) = want
&& other != "unsettled"
&& other != "settled"
{
return Err(ApiError::invalid(format!(
"unknown state filter `{other}` (unsettled|settled)"
)));
}
// Newest first, like the log.
decisions.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
Ok(Json(json!({ "decisions": decisions })))
})
}
/// `GET /api/repos/{slug}/decisions/{hex}`.
pub(crate) async fn detail(
State(state): State<SharedState>,
Path((slug, hex)): Path<(String, String)>,
) -> Result<Json<DecisionDto>, ApiError> {
with_repo(&state, &slug, |entry| {
let id = dto::parse_decision_id(&hex)?;
if let Some(record) = entry.repo.pending_decisions().find(|r| r.id() == id) {
return Ok(Json(DecisionDto::of(record, entry)));
}
if let Some(record) = entry.repo.settled_decision(id) {
let mut d = DecisionDto::of(record, entry);
d.attestation = record.attestation().map(|a| AttestationDto::of(a, entry));
return Ok(Json(d));
}
Err(ApiError::not_found("no decision with that id"))
})
}
/// `POST /api/repos/{slug}/decisions` — propose. Anyone may, including on
/// behalf of an agent; `proposed_by` says so honestly. It lands unsettled.
pub(crate) async fn propose(
State(state): State<SharedState>,
Path(slug): Path<String>,
Json(req): Json<ProposeDecisionRequest>,
) -> Result<(StatusCode, Json<DecisionDto>), ApiError> {
let dto = with_repo_mut(&state, &slug, |entry| {
let at = entry.repo.now();
let decision = req.into_decision(entry, at)?;
let id = entry.repo.propose_decision(decision);
entry
.repo
.pending_decisions()
.find(|r| r.id() == id)
.map(|r| DecisionDto::of(r, entry))
.ok_or_else(|| ApiError::internal("proposed decision vanished"))
})?;
state.telemetry.emit(
Envelope::event("jac.decision.proposed", dto.at)
.attr("repo", slug)
.attr("families", dto.families.join(","))
.attr("proposer_kind", dto.proposed_by.kind),
);
Ok((StatusCode::CREATED, Json(dto)))
}
/// An attestation request: who signs, and their verbatim words.
#[derive(Debug, Deserialize)]
pub(crate) struct AttestRequest {
/// `hum-…` — only a human id fits. An agent id is refused here at parse
/// time, and even a forged request could not mint an attestation: no
/// constructor accepts an agent.
pub by: String,
pub statement: String,
}
/// `POST /api/repos/{slug}/decisions/{hex}/attest`.
pub(crate) async fn attest(
State(state): State<SharedState>,
Path((slug, hex)): Path<(String, String)>,
Json(req): Json<AttestRequest>,
) -> Result<Json<DecisionDto>, ApiError> {
let statement_chars = req.statement.trim().chars().count();
let dto = with_repo_mut(&state, &slug, |entry| {
let id = dto::parse_decision_id(&hex)?;
let human_id = HumanId::from_str(&req.by).map_err(|e| {
ApiError::invalid(format!(
"`by` must be a hum- id — only a human can attest: {e}"
))
})?;
let identity = entry.humans.get(&human_id).cloned().ok_or_else(|| {
ApiError::invalid(format!("human {human_id} is not registered in this repo"))
})?;
let statement = Statement::new(&req.statement)?;
entry.repo.attest(id, &identity, statement)?;
let record = entry
.repo
.settled_decision(id)
.ok_or_else(|| ApiError::internal("attested decision vanished"))?;
let mut d = DecisionDto::of(record, entry);
d.attestation = record.attestation().map(|a| AttestationDto::of(a, entry));
Ok(d)
})?;
let attested_at = dto.attestation.as_ref().map_or(dto.at, |a| a.at);
state.telemetry.emit(
Envelope::event("jac.decision.attested", attested_at)
.attr("repo", slug)
.measurement(
"statement_chars",
f64::from(u32::try_from(statement_chars).unwrap_or(u32::MAX)),
),
);
Ok(Json(dto))
}