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::{tweet_weighted_len, MAX_TWEET_CHARS};
10use tuitbot_core::storage::{approval_queue, scheduled_content};
11
12use crate::account::{require_mutate, AccountContext};
13use crate::error::ApiError;
14use crate::state::AppState;
15
16#[derive(Deserialize)]
17pub struct CreateDraftRequest {
18    pub content_type: String,
19    pub content: String,
20    #[serde(default = "default_source")]
21    pub source: String,
22}
23
24fn default_source() -> String {
25    "manual".to_string()
26}
27
28pub async fn list_drafts(
29    State(state): State<Arc<AppState>>,
30    ctx: AccountContext,
31) -> Result<Json<Vec<scheduled_content::ScheduledContent>>, ApiError> {
32    let drafts = scheduled_content::list_drafts_for(&state.db, &ctx.account_id)
33        .await
34        .map_err(ApiError::Storage)?;
35    Ok(Json(drafts))
36}
37
38pub async fn create_draft(
39    State(state): State<Arc<AppState>>,
40    ctx: AccountContext,
41    Json(body): Json<CreateDraftRequest>,
42) -> Result<Json<Value>, ApiError> {
43    require_mutate(&ctx)?;
44
45    // Validate content.
46    if body.content.trim().is_empty() {
47        return Err(ApiError::BadRequest(
48            "content must not be empty".to_string(),
49        ));
50    }
51
52    if body.content_type == "tweet"
53        && !tuitbot_core::content::validate_tweet_length(&body.content, MAX_TWEET_CHARS)
54    {
55        return Err(ApiError::BadRequest(format!(
56            "Tweet exceeds {} characters (weighted length: {})",
57            MAX_TWEET_CHARS,
58            tweet_weighted_len(&body.content)
59        )));
60    }
61
62    let id = scheduled_content::insert_draft_for(
63        &state.db,
64        &ctx.account_id,
65        &body.content_type,
66        &body.content,
67        &body.source,
68    )
69    .await
70    .map_err(ApiError::Storage)?;
71
72    Ok(Json(json!({ "id": id, "status": "draft" })))
73}
74
75#[derive(Deserialize)]
76pub struct EditDraftRequest {
77    pub content: String,
78}
79
80pub async fn edit_draft(
81    State(state): State<Arc<AppState>>,
82    ctx: AccountContext,
83    Path(id): Path<i64>,
84    Json(body): Json<EditDraftRequest>,
85) -> Result<Json<Value>, ApiError> {
86    require_mutate(&ctx)?;
87
88    if body.content.trim().is_empty() {
89        return Err(ApiError::BadRequest(
90            "content must not be empty".to_string(),
91        ));
92    }
93
94    scheduled_content::update_draft_for(&state.db, &ctx.account_id, id, &body.content)
95        .await
96        .map_err(ApiError::Storage)?;
97
98    Ok(Json(json!({ "id": id, "status": "draft" })))
99}
100
101pub async fn delete_draft(
102    State(state): State<Arc<AppState>>,
103    ctx: AccountContext,
104    Path(id): Path<i64>,
105) -> Result<Json<Value>, ApiError> {
106    require_mutate(&ctx)?;
107
108    scheduled_content::delete_draft_for(&state.db, &ctx.account_id, id)
109        .await
110        .map_err(ApiError::Storage)?;
111
112    Ok(Json(json!({ "id": id, "status": "cancelled" })))
113}
114
115#[derive(Deserialize)]
116pub struct ScheduleDraftRequest {
117    pub scheduled_for: String,
118}
119
120pub async fn schedule_draft(
121    State(state): State<Arc<AppState>>,
122    ctx: AccountContext,
123    Path(id): Path<i64>,
124    Json(body): Json<ScheduleDraftRequest>,
125) -> Result<Json<Value>, ApiError> {
126    require_mutate(&ctx)?;
127
128    scheduled_content::schedule_draft_for(&state.db, &ctx.account_id, id, &body.scheduled_for)
129        .await
130        .map_err(ApiError::Storage)?;
131
132    Ok(Json(
133        json!({ "id": id, "status": "scheduled", "scheduled_for": body.scheduled_for }),
134    ))
135}
136
137pub async fn publish_draft(
138    State(state): State<Arc<AppState>>,
139    ctx: AccountContext,
140    Path(id): Path<i64>,
141) -> Result<Json<Value>, ApiError> {
142    require_mutate(&ctx)?;
143
144    // Get the draft.
145    let item = scheduled_content::get_by_id_for(&state.db, &ctx.account_id, id)
146        .await
147        .map_err(ApiError::Storage)?
148        .ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
149
150    if item.status != "draft" {
151        return Err(ApiError::BadRequest(format!(
152            "Item is in '{}' status, not 'draft'",
153            item.status
154        )));
155    }
156
157    // Queue into approval queue for immediate posting.
158    let queue_id = approval_queue::enqueue_for(
159        &state.db,
160        &ctx.account_id,
161        &item.content_type,
162        "", // no target tweet
163        "", // no target author
164        &item.content,
165        "",  // topic
166        "",  // archetype
167        0.0, // score
168        "[]",
169    )
170    .await
171    .map_err(ApiError::Storage)?;
172
173    // Mark as approved immediately so the approval poster picks it up.
174    approval_queue::update_status_for(&state.db, &ctx.account_id, queue_id, "approved")
175        .await
176        .map_err(ApiError::Storage)?;
177
178    // Mark the draft as posted.
179    scheduled_content::update_status_for(&state.db, &ctx.account_id, id, "posted", None)
180        .await
181        .map_err(ApiError::Storage)?;
182
183    Ok(Json(
184        json!({ "id": id, "approval_queue_id": queue_id, "status": "queued_for_posting" }),
185    ))
186}