diff --git a/crates/postghost-cli/src/main.rs b/crates/postghost-cli/src/main.rs index f1834e7..34ab1bb 100644 --- a/crates/postghost-cli/src/main.rs +++ b/crates/postghost-cli/src/main.rs @@ -82,11 +82,7 @@ async fn main() -> Result<()> { } } - Commands::Schedule { - id, - platform, - at, - } => { + Commands::Schedule { id, platform, at } => { let resp = client .post(format!("{}/api/v1/schedule", base)) .json(&json!({ @@ -146,7 +142,10 @@ async fn main() -> Result<()> { } Commands::Doctor => { - println!("postghost {} — checking server...", env!("CARGO_PKG_VERSION")); + println!( + "postghost {} — checking server...", + env!("CARGO_PKG_VERSION") + ); match client.get(format!("{}/health", base)).send().await { Ok(resp) if resp.status().is_success() => { let body: serde_json::Value = resp.json().await?; diff --git a/crates/postghost-server/src/api.rs b/crates/postghost-server/src/api.rs index 79874f7..32cf1f2 100644 --- a/crates/postghost-server/src/api.rs +++ b/crates/postghost-server/src/api.rs @@ -27,7 +27,10 @@ pub fn build_router(state: AppState) -> Router { Router::new() .route("/health", get(health)) .route("/api/v1/content", get(list_content).post(create_content)) - .route("/api/v1/content/:id", get(get_content).delete(delete_content)) + .route( + "/api/v1/content/:id", + get(get_content).delete(delete_content), + ) .route("/api/v1/content/:id/variants", post(create_variant)) .route("/api/v1/schedule", get(list_schedule).post(create_schedule)) .route("/api/v1/publish/:id", post(publish_content)) @@ -93,7 +96,9 @@ async fn create_content( )) } -async fn list_content(State(state): State>) -> Result, (StatusCode, String)> { +async fn list_content( + State(state): State>, +) -> Result, (StatusCode, String)> { let items: Vec = state .storage .list_content() @@ -189,8 +194,8 @@ async fn create_schedule( State(state): State>, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { - let content_id = Uuid::parse_str(&req.content_id) - .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + let content_id = + Uuid::parse_str(&req.content_id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; let scheduled_for = chrono::DateTime::parse_from_rfc3339(&req.scheduled_for) .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? .with_timezone(&Utc); @@ -238,8 +243,7 @@ async fn publish_content( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { - let content_id = Uuid::parse_str(&id) - .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + let content_id = Uuid::parse_str(&id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; let content = state .storage .get_content(content_id) diff --git a/crates/postghost-server/src/storage.rs b/crates/postghost-server/src/storage.rs index a3b93c3..e7d2a70 100644 --- a/crates/postghost-server/src/storage.rs +++ b/crates/postghost-server/src/storage.rs @@ -2,13 +2,24 @@ use std::sync::Mutex; use anyhow::{Context, Result}; use chrono::Utc; -use postghost::{ - Content, ContentFormat, ContentId, Platform, PlatformVariant, Schedule, ScheduleId, - ScheduleStatus, WorkflowState, -}; +use postghost::{Content, ContentId, PlatformVariant, Schedule, WorkflowState}; use rusqlite::Connection; use uuid::Uuid; +/// Serialize a serde enum/struct to its bare string representation. +/// serde_json serializes a snake_case enum variant as `"\"variant_name\""`; +/// for SQLite string columns we want the bare `"variant_name"`. +fn enum_to_str(value: &T) -> Result { + let json = serde_json::to_string(value)?; + Ok(json.trim_matches('"').to_string()) +} + +/// Deserialize a bare string from SQLite back into a serde type. +/// Wraps the value in JSON quotes so serde_json can parse it. +fn str_to_enum(s: &str) -> Result { + serde_json::from_str(&format!("\"{}\"", s)).context("invalid enum value in database") +} + pub struct SqliteStorage { conn: Mutex, } @@ -75,9 +86,9 @@ impl SqliteStorage { content.id.to_string(), content.title, content.body, - serde_json::to_string(&content.format)?, + enum_to_str(&content.format)?, serde_json::to_string(&content.tags)?, - serde_json::to_string(&content.variants.first().map(|v| WorkflowState::Draft).unwrap_or_default())?, + enum_to_str(&content.variants.first().map(|_| WorkflowState::Draft).unwrap_or_default())?, content.created_at.to_rfc3339(), content.updated_at.to_rfc3339(), ], @@ -94,10 +105,12 @@ impl SqliteStorage { id: Uuid::parse_str(row.get::<_, String>(0)?.as_str())?, title: row.get(1)?, body: row.get(2)?, - format: serde_json::from_str(&row.get::<_, String>(3)?)?, + format: str_to_enum(&row.get::<_, String>(3)?)?, tags: serde_json::from_str(&row.get::<_, String>(4)?).unwrap_or_default(), - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?)?.with_timezone(&Utc), - updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(6)?)?.with_timezone(&Utc), + created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?)? + .with_timezone(&Utc), + updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(6)?)? + .with_timezone(&Utc), variants: vec![], })) } else { @@ -118,7 +131,8 @@ impl SqliteStorage { updated_at: row.get(5)?, }) })?; - rows.collect::>>().map_err(Into::into) + rows.collect::>>() + .map_err(Into::into) } pub fn delete_content(&self, id: ContentId) -> Result<()> { @@ -133,7 +147,7 @@ impl SqliteStorage { "INSERT INTO content_variants (content_id, platform, formatted_text, metadata, formatted_at) VALUES (?1, ?2, ?3, ?4, ?5)", rusqlite::params![ content_id.to_string(), - serde_json::to_string(&variant.platform)?, + variant.platform.to_db_key(), variant.formatted_text, variant.metadata.to_string(), variant.formatted_at.to_rfc3339(), @@ -149,9 +163,9 @@ impl SqliteStorage { rusqlite::params![ schedule.id.to_string(), schedule.content_id.to_string(), - serde_json::to_string(&schedule.platform)?, + schedule.platform.to_db_key(), schedule.scheduled_for.to_rfc3339(), - serde_json::to_string(&schedule.status)?, + enum_to_str(&schedule.status)?, schedule.created_at.to_rfc3339(), ], )?; @@ -171,7 +185,8 @@ impl SqliteStorage { created_at: row.get(5)?, }) })?; - rows.collect::>>().map_err(Into::into) + rows.collect::>>() + .map_err(Into::into) } } @@ -198,6 +213,7 @@ pub struct ScheduleRow { #[cfg(test)] mod tests { use super::*; + use postghost::{ContentFormat, Platform, ScheduleStatus}; #[test] fn test_save_and_get_content() { @@ -274,4 +290,38 @@ mod tests { assert_eq!(pending.len(), 1); assert_eq!(pending[0].content_id, content_id.to_string()); } + + #[test] + fn test_enum_round_trip() { + // Unit-variant enums: serde_json adds quotes, enum_to_str strips them. + let s = enum_to_str(&ScheduleStatus::Pending).unwrap(); + assert_eq!(s, "pending"); + + let status: ScheduleStatus = str_to_enum("pending").unwrap(); + assert_eq!(status, ScheduleStatus::Pending); + } + + #[test] + fn test_platform_db_key_round_trip() { + // Platform::Other carries data — the generic enum_to_str/str_to_enum + // helpers corrupt it because serde_json serializes Other("x") as + // {"other":"x"}, not "other". Platform::to_db_key/from_db_key handles + // this with a dedicated "other:" format. + for p in [ + Platform::Twitter, + Platform::LinkedIn, + Platform::Instagram, + Platform::Blog, + Platform::Other("mastodon".to_string()), + Platform::Other("custom:with:colons".to_string()), + ] { + let key = p.to_db_key(); + assert_eq!( + Platform::from_db_key(&key), + p, + "round-trip failed for {:?}", + p + ); + } + } } diff --git a/crates/postghost/src/platform.rs b/crates/postghost/src/platform.rs index 0b5f70a..e94e19c 100644 --- a/crates/postghost/src/platform.rs +++ b/crates/postghost/src/platform.rs @@ -34,6 +34,36 @@ impl Platform { pub fn supports_threads(&self) -> bool { matches!(self, Platform::Twitter | Platform::Other(_)) } + + /// Canonical string key for database storage. + /// Unit variants use their serde name (e.g. `"twitter"`). + /// `Other` uses `"other:"` so the payload survives round-trip. + pub fn to_db_key(&self) -> String { + match self { + Platform::Twitter => "twitter".to_string(), + Platform::LinkedIn => "linked_in".to_string(), + Platform::Instagram => "instagram".to_string(), + Platform::Blog => "blog".to_string(), + Platform::Other(name) => format!("other:{}", name), + } + } + + /// Parse a database key back into a Platform. + pub fn from_db_key(s: &str) -> Self { + match s { + "twitter" => Platform::Twitter, + "linked_in" => Platform::LinkedIn, + "instagram" => Platform::Instagram, + "blog" => Platform::Blog, + other => { + if let Some(name) = other.strip_prefix("other:") { + Platform::Other(name.to_string()) + } else { + Platform::Other(other.to_string()) + } + } + } + } } /// Specification for how content should be formatted for a platform.