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::provenance::ProvenanceRef;
14use tuitbot_core::storage::{approval_queue, provenance, scheduled_content};
15
16use crate::account::{require_mutate, AccountContext};
17use crate::error::ApiError;
18use crate::state::AppState;
19
20use super::compose::ThreadBlockRequest;
21
22#[derive(Deserialize)]
23pub struct CreateDraftRequest {
24    pub content_type: String,
25    pub content: String,
26    #[serde(default = "default_source")]
27    pub source: String,
28    #[serde(default)]
29    pub blocks: Option<Vec<ThreadBlockRequest>>,
30    /// Optional provenance refs linking this draft to vault source material.
31    #[serde(default)]
32    pub provenance: Option<Vec<ProvenanceRef>>,
33}
34
35fn default_source() -> String {
36    "manual".to_string()
37}
38
39pub async fn list_drafts(
40    State(state): State<Arc<AppState>>,
41    ctx: AccountContext,
42) -> Result<Json<Vec<scheduled_content::ScheduledContent>>, ApiError> {
43    let drafts = scheduled_content::list_drafts_for(&state.db, &ctx.account_id)
44        .await
45        .map_err(ApiError::Storage)?;
46    Ok(Json(drafts))
47}
48
49pub async fn create_draft(
50    State(state): State<Arc<AppState>>,
51    ctx: AccountContext,
52    Json(mut body): Json<CreateDraftRequest>,
53) -> Result<Json<Value>, ApiError> {
54    require_mutate(&ctx)?;
55
56    let blocks = body.blocks.take();
57
58    // When blocks are provided for a thread, use them.
59    let content = if body.content_type == "thread" {
60        if let Some(block_requests) = blocks {
61            let core_blocks: Vec<ThreadBlock> =
62                block_requests.into_iter().map(|b| b.into_core()).collect();
63            validate_thread_blocks(&core_blocks)
64                .map_err(|e| ApiError::BadRequest(e.api_message()))?;
65            serialize_blocks_for_storage(&core_blocks)
66        } else {
67            validate_draft_content(&body.content_type, &body.content)?;
68            body.content.clone()
69        }
70    } else {
71        validate_draft_content(&body.content_type, &body.content)?;
72        body.content.clone()
73    };
74
75    let id = if let Some(ref refs) = body.provenance {
76        scheduled_content::insert_draft_with_provenance_for(
77            &state.db,
78            &ctx.account_id,
79            &body.content_type,
80            &content,
81            &body.source,
82            refs,
83        )
84        .await
85        .map_err(ApiError::Storage)?
86    } else {
87        scheduled_content::insert_draft_for(
88            &state.db,
89            &ctx.account_id,
90            &body.content_type,
91            &content,
92            &body.source,
93        )
94        .await
95        .map_err(ApiError::Storage)?
96    };
97
98    Ok(Json(json!({ "id": id, "status": "draft" })))
99}
100
101/// Validate draft content for legacy (non-blocks) payloads.
102fn validate_draft_content(content_type: &str, content: &str) -> Result<(), ApiError> {
103    if content.trim().is_empty() {
104        return Err(ApiError::BadRequest(
105            "content must not be empty".to_string(),
106        ));
107    }
108
109    if content_type == "tweet"
110        && !tuitbot_core::content::validate_tweet_length(content, MAX_TWEET_CHARS)
111    {
112        return Err(ApiError::BadRequest(format!(
113            "Tweet exceeds {} characters (weighted length: {})",
114            MAX_TWEET_CHARS,
115            tweet_weighted_len(content)
116        )));
117    }
118
119    Ok(())
120}
121
122#[derive(Deserialize)]
123pub struct EditDraftRequest {
124    #[serde(default)]
125    pub content: Option<String>,
126    #[serde(default)]
127    pub blocks: Option<Vec<ThreadBlockRequest>>,
128}
129
130pub async fn edit_draft(
131    State(state): State<Arc<AppState>>,
132    ctx: AccountContext,
133    Path(id): Path<i64>,
134    Json(body): Json<EditDraftRequest>,
135) -> Result<Json<Value>, ApiError> {
136    require_mutate(&ctx)?;
137
138    let content = if let Some(block_requests) = body.blocks {
139        let core_blocks: Vec<ThreadBlock> =
140            block_requests.into_iter().map(|b| b.into_core()).collect();
141        validate_thread_blocks(&core_blocks).map_err(|e| ApiError::BadRequest(e.api_message()))?;
142        serialize_blocks_for_storage(&core_blocks)
143    } else if let Some(ref text) = body.content {
144        if text.trim().is_empty() {
145            return Err(ApiError::BadRequest(
146                "content must not be empty".to_string(),
147            ));
148        }
149        text.clone()
150    } else {
151        return Err(ApiError::BadRequest(
152            "must provide either 'content' or 'blocks'".to_string(),
153        ));
154    };
155
156    scheduled_content::update_draft_for(&state.db, &ctx.account_id, id, &content)
157        .await
158        .map_err(ApiError::Storage)?;
159
160    Ok(Json(json!({ "id": id, "status": "draft" })))
161}
162
163pub async fn delete_draft(
164    State(state): State<Arc<AppState>>,
165    ctx: AccountContext,
166    Path(id): Path<i64>,
167) -> Result<Json<Value>, ApiError> {
168    require_mutate(&ctx)?;
169
170    scheduled_content::delete_draft_for(&state.db, &ctx.account_id, id)
171        .await
172        .map_err(ApiError::Storage)?;
173
174    Ok(Json(json!({ "id": id, "status": "cancelled" })))
175}
176
177#[derive(Deserialize)]
178pub struct ScheduleDraftRequest {
179    pub scheduled_for: String,
180}
181
182pub async fn schedule_draft(
183    State(state): State<Arc<AppState>>,
184    ctx: AccountContext,
185    Path(id): Path<i64>,
186    Json(body): Json<ScheduleDraftRequest>,
187) -> Result<Json<Value>, ApiError> {
188    require_mutate(&ctx)?;
189
190    let normalized = tuitbot_core::scheduling::validate_and_normalize(
191        &body.scheduled_for,
192        tuitbot_core::scheduling::DEFAULT_GRACE_SECONDS,
193    )
194    .map_err(|e| ApiError::BadRequest(e.to_string()))?;
195
196    scheduled_content::schedule_draft_for(&state.db, &ctx.account_id, id, &normalized)
197        .await
198        .map_err(ApiError::Storage)?;
199
200    Ok(Json(
201        json!({ "id": id, "status": "scheduled", "scheduled_for": normalized }),
202    ))
203}
204
205pub async fn publish_draft(
206    State(state): State<Arc<AppState>>,
207    ctx: AccountContext,
208    Path(id): Path<i64>,
209) -> Result<Json<Value>, ApiError> {
210    require_mutate(&ctx)?;
211    super::require_post_capable(&state, &ctx.account_id).await?;
212
213    // Get the draft.
214    let item = scheduled_content::get_by_id_for(&state.db, &ctx.account_id, id)
215        .await
216        .map_err(ApiError::Storage)?
217        .ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
218
219    if item.status != "draft" {
220        return Err(ApiError::BadRequest(format!(
221            "Item is in '{}' status, not 'draft'",
222            item.status
223        )));
224    }
225
226    // Load provenance links from the draft's scheduled_content record.
227    let draft_links =
228        provenance::get_links_for(&state.db, &ctx.account_id, "scheduled_content", id)
229            .await
230            .map_err(ApiError::Storage)?;
231
232    let prov_input = if draft_links.is_empty() {
233        None
234    } else {
235        let refs: Vec<ProvenanceRef> = draft_links
236            .iter()
237            .map(|l| ProvenanceRef {
238                node_id: l.node_id,
239                chunk_id: l.chunk_id,
240                seed_id: l.seed_id,
241                source_path: l.source_path.clone(),
242                heading_path: l.heading_path.clone(),
243                snippet: l.snippet.clone(),
244            })
245            .collect();
246        let source_node_id = refs.iter().find_map(|r| r.node_id);
247        let source_seed_id = refs.iter().find_map(|r| r.seed_id);
248        Some(approval_queue::ProvenanceInput {
249            source_node_id,
250            source_seed_id,
251            source_chunks_json: serde_json::to_string(&refs).unwrap_or_else(|_| "[]".to_string()),
252            refs,
253        })
254    };
255
256    // Queue into approval queue for immediate posting.
257    let queue_id = approval_queue::enqueue_with_provenance_for(
258        &state.db,
259        &ctx.account_id,
260        &item.content_type,
261        "", // no target tweet
262        "", // no target author
263        &item.content,
264        "",  // topic
265        "",  // archetype
266        0.0, // score
267        "[]",
268        None,
269        None,
270        prov_input.as_ref(),
271        None, // no scheduling intent — direct publish via approval poster
272    )
273    .await
274    .map_err(ApiError::Storage)?;
275
276    // Mark as approved immediately so the approval poster picks it up.
277    approval_queue::update_status_for(&state.db, &ctx.account_id, queue_id, "approved")
278        .await
279        .map_err(ApiError::Storage)?;
280
281    // Mark the draft as posted.
282    scheduled_content::update_status_for(&state.db, &ctx.account_id, id, "posted", None)
283        .await
284        .map_err(ApiError::Storage)?;
285
286    Ok(Json(
287        json!({ "id": id, "approval_queue_id": queue_id, "status": "queued_for_posting" }),
288    ))
289}