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
11 changes: 5 additions & 6 deletions crates/postghost-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({
Expand Down Expand Up @@ -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?;
Expand Down
16 changes: 10 additions & 6 deletions crates/postghost-server/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -93,7 +96,9 @@ async fn create_content(
))
}

async fn list_content(State(state): State<Arc<AppState>>) -> Result<Json<Value>, (StatusCode, String)> {
async fn list_content(
State(state): State<Arc<AppState>>,
) -> Result<Json<Value>, (StatusCode, String)> {
let items: Vec<ContentSummary> = state
.storage
.list_content()
Expand Down Expand Up @@ -189,8 +194,8 @@ async fn create_schedule(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateScheduleRequest>,
) -> Result<(StatusCode, Json<Value>), (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);
Expand Down Expand Up @@ -238,8 +243,7 @@ async fn publish_content(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<Value>, (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)
Expand Down
78 changes: 64 additions & 14 deletions crates/postghost-server/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: serde::Serialize>(value: &T) -> Result<String> {
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<T: serde::de::DeserializeOwned>(s: &str) -> Result<T> {
serde_json::from_str(&format!("\"{}\"", s)).context("invalid enum value in database")
}

pub struct SqliteStorage {
conn: Mutex<Connection>,
}
Expand Down Expand Up @@ -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(),
],
Expand All @@ -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 {
Expand All @@ -118,7 +131,8 @@ impl SqliteStorage {
updated_at: row.get(5)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}

pub fn delete_content(&self, id: ContentId) -> Result<()> {
Expand All @@ -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(),
Expand All @@ -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(),
],
)?;
Expand All @@ -171,7 +185,8 @@ impl SqliteStorage {
created_at: row.get(5)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
}

Expand All @@ -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() {
Expand Down Expand Up @@ -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:<name>" 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
);
}
}
}
30 changes: 30 additions & 0 deletions crates/postghost/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<value>"` 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.
Expand Down
Loading