Skip to main content

tuitbot_server/routes/
content.rs

1//! Content endpoints (tweets, threads, calendar, compose, scheduled content).
2
3use std::sync::Arc;
4
5use axum::extract::{Path, Query, State};
6use axum::Json;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use tuitbot_core::config::Config;
10use tuitbot_core::storage::{approval_queue, replies, scheduled_content, threads};
11
12use crate::error::ApiError;
13use crate::state::AppState;
14use crate::ws::WsEvent;
15
16// ---------------------------------------------------------------------------
17// Existing endpoints
18// ---------------------------------------------------------------------------
19
20/// Query parameters for the tweets endpoint.
21#[derive(Deserialize)]
22pub struct TweetsQuery {
23    /// Maximum number of tweets to return (default: 50).
24    #[serde(default = "default_tweet_limit")]
25    pub limit: u32,
26}
27
28fn default_tweet_limit() -> u32 {
29    50
30}
31
32/// Query parameters for the threads endpoint.
33#[derive(Deserialize)]
34pub struct ThreadsQuery {
35    /// Maximum number of threads to return (default: 20).
36    #[serde(default = "default_thread_limit")]
37    pub limit: u32,
38}
39
40fn default_thread_limit() -> u32 {
41    20
42}
43
44/// `GET /api/content/tweets` — recent original tweets posted.
45pub async fn list_tweets(
46    State(state): State<Arc<AppState>>,
47    Query(params): Query<TweetsQuery>,
48) -> Result<Json<Value>, ApiError> {
49    let tweets = threads::get_recent_original_tweets(&state.db, params.limit).await?;
50    Ok(Json(json!(tweets)))
51}
52
53/// `GET /api/content/threads` — recent threads posted.
54pub async fn list_threads(
55    State(state): State<Arc<AppState>>,
56    Query(params): Query<ThreadsQuery>,
57) -> Result<Json<Value>, ApiError> {
58    let threads = threads::get_recent_threads(&state.db, params.limit).await?;
59    Ok(Json(json!(threads)))
60}
61
62/// Request body for composing a manual tweet.
63#[derive(Deserialize)]
64pub struct ComposeTweetRequest {
65    /// The tweet text.
66    pub text: String,
67    /// Optional ISO 8601 timestamp to schedule the tweet.
68    pub scheduled_for: Option<String>,
69}
70
71/// `POST /api/content/tweets` — compose and queue a manual tweet.
72pub async fn compose_tweet(
73    State(state): State<Arc<AppState>>,
74    Json(body): Json<ComposeTweetRequest>,
75) -> Result<Json<Value>, ApiError> {
76    let text = body.text.trim();
77    if text.is_empty() {
78        return Err(ApiError::BadRequest("text is required".to_string()));
79    }
80
81    // Check if approval mode is enabled.
82    let approval_mode = read_approval_mode(&state)?;
83
84    if approval_mode {
85        let id = approval_queue::enqueue(
86            &state.db, "tweet", "", // no target tweet
87            "", // no target author
88            text, "", // no topic
89            "", // no archetype
90            0.0,
91        )
92        .await?;
93
94        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
95            id,
96            action_type: "tweet".to_string(),
97            content: text.to_string(),
98        });
99
100        Ok(Json(json!({
101            "status": "queued_for_approval",
102            "id": id,
103        })))
104    } else {
105        // Without X API client in AppState, we can only acknowledge the intent.
106        Ok(Json(json!({
107            "status": "accepted",
108            "text": text,
109            "scheduled_for": body.scheduled_for,
110        })))
111    }
112}
113
114/// Request body for composing a manual thread.
115#[derive(Deserialize)]
116pub struct ComposeThreadRequest {
117    /// The tweets forming the thread.
118    pub tweets: Vec<String>,
119    /// Optional ISO 8601 timestamp to schedule the thread.
120    pub scheduled_for: Option<String>,
121}
122
123/// `POST /api/content/threads` — compose and queue a manual thread.
124pub async fn compose_thread(
125    State(state): State<Arc<AppState>>,
126    Json(body): Json<ComposeThreadRequest>,
127) -> Result<Json<Value>, ApiError> {
128    if body.tweets.is_empty() {
129        return Err(ApiError::BadRequest(
130            "tweets array must not be empty".to_string(),
131        ));
132    }
133
134    let approval_mode = read_approval_mode(&state)?;
135    let combined = body.tweets.join("\n---\n");
136
137    if approval_mode {
138        let id =
139            approval_queue::enqueue(&state.db, "thread", "", "", &combined, "", "", 0.0).await?;
140
141        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
142            id,
143            action_type: "thread".to_string(),
144            content: combined,
145        });
146
147        Ok(Json(json!({
148            "status": "queued_for_approval",
149            "id": id,
150        })))
151    } else {
152        Ok(Json(json!({
153            "status": "accepted",
154            "tweet_count": body.tweets.len(),
155            "scheduled_for": body.scheduled_for,
156        })))
157    }
158}
159
160// ---------------------------------------------------------------------------
161// Calendar endpoints
162// ---------------------------------------------------------------------------
163
164/// A unified calendar item merging content from all sources.
165#[derive(Debug, Serialize)]
166pub struct CalendarItem {
167    pub id: i64,
168    pub content_type: String,
169    pub content: String,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub target_author: Option<String>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub topic: Option<String>,
174    pub timestamp: String,
175    pub status: String,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub performance_score: Option<f64>,
178    pub source: String,
179}
180
181/// Query parameters for the calendar endpoint.
182#[derive(Deserialize)]
183pub struct CalendarQuery {
184    /// Start of the date range (ISO 8601).
185    pub from: String,
186    /// End of the date range (ISO 8601).
187    pub to: String,
188}
189
190/// `GET /api/content/calendar?from=...&to=...` — unified content timeline.
191pub async fn calendar(
192    State(state): State<Arc<AppState>>,
193    Query(params): Query<CalendarQuery>,
194) -> Result<Json<Value>, ApiError> {
195    let from = &params.from;
196    let to = &params.to;
197
198    let mut items: Vec<CalendarItem> = Vec::new();
199
200    // Tweets
201    let tweets = threads::get_tweets_in_range(&state.db, from, to).await?;
202    for t in tweets {
203        items.push(CalendarItem {
204            id: t.id,
205            content_type: "tweet".to_string(),
206            content: t.content,
207            target_author: None,
208            topic: t.topic,
209            timestamp: t.created_at,
210            status: t.status,
211            performance_score: None,
212            source: "autonomous".to_string(),
213        });
214    }
215
216    // Threads
217    let thread_list = threads::get_threads_in_range(&state.db, from, to).await?;
218    for t in thread_list {
219        items.push(CalendarItem {
220            id: t.id,
221            content_type: "thread".to_string(),
222            content: t.topic.clone(),
223            target_author: None,
224            topic: Some(t.topic),
225            timestamp: t.created_at,
226            status: t.status,
227            performance_score: None,
228            source: "autonomous".to_string(),
229        });
230    }
231
232    // Replies
233    let reply_list = replies::get_replies_in_range(&state.db, from, to).await?;
234    for r in reply_list {
235        items.push(CalendarItem {
236            id: r.id,
237            content_type: "reply".to_string(),
238            content: r.reply_content,
239            target_author: Some(r.target_tweet_id),
240            topic: None,
241            timestamp: r.created_at,
242            status: r.status,
243            performance_score: None,
244            source: "autonomous".to_string(),
245        });
246    }
247
248    // Approval queue items
249    let pending = approval_queue::get_by_statuses(&state.db, &["pending"], None).await?;
250    for a in pending {
251        // Only include if the item falls within range
252        if a.created_at >= *from && a.created_at <= *to {
253            items.push(CalendarItem {
254                id: a.id,
255                content_type: a.action_type,
256                content: a.generated_content,
257                target_author: if a.target_author.is_empty() {
258                    None
259                } else {
260                    Some(a.target_author)
261                },
262                topic: if a.topic.is_empty() {
263                    None
264                } else {
265                    Some(a.topic)
266                },
267                timestamp: a.created_at,
268                status: "pending".to_string(),
269                performance_score: None,
270                source: "approval".to_string(),
271            });
272        }
273    }
274
275    // Scheduled content
276    let scheduled = scheduled_content::get_in_range(&state.db, from, to).await?;
277    for s in scheduled {
278        items.push(CalendarItem {
279            id: s.id,
280            content_type: s.content_type,
281            content: s.content,
282            target_author: None,
283            topic: None,
284            timestamp: s.scheduled_for.unwrap_or(s.created_at),
285            status: s.status,
286            performance_score: None,
287            source: "manual".to_string(),
288        });
289    }
290
291    // Sort by timestamp ascending
292    items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
293
294    Ok(Json(json!(items)))
295}
296
297/// `GET /api/content/schedule` — the configured posting schedule.
298pub async fn schedule(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
299    let config = read_config(&state)?;
300
301    Ok(Json(json!({
302        "timezone": config.schedule.timezone,
303        "active_hours": {
304            "start": config.schedule.active_hours_start,
305            "end": config.schedule.active_hours_end,
306        },
307        "preferred_times": config.schedule.preferred_times,
308        "preferred_times_override": config.schedule.preferred_times_override,
309        "thread_day": config.schedule.thread_preferred_day,
310        "thread_time": config.schedule.thread_preferred_time,
311    })))
312}
313
314/// Request body for the unified compose endpoint.
315#[derive(Deserialize)]
316pub struct ComposeRequest {
317    /// Content type: "tweet" or "thread".
318    pub content_type: String,
319    /// Content text (string for tweet, JSON array string for thread).
320    pub content: String,
321    /// Optional ISO 8601 timestamp to schedule the content.
322    pub scheduled_for: Option<String>,
323}
324
325/// `POST /api/content/compose` — compose manual content (tweet or thread).
326pub async fn compose(
327    State(state): State<Arc<AppState>>,
328    Json(body): Json<ComposeRequest>,
329) -> Result<Json<Value>, ApiError> {
330    let content = body.content.trim().to_string();
331    if content.is_empty() {
332        return Err(ApiError::BadRequest("content is required".to_string()));
333    }
334
335    match body.content_type.as_str() {
336        "tweet" => {
337            if content.len() > 280 {
338                return Err(ApiError::BadRequest(
339                    "tweet content must not exceed 280 characters".to_string(),
340                ));
341            }
342        }
343        "thread" => {
344            // Validate that content is a JSON array of strings
345            let tweets: Result<Vec<String>, _> = serde_json::from_str(&content);
346            match tweets {
347                Ok(ref t) if t.is_empty() => {
348                    return Err(ApiError::BadRequest(
349                        "thread must contain at least one tweet".to_string(),
350                    ));
351                }
352                Ok(ref t) => {
353                    for (i, tweet) in t.iter().enumerate() {
354                        if tweet.len() > 280 {
355                            return Err(ApiError::BadRequest(format!(
356                                "tweet {} exceeds 280 characters",
357                                i + 1
358                            )));
359                        }
360                    }
361                }
362                Err(_) => {
363                    return Err(ApiError::BadRequest(
364                        "thread content must be a JSON array of strings".to_string(),
365                    ));
366                }
367            }
368        }
369        _ => {
370            return Err(ApiError::BadRequest(
371                "content_type must be 'tweet' or 'thread'".to_string(),
372            ));
373        }
374    }
375
376    let approval_mode = read_approval_mode(&state)?;
377
378    if approval_mode {
379        let id =
380            approval_queue::enqueue(&state.db, &body.content_type, "", "", &content, "", "", 0.0)
381                .await?;
382
383        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
384            id,
385            action_type: body.content_type,
386            content: content.clone(),
387        });
388
389        Ok(Json(json!({
390            "status": "queued_for_approval",
391            "id": id,
392        })))
393    } else {
394        let id = scheduled_content::insert(
395            &state.db,
396            &body.content_type,
397            &content,
398            body.scheduled_for.as_deref(),
399        )
400        .await?;
401
402        let _ = state.event_tx.send(WsEvent::ContentScheduled {
403            id,
404            content_type: body.content_type,
405            scheduled_for: body.scheduled_for,
406        });
407
408        Ok(Json(json!({
409            "status": "scheduled",
410            "id": id,
411        })))
412    }
413}
414
415/// Request body for editing a scheduled content item.
416#[derive(Deserialize)]
417pub struct EditScheduledRequest {
418    /// Updated content text.
419    pub content: Option<String>,
420    /// Updated scheduled time.
421    pub scheduled_for: Option<String>,
422}
423
424/// `PATCH /api/content/scheduled/{id}` — edit a scheduled content item.
425pub async fn edit_scheduled(
426    State(state): State<Arc<AppState>>,
427    Path(id): Path<i64>,
428    Json(body): Json<EditScheduledRequest>,
429) -> Result<Json<Value>, ApiError> {
430    let item = scheduled_content::get_by_id(&state.db, id)
431        .await?
432        .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
433
434    if item.status != "scheduled" {
435        return Err(ApiError::BadRequest(
436            "can only edit items with status 'scheduled'".to_string(),
437        ));
438    }
439
440    let new_content = body.content.as_deref().unwrap_or(&item.content);
441    let new_scheduled_for = match &body.scheduled_for {
442        Some(t) => Some(t.as_str()),
443        None => item.scheduled_for.as_deref(),
444    };
445
446    scheduled_content::update_content(&state.db, id, new_content, new_scheduled_for).await?;
447
448    let updated = scheduled_content::get_by_id(&state.db, id)
449        .await?
450        .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
451
452    Ok(Json(json!(updated)))
453}
454
455/// `DELETE /api/content/scheduled/{id}` — cancel a scheduled content item.
456pub async fn cancel_scheduled(
457    State(state): State<Arc<AppState>>,
458    Path(id): Path<i64>,
459) -> Result<Json<Value>, ApiError> {
460    let item = scheduled_content::get_by_id(&state.db, id)
461        .await?
462        .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
463
464    if item.status != "scheduled" {
465        return Err(ApiError::BadRequest(
466            "can only cancel items with status 'scheduled'".to_string(),
467        ));
468    }
469
470    scheduled_content::cancel(&state.db, id).await?;
471
472    Ok(Json(json!({
473        "status": "cancelled",
474        "id": id,
475    })))
476}
477
478// ---------------------------------------------------------------------------
479// Helpers
480// ---------------------------------------------------------------------------
481
482/// Read `approval_mode` from the config file.
483fn read_approval_mode(state: &AppState) -> Result<bool, ApiError> {
484    let config = read_config(state)?;
485    Ok(config.approval_mode)
486}
487
488/// Read the full config from the config file.
489fn read_config(state: &AppState) -> Result<Config, ApiError> {
490    let contents = std::fs::read_to_string(&state.config_path).unwrap_or_default();
491    let config: Config = toml::from_str(&contents).unwrap_or_default();
492    Ok(config)
493}