//! Publish approval gate for research topics — see 0032 migration header. //! Small table with a small state machine (pending → approved | rejected). //! One pending row per topic at a time; enforced at the route layer by //! looking up `pending_for_topic` before create. use serde::{Deserialize, Serialize}; use sqlx::PgPool; use time::OffsetDateTime; use uuid::Uuid; use crate::DbError; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PublishApproval { pub id: Uuid, pub workspace_id: Uuid, pub topic_id: Uuid, pub requested_by: Uuid, pub status: String, pub decided_by: Option, pub decided_at: Option, pub created_at: OffsetDateTime, } pub async fn create( pool: &PgPool, workspace_id: Uuid, topic_id: Uuid, requested_by: Uuid, ) -> Result { let id = Uuid::now_v7(); sqlx::query!( "INSERT INTO research_publish_approvals (id, workspace_id, topic_id, requested_by, status) VALUES ($1, $2, $3, $4, 'pending')", id, workspace_id, topic_id, requested_by, ) .execute(pool) .await?; Ok(id) } /// Pending approval for a topic, if any. The route layer uses this to /// short-circuit before writing a duplicate request. pub async fn pending_for_topic( pool: &PgPool, topic_id: Uuid, ) -> Result, DbError> { let row = sqlx::query_as!( PublishApproval, "SELECT id, workspace_id, topic_id, requested_by, status, decided_by, decided_at, created_at FROM research_publish_approvals WHERE topic_id = $1 AND status = 'pending' LIMIT 1", topic_id, ) .fetch_optional(pool) .await?; Ok(row) } pub async fn get( pool: &PgPool, id: Uuid, workspace_id: Uuid, ) -> Result, DbError> { let row = sqlx::query_as!( PublishApproval, "SELECT id, workspace_id, topic_id, requested_by, status, decided_by, decided_at, created_at FROM research_publish_approvals WHERE id = $1 AND workspace_id = $2", id, workspace_id, ) .fetch_optional(pool) .await?; Ok(row) } pub async fn list_pending( pool: &PgPool, workspace_id: Uuid, ) -> Result, DbError> { let rows = sqlx::query_as!( PublishApproval, "SELECT id, workspace_id, topic_id, requested_by, status, decided_by, decided_at, created_at FROM research_publish_approvals WHERE workspace_id = $1 AND status = 'pending' ORDER BY created_at DESC", workspace_id, ) .fetch_all(pool) .await?; Ok(rows) } /// Atomically flip a pending row to approved/rejected. Returns whether the /// caller was the one who won the race — false when the row was already /// decided (idempotent). pub async fn decide( pool: &PgPool, id: Uuid, workspace_id: Uuid, decided_by: Uuid, approve: bool, ) -> Result { let new_status = if approve { "approved" } else { "rejected" }; let result = sqlx::query!( "UPDATE research_publish_approvals SET status = $4, decided_by = $3, decided_at = now() WHERE id = $1 AND workspace_id = $2 AND status = 'pending'", id, workspace_id, decided_by, new_status, ) .execute(pool) .await?; Ok(result.rows_affected() > 0) }