//! Internal item management: create, update, delete, publish, unpublish, and version history. use axum::{ extract::{Path, Query, State}, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::ServiceAuth, db::{self, AiTier, ItemId, ItemType, PriceCents, ProjectId, UserId}, error::{AppError, Result}, validation, AppState, }; // ── Shared types ── #[derive(Serialize)] struct ItemDetailResponse { id: ItemId, title: String, description: Option, price_cents: i32, item_type: ItemType, is_public: bool, slug: String, sort_order: i32, sales_count: i32, download_count: i32, play_count: i32, pwyw_enabled: bool, pwyw_min_cents: Option, has_audio: bool, has_cover: bool, ai_tier: AiTier, ai_disclosure: Option, created_at: String, updated_at: String, } impl ItemDetailResponse { fn from_db(item: &db::DbItem) -> Self { Self { id: item.id, title: item.title.clone(), description: item.description.clone(), price_cents: item.price_cents, item_type: item.item_type, is_public: item.is_public, slug: item.slug.clone(), sort_order: item.sort_order, sales_count: item.sales_count, download_count: item.download_count, play_count: item.play_count, pwyw_enabled: item.pwyw_enabled, pwyw_min_cents: item.pwyw_min_cents, has_audio: item.audio_s3_key.is_some(), has_cover: item.cover_s3_key.is_some(), ai_tier: item.ai_tier, ai_disclosure: item.ai_disclosure.clone(), created_at: item.created_at.to_rfc3339(), updated_at: item.updated_at.to_rfc3339(), } } } #[derive(Deserialize)] pub(super) struct ItemUserQuery { user_id: UserId, } // ── Create item (for CLI upload pipeline) ── #[derive(Deserialize)] pub(super) struct CreateItemRequest { user_id: UserId, project_id: ProjectId, title: String, item_type: String, #[serde(default)] price_cents: i32, #[serde(default)] ai_tier: Option, #[serde(default)] ai_disclosure: Option, } #[derive(Serialize)] struct CreateItemResponse { item_id: ItemId, project_id: ProjectId, } /// POST /api/internal/creator/items /// /// Create a new item in a project. Used by the CLI upload pipeline. #[tracing::instrument(skip_all, name = "internal::create_item")] pub(super) async fn create_item( State(state): State, _auth: ServiceAuth, Json(req): Json, ) -> Result { // Validate title validation::validate_item_title(&req.title)?; // Parse item type let item_type: ItemType = req .item_type .parse() .map_err(|_| AppError::BadRequest(format!("Invalid item type: {}", req.item_type)))?; // Verify project ownership let project = db::projects::get_project_by_id(&state.db, req.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != req.user_id { return Err(AppError::Forbidden); } let ai_tier = req.ai_tier.unwrap_or(AiTier::Handmade); let item = db::items::create_item( &state.db, req.project_id, &req.title, None, PriceCents::new(req.price_cents)?, item_type, ai_tier, req.ai_disclosure.as_deref(), ) .await?; tracing::info!( user = %req.user_id, item = %item.id, "item created via CLI" ); Ok(Json(CreateItemResponse { item_id: item.id, project_id: req.project_id, })) } // ── Item detail ── /// GET /api/internal/creator/items/{id}?user_id={uuid} /// /// Get full item detail. Verifies ownership through the project. #[tracing::instrument(skip_all, name = "internal::get_item")] pub(super) async fn get_item( State(state): State, _auth: ServiceAuth, Path(item_id): Path, Query(query): Query, ) -> Result { let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; // Verify ownership through project let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != query.user_id { return Err(AppError::Forbidden); } Ok(Json(ItemDetailResponse::from_db(&item))) } // ── Update item ── #[derive(Deserialize)] pub(super) struct UpdateItemRequest { user_id: UserId, #[serde(default)] title: Option, #[serde(default)] description: Option, #[serde(default)] price_cents: Option, #[serde(default)] is_public: Option, #[serde(default)] pwyw_enabled: Option, #[serde(default)] pwyw_min_cents: Option, #[serde(default)] ai_tier: Option, #[serde(default)] ai_disclosure: Option, } /// PUT /api/internal/creator/items/{id} /// /// Partial update of item fields. Only provided fields are changed. #[tracing::instrument(skip_all, name = "internal::update_item")] pub(super) async fn update_item( State(state): State, _auth: ServiceAuth, Path(item_id): Path, Json(req): Json, ) -> Result { let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != req.user_id { return Err(AppError::Forbidden); } if let Some(ref title) = req.title { validation::validate_item_title(title)?; } // Build ai_disclosure double-Option let ai_disclosure: Option> = if let Some(ai_tier) = req.ai_tier { match ai_tier { AiTier::Assisted => Some(req.ai_disclosure.as_deref()), _ => Some(None), } } else if req.ai_disclosure.is_some() { Some(req.ai_disclosure.as_deref()) } else { None }; let updated = db::items::update_item( &state.db, item_id, req.user_id, req.title.as_deref(), req.description.as_deref(), req.price_cents.map(PriceCents::new).transpose()?, None, // item_type req.is_public, req.pwyw_enabled, req.pwyw_min_cents.map(PriceCents::new).transpose()?, None, // publish_at None, // web_only req.ai_tier, ai_disclosure, ) .await?; tracing::info!(user = %req.user_id, item = %item_id, "item updated via CLI"); Ok(Json(ItemDetailResponse::from_db(&updated))) } // ── Delete item ── /// DELETE /api/internal/creator/items/{id}?user_id={uuid} /// /// Permanently delete an item. Verifies ownership. #[tracing::instrument(skip_all, name = "internal::delete_item")] pub(super) async fn delete_item( State(state): State, _auth: ServiceAuth, Path(item_id): Path, Query(query): Query, ) -> Result { let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != query.user_id { return Err(AppError::Forbidden); } // Decrement storage for any S3 files let file_sizes = db::items::get_item_file_sizes(&state.db, item_id).await?; let version_size = db::versions::sum_file_sizes_for_item(&state.db, item_id).await?; let total_bytes = file_sizes.audio_file_size_bytes.unwrap_or(0) + file_sizes.cover_file_size_bytes.unwrap_or(0) + file_sizes.video_file_size_bytes.unwrap_or(0) + version_size; if total_bytes > 0 { db::creator_tiers::decrement_storage_used(&state.db, query.user_id, total_bytes).await?; } db::items::delete_item(&state.db, item_id, query.user_id).await?; tracing::info!(user = %query.user_id, item = %item_id, "item deleted via CLI"); Ok(axum::http::StatusCode::NO_CONTENT) } // ── Publish / Unpublish ── #[derive(Deserialize)] pub(super) struct PublishRequest { user_id: UserId, } /// POST /api/internal/creator/items/{id}/publish /// /// Set is_public=true on an item. #[tracing::instrument(skip_all, name = "internal::publish_item")] pub(super) async fn publish_item( State(state): State, _auth: ServiceAuth, Path(item_id): Path, Json(req): Json, ) -> Result { let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != req.user_id { return Err(AppError::Forbidden); } let updated = db::items::update_item( &state.db, item_id, req.user_id, None, None, None, None, Some(true), // is_public None, None, None, None, None, None, // ai_tier, ai_disclosure ) .await?; if let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await { tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after publish"); } tracing::info!(user = %req.user_id, item = %item_id, "item published via CLI"); Ok(Json(ItemDetailResponse::from_db(&updated))) } /// POST /api/internal/creator/items/{id}/unpublish /// /// Set is_public=false on an item. #[tracing::instrument(skip_all, name = "internal::unpublish_item")] pub(super) async fn unpublish_item( State(state): State, _auth: ServiceAuth, Path(item_id): Path, Json(req): Json, ) -> Result { let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != req.user_id { return Err(AppError::Forbidden); } let updated = db::items::update_item( &state.db, item_id, req.user_id, None, None, None, None, Some(false), // is_public None, None, None, None, None, None, // ai_tier, ai_disclosure ) .await?; if let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await { tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after unpublish"); } tracing::info!(user = %req.user_id, item = %item_id, "item unpublished via CLI"); Ok(Json(ItemDetailResponse::from_db(&updated))) } // ── Item versions ── #[derive(Serialize)] struct VersionResponse { id: String, version_number: String, changelog: Option, file_name: Option, file_size_bytes: Option, download_count: i32, is_current: bool, created_at: String, } /// GET /api/internal/creator/items/{id}/versions?user_id={uuid} /// /// List versions for an item (newest first). #[tracing::instrument(skip_all, name = "internal::item_versions")] pub(super) async fn item_versions( State(state): State, _auth: ServiceAuth, Path(item_id): Path, Query(query): Query, ) -> Result { let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != query.user_id { return Err(AppError::Forbidden); } let versions = db::versions::get_versions_by_item(&state.db, item_id).await?; let data: Vec = versions .into_iter() .map(|v| VersionResponse { id: v.id.to_string(), version_number: v.version_number, changelog: v.changelog, file_name: v.file_name, file_size_bytes: v.file_size_bytes, download_count: v.download_count, is_current: v.is_current, created_at: v.created_at.to_rfc3339(), }) .collect(); Ok(Json(data)) }