//! Model-authored mission rosters, and whether a human accepted them. //! //! See `migrations/0070_mission_team_proposals.sql` for why a proposal is //! persisted rather than applied on arrival. use crate::DbError; use serde_json::Value; use sqlx::PgPool; use time::OffsetDateTime; use uuid::Uuid; #[derive(Debug, Clone, serde::Serialize)] pub struct MissionTeamProposal { pub id: Uuid, pub mission_id: Uuid, pub roster: Value, pub author_model: String, pub status: String, pub note: Option, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339::option")] pub decided_at: Option, } pub async fn insert( pool: &PgPool, id: Uuid, mission_id: Uuid, workspace_id: Uuid, roster: &Value, author_model: &str, ) -> Result<(), DbError> { sqlx::query( "INSERT INTO mission_team_proposals (id, mission_id, workspace_id, roster, author_model) VALUES ($1, $2, $3, $4, $5)", ) .bind(id) .bind(mission_id) .bind(workspace_id) .bind(roster) .bind(author_model) .execute(pool) .await?; Ok(()) } /// Every proposal for a mission, newest first. Rejected ones are included on /// purpose: what a human turned down is the only record of what the planner /// gets wrong. pub async fn list( pool: &PgPool, mission_id: Uuid, workspace_id: Uuid, ) -> Result, DbError> { let rows = sqlx::query_as::<_, (Uuid, Uuid, Value, String, String, Option, OffsetDateTime, Option)>( "SELECT id, mission_id, roster, author_model, status, note, created_at, decided_at FROM mission_team_proposals WHERE mission_id = $1 AND workspace_id = $2 ORDER BY created_at DESC", ) .bind(mission_id) .bind(workspace_id) .fetch_all(pool) .await?; Ok(rows .into_iter() .map( |(id, mission_id, roster, author_model, status, note, created_at, decided_at)| { MissionTeamProposal { id, mission_id, roster, author_model, status, note, created_at, decided_at, } }, ) .collect()) } pub async fn get( pool: &PgPool, id: Uuid, workspace_id: Uuid, ) -> Result, DbError> { let row = sqlx::query_as::<_, (Uuid, Uuid, Value, String, String, Option, OffsetDateTime, Option)>( "SELECT id, mission_id, roster, author_model, status, note, created_at, decided_at FROM mission_team_proposals WHERE id = $1 AND workspace_id = $2", ) .bind(id) .bind(workspace_id) .fetch_optional(pool) .await?; Ok(row.map( |(id, mission_id, roster, author_model, status, note, created_at, decided_at)| { MissionTeamProposal { id, mission_id, roster, author_model, status, note, created_at, decided_at, } }, )) } /// Approve a proposal AND apply it to its mission, atomically. /// /// One transaction, because the two halves are one decision. The first version /// claimed the proposal and then wrote the mission in two statements, and /// production found the hole on the first real approval: the write failed, the /// claim stood, and the mission was left with no roster while its proposal said /// `approved` — a state the partial unique index then makes permanent, since no /// second proposal for that mission can ever be approved. /// /// Returns false when the proposal was already decided (a double-clicked /// approve), in which case nothing is written. pub async fn approve_and_apply( pool: &PgPool, id: Uuid, mission_id: Uuid, workspace_id: Uuid, graph: &Value, note: Option<&str>, decided_by: Option, ) -> Result { let mut tx = pool.begin().await?; let claimed = sqlx::query( "UPDATE mission_team_proposals SET status = 'approved', note = $3, decided_at = now(), decided_by = $4 WHERE id = $1 AND workspace_id = $2 AND status = 'proposed'", ) .bind(id) .bind(workspace_id) .bind(note) .bind(decided_by) .execute(&mut *tx) .await? .rows_affected(); if claimed != 1 { tx.rollback().await?; return Ok(false); } // `jsonb_set` REFUSES a scalar, and a mission created without a `config` // stores jsonb `null` — a scalar. `coalesce` does not help: it guards SQL // NULL, and this is a JSON null, which is a perfectly good non-NULL value of // the wrong shape. Production hit this on the first real approval with // "cannot set path in scalar". let applied = sqlx::query( "UPDATE missions SET config = jsonb_set( CASE WHEN jsonb_typeof(config) = 'object' THEN config ELSE '{}'::jsonb END, '{roster}', $3::jsonb, true), team_engine = 'composed', updated_at = now() WHERE id = $1 AND workspace_id = $2", ) .bind(mission_id) .bind(workspace_id) .bind(graph) .execute(&mut *tx) .await? .rows_affected(); if applied != 1 { tx.rollback().await?; return Err(DbError::NotFound); } tx.commit().await?; Ok(true) } /// Record a decision. Only a `proposed` row may be decided, so approving twice /// — a double-click, a retried request — cannot re-apply a roster to a mission /// that has since moved on. Returns whether this call was the one that decided. pub async fn decide( pool: &PgPool, id: Uuid, workspace_id: Uuid, status: &str, note: Option<&str>, decided_by: Option, ) -> Result { let done = sqlx::query( "UPDATE mission_team_proposals SET status = $3, note = $4, decided_at = now(), decided_by = $5 WHERE id = $1 AND workspace_id = $2 AND status = 'proposed'", ) .bind(id) .bind(workspace_id) .bind(status) .bind(note) .bind(decided_by) .execute(pool) .await? .rows_affected(); Ok(done == 1) }