use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tuitbot_core::storage::{provenance, scheduled_content};
use crate::account::{require_mutate, AccountContext};
use crate::error::ApiError;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct DraftListQuery {
pub status: Option<String>,
pub tag: Option<i64>,
pub search: Option<String>,
pub archived: Option<bool>,
}
#[derive(Serialize)]
pub struct DraftSummary {
pub id: i64,
pub title: Option<String>,
pub content_type: String,
pub content_preview: String,
pub status: String,
pub scheduled_for: Option<String>,
pub archived_at: Option<String>,
pub updated_at: String,
pub created_at: String,
pub source: String,
}
#[derive(Deserialize)]
pub struct CreateStudioDraftBody {
#[serde(default = "default_tweet")]
pub content_type: String,
#[serde(default = "default_blank_content")]
pub content: String,
#[serde(default = "default_manual")]
pub source: String,
pub title: Option<String>,
}
fn default_tweet() -> String {
"tweet".to_string()
}
fn default_blank_content() -> String {
" ".to_string()
}
fn default_manual() -> String {
"manual".to_string()
}
#[derive(Deserialize)]
pub struct AutosavePatchBody {
pub content: String,
pub content_type: String,
pub updated_at: String,
}
#[derive(Deserialize)]
pub struct MetaPatchBody {
pub title: Option<String>,
pub notes: Option<String>,
}
#[derive(Deserialize)]
pub struct ScheduleBody {
pub scheduled_for: String,
}
#[derive(Deserialize)]
pub struct CreateRevisionBody {
#[serde(default = "default_manual_trigger")]
pub trigger_kind: String,
}
fn default_manual_trigger() -> String {
"manual".to_string()
}
fn content_preview(content: &str, content_type: &str) -> String {
let text = if content_type == "thread" {
extract_first_block_text(content)
} else {
content.to_string()
};
let trimmed = text.trim();
if trimmed.len() <= 60 {
trimmed.to_string()
} else {
let mut preview = trimmed.chars().take(57).collect::<String>();
preview.push_str("...");
preview
}
}
fn extract_first_block_text(content: &str) -> String {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
if let Some(blocks) = parsed.get("blocks").and_then(|b| b.as_array()) {
if let Some(first) = blocks.first() {
if let Some(text) = first.get("text").and_then(|t| t.as_str()) {
return text.to_string();
}
}
}
if let Some(arr) = parsed.as_array() {
if let Some(first) = arr.first().and_then(|v| v.as_str()) {
return first.to_string();
}
}
}
content.to_string()
}
fn to_summary(item: &scheduled_content::ScheduledContent) -> DraftSummary {
DraftSummary {
id: item.id,
title: item.title.clone(),
content_type: item.content_type.clone(),
content_preview: content_preview(&item.content, &item.content_type),
status: item.status.clone(),
scheduled_for: item.scheduled_for.clone(),
archived_at: item.archived_at.clone(),
updated_at: item.updated_at.clone(),
created_at: item.created_at.clone(),
source: item.source.clone(),
}
}
mod handlers;
pub use handlers::*;
pub async fn unschedule_studio_draft(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<i64>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
let item = scheduled_content::get_by_id_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?
.ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
if item.status != "scheduled" {
return Err(ApiError::BadRequest(format!(
"Item is in '{}' status, not 'scheduled'",
item.status
)));
}
let _ = scheduled_content::insert_revision_for(
&state.db,
&ctx.account_id,
id,
&item.content,
&item.content_type,
"unschedule",
)
.await;
let unscheduled = scheduled_content::unschedule_draft_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?;
if !unscheduled {
return Err(ApiError::BadRequest(
"Failed to unschedule — item may have changed status".to_string(),
));
}
let _ =
scheduled_content::insert_activity_for(&state.db, &ctx.account_id, id, "unscheduled", None)
.await;
Ok(Json(json!({ "id": id, "status": "draft" })))
}
#[derive(Deserialize)]
pub struct RescheduleBody {
pub scheduled_for: String,
}
pub async fn reschedule_studio_draft(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<i64>,
Json(body): Json<RescheduleBody>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
let item = scheduled_content::get_by_id_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?
.ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
if item.status != "scheduled" {
return Err(ApiError::BadRequest(format!(
"Item is in '{}' status, not 'scheduled'",
item.status
)));
}
let normalized = tuitbot_core::scheduling::validate_and_normalize(
&body.scheduled_for,
tuitbot_core::scheduling::DEFAULT_GRACE_SECONDS,
)
.map_err(|e| ApiError::BadRequest(e.to_string()))?;
let _ = scheduled_content::insert_revision_for(
&state.db,
&ctx.account_id,
id,
&item.content,
&item.content_type,
"reschedule",
)
.await;
let updated =
scheduled_content::reschedule_draft_for(&state.db, &ctx.account_id, id, &normalized)
.await
.map_err(ApiError::Storage)?;
if !updated {
return Err(ApiError::BadRequest(
"Failed to reschedule — item may have changed status".to_string(),
));
}
let _ = scheduled_content::insert_activity_for(
&state.db,
&ctx.account_id,
id,
"rescheduled",
Some(
&json!({
"from": item.scheduled_for,
"to": normalized
})
.to_string(),
),
)
.await;
Ok(Json(json!({
"id": id,
"status": "scheduled",
"scheduled_for": normalized
})))
}
pub async fn archive_studio_draft(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<i64>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
scheduled_content::archive_draft_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?;
let _ =
scheduled_content::insert_activity_for(&state.db, &ctx.account_id, id, "archived", None)
.await;
let item = scheduled_content::get_by_id_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?;
let archived_at = item.and_then(|i| i.archived_at).unwrap_or_default();
Ok(Json(json!({ "id": id, "archived_at": archived_at })))
}
pub async fn restore_studio_draft(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<i64>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
scheduled_content::restore_draft_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?;
let _ =
scheduled_content::insert_activity_for(&state.db, &ctx.account_id, id, "restored", None)
.await;
Ok(Json(json!({ "id": id })))
}
pub async fn duplicate_studio_draft(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<i64>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
let new_id = scheduled_content::duplicate_draft_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?
.ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
let _ = provenance::copy_links_for(
&state.db,
&ctx.account_id,
"scheduled_content",
id,
"scheduled_content",
new_id,
)
.await;
let _ = scheduled_content::insert_activity_for(
&state.db,
&ctx.account_id,
new_id,
"created",
Some(&json!({ "source": "duplicate", "original_id": id }).to_string()),
)
.await;
Ok(Json(json!({ "id": new_id })))
}
pub async fn restore_from_revision(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path((id, rev_id)): Path<(i64, i64)>,
) -> Result<Json<scheduled_content::ScheduledContent>, ApiError> {
require_mutate(&ctx)?;
let current = scheduled_content::get_by_id_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?
.ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
let target_rev = scheduled_content::get_revision_for(&state.db, &ctx.account_id, id, rev_id)
.await
.map_err(ApiError::Storage)?
.ok_or_else(|| ApiError::NotFound(format!("Revision {rev_id} not found")))?;
let _ = scheduled_content::insert_revision_for(
&state.db,
&ctx.account_id,
id,
¤t.content,
¤t.content_type,
"pre_restore",
)
.await;
let _ = scheduled_content::autosave_draft_for(
&state.db,
&ctx.account_id,
id,
&target_rev.content,
&target_rev.content_type,
¤t.updated_at,
)
.await
.map_err(ApiError::Storage)?;
let _ = scheduled_content::insert_activity_for(
&state.db,
&ctx.account_id,
id,
"revision_restored",
Some(&json!({"from_revision_id": rev_id}).to_string()),
)
.await;
let updated = scheduled_content::get_by_id_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?
.ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
Ok(Json(updated))
}
pub async fn list_draft_revisions(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<i64>,
) -> Result<Json<Vec<scheduled_content::ContentRevision>>, ApiError> {
let revisions = scheduled_content::list_revisions_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?;
Ok(Json(revisions))
}
pub async fn create_draft_revision(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<i64>,
Json(body): Json<CreateRevisionBody>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
let item = scheduled_content::get_by_id_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?
.ok_or_else(|| ApiError::NotFound(format!("Draft {id} not found")))?;
let rev_id = scheduled_content::insert_revision_for(
&state.db,
&ctx.account_id,
id,
&item.content,
&item.content_type,
&body.trigger_kind,
)
.await
.map_err(ApiError::Storage)?;
Ok(Json(json!({ "id": rev_id })))
}
pub async fn list_draft_activity(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<i64>,
) -> Result<Json<Vec<scheduled_content::ContentActivity>>, ApiError> {
let activity = scheduled_content::list_activity_for(&state.db, &ctx.account_id, id)
.await
.map_err(ApiError::Storage)?;
Ok(Json(activity))
}
#[cfg(test)]
mod tests;