//! Internal creator dashboard: projects, stats, analytics, transactions, and sales export. use axum::{ extract::{Path, Query, State}, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::ServiceAuth, db::{self, ItemId, ItemType, ProjectId, ProjectType, TransactionId, UserId}, error::{AppError, Result}, helpers, AppState, }; // ── Creator projects ── #[derive(Deserialize)] pub(super) struct UserIdQuery { user_id: UserId, } #[derive(Serialize)] struct CreatorProject { id: ProjectId, slug: String, title: String, project_type: ProjectType, is_public: bool, item_count: i64, revenue_cents: i64, } /// GET /api/internal/creator/projects?user_id={uuid} /// /// List all projects for a creator with item counts and revenue. #[tracing::instrument(skip_all, name = "internal::creator_projects")] pub(super) async fn creator_projects( State(state): State, _auth: ServiceAuth, Query(query): Query, ) -> Result { let projects = db::projects::get_projects_by_user(&state.db, query.user_id).await?; let revenue = db::transactions::get_revenue_by_user_projects(&state.db, query.user_id).await?; // Build revenue lookup: project_id -> cents let revenue_map: std::collections::HashMap = revenue .into_iter() .map(|(pid, _title, cents)| (pid, cents)) .collect(); // Count items per project in a single query let item_counts = db::items::count_items_by_user_projects(&state.db, query.user_id).await?; let count_map: std::collections::HashMap = item_counts.into_iter().collect(); let data: Vec = projects .into_iter() .map(|p| CreatorProject { id: p.id, slug: p.slug.to_string(), title: p.title, project_type: p.project_type, is_public: p.is_public, item_count: count_map.get(&p.id).copied().unwrap_or(0), revenue_cents: revenue_map.get(&p.id).copied().unwrap_or(0), }) .collect(); Ok(Json(data)) } // ── Project items ── #[derive(Serialize)] struct CreatorItem { id: ItemId, title: String, item_type: ItemType, price_cents: i32, is_public: bool, sort_order: i32, } /// GET /api/internal/creator/projects/{id}/items?user_id={uuid} /// /// List items in a project (verifies ownership). #[tracing::instrument(skip_all, name = "internal::creator_project_items")] pub(super) async fn creator_project_items( State(state): State, _auth: ServiceAuth, Path(project_id): Path, Query(query): Query, ) -> Result { // Verify ownership let project = db::projects::get_project_by_id(&state.db, project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != query.user_id { return Err(AppError::Forbidden); } let items = db::items::get_items_by_project(&state.db, project_id).await?; let data: Vec = items .into_iter() .map(|i| CreatorItem { id: i.id, title: i.title, item_type: i.item_type, price_cents: i.price_cents, is_public: i.is_public, sort_order: i.sort_order, }) .collect(); Ok(Json(data)) } // ── Creator stats ── #[derive(Deserialize)] pub(super) struct StatsQuery { user_id: UserId, /// Time range: "7d", "30d", "90d", or "all" #[serde(default = "default_range")] range: String, } fn default_range() -> String { "30d".to_string() } #[derive(Serialize)] struct CreatorStats { current_revenue_cents: i64, previous_revenue_cents: i64, current_sales: i64, previous_sales: i64, current_followers: i64, previous_followers: i64, total_projects: i64, total_items: i64, } /// GET /api/internal/creator/stats?user_id={uuid}&range=30d /// /// Period comparison stats for the creator dashboard. #[tracing::instrument(skip_all, name = "internal::creator_stats")] pub(super) async fn creator_stats( State(state): State, _auth: ServiceAuth, Query(query): Query, ) -> Result { let range: db::analytics::TimeRange = query .range .parse() .map_err(|_| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?; let comparison = db::analytics::get_period_comparison(&state.db, query.user_id, None, None, &range).await?; let projects = db::projects::get_projects_by_user(&state.db, query.user_id).await?; let items = db::items::get_items_by_user(&state.db, query.user_id).await?; Ok(Json(CreatorStats { current_revenue_cents: comparison.current_revenue_cents.as_i64(), previous_revenue_cents: comparison.previous_revenue_cents.as_i64(), current_sales: comparison.current_sales, previous_sales: comparison.previous_sales, current_followers: comparison.current_followers, previous_followers: comparison.previous_followers, total_projects: projects.len() as i64, total_items: items.len() as i64, })) } // ── Analytics ── #[derive(Serialize)] struct AnalyticsBucket { label: String, revenue_cents: i64, sales_count: i64, } #[derive(Serialize)] struct ProjectRevenueSummary { id: ProjectId, title: String, revenue_cents: i64, } #[derive(Serialize)] struct AnalyticsResponse { buckets: Vec, current_revenue_cents: i64, previous_revenue_cents: i64, current_sales: i64, previous_sales: i64, current_followers: i64, previous_followers: i64, top_projects: Vec, } /// GET /api/internal/creator/analytics?user_id={uuid}&range=30d /// /// Revenue timeseries, period comparison, and top projects. #[tracing::instrument(skip_all, name = "internal::creator_analytics")] pub(super) async fn creator_analytics( State(state): State, _auth: ServiceAuth, Query(query): Query, ) -> Result { let range: db::analytics::TimeRange = query .range .parse() .map_err(|_| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?; let buckets = db::analytics::get_revenue_timeseries(&state.db, query.user_id, None, None, &range) .await?; let comparison = db::analytics::get_period_comparison(&state.db, query.user_id, None, None, &range).await?; let revenue = db::transactions::get_revenue_by_user_projects(&state.db, query.user_id).await?; let top_projects: Vec = revenue .into_iter() .map(|(id, title, cents)| ProjectRevenueSummary { id, title, revenue_cents: cents, }) .collect(); Ok(Json(AnalyticsResponse { buckets: buckets .into_iter() .map(|b| AnalyticsBucket { label: b.label, revenue_cents: b.revenue_cents.as_i64(), sales_count: b.sales_count, }) .collect(), current_revenue_cents: comparison.current_revenue_cents.as_i64(), previous_revenue_cents: comparison.previous_revenue_cents.as_i64(), current_sales: comparison.current_sales, previous_sales: comparison.previous_sales, current_followers: comparison.current_followers, previous_followers: comparison.previous_followers, top_projects, })) } // ── Transactions ── #[derive(Serialize)] struct TransactionResponse { id: TransactionId, item_title: Option, amount_cents: i32, status: String, created_at: String, completed_at: Option, } /// GET /api/internal/creator/transactions?user_id={uuid} /// /// Recent seller transactions (up to 100). #[tracing::instrument(skip_all, name = "internal::creator_transactions")] pub(super) async fn creator_transactions( State(state): State, _auth: ServiceAuth, Query(query): Query, ) -> Result { let txs = db::transactions::get_transactions_by_seller(&state.db, query.user_id, Some(100)).await?; let data: Vec = txs .into_iter() .map(|t| TransactionResponse { id: t.id, item_title: t.item_title, amount_cents: t.amount_cents.as_i64() as i32, status: t.status.to_string(), created_at: t.created_at.to_rfc3339(), completed_at: t.completed_at.map(|dt| dt.to_rfc3339()), }) .collect(); Ok(Json(data)) } // ── Export sales CSV ── /// GET /api/internal/creator/export/sales?user_id={uuid} /// /// Returns sales data as a CSV string. #[tracing::instrument(skip_all, name = "internal::export_sales")] pub(super) async fn export_sales( State(state): State, _auth: ServiceAuth, Query(query): Query, ) -> Result { let rows = db::transactions::get_seller_transactions_for_export(&state.db, query.user_id).await?; let mut csv = String::from("Date,Item ID,Item Title,Amount,Status,Buyer Email\n"); for tx in &rows { let date = tx.created_at.format("%Y-%m-%d %H:%M:%S").to_string(); let item_id = tx .item_id .map(|id| id.to_string()) .unwrap_or_default(); let item_title = tx.item_title.as_deref().unwrap_or(""); let amount = format!("{}.{:02}", tx.amount_cents / 100, tx.amount_cents.abs() % 100); let status = tx.status.to_string(); let email = tx.buyer_email.as_deref().unwrap_or(""); csv.push_str(&format!( "{},{},{},{},{},{}\n", helpers::sanitize_csv_cell(&date), helpers::sanitize_csv_cell(&item_id), helpers::sanitize_csv_cell(item_title), amount, helpers::sanitize_csv_cell(&status), helpers::sanitize_csv_cell(email), )); } Ok(Json(serde_json::json!({ "csv": csv, "row_count": rows.len() }))) }