//! Internal creator dashboard: projects, stats, analytics, transactions, and sales export. use crate::auth::InternalActor; use axum::{ Json, extract::{Path, Query, State}, response::IntoResponse, }; use serde::{Deserialize, Serialize}; use std::fmt::Write as _; use sqlx::PgPool; use crate::{ auth::ServiceAuth, db::{self, ItemId, ItemType, ProjectId, ProjectType, TransactionId}, error::{AppError, Result}, helpers, }; // ── Creator projects ── #[derive(Deserialize)] pub(super) struct UserIdQuery {} #[derive(Serialize)] struct CreatorProject { id: ProjectId, slug: String, title: String, project_type: ProjectType, is_public: bool, item_count: i64, /// Revenue in `currency`, which is the project's largest single-currency /// total. Kept as a bare number so existing consumers keep working, but it /// is only meaningful next to `currency`: a consumer that assumes USD is /// wrong the moment the creator settles anywhere else. revenue_cents: i64, /// The ISO code `revenue_cents` is denominated in, lowercase. currency: String, /// Every currency this project earned in, for the uncommon case where a /// creator's settlement currency changed and older sales are denominated in /// the previous one. Normally a single entry matching `revenue_cents`. revenue_cents_by_currency: std::collections::BTreeMap, } /// 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(db): State, actor: InternalActor, _auth: ServiceAuth, Query(_query): Query, ) -> Result { let projects = db::projects::get_projects_by_user(&db, actor.user_id()).await?; let revenue = db::transactions::get_revenue_by_user_projects(&db, actor.user_id()).await?; // Build revenue lookup: project_id -> cents let revenue_map: std::collections::HashMap = revenue .into_iter() .map(|(pid, _title, money)| (pid, money)) .collect(); // Count items per project in a single query let item_counts = db::items::count_items_by_user_projects(&db, actor.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: money_for(&revenue_map, p.id).0, currency: money_for(&revenue_map, p.id).1, revenue_cents_by_currency: revenue_map.get(&p.id).map(by_currency).unwrap_or_default(), }) .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(db): State, actor: InternalActor, _auth: ServiceAuth, Path(project_id): Path, Query(_query): Query, ) -> Result { // Verify ownership let project = db::projects::get_project_by_id(&db, project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != actor.user_id() { return Err(AppError::Forbidden); } let items = db::items::get_items_by_project(&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 { /// 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(db): State, actor: InternalActor, _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(&db, actor.user_id(), None, None, &range).await?; let total_projects = db::projects::count_projects_by_user(&db, actor.user_id()).await?; let total_items = db::items::count_items_by_user(&db, actor.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, total_items, })) } // ── Analytics ── #[derive(Serialize)] struct AnalyticsBucket { label: String, revenue_cents: i64, sales_count: i64, } #[derive(Serialize)] struct ProjectRevenueSummary { id: ProjectId, title: String, /// Revenue in `currency`. See `CreatorProject::revenue_cents`. revenue_cents: i64, /// The ISO code `revenue_cents` is denominated in, lowercase. currency: String, /// Every currency this project earned in. See /// `CreatorProject::revenue_cents_by_currency`. revenue_cents_by_currency: std::collections::BTreeMap, } #[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(db): State, actor: InternalActor, _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(&db, actor.user_id(), None, None, &range).await?; let comparison = db::analytics::get_period_comparison(&db, actor.user_id(), None, None, &range).await?; let revenue = db::transactions::get_revenue_by_user_projects(&db, actor.user_id()).await?; let top_projects: Vec = revenue .into_iter() .map(|(id, title, money)| { let (revenue_cents, currency) = dominant(&money); ProjectRevenueSummary { id, title, revenue_cents, currency, revenue_cents_by_currency: by_currency(&money), } }) .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(db): State, actor: InternalActor, _auth: ServiceAuth, Query(_query): Query, ) -> Result { let txs = db::transactions::get_transactions_by_seller(&db, actor.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(db): State, actor: InternalActor, _auth: ServiceAuth, Query(_query): Query, ) -> Result { // Page the read so a whale seller's full history isn't loaded by one // unbounded query; peak memory is one batch plus the accumulating CSV. // Mirrors the streaming web export's batch size and row ceiling (ultra-fuzz // Run 10 Perf S5). const BATCH: i64 = 2_000; const MAX_ROWS: usize = 1_000_000; let mut csv = String::from("Date,Item ID,Item Title,Amount,Status,Buyer Email\n"); let mut offset = 0i64; let mut total = 0usize; loop { let rows = db::transactions::get_seller_transactions_for_export_page( &db, actor.user_id(), BATCH, offset, ) .await?; let n = rows.len(); 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(""); writeln!( csv, "{},{},{},{},{},{}", 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), ) .unwrap(); } offset += n as i64; total += n; if (n as i64) < BATCH { break; } if total >= MAX_ROWS { tracing::warn!( user_id = %actor.user_id(), rows = total, "internal sales export hit the row ceiling; truncating" ); break; } } Ok(Json(serde_json::json!({ "csv": csv, "row_count": total }))) } /// The largest single-currency total and its ISO code. /// /// "Largest" rather than a sum, because there is no rate that would make a sum /// true. A project with no sales reports zero in USD, the platform default. fn dominant(money: &crate::currency::MoneyByCurrency) -> (i64, String) { money.iter().max_by_key(|(_, cents)| *cents).map_or_else( || { ( 0, crate::currency::SettlementCurrency::default() .code() .to_string(), ) }, |(currency, cents)| (cents, currency.code().to_string()), ) } /// Every currency, keyed by lowercase ISO code. fn by_currency( money: &crate::currency::MoneyByCurrency, ) -> std::collections::BTreeMap { money .iter() .map(|(c, cents)| (c.code().to_string(), cents)) .collect() } /// `dominant` for a project id that may have no revenue row at all. fn money_for( map: &std::collections::HashMap, id: ProjectId, ) -> (i64, String) { map.get(&id).map_or_else( || { ( 0, crate::currency::SettlementCurrency::default() .code() .to_string(), ) }, dominant, ) }