diff --git a/crates/postghost-cli/src/cli.rs b/crates/postghost-cli/src/cli.rs index d56f51a..43730a0 100644 --- a/crates/postghost-cli/src/cli.rs +++ b/crates/postghost-cli/src/cli.rs @@ -70,6 +70,16 @@ pub enum Commands { id: String, }, + /// Transition the workflow state of content + State { + /// Content ID + #[arg(long)] + id: String, + /// Transition to apply: submit_for_review, approve, schedule, publish, archive, request_changes + #[arg(long)] + transition: String, + }, + /// List all content List { /// Filter by workflow state diff --git a/crates/postghost-cli/src/main.rs b/crates/postghost-cli/src/main.rs index 34ab1bb..7e6f46e 100644 --- a/crates/postghost-cli/src/main.rs +++ b/crates/postghost-cli/src/main.rs @@ -113,6 +113,23 @@ async fn main() -> Result<()> { } } + Commands::State { id, transition } => { + let resp = client + .patch(format!("{}/api/v1/content/{}/state", base, id)) + .json(&json!({ "transition": transition })) + .send() + .await?; + if resp.status().is_success() { + let body: serde_json::Value = resp.json().await?; + println!( + "State transitioned: {}", + serde_json::to_string_pretty(&body)? + ); + } else { + eprintln!("Error: HTTP {} — {}", resp.status(), resp.text().await?); + } + } + Commands::List { state: _ } => { let resp = client .get(format!("{}/api/v1/content", base)) diff --git a/crates/postghost-server/src/api.rs b/crates/postghost-server/src/api.rs index 32cf1f2..604def1 100644 --- a/crates/postghost-server/src/api.rs +++ b/crates/postghost-server/src/api.rs @@ -4,7 +4,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::Json, - routing::{get, post}, + routing::{get, patch, post}, Router, }; use chrono::Utc; @@ -32,6 +32,10 @@ pub fn build_router(state: AppState) -> Router { get(get_content).delete(delete_content), ) .route("/api/v1/content/:id/variants", post(create_variant)) + .route( + "/api/v1/content/:id/state", + patch(transition_workflow_state), + ) .route("/api/v1/schedule", get(list_schedule).post(create_schedule)) .route("/api/v1/publish/:id", post(publish_content)) .with_state(state) @@ -133,6 +137,46 @@ async fn delete_content( Ok(StatusCode::NO_CONTENT) } +// --- Workflow state transitions --- + +#[derive(Debug, Deserialize)] +pub struct TransitionStateRequest { + pub transition: postghost::WorkflowTransition, +} + +/// Transition the workflow state of a content item. +/// +/// Validates the transition using `WorkflowTransition::apply()`. +/// Returns 422 for invalid transitions, 404 if content not found. +async fn transition_workflow_state( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let content_id = Uuid::parse_str(&id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + // Atomically read-validate-write under a single storage lock. + match state + .storage + .apply_workflow_transition(content_id, req.transition) + { + Ok(Some((prev, new))) => Ok(Json(json!({ + "content_id": content_id, + "previous_state": prev, + "new_state": new, + }))), + Ok(None) => Err((StatusCode::NOT_FOUND, "Content not found".to_string())), + Err(e) => { + let msg = e.to_string(); + if msg.contains("invalid transition") { + Err((StatusCode::UNPROCESSABLE_ENTITY, msg)) + } else { + Err((StatusCode::INTERNAL_SERVER_ERROR, msg)) + } + } + } +} + // --- Variants --- #[derive(Debug, Deserialize)] diff --git a/crates/postghost-server/src/storage.rs b/crates/postghost-server/src/storage.rs index d5c81f2..16f8bde 100644 --- a/crates/postghost-server/src/storage.rs +++ b/crates/postghost-server/src/storage.rs @@ -2,7 +2,9 @@ use std::sync::Mutex; use anyhow::{Context, Result}; use chrono::Utc; -use postghost::{Content, ContentId, Platform, PlatformVariant, Schedule, WorkflowState}; +use postghost::{ + Content, ContentId, Platform, PlatformVariant, Schedule, WorkflowState, WorkflowTransition, +}; use rusqlite::Connection; use uuid::Uuid; @@ -88,7 +90,7 @@ impl SqliteStorage { content.body, enum_to_str(&content.format)?, serde_json::to_string(&content.tags)?, - enum_to_str(&content.variants.first().map(|_| WorkflowState::Draft).unwrap_or_default())?, + enum_to_str(&WorkflowState::Draft)?, content.created_at.to_rfc3339(), content.updated_at.to_rfc3339(), ], @@ -177,6 +179,82 @@ impl SqliteStorage { }) } + /// Atomically apply a workflow transition: read current state, validate, + /// and persist — all under a single mutex lock to prevent races. + /// + /// Returns `Ok(Some((previous, new)))` on success, `Ok(None)` if the + /// content ID doesn't exist, or `Err` on storage errors. + pub fn apply_workflow_transition( + &self, + id: ContentId, + transition: WorkflowTransition, + ) -> Result> { + let conn = self.conn.lock().unwrap(); + let id_str = id.to_string(); + + // Read current state within the same lock. + let current_state: WorkflowState = { + let mut stmt = conn.prepare("SELECT workflow_state FROM content WHERE id = ?1")?; + let mut rows = stmt.query([&id_str])?; + match rows.next()? { + Some(row) => str_to_enum(&row.get::<_, String>(0)?)?, + None => return Ok(None), + } + }; + + // Validate via domain state machine. + let new_state = match transition.apply(current_state) { + Some(ns) => ns, + None => { + return Err(anyhow::anyhow!( + "invalid transition {:?} from state {:?}", + transition, + current_state + )) + } + }; + + // Persist within the same lock. + let rows = conn.execute( + "UPDATE content SET workflow_state = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![enum_to_str(&new_state)?, Utc::now().to_rfc3339(), &id_str,], + )?; + if rows == 0 { + return Ok(None); + } + + Ok(Some((current_state, new_state))) + } + + /// Fetch the current workflow state for a content item. + pub fn get_workflow_state(&self, id: ContentId) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT workflow_state FROM content WHERE id = ?1")?; + let mut rows = stmt.query([id.to_string()])?; + match rows.next()? { + Some(row) => { + let state_str: String = row.get(0)?; + Ok(Some(str_to_enum(&state_str)?)) + } + None => Ok(None), + } + } + + /// Persist the new workflow state for a content item. + /// Returns `Ok(true)` if the row was updated, `Ok(false)` if the ID doesn't exist. + pub fn update_workflow_state(&self, id: ContentId, new_state: WorkflowState) -> Result { + let conn = self.conn.lock().unwrap(); + let rows = conn.execute( + "UPDATE content SET workflow_state = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![ + enum_to_str(&new_state)?, + Utc::now().to_rfc3339(), + id.to_string(), + ], + )?; + Ok(rows > 0) + } + pub fn delete_content(&self, id: ContentId) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM content WHERE id = ?1", [id.to_string()])?; @@ -449,4 +527,101 @@ mod tests { let bare_entry = list.iter().find(|c| c.id == bare_id.to_string()).unwrap(); assert_eq!(bare_entry.variant_count, 0); } + + #[test] + fn test_workflow_state_read_and_update() { + // COD-376: get_workflow_state and update_workflow_state round-trip. + let storage = SqliteStorage::in_memory().unwrap(); + let now = Utc::now(); + let content_id = Uuid::new_v4(); + let content = Content { + id: content_id, + title: "Workflow Test".to_string(), + body: "body".to_string(), + format: ContentFormat::Markdown, + tags: vec![], + created_at: now, + updated_at: now, + variants: vec![], + }; + storage.save_content(&content).unwrap(); + + // Fresh content should be in Draft state. + let initial = storage.get_workflow_state(content_id).unwrap(); + assert_eq!(initial, Some(WorkflowState::Draft)); + + // Transition Draft → InReview → Approved. + let updated = storage + .update_workflow_state(content_id, WorkflowState::InReview) + .unwrap(); + assert!(updated); + assert_eq!( + storage.get_workflow_state(content_id).unwrap(), + Some(WorkflowState::InReview) + ); + + storage + .update_workflow_state(content_id, WorkflowState::Approved) + .unwrap(); + assert_eq!( + storage.get_workflow_state(content_id).unwrap(), + Some(WorkflowState::Approved) + ); + + // Non-existent content returns None. + let ghost = storage.get_workflow_state(Uuid::new_v4()).unwrap(); + assert_eq!(ghost, None); + + // update on non-existent returns false. + let ghost_update = storage + .update_workflow_state(Uuid::new_v4(), WorkflowState::Archived) + .unwrap(); + assert!(!ghost_update); + } + + #[test] + fn test_apply_workflow_transition_atomic() { + // COD-376: atomic read-validate-write via apply_workflow_transition. + let storage = SqliteStorage::in_memory().unwrap(); + let now = Utc::now(); + let content_id = Uuid::new_v4(); + let content = Content { + id: content_id, + title: "Atomic Transition Test".to_string(), + body: "body".to_string(), + format: ContentFormat::Markdown, + tags: vec![], + created_at: now, + updated_at: now, + variants: vec![], + }; + storage.save_content(&content).unwrap(); + + // Valid: Draft → InReview. + let result = storage + .apply_workflow_transition(content_id, WorkflowTransition::SubmitForReview) + .unwrap(); + assert_eq!( + result, + Some((WorkflowState::Draft, WorkflowState::InReview)) + ); + + // Invalid: InReview → Schedule (must go through Approve first). + let err = storage + .apply_workflow_transition(content_id, WorkflowTransition::Schedule) + .unwrap_err(); + assert!(err.to_string().contains("invalid transition")); + + // State should still be InReview (failed transition didn't change it). + assert_eq!( + storage.get_workflow_state(content_id).unwrap(), + Some(WorkflowState::InReview) + ); + + // Non-existent content returns None. + let ghost = storage + .apply_workflow_transition(Uuid::new_v4(), WorkflowTransition::Archive) + .unwrap(); + assert_eq!(ghost, None); + } }