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::content::{tweet_weighted_len, MAX_TWEET_CHARS};
11use tuitbot_core::storage::{approval_queue, replies, scheduled_content, threads};
12
13use crate::error::ApiError;
14use crate::state::AppState;
15use crate::ws::WsEvent;
16
17// ---------------------------------------------------------------------------
18// Existing endpoints
19// ---------------------------------------------------------------------------
20
21/// Query parameters for the tweets endpoint.
22#[derive(Deserialize)]
23pub struct TweetsQuery {
24    /// Maximum number of tweets to return (default: 50).
25    #[serde(default = "default_tweet_limit")]
26    pub limit: u32,
27}
28
29fn default_tweet_limit() -> u32 {
30    50
31}
32
33/// Query parameters for the threads endpoint.
34#[derive(Deserialize)]
35pub struct ThreadsQuery {
36    /// Maximum number of threads to return (default: 20).
37    #[serde(default = "default_thread_limit")]
38    pub limit: u32,
39}
40
41fn default_thread_limit() -> u32 {
42    20
43}
44
45/// `GET /api/content/tweets` — recent original tweets posted.
46pub async fn list_tweets(
47    State(state): State<Arc<AppState>>,
48    Query(params): Query<TweetsQuery>,
49) -> Result<Json<Value>, ApiError> {
50    let tweets = threads::get_recent_original_tweets(&state.db, params.limit).await?;
51    Ok(Json(json!(tweets)))
52}
53
54/// `GET /api/content/threads` — recent threads posted.
55pub async fn list_threads(
56    State(state): State<Arc<AppState>>,
57    Query(params): Query<ThreadsQuery>,
58) -> Result<Json<Value>, ApiError> {
59    let threads = threads::get_recent_threads(&state.db, params.limit).await?;
60    Ok(Json(json!(threads)))
61}
62
63/// Request body for composing a manual tweet.
64#[derive(Deserialize)]
65pub struct ComposeTweetRequest {
66    /// The tweet text.
67    pub text: String,
68    /// Optional ISO 8601 timestamp to schedule the tweet.
69    pub scheduled_for: Option<String>,
70}
71
72/// `POST /api/content/tweets` — compose and queue a manual tweet.
73pub async fn compose_tweet(
74    State(state): State<Arc<AppState>>,
75    Json(body): Json<ComposeTweetRequest>,
76) -> Result<Json<Value>, ApiError> {
77    let text = body.text.trim();
78    if text.is_empty() {
79        return Err(ApiError::BadRequest("text is required".to_string()));
80    }
81
82    // Check if approval mode is enabled.
83    let approval_mode = read_approval_mode(&state)?;
84
85    if approval_mode {
86        let id = approval_queue::enqueue(
87            &state.db, "tweet", "", // no target tweet
88            "", // no target author
89            text, "", // no topic
90            "", // no archetype
91            0.0, "[]",
92        )
93        .await?;
94
95        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
96            id,
97            action_type: "tweet".to_string(),
98            content: text.to_string(),
99            media_paths: vec![],
100        });
101
102        Ok(Json(json!({
103            "status": "queued_for_approval",
104            "id": id,
105        })))
106    } else {
107        // Without X API client in AppState, we can only acknowledge the intent.
108        Ok(Json(json!({
109            "status": "accepted",
110            "text": text,
111            "scheduled_for": body.scheduled_for,
112        })))
113    }
114}
115
116/// Request body for composing a manual thread.
117#[derive(Deserialize)]
118pub struct ComposeThreadRequest {
119    /// The tweets forming the thread.
120    pub tweets: Vec<String>,
121    /// Optional ISO 8601 timestamp to schedule the thread.
122    pub scheduled_for: Option<String>,
123}
124
125/// `POST /api/content/threads` — compose and queue a manual thread.
126pub async fn compose_thread(
127    State(state): State<Arc<AppState>>,
128    Json(body): Json<ComposeThreadRequest>,
129) -> Result<Json<Value>, ApiError> {
130    if body.tweets.is_empty() {
131        return Err(ApiError::BadRequest(
132            "tweets array must not be empty".to_string(),
133        ));
134    }
135
136    let approval_mode = read_approval_mode(&state)?;
137    let combined = body.tweets.join("\n---\n");
138
139    if approval_mode {
140        let id = approval_queue::enqueue(&state.db, "thread", "", "", &combined, "", "", 0.0, "[]")
141            .await?;
142
143        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
144            id,
145            action_type: "thread".to_string(),
146            content: combined,
147            media_paths: vec![],
148        });
149
150        Ok(Json(json!({
151            "status": "queued_for_approval",
152            "id": id,
153        })))
154    } else {
155        Ok(Json(json!({
156            "status": "accepted",
157            "tweet_count": body.tweets.len(),
158            "scheduled_for": body.scheduled_for,
159        })))
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Calendar endpoints
165// ---------------------------------------------------------------------------
166
167/// A unified calendar item merging content from all sources.
168#[derive(Debug, Serialize)]
169pub struct CalendarItem {
170    pub id: i64,
171    pub content_type: String,
172    pub content: String,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub target_author: Option<String>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub topic: Option<String>,
177    pub timestamp: String,
178    pub status: String,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub performance_score: Option<f64>,
181    pub source: String,
182}
183
184/// Query parameters for the calendar endpoint.
185#[derive(Deserialize)]
186pub struct CalendarQuery {
187    /// Start of the date range (ISO 8601).
188    pub from: String,
189    /// End of the date range (ISO 8601).
190    pub to: String,
191}
192
193/// `GET /api/content/calendar?from=...&to=...` — unified content timeline.
194pub async fn calendar(
195    State(state): State<Arc<AppState>>,
196    Query(params): Query<CalendarQuery>,
197) -> Result<Json<Value>, ApiError> {
198    let from = &params.from;
199    let to = &params.to;
200
201    let mut items: Vec<CalendarItem> = Vec::new();
202
203    // Tweets
204    let tweets = threads::get_tweets_in_range(&state.db, from, to).await?;
205    for t in tweets {
206        items.push(CalendarItem {
207            id: t.id,
208            content_type: "tweet".to_string(),
209            content: t.content,
210            target_author: None,
211            topic: t.topic,
212            timestamp: t.created_at,
213            status: t.status,
214            performance_score: None,
215            source: "autonomous".to_string(),
216        });
217    }
218
219    // Threads
220    let thread_list = threads::get_threads_in_range(&state.db, from, to).await?;
221    for t in thread_list {
222        items.push(CalendarItem {
223            id: t.id,
224            content_type: "thread".to_string(),
225            content: t.topic.clone(),
226            target_author: None,
227            topic: Some(t.topic),
228            timestamp: t.created_at,
229            status: t.status,
230            performance_score: None,
231            source: "autonomous".to_string(),
232        });
233    }
234
235    // Replies
236    let reply_list = replies::get_replies_in_range(&state.db, from, to).await?;
237    for r in reply_list {
238        items.push(CalendarItem {
239            id: r.id,
240            content_type: "reply".to_string(),
241            content: r.reply_content,
242            target_author: Some(r.target_tweet_id),
243            topic: None,
244            timestamp: r.created_at,
245            status: r.status,
246            performance_score: None,
247            source: "autonomous".to_string(),
248        });
249    }
250
251    // Approval queue items
252    let pending = approval_queue::get_by_statuses(&state.db, &["pending"], None).await?;
253    for a in pending {
254        // Only include if the item falls within range
255        if a.created_at >= *from && a.created_at <= *to {
256            items.push(CalendarItem {
257                id: a.id,
258                content_type: a.action_type,
259                content: a.generated_content,
260                target_author: if a.target_author.is_empty() {
261                    None
262                } else {
263                    Some(a.target_author)
264                },
265                topic: if a.topic.is_empty() {
266                    None
267                } else {
268                    Some(a.topic)
269                },
270                timestamp: a.created_at,
271                status: "pending".to_string(),
272                performance_score: None,
273                source: "approval".to_string(),
274            });
275        }
276    }
277
278    // Scheduled content
279    let scheduled = scheduled_content::get_in_range(&state.db, from, to).await?;
280    for s in scheduled {
281        items.push(CalendarItem {
282            id: s.id,
283            content_type: s.content_type,
284            content: s.content,
285            target_author: None,
286            topic: None,
287            timestamp: s.scheduled_for.unwrap_or(s.created_at),
288            status: s.status,
289            performance_score: None,
290            source: "manual".to_string(),
291        });
292    }
293
294    // Sort by timestamp ascending
295    items.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
296
297    Ok(Json(json!(items)))
298}
299
300/// `GET /api/content/schedule` — the configured posting schedule.
301pub async fn schedule(State(state): State<Arc<AppState>>) -> Result<Json<Value>, ApiError> {
302    let config = read_config(&state)?;
303
304    Ok(Json(json!({
305        "timezone": config.schedule.timezone,
306        "active_hours": {
307            "start": config.schedule.active_hours_start,
308            "end": config.schedule.active_hours_end,
309        },
310        "preferred_times": config.schedule.preferred_times,
311        "preferred_times_override": config.schedule.preferred_times_override,
312        "thread_day": config.schedule.thread_preferred_day,
313        "thread_time": config.schedule.thread_preferred_time,
314    })))
315}
316
317/// Request body for the unified compose endpoint.
318#[derive(Deserialize)]
319pub struct ComposeRequest {
320    /// Content type: "tweet" or "thread".
321    pub content_type: String,
322    /// Content text (string for tweet, JSON array string for thread).
323    pub content: String,
324    /// Optional ISO 8601 timestamp to schedule the content.
325    pub scheduled_for: Option<String>,
326    /// Optional local media file paths to attach.
327    #[serde(default)]
328    pub media_paths: Option<Vec<String>>,
329}
330
331/// `POST /api/content/compose` — compose manual content (tweet or thread).
332pub async fn compose(
333    State(state): State<Arc<AppState>>,
334    Json(body): Json<ComposeRequest>,
335) -> Result<Json<Value>, ApiError> {
336    let content = body.content.trim().to_string();
337    if content.is_empty() {
338        return Err(ApiError::BadRequest("content is required".to_string()));
339    }
340
341    match body.content_type.as_str() {
342        "tweet" => {
343            if tweet_weighted_len(&content) > MAX_TWEET_CHARS {
344                return Err(ApiError::BadRequest(
345                    "tweet content must not exceed 280 characters".to_string(),
346                ));
347            }
348        }
349        "thread" => {
350            // Validate that content is a JSON array of strings
351            let tweets: Result<Vec<String>, _> = serde_json::from_str(&content);
352            match tweets {
353                Ok(ref t) if t.is_empty() => {
354                    return Err(ApiError::BadRequest(
355                        "thread must contain at least one tweet".to_string(),
356                    ));
357                }
358                Ok(ref t) => {
359                    for (i, tweet) in t.iter().enumerate() {
360                        if tweet_weighted_len(tweet) > MAX_TWEET_CHARS {
361                            return Err(ApiError::BadRequest(format!(
362                                "tweet {} exceeds 280 characters",
363                                i + 1
364                            )));
365                        }
366                    }
367                }
368                Err(_) => {
369                    return Err(ApiError::BadRequest(
370                        "thread content must be a JSON array of strings".to_string(),
371                    ));
372                }
373            }
374        }
375        _ => {
376            return Err(ApiError::BadRequest(
377                "content_type must be 'tweet' or 'thread'".to_string(),
378            ));
379        }
380    }
381
382    let approval_mode = read_approval_mode(&state)?;
383
384    if approval_mode {
385        let media_paths = body.media_paths.as_deref().unwrap_or(&[]);
386        let media_json = serde_json::to_string(media_paths).unwrap_or_else(|_| "[]".to_string());
387        let id = approval_queue::enqueue(
388            &state.db,
389            &body.content_type,
390            "",
391            "",
392            &content,
393            "",
394            "",
395            0.0,
396            &media_json,
397        )
398        .await?;
399
400        let _ = state.event_tx.send(WsEvent::ApprovalQueued {
401            id,
402            action_type: body.content_type,
403            content: content.clone(),
404            media_paths: media_paths.to_vec(),
405        });
406
407        Ok(Json(json!({
408            "status": "queued_for_approval",
409            "id": id,
410        })))
411    } else {
412        let id = scheduled_content::insert(
413            &state.db,
414            &body.content_type,
415            &content,
416            body.scheduled_for.as_deref(),
417        )
418        .await?;
419
420        let _ = state.event_tx.send(WsEvent::ContentScheduled {
421            id,
422            content_type: body.content_type,
423            scheduled_for: body.scheduled_for,
424        });
425
426        Ok(Json(json!({
427            "status": "scheduled",
428            "id": id,
429        })))
430    }
431}
432
433/// Request body for editing a scheduled content item.
434#[derive(Deserialize)]
435pub struct EditScheduledRequest {
436    /// Updated content text.
437    pub content: Option<String>,
438    /// Updated scheduled time.
439    pub scheduled_for: Option<String>,
440}
441
442/// `PATCH /api/content/scheduled/{id}` — edit a scheduled content item.
443pub async fn edit_scheduled(
444    State(state): State<Arc<AppState>>,
445    Path(id): Path<i64>,
446    Json(body): Json<EditScheduledRequest>,
447) -> Result<Json<Value>, ApiError> {
448    let item = scheduled_content::get_by_id(&state.db, id)
449        .await?
450        .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
451
452    if item.status != "scheduled" {
453        return Err(ApiError::BadRequest(
454            "can only edit items with status 'scheduled'".to_string(),
455        ));
456    }
457
458    let new_content = body.content.as_deref().unwrap_or(&item.content);
459    let new_scheduled_for = match &body.scheduled_for {
460        Some(t) => Some(t.as_str()),
461        None => item.scheduled_for.as_deref(),
462    };
463
464    scheduled_content::update_content(&state.db, id, new_content, new_scheduled_for).await?;
465
466    let updated = scheduled_content::get_by_id(&state.db, id)
467        .await?
468        .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
469
470    Ok(Json(json!(updated)))
471}
472
473/// `DELETE /api/content/scheduled/{id}` — cancel a scheduled content item.
474pub async fn cancel_scheduled(
475    State(state): State<Arc<AppState>>,
476    Path(id): Path<i64>,
477) -> Result<Json<Value>, ApiError> {
478    let item = scheduled_content::get_by_id(&state.db, id)
479        .await?
480        .ok_or_else(|| ApiError::NotFound(format!("scheduled content {id} not found")))?;
481
482    if item.status != "scheduled" {
483        return Err(ApiError::BadRequest(
484            "can only cancel items with status 'scheduled'".to_string(),
485        ));
486    }
487
488    scheduled_content::cancel(&state.db, id).await?;
489
490    Ok(Json(json!({
491        "status": "cancelled",
492        "id": id,
493    })))
494}
495
496// ---------------------------------------------------------------------------
497// Helpers
498// ---------------------------------------------------------------------------
499
500/// Read `approval_mode` from the config file.
501fn read_approval_mode(state: &AppState) -> Result<bool, ApiError> {
502    let config = read_config(state)?;
503    Ok(config.effective_approval_mode())
504}
505
506/// Read the full config from the config file.
507fn read_config(state: &AppState) -> Result<Config, ApiError> {
508    let contents = std::fs::read_to_string(&state.config_path).unwrap_or_default();
509    let config: Config = toml::from_str(&contents).unwrap_or_default();
510    Ok(config)
511}
512
513// ---------------------------------------------------------------------------
514// Draft endpoints
515// ---------------------------------------------------------------------------
516
517#[derive(Deserialize)]
518pub struct CreateDraftRequest {
519    pub content_type: String,
520    pub content: String,
521    #[serde(default = "default_source")]
522    pub source: String,
523}
524
525fn default_source() -> String {
526    "manual".to_string()
527}
528
529pub async fn list_drafts(
530    State(state): State<Arc<AppState>>,
531) -> Result<Json<Vec<scheduled_content::ScheduledContent>>, ApiError> {
532    let drafts = scheduled_content::list_drafts(&state.db)
533        .await
534        .map_err(ApiError::Storage)?;
535    Ok(Json(drafts))
536}
537
538pub async fn create_draft(
539    State(state): State<Arc<AppState>>,
540    Json(body): Json<CreateDraftRequest>,
541) -> Result<Json<Value>, ApiError> {
542    // Validate content.
543    if body.content.trim().is_empty() {
544        return Err(ApiError::BadRequest(
545            "content must not be empty".to_string(),
546        ));
547    }
548
549    if body.content_type == "tweet"
550        && !tuitbot_core::content::validate_tweet_length(&body.content, MAX_TWEET_CHARS)
551    {
552        return Err(ApiError::BadRequest(format!(
553            "Tweet exceeds {} characters (weighted length: {})",
554            MAX_TWEET_CHARS,
555            tweet_weighted_len(&body.content)
556        )));
557    }
558
559    let id =
560        scheduled_content::insert_draft(&state.db, &body.content_type, &body.content, &body.source)
561            .await
562            .map_err(ApiError::Storage)?;
563
564    Ok(Json(json!({ "id": id, "status": "draft" })))
565}
566
567#[derive(Deserialize)]
568pub struct EditDraftRequest {
569    pub content: String,
570}
571
572pub async fn edit_draft(
573    State(state): State<Arc<AppState>>,
574    Path(id): Path<i64>,
575    Json(body): Json<EditDraftRequest>,
576) -> Result<Json<Value>, ApiError> {
577    if body.content.trim().is_empty() {
578        return Err(ApiError::BadRequest(
579            "content must not be empty".to_string(),
580        ));
581    }
582
583    scheduled_content::update_draft(&state.db, id, &body.content)
584        .await
585        .map_err(ApiError::Storage)?;
586
587    Ok(Json(json!({ "id": id, "status": "draft" })))
588}
589
590pub async fn delete_draft(
591    State(state): State<Arc<AppState>>,
592    Path(id): Path<i64>,
593) -> Result<Json<Value>, ApiError> {
594    scheduled_content::delete_draft(&state.db, id)
595        .await
596        .map_err(ApiError::Storage)?;
597
598    Ok(Json(json!({ "id": id, "status": "cancelled" })))
599}
600
601#[derive(Deserialize)]
602pub struct ScheduleDraftRequest {
603    pub scheduled_for: String,
604}
605
606pub async fn schedule_draft(
607    State(state): State<Arc<AppState>>,
608    Path(id): Path<i64>,
609    Json(body): Json<ScheduleDraftRequest>,
610) -> Result<Json<Value>, ApiError> {
611    scheduled_content::schedule_draft(&state.db, id, &body.scheduled_for)
612        .await
613        .map_err(ApiError::Storage)?;
614
615    Ok(Json(
616        json!({ "id": id, "status": "scheduled", "scheduled_for": body.scheduled_for }),
617    ))
618}
619
620pub async fn publish_draft(
621    State(state): State<Arc<AppState>>,
622    Path(id): Path<i64>,
623) -> Result<Json<Value>, ApiError> {
624    // Get the draft.
625    let item = scheduled_content::get_by_id(&state.db, id)
626        .await
627        .map_err(ApiError::Storage)?
628        .ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
629
630    if item.status != "draft" {
631        return Err(ApiError::BadRequest(format!(
632            "Item is in '{}' status, not 'draft'",
633            item.status
634        )));
635    }
636
637    // Queue into approval queue for immediate posting.
638    let queue_id = approval_queue::enqueue(
639        &state.db,
640        &item.content_type,
641        "", // no target tweet
642        "", // no target author
643        &item.content,
644        "",  // topic
645        "",  // archetype
646        0.0, // score
647        "[]",
648    )
649    .await
650    .map_err(ApiError::Storage)?;
651
652    // Mark as approved immediately so the approval poster picks it up.
653    approval_queue::update_status(&state.db, queue_id, "approved")
654        .await
655        .map_err(ApiError::Storage)?;
656
657    // Mark the draft as posted.
658    scheduled_content::update_status(&state.db, id, "posted", None)
659        .await
660        .map_err(ApiError::Storage)?;
661
662    Ok(Json(
663        json!({ "id": id, "approval_queue_id": queue_id, "status": "queued_for_posting" }),
664    ))
665}