Skip to main content

tuitbot_server/routes/content/
calendar.rs

1//! Calendar and schedule endpoints.
2
3use std::sync::Arc;
4
5use axum::extract::{Query, State};
6use axum::Json;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use tuitbot_core::storage::{approval_queue, replies, scheduled_content, threads};
10
11use crate::account::AccountContext;
12use crate::error::ApiError;
13use crate::state::AppState;
14
15use super::read_config;
16
17/// A unified calendar item merging content from all sources.
18#[derive(Debug, Serialize)]
19pub struct CalendarItem {
20    pub id: i64,
21    pub content_type: String,
22    pub content: String,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub target_author: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub topic: Option<String>,
27    pub timestamp: String,
28    pub status: String,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub performance_score: Option<f64>,
31    pub source: String,
32}
33
34/// Query parameters for the calendar endpoint.
35#[derive(Deserialize)]
36pub struct CalendarQuery {
37    /// Start of the date range (ISO 8601).
38    pub from: String,
39    /// End of the date range (ISO 8601).
40    pub to: String,
41}
42
43/// `GET /api/content/calendar?from=...&to=...` — unified content timeline.
44pub async fn calendar(
45    State(state): State<Arc<AppState>>,
46    ctx: AccountContext,
47    Query(params): Query<CalendarQuery>,
48) -> Result<Json<Value>, ApiError> {
49    let from = &params.from;
50    let to = &params.to;
51
52    let mut items: Vec<CalendarItem> = Vec::new();
53
54    // Tweets
55    let tweets = threads::get_tweets_in_range_for(&state.db, &ctx.account_id, from, to).await?;
56    for t in tweets {
57        items.push(CalendarItem {
58            id: t.id,
59            content_type: "tweet".to_string(),
60            content: t.content,
61            target_author: None,
62            topic: t.topic,
63            timestamp: t.created_at,
64            status: t.status,
65            performance_score: None,
66            source: "autonomous".to_string(),
67        });
68    }
69
70    // Threads
71    let thread_list =
72        threads::get_threads_in_range_for(&state.db, &ctx.account_id, from, to).await?;
73    for t in thread_list {
74        items.push(CalendarItem {
75            id: t.id,
76            content_type: "thread".to_string(),
77            content: t.topic.clone(),
78            target_author: None,
79            topic: Some(t.topic),
80            timestamp: t.created_at,
81            status: t.status,
82            performance_score: None,
83            source: "autonomous".to_string(),
84        });
85    }
86
87    // Replies
88    let reply_list =
89        replies::get_replies_in_range_for(&state.db, &ctx.account_id, from, to).await?;
90    for r in reply_list {
91        items.push(CalendarItem {
92            id: r.id,
93            content_type: "reply".to_string(),
94            content: r.reply_content,
95            target_author: Some(r.target_tweet_id),
96            topic: None,
97            timestamp: r.created_at,
98            status: r.status,
99            performance_score: None,
100            source: "autonomous".to_string(),
101        });
102    }
103
104    // Approval queue items
105    let pending =
106        approval_queue::get_by_statuses_for(&state.db, &ctx.account_id, &["pending"], None).await?;
107    for a in pending {
108        // Only include if the item falls within range
109        if a.created_at >= *from && a.created_at <= *to {
110            items.push(CalendarItem {
111                id: a.id,
112                content_type: a.action_type,
113                content: a.generated_content,
114                target_author: if a.target_author.is_empty() {
115                    None
116                } else {
117                    Some(a.target_author)
118                },
119                topic: if a.topic.is_empty() {
120                    None
121                } else {
122                    Some(a.topic)
123                },
124                timestamp: a.created_at,
125                status: "pending".to_string(),
126                performance_score: None,
127                source: "approval".to_string(),
128            });
129        }
130    }
131
132    // Scheduled content
133    let scheduled =
134        scheduled_content::get_in_range_for(&state.db, &ctx.account_id, from, to).await?;
135    for s in scheduled {
136        items.push(CalendarItem {
137            id: s.id,
138            content_type: s.content_type,
139            content: s.content,
140            target_author: None,
141            topic: None,
142            timestamp: s.scheduled_for.unwrap_or(s.created_at),
143            status: s.status,
144            performance_score: None,
145            source: "manual".to_string(),
146        });
147    }
148
149    // Sort by timestamp ascending
150    items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
151
152    Ok(Json(json!(items)))
153}
154
155/// `GET /api/content/schedule` — the configured posting schedule.
156pub async fn schedule(
157    State(state): State<Arc<AppState>>,
158    _ctx: AccountContext,
159) -> Result<Json<Value>, ApiError> {
160    let config = read_config(&state)?;
161
162    Ok(Json(json!({
163        "timezone": config.schedule.timezone,
164        "active_hours": {
165            "start": config.schedule.active_hours_start,
166            "end": config.schedule.active_hours_end,
167        },
168        "preferred_times": config.schedule.preferred_times,
169        "preferred_times_override": config.schedule.preferred_times_override,
170        "thread_day": config.schedule.thread_preferred_day,
171        "thread_time": config.schedule.thread_preferred_time,
172    })))
173}