pub mod activity;
pub mod drafts;
pub mod revisions;
pub mod tags;
pub use activity::*;
pub use drafts::*;
pub use revisions::*;
pub use tags::*;
use super::accounts::DEFAULT_ACCOUNT_ID;
use super::DbPool;
use crate::error::StorageError;
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
pub struct ScheduledContent {
pub id: i64,
pub content_type: String,
pub content: String,
pub scheduled_for: Option<String>,
pub status: String,
pub posted_tweet_id: Option<String>,
pub created_at: String,
pub updated_at: String,
#[serde(serialize_with = "serialize_json_string")]
pub qa_report: String,
#[serde(serialize_with = "serialize_json_string")]
pub qa_hard_flags: String,
#[serde(serialize_with = "serialize_json_string")]
pub qa_soft_flags: String,
#[serde(serialize_with = "serialize_json_string")]
pub qa_recommendations: String,
pub qa_score: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived_at: Option<String>,
pub source: String,
}
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
pub struct ContentRevision {
pub id: i64,
pub content_id: i64,
pub account_id: String,
pub content: String,
pub content_type: String,
pub trigger_kind: String,
pub created_at: String,
}
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
pub struct ContentTag {
pub id: i64,
pub account_id: String,
pub name: String,
pub color: Option<String>,
}
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
pub struct ContentActivity {
pub id: i64,
pub content_id: i64,
pub account_id: String,
pub action: String,
pub detail: Option<String>,
pub created_at: String,
}
fn serialize_json_string<S: serde::Serializer>(
value: &str,
serializer: S,
) -> Result<S::Ok, S::Error> {
use serde::Serialize;
let parsed: serde_json::Value =
serde_json::from_str(value).unwrap_or(serde_json::Value::Array(vec![]));
parsed.serialize(serializer)
}
pub async fn insert_for(
pool: &DbPool,
account_id: &str,
content_type: &str,
content: &str,
scheduled_for: Option<&str>,
) -> Result<i64, StorageError> {
let result = sqlx::query(
"INSERT INTO scheduled_content (account_id, content_type, content, scheduled_for) \
VALUES (?, ?, ?, ?)",
)
.bind(account_id)
.bind(content_type)
.bind(content)
.bind(scheduled_for)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(result.last_insert_rowid())
}
pub async fn insert(
pool: &DbPool,
content_type: &str,
content: &str,
scheduled_for: Option<&str>,
) -> Result<i64, StorageError> {
insert_for(
pool,
DEFAULT_ACCOUNT_ID,
content_type,
content,
scheduled_for,
)
.await
}
pub async fn get_by_id_for(
pool: &DbPool,
account_id: &str,
id: i64,
) -> Result<Option<ScheduledContent>, StorageError> {
sqlx::query_as::<_, ScheduledContent>(
"SELECT * FROM scheduled_content WHERE id = ? AND account_id = ?",
)
.bind(id)
.bind(account_id)
.fetch_optional(pool)
.await
.map_err(|e| StorageError::Query { source: e })
}
pub async fn get_by_id(pool: &DbPool, id: i64) -> Result<Option<ScheduledContent>, StorageError> {
get_by_id_for(pool, DEFAULT_ACCOUNT_ID, id).await
}
pub async fn get_in_range_for(
pool: &DbPool,
account_id: &str,
from: &str,
to: &str,
) -> Result<Vec<ScheduledContent>, StorageError> {
sqlx::query_as::<_, ScheduledContent>(
"SELECT * FROM scheduled_content \
WHERE account_id = ? \
AND ((scheduled_for BETWEEN ? AND ?) \
OR (scheduled_for IS NULL AND created_at BETWEEN ? AND ?)) \
ORDER BY COALESCE(scheduled_for, created_at) ASC",
)
.bind(account_id)
.bind(from)
.bind(to)
.bind(from)
.bind(to)
.fetch_all(pool)
.await
.map_err(|e| StorageError::Query { source: e })
}
pub async fn get_in_range(
pool: &DbPool,
from: &str,
to: &str,
) -> Result<Vec<ScheduledContent>, StorageError> {
get_in_range_for(pool, DEFAULT_ACCOUNT_ID, from, to).await
}
pub async fn get_due_items_for(
pool: &DbPool,
account_id: &str,
) -> Result<Vec<ScheduledContent>, StorageError> {
sqlx::query_as::<_, ScheduledContent>(
"SELECT * FROM scheduled_content \
WHERE status = 'scheduled' AND scheduled_for IS NOT NULL \
AND scheduled_for <= datetime('now') AND account_id = ? \
ORDER BY scheduled_for ASC",
)
.bind(account_id)
.fetch_all(pool)
.await
.map_err(|e| StorageError::Query { source: e })
}
pub async fn get_due_items(pool: &DbPool) -> Result<Vec<ScheduledContent>, StorageError> {
get_due_items_for(pool, DEFAULT_ACCOUNT_ID).await
}
pub async fn update_status_for(
pool: &DbPool,
account_id: &str,
id: i64,
status: &str,
posted_tweet_id: Option<&str>,
) -> Result<(), StorageError> {
sqlx::query(
"UPDATE scheduled_content \
SET status = ?, posted_tweet_id = ?, updated_at = datetime('now') \
WHERE id = ? AND account_id = ?",
)
.bind(status)
.bind(posted_tweet_id)
.bind(id)
.bind(account_id)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(())
}
pub async fn update_status(
pool: &DbPool,
id: i64,
status: &str,
posted_tweet_id: Option<&str>,
) -> Result<(), StorageError> {
update_status_for(pool, DEFAULT_ACCOUNT_ID, id, status, posted_tweet_id).await
}
pub async fn cancel_for(pool: &DbPool, account_id: &str, id: i64) -> Result<(), StorageError> {
sqlx::query(
"UPDATE scheduled_content \
SET status = 'cancelled', updated_at = datetime('now') \
WHERE id = ? AND status = 'scheduled' AND account_id = ?",
)
.bind(id)
.bind(account_id)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(())
}
pub async fn cancel(pool: &DbPool, id: i64) -> Result<(), StorageError> {
cancel_for(pool, DEFAULT_ACCOUNT_ID, id).await
}
pub async fn update_content_for(
pool: &DbPool,
account_id: &str,
id: i64,
content: &str,
scheduled_for: Option<&str>,
) -> Result<(), StorageError> {
sqlx::query(
"UPDATE scheduled_content \
SET content = ?, scheduled_for = ?, updated_at = datetime('now') \
WHERE id = ? AND status = 'scheduled' AND account_id = ?",
)
.bind(content)
.bind(scheduled_for)
.bind(id)
.bind(account_id)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(())
}
pub async fn update_content(
pool: &DbPool,
id: i64,
content: &str,
scheduled_for: Option<&str>,
) -> Result<(), StorageError> {
update_content_for(pool, DEFAULT_ACCOUNT_ID, id, content, scheduled_for).await
}
#[allow(clippy::too_many_arguments)]
pub async fn update_qa_fields_for(
pool: &DbPool,
account_id: &str,
id: i64,
qa_report: &str,
qa_hard_flags: &str,
qa_soft_flags: &str,
qa_recommendations: &str,
qa_score: f64,
) -> Result<(), StorageError> {
sqlx::query(
"UPDATE scheduled_content SET qa_report = ?, qa_hard_flags = ?, qa_soft_flags = ?, \
qa_recommendations = ?, qa_score = ?, updated_at = datetime('now') \
WHERE id = ? AND account_id = ?",
)
.bind(qa_report)
.bind(qa_hard_flags)
.bind(qa_soft_flags)
.bind(qa_recommendations)
.bind(qa_score)
.bind(id)
.bind(account_id)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn update_qa_fields(
pool: &DbPool,
id: i64,
qa_report: &str,
qa_hard_flags: &str,
qa_soft_flags: &str,
qa_recommendations: &str,
qa_score: f64,
) -> Result<(), StorageError> {
update_qa_fields_for(
pool,
DEFAULT_ACCOUNT_ID,
id,
qa_report,
qa_hard_flags,
qa_soft_flags,
qa_recommendations,
qa_score,
)
.await
}
pub async fn insert_draft_for(
pool: &DbPool,
account_id: &str,
content_type: &str,
content: &str,
source: &str,
) -> Result<i64, StorageError> {
let result = sqlx::query(
"INSERT INTO scheduled_content (account_id, content_type, content, status, source) \
VALUES (?, ?, ?, 'draft', ?)",
)
.bind(account_id)
.bind(content_type)
.bind(content)
.bind(source)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(result.last_insert_rowid())
}
pub async fn insert_draft(
pool: &DbPool,
content_type: &str,
content: &str,
source: &str,
) -> Result<i64, StorageError> {
insert_draft_for(pool, DEFAULT_ACCOUNT_ID, content_type, content, source).await
}
pub async fn insert_draft_with_provenance_for(
pool: &DbPool,
account_id: &str,
content_type: &str,
content: &str,
source: &str,
refs: &[super::provenance::ProvenanceRef],
) -> Result<i64, StorageError> {
let id = insert_draft_for(pool, account_id, content_type, content, source).await?;
if !refs.is_empty() {
super::provenance::insert_links_for(pool, account_id, "scheduled_content", id, refs)
.await?;
}
Ok(id)
}
pub async fn list_drafts_for(
pool: &DbPool,
account_id: &str,
) -> Result<Vec<ScheduledContent>, StorageError> {
sqlx::query_as::<_, ScheduledContent>(
"SELECT * FROM scheduled_content \
WHERE status = 'draft' AND account_id = ? AND archived_at IS NULL \
ORDER BY created_at DESC",
)
.bind(account_id)
.fetch_all(pool)
.await
.map_err(|e| StorageError::Query { source: e })
}
pub async fn list_drafts(pool: &DbPool) -> Result<Vec<ScheduledContent>, StorageError> {
list_drafts_for(pool, DEFAULT_ACCOUNT_ID).await
}
pub async fn update_draft_for(
pool: &DbPool,
account_id: &str,
id: i64,
content: &str,
) -> Result<(), StorageError> {
sqlx::query(
"UPDATE scheduled_content SET content = ?, updated_at = datetime('now') \
WHERE id = ? AND status = 'draft' AND account_id = ?",
)
.bind(content)
.bind(id)
.bind(account_id)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(())
}
pub async fn update_draft(pool: &DbPool, id: i64, content: &str) -> Result<(), StorageError> {
update_draft_for(pool, DEFAULT_ACCOUNT_ID, id, content).await
}
pub async fn delete_draft_for(
pool: &DbPool,
account_id: &str,
id: i64,
) -> Result<(), StorageError> {
sqlx::query(
"UPDATE scheduled_content SET status = 'cancelled', updated_at = datetime('now') \
WHERE id = ? AND status = 'draft' AND account_id = ?",
)
.bind(id)
.bind(account_id)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(())
}
pub async fn delete_draft(pool: &DbPool, id: i64) -> Result<(), StorageError> {
delete_draft_for(pool, DEFAULT_ACCOUNT_ID, id).await
}
pub async fn schedule_draft_for(
pool: &DbPool,
account_id: &str,
id: i64,
scheduled_for: &str,
) -> Result<(), StorageError> {
sqlx::query(
"UPDATE scheduled_content SET status = 'scheduled', scheduled_for = ?, \
updated_at = datetime('now') WHERE id = ? AND status = 'draft' AND account_id = ?",
)
.bind(scheduled_for)
.bind(id)
.bind(account_id)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(())
}
pub async fn schedule_draft(
pool: &DbPool,
id: i64,
scheduled_for: &str,
) -> Result<(), StorageError> {
schedule_draft_for(pool, DEFAULT_ACCOUNT_ID, id, scheduled_for).await
}
pub async fn reschedule_draft_for(
pool: &DbPool,
account_id: &str,
id: i64,
new_scheduled_for: &str,
) -> Result<bool, StorageError> {
let result = sqlx::query(
"UPDATE scheduled_content SET scheduled_for = ?, updated_at = datetime('now') \
WHERE id = ? AND account_id = ? AND status = 'scheduled'",
)
.bind(new_scheduled_for)
.bind(id)
.bind(account_id)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(result.rows_affected() > 0)
}
pub async fn unschedule_draft_for(
pool: &DbPool,
account_id: &str,
id: i64,
) -> Result<bool, StorageError> {
let result = sqlx::query(
"UPDATE scheduled_content SET status = 'draft', scheduled_for = NULL, \
updated_at = datetime('now') \
WHERE id = ? AND account_id = ? AND status = 'scheduled'",
)
.bind(id)
.bind(account_id)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(result.rows_affected() > 0)
}
pub async fn autosave_draft_for(
pool: &DbPool,
account_id: &str,
id: i64,
content: &str,
content_type: &str,
expected_updated_at: &str,
) -> Result<Option<String>, StorageError> {
let result = sqlx::query(
"UPDATE scheduled_content \
SET content = ?, content_type = ?, updated_at = datetime('now') \
WHERE id = ? AND account_id = ? AND updated_at = ?",
)
.bind(content)
.bind(content_type)
.bind(id)
.bind(account_id)
.bind(expected_updated_at)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
if result.rows_affected() == 0 {
return Ok(None);
}
let row = sqlx::query_as::<_, ScheduledContent>(
"SELECT * FROM scheduled_content WHERE id = ? AND account_id = ?",
)
.bind(id)
.bind(account_id)
.fetch_optional(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(row.map(|r| r.updated_at))
}
#[cfg(test)]
mod tests;