Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/postghost-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions crates/postghost-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
46 changes: 45 additions & 1 deletion crates/postghost-server/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{get, post},
routing::{get, patch, post},
Router,
};
use chrono::Utc;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<TransitionStateRequest>,
) -> Result<Json<Value>, (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)]
Expand Down
179 changes: 177 additions & 2 deletions crates/postghost-server/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(),
],
Expand Down Expand Up @@ -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<Option<(WorkflowState, WorkflowState)>> {
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<Option<WorkflowState>> {
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<bool> {
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()])?;
Expand Down Expand Up @@ -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);
}
}
Loading