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}
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 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
101fn 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 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 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 let queue_id = approval_queue::enqueue_with_provenance_for(
258 &state.db,
259 &ctx.account_id,
260 &item.content_type,
261 "", "", &item.content,
264 "", "", 0.0, "[]",
268 None,
269 None,
270 prov_input.as_ref(),
271 None, )
273 .await
274 .map_err(ApiError::Storage)?;
275
276 approval_queue::update_status_for(&state.db, &ctx.account_id, queue_id, "approved")
278 .await
279 .map_err(ApiError::Storage)?;
280
281 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}