Skip to main content

tuitbot_server/routes/content/
drafts.rs

1//! Draft content endpoints.
2
3use std::sync::Arc;
4
5use axum::extract::{Path, State};
6use axum::Json;
7use serde::Deserialize;
8use serde_json::{json, Value};
9use tuitbot_core::content::{
10    serialize_blocks_for_storage, tweet_weighted_len, validate_thread_blocks, ThreadBlock,
11    MAX_TWEET_CHARS,
12};
13use tuitbot_core::storage::{approval_queue, scheduled_content};
14
15use crate::account::{require_mutate, AccountContext};
16use crate::error::ApiError;
17use crate::state::AppState;
18
19use super::compose::ThreadBlockRequest;
20
21#[derive(Deserialize)]
22pub struct CreateDraftRequest {
23    pub content_type: String,
24    pub content: String,
25    #[serde(default = "default_source")]
26    pub source: String,
27    #[serde(default)]
28    pub blocks: Option<Vec<ThreadBlockRequest>>,
29}
30
31fn default_source() -> String {
32    "manual".to_string()
33}
34
35pub async fn list_drafts(
36    State(state): State<Arc<AppState>>,
37    ctx: AccountContext,
38) -> Result<Json<Vec<scheduled_content::ScheduledContent>>, ApiError> {
39    let drafts = scheduled_content::list_drafts_for(&state.db, &ctx.account_id)
40        .await
41        .map_err(ApiError::Storage)?;
42    Ok(Json(drafts))
43}
44
45pub async fn create_draft(
46    State(state): State<Arc<AppState>>,
47    ctx: AccountContext,
48    Json(mut body): Json<CreateDraftRequest>,
49) -> Result<Json<Value>, ApiError> {
50    require_mutate(&ctx)?;
51
52    let blocks = body.blocks.take();
53
54    // When blocks are provided for a thread, use them.
55    let content = if body.content_type == "thread" {
56        if let Some(block_requests) = blocks {
57            let core_blocks: Vec<ThreadBlock> =
58                block_requests.into_iter().map(|b| b.into_core()).collect();
59            validate_thread_blocks(&core_blocks)
60                .map_err(|e| ApiError::BadRequest(e.api_message()))?;
61            serialize_blocks_for_storage(&core_blocks)
62        } else {
63            validate_draft_content(&body.content_type, &body.content)?;
64            body.content.clone()
65        }
66    } else {
67        validate_draft_content(&body.content_type, &body.content)?;
68        body.content.clone()
69    };
70
71    let id = scheduled_content::insert_draft_for(
72        &state.db,
73        &ctx.account_id,
74        &body.content_type,
75        &content,
76        &body.source,
77    )
78    .await
79    .map_err(ApiError::Storage)?;
80
81    Ok(Json(json!({ "id": id, "status": "draft" })))
82}
83
84/// Validate draft content for legacy (non-blocks) payloads.
85fn validate_draft_content(content_type: &str, content: &str) -> Result<(), ApiError> {
86    if content.trim().is_empty() {
87        return Err(ApiError::BadRequest(
88            "content must not be empty".to_string(),
89        ));
90    }
91
92    if content_type == "tweet"
93        && !tuitbot_core::content::validate_tweet_length(content, MAX_TWEET_CHARS)
94    {
95        return Err(ApiError::BadRequest(format!(
96            "Tweet exceeds {} characters (weighted length: {})",
97            MAX_TWEET_CHARS,
98            tweet_weighted_len(content)
99        )));
100    }
101
102    Ok(())
103}
104
105#[derive(Deserialize)]
106pub struct EditDraftRequest {
107    #[serde(default)]
108    pub content: Option<String>,
109    #[serde(default)]
110    pub blocks: Option<Vec<ThreadBlockRequest>>,
111}
112
113pub async fn edit_draft(
114    State(state): State<Arc<AppState>>,
115    ctx: AccountContext,
116    Path(id): Path<i64>,
117    Json(body): Json<EditDraftRequest>,
118) -> Result<Json<Value>, ApiError> {
119    require_mutate(&ctx)?;
120
121    let content = if let Some(block_requests) = body.blocks {
122        let core_blocks: Vec<ThreadBlock> =
123            block_requests.into_iter().map(|b| b.into_core()).collect();
124        validate_thread_blocks(&core_blocks).map_err(|e| ApiError::BadRequest(e.api_message()))?;
125        serialize_blocks_for_storage(&core_blocks)
126    } else if let Some(ref text) = body.content {
127        if text.trim().is_empty() {
128            return Err(ApiError::BadRequest(
129                "content must not be empty".to_string(),
130            ));
131        }
132        text.clone()
133    } else {
134        return Err(ApiError::BadRequest(
135            "must provide either 'content' or 'blocks'".to_string(),
136        ));
137    };
138
139    scheduled_content::update_draft_for(&state.db, &ctx.account_id, id, &content)
140        .await
141        .map_err(ApiError::Storage)?;
142
143    Ok(Json(json!({ "id": id, "status": "draft" })))
144}
145
146pub async fn delete_draft(
147    State(state): State<Arc<AppState>>,
148    ctx: AccountContext,
149    Path(id): Path<i64>,
150) -> Result<Json<Value>, ApiError> {
151    require_mutate(&ctx)?;
152
153    scheduled_content::delete_draft_for(&state.db, &ctx.account_id, id)
154        .await
155        .map_err(ApiError::Storage)?;
156
157    Ok(Json(json!({ "id": id, "status": "cancelled" })))
158}
159
160#[derive(Deserialize)]
161pub struct ScheduleDraftRequest {
162    pub scheduled_for: String,
163}
164
165pub async fn schedule_draft(
166    State(state): State<Arc<AppState>>,
167    ctx: AccountContext,
168    Path(id): Path<i64>,
169    Json(body): Json<ScheduleDraftRequest>,
170) -> Result<Json<Value>, ApiError> {
171    require_mutate(&ctx)?;
172
173    scheduled_content::schedule_draft_for(&state.db, &ctx.account_id, id, &body.scheduled_for)
174        .await
175        .map_err(ApiError::Storage)?;
176
177    Ok(Json(
178        json!({ "id": id, "status": "scheduled", "scheduled_for": body.scheduled_for }),
179    ))
180}
181
182pub async fn publish_draft(
183    State(state): State<Arc<AppState>>,
184    ctx: AccountContext,
185    Path(id): Path<i64>,
186) -> Result<Json<Value>, ApiError> {
187    require_mutate(&ctx)?;
188
189    // Get the draft.
190    let item = scheduled_content::get_by_id_for(&state.db, &ctx.account_id, id)
191        .await
192        .map_err(ApiError::Storage)?
193        .ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
194
195    if item.status != "draft" {
196        return Err(ApiError::BadRequest(format!(
197            "Item is in '{}' status, not 'draft'",
198            item.status
199        )));
200    }
201
202    // Queue into approval queue for immediate posting.
203    let queue_id = approval_queue::enqueue_for(
204        &state.db,
205        &ctx.account_id,
206        &item.content_type,
207        "", // no target tweet
208        "", // no target author
209        &item.content,
210        "",  // topic
211        "",  // archetype
212        0.0, // score
213        "[]",
214    )
215    .await
216    .map_err(ApiError::Storage)?;
217
218    // Mark as approved immediately so the approval poster picks it up.
219    approval_queue::update_status_for(&state.db, &ctx.account_id, queue_id, "approved")
220        .await
221        .map_err(ApiError::Storage)?;
222
223    // Mark the draft as posted.
224    scheduled_content::update_status_for(&state.db, &ctx.account_id, id, "posted", None)
225        .await
226        .map_err(ApiError::Storage)?;
227
228    Ok(Json(
229        json!({ "id": id, "approval_queue_id": queue_id, "status": "queued_for_posting" }),
230    ))
231}