| 1 |
|
| 2 |
|
| 3 |
use axum::{ |
| 4 |
extract::{Path, State}, |
| 5 |
http::{header::HeaderMap, StatusCode}, |
| 6 |
response::{IntoResponse, Response}, |
| 7 |
Form, Json, |
| 8 |
}; |
| 9 |
use serde::{Deserialize, Serialize}; |
| 10 |
|
| 11 |
use crate::{ |
| 12 |
auth::AuthUser, |
| 13 |
db::{self, AiTier, ContentData, ItemId, ItemType, PriceCents, ProjectId}, |
| 14 |
error::{AppError, Result}, |
| 15 |
helpers::{is_htmx_request, parse_schedule_datetime}, |
| 16 |
templates::SaveStatusTemplate, |
| 17 |
validation, |
| 18 |
AppState, |
| 19 |
}; |
| 20 |
|
| 21 |
use super::super::{verify_item_ownership, verify_project_ownership}; |
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
#[derive(Debug, Deserialize)] |
| 29 |
pub struct CreateItemRequest { |
| 30 |
pub title: String, |
| 31 |
pub description: Option<String>, |
| 32 |
|
| 33 |
pub price_cents: Option<PriceCents>, |
| 34 |
pub item_type: Option<ItemType>, |
| 35 |
|
| 36 |
pub ai_tier: Option<AiTier>, |
| 37 |
|
| 38 |
pub ai_disclosure: Option<String>, |
| 39 |
} |
| 40 |
|
| 41 |
|
| 42 |
#[derive(Debug, Serialize)] |
| 43 |
pub struct ItemResponse { |
| 44 |
pub id: ItemId, |
| 45 |
pub project_id: ProjectId, |
| 46 |
pub title: String, |
| 47 |
pub description: Option<String>, |
| 48 |
pub price_cents: i32, |
| 49 |
pub item_type: String, |
| 50 |
pub is_public: bool, |
| 51 |
pub publish_at: Option<String>, |
| 52 |
pub web_only: bool, |
| 53 |
pub ai_tier: AiTier, |
| 54 |
pub ai_disclosure: Option<String>, |
| 55 |
} |
| 56 |
|
| 57 |
|
| 58 |
#[tracing::instrument(skip_all, name = "items::create_item", fields(project_id))] |
| 59 |
pub(in crate::routes::api) async fn create_item( |
| 60 |
State(state): State<AppState>, |
| 61 |
headers: HeaderMap, |
| 62 |
AuthUser(user): AuthUser, |
| 63 |
Path(project_id): Path<ProjectId>, |
| 64 |
Form(req): Form<CreateItemRequest>, |
| 65 |
) -> Result<Response> { |
| 66 |
tracing::Span::current().record("project_id", tracing::field::display(&project_id)); |
| 67 |
user.check_not_suspended()?; |
| 68 |
|
| 69 |
validation::validate_item_title(&req.title)?; |
| 70 |
if let Some(ref desc) = req.description { |
| 71 |
validation::validate_item_description(desc)?; |
| 72 |
} |
| 73 |
|
| 74 |
verify_project_ownership(&state, project_id, user.id).await?; |
| 75 |
|
| 76 |
|
| 77 |
let project = db::projects::get_project_by_id(&state.db, project_id) |
| 78 |
.await? |
| 79 |
.ok_or(AppError::NotFound)?; |
| 80 |
let item_type = req.item_type.unwrap_or(ItemType::Digital); |
| 81 |
let allowed = db::ProjectFeature::allowed_item_type_cards(&project.features); |
| 82 |
if !allowed.iter().any(|(v, _, _)| *v == item_type.to_string().as_str()) { |
| 83 |
return Err(AppError::validation(format!( |
| 84 |
"Item type '{}' is not available for this project's features", |
| 85 |
item_type |
| 86 |
))); |
| 87 |
} |
| 88 |
|
| 89 |
|
| 90 |
let ai_tier = req.ai_tier.unwrap_or(project.ai_tier); |
| 91 |
let ai_disclosure = match ai_tier { |
| 92 |
AiTier::Assisted => { |
| 93 |
let text = req.ai_disclosure.as_deref() |
| 94 |
.or(project.ai_disclosure.as_deref()) |
| 95 |
.unwrap_or("").trim(); |
| 96 |
if text.is_empty() { None } else { Some(text.to_string()) } |
| 97 |
} |
| 98 |
_ => None, |
| 99 |
}; |
| 100 |
|
| 101 |
let item = db::items::create_item( |
| 102 |
&state.db, |
| 103 |
project_id, |
| 104 |
&req.title, |
| 105 |
req.description.as_deref(), |
| 106 |
req.price_cents.unwrap_or(PriceCents::from_db(0)), |
| 107 |
item_type, |
| 108 |
ai_tier, |
| 109 |
ai_disclosure.as_deref(), |
| 110 |
) |
| 111 |
.await?; |
| 112 |
|
| 113 |
db::projects::bump_cache_generation(&state.db, project_id).await?; |
| 114 |
|
| 115 |
if is_htmx_request(&headers) { |
| 116 |
|
| 117 |
let mut response = Response::new(axum::body::Body::empty()); |
| 118 |
response.headers_mut().insert( |
| 119 |
"HX-Redirect", |
| 120 |
format!("/dashboard/item/{}", item.id) |
| 121 |
.parse() |
| 122 |
.expect("static redirect path is valid"), |
| 123 |
); |
| 124 |
return Ok(response); |
| 125 |
} |
| 126 |
|
| 127 |
Ok(Json(ItemResponse { |
| 128 |
id: item.id, |
| 129 |
project_id: item.project_id, |
| 130 |
title: item.title, |
| 131 |
description: item.description, |
| 132 |
price_cents: item.price_cents, |
| 133 |
item_type: item.item_type.to_string(), |
| 134 |
is_public: item.is_public, |
| 135 |
publish_at: item.publish_at.map(|d| d.to_rfc3339()), |
| 136 |
web_only: item.web_only, |
| 137 |
ai_tier: item.ai_tier, |
| 138 |
ai_disclosure: item.ai_disclosure, |
| 139 |
}).into_response()) |
| 140 |
} |
| 141 |
|
| 142 |
|
| 143 |
#[derive(Debug, Deserialize)] |
| 144 |
pub struct UpdateItemRequest { |
| 145 |
pub title: Option<String>, |
| 146 |
pub description: Option<String>, |
| 147 |
|
| 148 |
pub price_cents: Option<PriceCents>, |
| 149 |
pub item_type: Option<ItemType>, |
| 150 |
pub is_public: Option<bool>, |
| 151 |
|
| 152 |
pub pwyw_enabled: Option<String>, |
| 153 |
pub pwyw_min_cents: Option<PriceCents>, |
| 154 |
|
| 155 |
pub publish_at: Option<String>, |
| 156 |
|
| 157 |
pub web_only: Option<bool>, |
| 158 |
|
| 159 |
pub ai_tier: Option<AiTier>, |
| 160 |
|
| 161 |
pub ai_disclosure: Option<String>, |
| 162 |
} |
| 163 |
|
| 164 |
|
| 165 |
#[tracing::instrument(skip_all, name = "items::update_item", fields(item_id))] |
| 166 |
pub(in crate::routes::api) async fn update_item( |
| 167 |
State(state): State<AppState>, |
| 168 |
headers: HeaderMap, |
| 169 |
AuthUser(user): AuthUser, |
| 170 |
Path(id): Path<ItemId>, |
| 171 |
Form(req): Form<UpdateItemRequest>, |
| 172 |
) -> Result<Response> { |
| 173 |
tracing::Span::current().record("item_id", tracing::field::display(&id)); |
| 174 |
user.check_not_suspended()?; |
| 175 |
verify_item_ownership(&state, id, user.id).await?; |
| 176 |
|
| 177 |
|
| 178 |
if let Some(ref title) = req.title { |
| 179 |
validation::validate_item_title(title)?; |
| 180 |
} |
| 181 |
if let Some(ref desc) = req.description { |
| 182 |
validation::validate_item_description(desc)?; |
| 183 |
} |
| 184 |
|
| 185 |
|
| 186 |
|
| 187 |
let pwyw_enabled = req.pwyw_enabled.as_deref().map(|v| v == "on"); |
| 188 |
|
| 189 |
|
| 190 |
let publish_at = parse_schedule_datetime(req.publish_at.as_deref()); |
| 191 |
|
| 192 |
|
| 193 |
if let Some(Some(dt)) = &publish_at |
| 194 |
&& *dt < chrono::Utc::now() |
| 195 |
{ |
| 196 |
return Err(AppError::BadRequest("Scheduled publish date must be in the future".to_string())); |
| 197 |
} |
| 198 |
|
| 199 |
|
| 200 |
let is_public = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() { |
| 201 |
Some(false) |
| 202 |
} else { |
| 203 |
req.is_public |
| 204 |
}; |
| 205 |
|
| 206 |
|
| 207 |
let ai_disclosure: Option<Option<&str>> = if let Some(ai_tier) = req.ai_tier { |
| 208 |
match ai_tier { |
| 209 |
AiTier::Assisted => { |
| 210 |
let text = req.ai_disclosure.as_deref().unwrap_or("").trim(); |
| 211 |
if text.is_empty() { |
| 212 |
return Err(AppError::validation( |
| 213 |
"AI disclosure is required for Assisted tier items".to_string(), |
| 214 |
)); |
| 215 |
} |
| 216 |
Some(Some(text)) |
| 217 |
} |
| 218 |
_ => Some(None), |
| 219 |
} |
| 220 |
} else if req.ai_disclosure.is_some() { |
| 221 |
|
| 222 |
Some(req.ai_disclosure.as_deref()) |
| 223 |
} else { |
| 224 |
None |
| 225 |
}; |
| 226 |
|
| 227 |
let updated = db::items::update_item( |
| 228 |
&state.db, |
| 229 |
id, |
| 230 |
user.id, |
| 231 |
req.title.as_deref(), |
| 232 |
req.description.as_deref(), |
| 233 |
req.price_cents, |
| 234 |
req.item_type, |
| 235 |
is_public, |
| 236 |
pwyw_enabled, |
| 237 |
req.pwyw_min_cents, |
| 238 |
publish_at, |
| 239 |
req.web_only, |
| 240 |
req.ai_tier, |
| 241 |
ai_disclosure, |
| 242 |
) |
| 243 |
.await?; |
| 244 |
|
| 245 |
|
| 246 |
|
| 247 |
if req.is_public == Some(true) && updated.is_public { |
| 248 |
crate::scheduler::send_release_announcements(&state, &updated).await; |
| 249 |
|
| 250 |
|
| 251 |
if updated.mt_thread_id.is_none() { |
| 252 |
crate::scheduler::spawn_mt_thread_for_item(&state, &updated, &user); |
| 253 |
} |
| 254 |
} |
| 255 |
|
| 256 |
db::projects::bump_cache_generation(&state.db, updated.project_id).await?; |
| 257 |
|
| 258 |
if is_htmx_request(&headers) { |
| 259 |
return Ok(axum::response::Html("Saved.".to_string()).into_response()); |
| 260 |
} |
| 261 |
|
| 262 |
Ok(Json(ItemResponse { |
| 263 |
id: updated.id, |
| 264 |
project_id: updated.project_id, |
| 265 |
title: updated.title, |
| 266 |
description: updated.description, |
| 267 |
price_cents: updated.price_cents, |
| 268 |
item_type: updated.item_type.to_string(), |
| 269 |
is_public: updated.is_public, |
| 270 |
publish_at: updated.publish_at.map(|d| d.to_rfc3339()), |
| 271 |
web_only: updated.web_only, |
| 272 |
ai_tier: updated.ai_tier, |
| 273 |
ai_disclosure: updated.ai_disclosure, |
| 274 |
}).into_response()) |
| 275 |
} |
| 276 |
|
| 277 |
|
| 278 |
#[tracing::instrument(skip_all, name = "items::delete_item", fields(item_id))] |
| 279 |
pub(in crate::routes::api) async fn delete_item( |
| 280 |
State(state): State<AppState>, |
| 281 |
_headers: HeaderMap, |
| 282 |
AuthUser(user): AuthUser, |
| 283 |
Path(id): Path<ItemId>, |
| 284 |
) -> Result<Response> { |
| 285 |
tracing::Span::current().record("item_id", tracing::field::display(&id)); |
| 286 |
user.check_not_suspended()?; |
| 287 |
let (item, _project) = verify_item_ownership(&state, id, user.id).await?; |
| 288 |
|
| 289 |
db::items::delete_item(&state.db, id, user.id).await?; |
| 290 |
db::projects::bump_cache_generation(&state.db, item.project_id).await?; |
| 291 |
|
| 292 |
|
| 293 |
|
| 294 |
Ok(crate::helpers::htmx_toast_response("Item moved to Recently Deleted. You can restore it within 7 days.", "success").into_response()) |
| 295 |
} |
| 296 |
|
| 297 |
|
| 298 |
#[tracing::instrument(skip_all, name = "items::restore_item", fields(item_id))] |
| 299 |
pub(in crate::routes::api) async fn restore_item( |
| 300 |
State(state): State<AppState>, |
| 301 |
AuthUser(user): AuthUser, |
| 302 |
Path(id): Path<ItemId>, |
| 303 |
) -> Result<impl IntoResponse> { |
| 304 |
user.check_not_suspended()?; |
| 305 |
verify_item_ownership(&state, id, user.id).await?; |
| 306 |
|
| 307 |
let restored = db::items::restore_item(&state.db, id, user.id).await?; |
| 308 |
if !restored { |
| 309 |
return Err(AppError::NotFound); |
| 310 |
} |
| 311 |
|
| 312 |
Ok(crate::helpers::htmx_toast_response("Item restored", "success")) |
| 313 |
} |
| 314 |
|
| 315 |
|
| 316 |
#[tracing::instrument(skip_all, name = "items::duplicate_item", fields(item_id))] |
| 317 |
pub(in crate::routes::api) async fn duplicate_item( |
| 318 |
State(state): State<AppState>, |
| 319 |
headers: HeaderMap, |
| 320 |
AuthUser(user): AuthUser, |
| 321 |
Path(id): Path<ItemId>, |
| 322 |
) -> Result<Response> { |
| 323 |
tracing::Span::current().record("item_id", tracing::field::display(&id)); |
| 324 |
user.check_not_suspended()?; |
| 325 |
verify_item_ownership(&state, id, user.id).await?; |
| 326 |
|
| 327 |
let new_item = db::items::duplicate_item(&state.db, id, user.id).await?; |
| 328 |
|
| 329 |
db::projects::bump_cache_generation(&state.db, new_item.project_id).await?; |
| 330 |
|
| 331 |
if is_htmx_request(&headers) { |
| 332 |
let mut response = Response::new(axum::body::Body::empty()); |
| 333 |
response.headers_mut().insert( |
| 334 |
"HX-Redirect", |
| 335 |
format!("/dashboard/item/{}", new_item.id) |
| 336 |
.parse() |
| 337 |
.expect("static redirect path is valid"), |
| 338 |
); |
| 339 |
return Ok(response); |
| 340 |
} |
| 341 |
|
| 342 |
Ok(Json(ItemResponse { |
| 343 |
id: new_item.id, |
| 344 |
project_id: new_item.project_id, |
| 345 |
title: new_item.title, |
| 346 |
description: new_item.description, |
| 347 |
price_cents: new_item.price_cents, |
| 348 |
item_type: new_item.item_type.to_string(), |
| 349 |
is_public: new_item.is_public, |
| 350 |
publish_at: new_item.publish_at.map(|d| d.to_rfc3339()), |
| 351 |
web_only: new_item.web_only, |
| 352 |
ai_tier: new_item.ai_tier, |
| 353 |
ai_disclosure: new_item.ai_disclosure, |
| 354 |
}).into_response()) |
| 355 |
} |
| 356 |
|
| 357 |
|
| 358 |
#[derive(Debug, Deserialize)] |
| 359 |
pub struct MoveItemRequest { |
| 360 |
pub direction: String, |
| 361 |
} |
| 362 |
|
| 363 |
|
| 364 |
#[tracing::instrument(skip_all, name = "items::move_item", fields(item_id))] |
| 365 |
pub(in crate::routes::api) async fn move_item( |
| 366 |
State(state): State<AppState>, |
| 367 |
AuthUser(user): AuthUser, |
| 368 |
Path(id): Path<ItemId>, |
| 369 |
Form(req): Form<MoveItemRequest>, |
| 370 |
) -> Result<impl IntoResponse> { |
| 371 |
tracing::Span::current().record("item_id", tracing::field::display(&id)); |
| 372 |
user.check_not_suspended()?; |
| 373 |
let (item, _project) = verify_item_ownership(&state, id, user.id).await?; |
| 374 |
|
| 375 |
db::items::move_item(&state.db, item.project_id, user.id, id, &req.direction).await?; |
| 376 |
db::projects::bump_cache_generation(&state.db, item.project_id).await?; |
| 377 |
|
| 378 |
Ok(StatusCode::NO_CONTENT) |
| 379 |
} |
| 380 |
|
| 381 |
|
| 382 |
|
| 383 |
|
| 384 |
|
| 385 |
|
| 386 |
#[derive(Debug, Deserialize)] |
| 387 |
pub struct UpdateTextRequest { |
| 388 |
pub body: String, |
| 389 |
} |
| 390 |
|
| 391 |
|
| 392 |
#[derive(Debug, Serialize)] |
| 393 |
struct UpdateTextResponse { |
| 394 |
id: ItemId, |
| 395 |
body: Option<String>, |
| 396 |
word_count: Option<i32>, |
| 397 |
reading_time_minutes: Option<i32>, |
| 398 |
} |
| 399 |
|
| 400 |
|
| 401 |
#[tracing::instrument(skip_all, name = "items::update_item_text", fields(item_id))] |
| 402 |
pub(in crate::routes::api) async fn update_item_text( |
| 403 |
State(state): State<AppState>, |
| 404 |
headers: HeaderMap, |
| 405 |
AuthUser(user): AuthUser, |
| 406 |
Path(id): Path<ItemId>, |
| 407 |
Json(req): Json<UpdateTextRequest>, |
| 408 |
) -> Result<Response> { |
| 409 |
tracing::Span::current().record("item_id", tracing::field::display(&id)); |
| 410 |
user.check_not_suspended()?; |
| 411 |
validation::validate_item_text_body(&req.body)?; |
| 412 |
verify_item_ownership(&state, id, user.id).await?; |
| 413 |
|
| 414 |
let item = db::items::update_item_text(&state.db, id, user.id, &req.body).await?; |
| 415 |
db::projects::bump_cache_generation(&state.db, item.project_id).await?; |
| 416 |
|
| 417 |
let (body, word_count, reading_time_minutes) = match item.content() { |
| 418 |
ContentData::Text { body, word_count, reading_time_minutes } => (body, word_count, reading_time_minutes), |
| 419 |
_ => (None, None, None), |
| 420 |
}; |
| 421 |
|
| 422 |
if is_htmx_request(&headers) { |
| 423 |
return Ok(axum::response::Html(SaveStatusTemplate { |
| 424 |
success: true, |
| 425 |
message: format!("{} words saved", word_count.unwrap_or(0)), |
| 426 |
}.render_string()).into_response()); |
| 427 |
} |
| 428 |
|
| 429 |
Ok(Json(UpdateTextResponse { |
| 430 |
id: item.id, |
| 431 |
body, |
| 432 |
word_count, |
| 433 |
reading_time_minutes, |
| 434 |
}).into_response()) |
| 435 |
} |
| 436 |
|