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::{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 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
84fn 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 super::require_post_capable(&state, &ctx.account_id).await?;
189
190 let item = scheduled_content::get_by_id_for(&state.db, &ctx.account_id, id)
192 .await
193 .map_err(ApiError::Storage)?
194 .ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
195
196 if item.status != "draft" {
197 return Err(ApiError::BadRequest(format!(
198 "Item is in '{}' status, not 'draft'",
199 item.status
200 )));
201 }
202
203 let queue_id = approval_queue::enqueue_for(
205 &state.db,
206 &ctx.account_id,
207 &item.content_type,
208 "", "", &item.content,
211 "", "", 0.0, "[]",
215 )
216 .await
217 .map_err(ApiError::Storage)?;
218
219 approval_queue::update_status_for(&state.db, &ctx.account_id, queue_id, "approved")
221 .await
222 .map_err(ApiError::Storage)?;
223
224 scheduled_content::update_status_for(&state.db, &ctx.account_id, id, "posted", None)
226 .await
227 .map_err(ApiError::Storage)?;
228
229 Ok(Json(
230 json!({ "id": id, "approval_queue_id": queue_id, "status": "queued_for_posting" }),
231 ))
232}