use super::accounts::DEFAULT_ACCOUNT_ID;
use super::DbPool;
use crate::error::StorageError;
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]
pub struct ReplySent {
pub id: i64,
pub target_tweet_id: String,
pub reply_tweet_id: Option<String>,
pub reply_content: String,
pub llm_provider: Option<String>,
pub llm_model: Option<String>,
pub created_at: String,
pub status: String,
pub error_message: Option<String>,
}
pub async fn insert_reply_for(
pool: &DbPool,
account_id: &str,
reply: &ReplySent,
) -> Result<i64, StorageError> {
let result = sqlx::query(
"INSERT INTO replies_sent \
(account_id, target_tweet_id, reply_tweet_id, reply_content, llm_provider, llm_model, \
created_at, status, error_message) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(account_id)
.bind(&reply.target_tweet_id)
.bind(&reply.reply_tweet_id)
.bind(&reply.reply_content)
.bind(&reply.llm_provider)
.bind(&reply.llm_model)
.bind(&reply.created_at)
.bind(&reply.status)
.bind(&reply.error_message)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(result.last_insert_rowid())
}
pub async fn insert_reply(pool: &DbPool, reply: &ReplySent) -> Result<i64, StorageError> {
insert_reply_for(pool, DEFAULT_ACCOUNT_ID, reply).await
}
pub async fn get_replies_since_for(
pool: &DbPool,
account_id: &str,
since: &str,
) -> Result<Vec<ReplySent>, StorageError> {
sqlx::query_as::<_, ReplySent>(
"SELECT * FROM replies_sent WHERE account_id = ? AND created_at >= ? ORDER BY created_at ASC",
)
.bind(account_id)
.bind(since)
.fetch_all(pool)
.await
.map_err(|e| StorageError::Query { source: e })
}
pub async fn get_replies_since(pool: &DbPool, since: &str) -> Result<Vec<ReplySent>, StorageError> {
get_replies_since_for(pool, DEFAULT_ACCOUNT_ID, since).await
}
pub async fn has_replied_to_for(
pool: &DbPool,
account_id: &str,
tweet_id: &str,
) -> Result<bool, StorageError> {
let row: (i64,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM replies_sent WHERE account_id = ? AND target_tweet_id = ?)",
)
.bind(account_id)
.bind(tweet_id)
.fetch_one(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(row.0 == 1)
}
pub async fn has_replied_to(pool: &DbPool, tweet_id: &str) -> Result<bool, StorageError> {
has_replied_to_for(pool, DEFAULT_ACCOUNT_ID, tweet_id).await
}
pub async fn get_recent_reply_contents_for(
pool: &DbPool,
account_id: &str,
limit: i64,
) -> Result<Vec<String>, StorageError> {
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT reply_content FROM replies_sent WHERE account_id = ? ORDER BY created_at DESC LIMIT ?",
)
.bind(account_id)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(rows.into_iter().map(|r| r.0).collect())
}
pub async fn get_recent_reply_contents(
pool: &DbPool,
limit: i64,
) -> Result<Vec<String>, StorageError> {
get_recent_reply_contents_for(pool, DEFAULT_ACCOUNT_ID, limit).await
}
pub async fn count_replies_today_for(pool: &DbPool, account_id: &str) -> Result<i64, StorageError> {
let row: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM replies_sent WHERE account_id = ? AND date(created_at) = date('now')",
)
.bind(account_id)
.fetch_one(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(row.0)
}
pub async fn count_replies_today(pool: &DbPool) -> Result<i64, StorageError> {
count_replies_today_for(pool, DEFAULT_ACCOUNT_ID).await
}
pub async fn get_replies_in_range_for(
pool: &DbPool,
account_id: &str,
from: &str,
to: &str,
) -> Result<Vec<ReplySent>, StorageError> {
sqlx::query_as::<_, ReplySent>(
"SELECT * FROM replies_sent \
WHERE account_id = ? AND created_at BETWEEN ? AND ? \
ORDER BY created_at ASC",
)
.bind(account_id)
.bind(from)
.bind(to)
.fetch_all(pool)
.await
.map_err(|e| StorageError::Query { source: e })
}
pub async fn get_replies_in_range(
pool: &DbPool,
from: &str,
to: &str,
) -> Result<Vec<ReplySent>, StorageError> {
get_replies_in_range_for(pool, DEFAULT_ACCOUNT_ID, from, to).await
}
pub async fn get_recent_replies_for(
pool: &DbPool,
account_id: &str,
limit: u32,
offset: u32,
) -> Result<Vec<ReplySent>, StorageError> {
sqlx::query_as::<_, ReplySent>(
"SELECT * FROM replies_sent WHERE account_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
)
.bind(account_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
.map_err(|e| StorageError::Query { source: e })
}
pub async fn get_recent_replies(
pool: &DbPool,
limit: u32,
offset: u32,
) -> Result<Vec<ReplySent>, StorageError> {
get_recent_replies_for(pool, DEFAULT_ACCOUNT_ID, limit, offset).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::init_test_db;
fn sample_reply(target_id: &str) -> ReplySent {
ReplySent {
id: 0, target_tweet_id: target_id.to_string(),
reply_tweet_id: Some("reply_123".to_string()),
reply_content: "Great point! Here's my take...".to_string(),
llm_provider: Some("openai".to_string()),
llm_model: Some("gpt-4o-mini".to_string()),
created_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
status: "sent".to_string(),
error_message: None,
}
}
#[tokio::test]
async fn insert_and_check_reply() {
let pool = init_test_db().await.expect("init db");
let reply = sample_reply("tweet_abc");
let id = insert_reply(&pool, &reply).await.expect("insert");
assert!(id > 0);
assert!(has_replied_to(&pool, "tweet_abc").await.expect("check"));
assert!(!has_replied_to(&pool, "tweet_xyz").await.expect("check"));
}
#[tokio::test]
async fn count_replies_today_works() {
let pool = init_test_db().await.expect("init db");
let reply = sample_reply("tweet_count");
insert_reply(&pool, &reply).await.expect("insert");
let count = count_replies_today(&pool).await.expect("count");
assert_eq!(count, 1);
}
#[tokio::test]
async fn get_recent_contents() {
let pool = init_test_db().await.expect("init db");
let mut r1 = sample_reply("t1");
r1.reply_content = "Reply one".to_string();
let mut r2 = sample_reply("t2");
r2.reply_content = "Reply two".to_string();
insert_reply(&pool, &r1).await.expect("ins1");
insert_reply(&pool, &r2).await.expect("ins2");
let contents = get_recent_reply_contents(&pool, 5).await.expect("get");
assert_eq!(contents.len(), 2);
}
#[tokio::test]
async fn get_replies_in_range_filters() {
let pool = init_test_db().await.expect("init db");
let mut r1 = sample_reply("t_range1");
r1.created_at = "2026-02-20T10:00:00Z".to_string();
insert_reply(&pool, &r1).await.expect("insert");
let mut r2 = sample_reply("t_range2");
r2.created_at = "2026-02-25T10:00:00Z".to_string();
insert_reply(&pool, &r2).await.expect("insert");
let in_range = get_replies_in_range(&pool, "2026-02-19T00:00:00Z", "2026-02-21T00:00:00Z")
.await
.expect("range");
assert_eq!(in_range.len(), 1);
assert_eq!(in_range[0].target_tweet_id, "t_range1");
let all = get_replies_in_range(&pool, "2026-02-01T00:00:00Z", "2026-02-28T00:00:00Z")
.await
.expect("range");
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn get_replies_since_filters() {
let pool = init_test_db().await.expect("init db");
let reply = sample_reply("tweet_since");
insert_reply(&pool, &reply).await.expect("insert");
let all = get_replies_since(&pool, "2000-01-01T00:00:00Z")
.await
.expect("get");
assert_eq!(all.len(), 1);
let none = get_replies_since(&pool, "2099-01-01T00:00:00Z")
.await
.expect("get");
assert!(none.is_empty());
}
}