//! Item-level dashboard tab handlers. use axum::extract::{Path, State}; use axum::response::IntoResponse; use crate::{ auth::AuthUser, db::{self, ItemId}, error::{AppError, Result}, templates::*, types::*, AppState, }; /// Resolve an item by ID, verify ownership, and return the item + project. async fn resolve_owned_item( state: &AppState, user_id: db::UserId, id: &str, ) -> Result<(db::DbItem, db::DbProject)> { let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?; let db_item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id) .await? .ok_or(AppError::NotFound)?; if db_project.user_id != user_id { return Err(AppError::Forbidden); } Ok((db_item, db_project)) } /// Build an Item view model from a DbItem (shared by item tab handlers). fn build_item_view(db_item: &db::DbItem, item_tags: &[db::DbItemTag]) -> Item { let is_free = db_item.price_cents == 0; Item::from_db_detail(db_item, item_tags, None, None, is_free, true) } /// Item overview tab: quick actions + analytics (lazy-loaded). #[tracing::instrument(skip_all, name = "item_tabs::item_tab_overview")] pub(in crate::routes::pages::dashboard) async fn item_tab_overview( State(state): State, AuthUser(session_user): AuthUser, Path(id): Path, ) -> Result { let (db_item, _db_project) = resolve_owned_item(&state, session_user.id, &id).await?; let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?; let item = build_item_view(&db_item, &item_tags); Ok(ItemOverviewTabTemplate { item }) } /// Item details tab: name, description, tags, content editor, bundle contents. #[tracing::instrument(skip_all, name = "item_tabs::item_tab_details")] pub(in crate::routes::pages::dashboard) async fn item_tab_details( State(state): State, AuthUser(session_user): AuthUser, Path(id): Path, ) -> Result { let (db_item, db_project) = resolve_owned_item(&state, session_user.id, &id).await?; let item_id = db_item.id; let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?; let item = build_item_view(&db_item, &item_tags); let (bundle_items, bundleable_items) = if db_item.item_type == db::ItemType::Bundle { let children = db::bundles::get_bundle_items(&state.db, item_id).await?; let bundle_child_ids: Vec = children.iter().map(|c| c.id).collect(); let child_items: Vec = children .iter() .map(|child| { let child_tags = vec![]; Item::from_db_list(child, &child_tags, false, true) }) .collect(); let available = db::bundles::get_bundleable_items( &state.db, db_project.id, Some(item_id), ) .await?; let available_items: Vec = available .iter() .filter(|a| !bundle_child_ids.contains(&a.id)) .map(|a| { let a_tags = vec![]; Item::from_db_list(a, &a_tags, false, true) }) .collect(); (child_items, available_items) } else { (vec![], vec![]) }; let db_sections = db::item_sections::list_by_item(&state.db, item_id).await?; let sections: Vec = db_sections.iter().map(crate::types::ItemSection::from).collect(); Ok(ItemDetailsTabTemplate { item, bundle_items, bundleable_items, sections, }) } /// Item pricing tab: PWYW settings, license keys, promo codes. #[tracing::instrument(skip_all, name = "item_tabs::item_tab_pricing")] pub(in crate::routes::pages::dashboard) async fn item_tab_pricing( State(state): State, AuthUser(session_user): AuthUser, Path(id): Path, ) -> Result { let (db_item, _db_project) = resolve_owned_item(&state, session_user.id, &id).await?; let item_id = db_item.id; let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?; let item = build_item_view(&db_item, &item_tags); let db_license_keys = if db_item.enable_license_keys { db::license_keys::get_license_keys_by_item(&state.db, item_id).await? } else { vec![] }; let license_keys: Vec = db_license_keys.into_iter().map(LicenseKeyRow::from).collect(); let db_promo_codes = db::promo_codes::get_promo_codes_by_item(&state.db, item_id).await?; let promo_codes: Vec = db_promo_codes.into_iter().map(PromoCodeRow::from).collect(); Ok(ItemPricingTabTemplate { item, license_keys, promo_codes, license_preset_options: crate::license_templates::preset_options(), }) } /// Item files tab: version upload + download table. #[tracing::instrument(skip_all, name = "item_tabs::item_tab_files")] pub(in crate::routes::pages::dashboard) async fn item_tab_files( State(state): State, AuthUser(session_user): AuthUser, Path(id): Path, ) -> Result { let (db_item, _db_project) = resolve_owned_item(&state, session_user.id, &id).await?; let item_id = db_item.id; let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?; let item = build_item_view(&db_item, &item_tags); let db_versions = db::versions::get_versions_by_item(&state.db, item_id).await?; let versions: Vec = db_versions.iter().map(Version::from_db).collect(); Ok(ItemFilesTabTemplate { item, versions }) } /// Item embed tab: copy-paste embed codes. #[tracing::instrument(skip_all, name = "item_tabs::item_tab_embed")] pub(in crate::routes::pages::dashboard) async fn item_tab_embed( State(state): State, AuthUser(session_user): AuthUser, Path(id): Path, ) -> Result { let (db_item, _db_project) = resolve_owned_item(&state, session_user.id, &id).await?; let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?; let is_audio = db_item.audio_s3_key.is_some(); let item = build_item_view(&db_item, &item_tags); Ok(ItemEmbedTabTemplate { item, host_url: state.config.host_url.clone(), is_audio, }) } /// Item sales tab: transaction history with refund buttons. #[tracing::instrument(skip_all, name = "item_tabs::item_tab_sales")] pub(in crate::routes::pages::dashboard) async fn item_tab_sales( State(state): State, AuthUser(session_user): AuthUser, Path(id): Path, ) -> Result { let (db_item, _db_project) = resolve_owned_item(&state, session_user.id, &id).await?; let item_id = db_item.id; let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?; let item = build_item_view(&db_item, &item_tags); let sales = db::transactions::get_sales_by_item(&state.db, item_id, session_user.id).await?; let rows: Vec = sales.iter().map(|tx| { let buyer_display = tx.guest_email.clone() .or_else(|| tx.buyer_id.map(|_| "Registered user".to_string())) .unwrap_or_else(|| "Unknown".to_string()); let cents = tx.amount_cents.as_i64(); SaleRow { transaction_id: tx.id.to_string(), buyer: buyer_display, amount_display: if cents == 0 { "Free".to_string() } else { format!("${}.{:02}", cents / 100, cents % 100) }, status: tx.status.to_string(), date: tx.created_at.format("%Y-%m-%d %H:%M").to_string(), refundable: tx.status == db::TransactionStatus::Completed && tx.stripe_payment_intent_id.is_some() && cents > 0, } }).collect(); Ok(ItemSalesTabTemplate { item, sales: rows }) }