Skip to content
Merged
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
135 changes: 130 additions & 5 deletions crates/postghost-server/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -98,9 +98,12 @@ impl SqliteStorage {

pub fn get_content(&self, id: ContentId) -> Result<Option<Content>> {
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)?,
Expand All @@ -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)
Expand All @@ -120,21 +123,60 @@ impl SqliteStorage {

pub fn list_content(&self) -> Result<Vec<ContentSummary>> {
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::<rusqlite::Result<Vec<_>>>()
.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<Vec<PlatformVariant>> {
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<Vec<(String, String, String, String)>> = 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()])?;
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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);
}
}
Loading