tuitbot_server/routes/content/
drafts.rs1use 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 #[serde(default)]
32 pub provenance: Option<Vec<ProvenanceRef>>,
33 #[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 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 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
110fn 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 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 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 let queue_id = approval_queue::enqueue_with_provenance_for(
273 &state.db,
274 &ctx.account_id,
275 &item.content_type,
276 "", "", &item.content,
279 "", "", 0.0, "[]",
283 None,
284 None,
285 prov_input.as_ref(),
286 None, )
288 .await
289 .map_err(ApiError::Storage)?;
290
291 approval_queue::update_status_for(&state.db, &ctx.account_id, queue_id, "approved")
293 .await
294 .map_err(ApiError::Storage)?;
295
296 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
306pub 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 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}