diff --git a/crates/postghost-server/src/storage.rs b/crates/postghost-server/src/storage.rs index e7d2a70..d5c81f2 100644 --- a/crates/postghost-server/src/storage.rs +++ b/crates/postghost-server/src/storage.rs @@ -2,7 +2,7 @@ use std::sync::Mutex; use anyhow::{Context, Result}; use chrono::Utc; -use postghost::{Content, ContentId, PlatformVariant, Schedule, WorkflowState}; +use postghost::{Content, ContentId, Platform, PlatformVariant, Schedule, WorkflowState}; use rusqlite::Connection; use uuid::Uuid; @@ -98,9 +98,12 @@ impl SqliteStorage { pub fn get_content(&self, id: ContentId) -> Result> { let conn = self.conn.lock().unwrap(); + let id_str = id.to_string(); let mut stmt = conn.prepare("SELECT id, title, body, format, tags, created_at, updated_at FROM content WHERE id = ?1")?; - let mut rows = stmt.query([id.to_string()])?; + let mut rows = stmt.query([&id_str])?; if let Some(row) = rows.next()? { + // Fetch variants for this content item. + let variants = Self::fetch_variants_for(&conn, &id_str)?; Ok(Some(Content { id: Uuid::parse_str(row.get::<_, String>(0)?.as_str())?, title: row.get(1)?, @@ -111,7 +114,7 @@ impl SqliteStorage { .with_timezone(&Utc), updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(6)?)? .with_timezone(&Utc), - variants: vec![], + variants, })) } else { Ok(None) @@ -120,21 +123,60 @@ impl SqliteStorage { pub fn list_content(&self) -> Result> { let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare("SELECT id, title, format, workflow_state, created_at, updated_at FROM content ORDER BY updated_at DESC")?; + let mut stmt = conn.prepare( + "SELECT c.id, c.title, c.format, c.workflow_state, c.created_at, c.updated_at, \ + (SELECT COUNT(*) FROM content_variants cv WHERE cv.content_id = c.id) AS variant_count \ + FROM content c ORDER BY c.updated_at DESC", + )?; let rows = stmt.query_map([], |row| { Ok(ContentSummary { - id: row.get::<_, String>(0)?, + id: row.get(0)?, title: row.get(1)?, format: row.get(2)?, workflow_state: row.get(3)?, created_at: row.get(4)?, updated_at: row.get(5)?, + variant_count: row.get(6)?, }) })?; rows.collect::>>() .map_err(Into::into) } + /// Fetch all platform variants for a content item by its string ID. + fn fetch_variants_for(conn: &Connection, content_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT platform, formatted_text, metadata, formatted_at FROM content_variants WHERE content_id = ?1 ORDER BY formatted_at ASC", + )?; + let rows = stmt.query_map([content_id], |row| { + let platform_key: String = row.get(0)?; + let formatted_text: String = row.get(1)?; + let metadata_str: String = row.get(2)?; + let formatted_at_str: String = row.get(3)?; + Ok((platform_key, formatted_text, metadata_str, formatted_at_str)) + })?; + let raw: rusqlite::Result> = rows.collect(); + raw.map_err(Into::into).and_then(|rows| { + rows.into_iter() + .map( + |(platform_key, formatted_text, metadata_str, formatted_at_str)| { + let metadata: serde_json::Value = serde_json::from_str(&metadata_str) + .context("invalid metadata JSON in content_variants row")?; + let formatted_at = chrono::DateTime::parse_from_rfc3339(&formatted_at_str) + .context("invalid formatted_at timestamp in content_variants row")? + .with_timezone(&Utc); + Ok(PlatformVariant { + platform: Platform::from_db_key(&platform_key), + formatted_text, + metadata, + formatted_at, + }) + }, + ) + .collect() + }) + } + 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()])?; @@ -198,6 +240,8 @@ pub struct ContentSummary { pub workflow_state: String, pub created_at: String, pub updated_at: String, + /// Number of platform variants attached to this content. + pub variant_count: usize, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -324,4 +368,85 @@ mod tests { ); } } + + #[test] + fn test_get_content_returns_variants() { + // COD-375: get_content() must return all saved platform variants, + // not an empty vec. + let storage = SqliteStorage::in_memory().unwrap(); + let now = Utc::now(); + let content_id = Uuid::new_v4(); + let content = Content { + id: content_id, + title: "Variant 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(); + + // Add two variants for different platforms. + let v1 = PlatformVariant { + platform: Platform::Twitter, + formatted_text: "Thread content #1".to_string(), + metadata: serde_json::json!({"thread_count": 3}), + formatted_at: now, + }; + let v2 = PlatformVariant { + platform: Platform::LinkedIn, + formatted_text: "Professional post".to_string(), + metadata: serde_json::json!({}), + formatted_at: now, + }; + storage.add_variant(content_id, &v1).unwrap(); + storage.add_variant(content_id, &v2).unwrap(); + + // Retrieve and verify. + let retrieved = storage.get_content(content_id).unwrap().unwrap(); + assert_eq!( + retrieved.variants.len(), + 2, + "expected 2 variants, got {}", + retrieved.variants.len() + ); + + // Variants are ordered by formatted_at ASC, so v1 first. + let tv = &retrieved.variants[0]; + assert_eq!(tv.platform, Platform::Twitter); + assert_eq!(tv.formatted_text, "Thread content #1"); + assert_eq!(tv.metadata["thread_count"], 3); + + let lv = &retrieved.variants[1]; + assert_eq!(lv.platform, Platform::LinkedIn); + assert_eq!(lv.formatted_text, "Professional post"); + + // list_content() should also reflect the variant count. + let list = storage.list_content().unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].variant_count, 2); + + // Content with no variants should return empty vec, not error. + let bare_id = Uuid::new_v4(); + let bare = Content { + id: bare_id, + title: "No Variants".to_string(), + body: "body".to_string(), + format: ContentFormat::Markdown, + tags: vec![], + created_at: now, + updated_at: now, + variants: vec![], + }; + storage.save_content(&bare).unwrap(); + let bare_retrieved = storage.get_content(bare_id).unwrap().unwrap(); + assert!(bare_retrieved.variants.is_empty()); + + // list_content variant_count for the bare entry. + let list = storage.list_content().unwrap(); + let bare_entry = list.iter().find(|c| c.id == bare_id.to_string()).unwrap(); + assert_eq!(bare_entry.variant_count, 0); + } }