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