//! Main dashboard pages: user dashboard, project dashboard, item dashboard. use crate::extractors::ValidatedQuery; use axum::{ extract::{Path, State}, response::IntoResponse, }; use tower_sessions::Session; use crate::{ auth::AuthUser, config::Config, constants::DASHBOARD_TRANSACTION_LIMIT, db::{self, ItemId, Slug, analytics::TimeRange}, error::{AppError, Result, ResultExt}, helpers::get_csrf_token, templates::{ DashboardItemTemplate, DashboardProjectTemplate, DashboardUserTemplate, ItemAnalyticsPartialTemplate, OnboardingChecklistPartialTemplate, }, types::{ ContentItem, Item, OnboardingChecklist, OnboardingStep, Project, ProjectCard, StatCard, User, }, }; use sqlx::PgPool; use super::AnalyticsQuery; const ONBOARDING_DISMISSED_KEY: &str = "onboarding_dismissed"; /// Pre-computed completion flags for each onboarding step. struct OnboardingProgress { profile_done: bool, stripe_done: bool, projects_done: bool, publish_done: bool, } /// Build the onboarding checklist from pre-computed step flags. fn build_onboarding_checklist(progress: &OnboardingProgress) -> OnboardingChecklist { let OnboardingProgress { profile_done, stripe_done, projects_done, publish_done, } = *progress; let steps = vec![ OnboardingStep { label: "Set up your profile: name, bio, and links", done: profile_done, link_tab: "tab-profile", link_label: "Go to Profile", }, OnboardingStep { label: "Connect Stripe: required to receive payments, 3% processing only", done: stripe_done, link_tab: "tab-payments", link_label: "Go to Payments", }, OnboardingStep { label: "Create your first project: blog, podcast, course, etc.", done: projects_done, link_tab: "tab-projects", link_label: "Go to Projects", }, OnboardingStep { label: "Publish your first item: upload files, set pricing, go live", done: publish_done, link_tab: "tab-projects", link_label: "Go to Projects", }, ]; let completed = steps.iter().filter(|s| s.done).count(); let total = steps.len(); // Guard the division here so the template never does arithmetic that could // panic the render (total is steps.len() = nonzero today, but the guard // keeps a future dynamic step list from turning a 0 into a 500). let progress_pct = (completed * 100).checked_div(total).unwrap_or(0) as u32; OnboardingChecklist { steps, completed, total, progress_pct, } } /// Render the main user dashboard with projects and transactions. #[tracing::instrument(skip_all, name = "dashboard::dashboard")] pub(super) async fn dashboard( State(db): State, State(config): State, session: Session, AuthUser(session_user): AuthUser, ) -> Result { let csrf_token = get_csrf_token(&session).await; // These four reads are independent, run them concurrently rather than in // series so the dashboard pays one round-trip's latency, not four (Run 11 // Perf MOD tail; revisits Run 10 M6 now that the heavy per-render work is gone). let (db_user, db_projects, incoming_txs, outgoing_txs) = tokio::try_join!( db::users::get_user_by_id(&db, session_user.id), db::projects::get_projects_by_user(&db, session_user.id), db::transactions::get_transactions_by_seller( &db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT) ), db::transactions::get_transactions_by_buyer( &db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT) ), )?; let db_user = db_user.ok_or(AppError::NotFound)?; let user = User::from(&db_user); let transactions = super::collect_transactions(&incoming_txs, &outgoing_txs); let projects: Vec = db_projects.iter().map(ProjectCard::from_db).collect(); // Build onboarding checklist for creators who haven't completed all steps let onboarding_dismissed = session .get::(ONBOARDING_DISMISSED_KEY) .await .ok() .flatten() .unwrap_or(false); let (onboarding, show_checklist_recovery) = if session_user.can_create_projects { let profile_done = db_user.display_name.as_ref().is_some_and(|n| !n.is_empty()); let stripe_done = user.stripe_connected; let projects_done = !db_projects.is_empty(); let publish_done = if projects_done { db::items::has_public_item_by_user(&db, session_user.id).await? } else { false }; let all_done = profile_done && stripe_done && projects_done && publish_done; if all_done { (None, false) } else if onboarding_dismissed { (None, true) } else { ( Some(build_onboarding_checklist(&OnboardingProgress { profile_done, stripe_done, projects_done, publish_done, })), false, ) } } else { (None, false) }; // Check if user has MT forum memberships (for Forums tab visibility) // Forums-tab visibility is config-gated (matching the landing and user-settings // call sites): show the tab whenever MT is configured; the tab lazy-loads the // user's actual memberships on open. Previously this blocked every dashboard // render on a synchronous 3s-timeout MT round-trip (ultra-fuzz Run 10 Perf S6). let has_mt_memberships = config.integrations.mt_base_url.is_some(); let suspended = db_user.is_suspended(); let suspension_reason = db_user.suspension_reason.clone(); let has_pending_appeal = db_user.appeal_submitted_at.is_some() && db_user.appeal_decided_at.is_none(); let appeal_decision = db_user.appeal_decision.clone(); let appeal_response = db_user.appeal_response.clone(); // Check for one-time password breach warning (set during signup/password change) let password_warning = session .get::("password_warning") .await .ok() .flatten(); if password_warning.is_some() { session.remove::("password_warning").await.ok(); } Ok(DashboardUserTemplate { csrf_token, session_user: Some(session_user), user, transactions, projects, onboarding, show_checklist_recovery, has_mt_memberships, suspended, suspension_reason, has_pending_appeal, appeal_decision, appeal_response, password_warning, deactivated: db_user.is_deactivated(), creator_paused: db_user.is_creator_paused(), git_enabled: config.build.git_repos_path.is_some(), }) } /// Render the dashboard view for a single owned project. #[tracing::instrument(skip_all, name = "dashboard::dashboard_project")] pub(super) async fn dashboard_project( State(db): State, State(config): State, session: Session, AuthUser(session_user): AuthUser, Path(slug): Path, ) -> Result { let csrf_token = get_csrf_token(&session).await; let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug) .await? .ok_or(AppError::NotFound)?; let db_items = db::items::get_items_by_project(&db, db_project.id).await?; let (revenue_cents, sales_count) = db::transactions::get_revenue_by_project(&db, db_project.id).await?; // Read the currency from the row rather than the session: a session copy // would keep rendering the old symbol until the creator logged out, which is // precisely the window in which they most need to see the change. let currency = db::users::get_user_by_id(&db, session_user.id) .await? .map(|u| u.settlement_currency) .unwrap_or_default(); let project = Project::from_db(&db_project, db_items.len() as u32); let stats = vec![ StatCard { label: "Total Revenue".to_string(), value: revenue_cents.display(currency), change: None, is_positive: true, }, StatCard { label: "Total Sales".to_string(), value: sales_count.to_string(), change: None, is_positive: true, }, StatCard { label: "Items".to_string(), value: db_items.len().to_string(), change: None, is_positive: true, }, ]; let items: Vec = db_items .iter() .enumerate() .map(|(i, item)| { ContentItem::from_db(item, (i + 1) as u32, session_user.settlement_currency) }) .collect(); let db_user = db::users::get_user_by_id(&db, session_user.id) .await? .ok_or(AppError::NotFound)?; let has_blog = db_project.features.iter().any(|f| f == "blog"); let synckit_enabled = db_project.features.iter().any(|f| f == "cloud_sync"); let git_enabled = config.build.git_repos_path.is_some(); Ok(DashboardProjectTemplate { csrf_token, session_user: Some(session_user.clone()), project, creator_username: session_user.username.to_string(), stats, items, stripe_connected: db_user.stripe_account_id.is_some(), has_blog, git_enabled, synckit_enabled, }) } /// Render the dashboard shell for a single owned item (tabs loaded via HTMX). #[tracing::instrument(skip_all, name = "dashboard::dashboard_item")] pub(super) async fn dashboard_item( State(db): State, session: Session, AuthUser(session_user): AuthUser, Path(id): Path, ) -> Result { let csrf_token = get_csrf_token(&session).await; let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?; let db_item = db::items::get_item_by_id(&db, item_id) .await? .ok_or(AppError::NotFound)?; let db_project = db::projects::get_project_by_id(&db, db_item.project_id) .await? .ok_or(AppError::NotFound)?; // Verify ownership if db_project.user_id != session_user.id { return Err(AppError::Forbidden); } let is_free = db_item.price_cents == 0; let item_tags = db::tags::get_tags_for_item(&db, item_id).await?; let item = Item::from_db_detail( &db_item, &item_tags, None, None, is_free, true, session_user.settlement_currency, ); Ok(DashboardItemTemplate { csrf_token, session_user: Some(session_user), item, project_title: db_project.title, project_slug: db_project.slug.to_string(), }) } /// Render the HTMX partial for item analytics (stats + revenue chart). #[tracing::instrument(skip_all, name = "dashboard::dashboard_item_analytics")] pub(super) async fn dashboard_item_analytics( State(db): State, AuthUser(session_user): AuthUser, Path(id): Path, ValidatedQuery(query): ValidatedQuery, ) -> Result { let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?; let db_item = db::items::get_item_by_id(&db, item_id) .await? .ok_or(AppError::NotFound)?; let db_project = db::projects::get_project_by_id(&db, db_item.project_id) .await? .ok_or(AppError::NotFound)?; if db_project.user_id != session_user.id { return Err(AppError::Forbidden); } let range = query .range .as_deref() .and_then(|s| s.parse::().ok()) .unwrap_or(TimeRange::Days30); let buckets = db::analytics::get_revenue_timeseries(&db, session_user.id, None, Some(item_id), &range) .await?; let comparison = db::analytics::get_period_comparison(&db, session_user.id, None, Some(item_id), &range) .await?; let bars = super::build_chart_bars(&buckets, session_user.settlement_currency); let currency = db::users::get_user_by_id(&db, session_user.id) .await? .map(|u| u.settlement_currency) .unwrap_or_default(); let revenue_str = comparison.current_revenue_cents.format_revenue(currency); let db_versions = db::versions::get_versions_by_item(&db, item_id).await?; let total_downloads: i32 = db_versions.iter().map(|v| v.download_count).sum(); let stats = vec![ StatCard { label: "Revenue".to_string(), value: revenue_str, change: comparison.revenue_change().map(|(t, _)| t), is_positive: comparison.revenue_change().is_none_or(|(_, p)| p), }, StatCard { label: "Sales".to_string(), value: comparison.current_sales.to_string(), change: comparison.sales_change().map(|(t, _)| t), is_positive: comparison.sales_change().is_none_or(|(_, p)| p), }, StatCard { label: "Downloads".to_string(), value: total_downloads.to_string(), change: None, is_positive: true, }, ]; Ok(ItemAnalyticsPartialTemplate { stats, bars, item_id: item_id.to_string(), active_range: range.to_string(), }) } /// Dismiss the onboarding checklist for the current session. /// Returns a recovery link so the user can bring it back. #[tracing::instrument(skip_all, name = "dashboard::dismiss_onboarding")] pub(super) async fn dismiss_onboarding( session: Session, AuthUser(_session_user): AuthUser, ) -> Result { session .insert(ONBOARDING_DISMISSED_KEY, true) .await .context("session insert")?; Ok(axum::response::Html( "", )) } /// Restore the onboarding checklist after it was dismissed. #[tracing::instrument(skip_all, name = "dashboard::restore_onboarding")] pub(super) async fn restore_onboarding( State(db): State, session: Session, AuthUser(session_user): AuthUser, ) -> Result { session.remove::(ONBOARDING_DISMISSED_KEY).await.ok(); let db_user = db::users::get_user_by_id(&db, session_user.id) .await? .ok_or(AppError::NotFound)?; let db_projects = db::projects::get_projects_by_user(&db, session_user.id).await?; let profile_done = db_user.display_name.as_ref().is_some_and(|n| !n.is_empty()); let stripe_done = db_user.stripe_account_id.is_some(); let projects_done = !db_projects.is_empty(); let publish_done = if projects_done { db::items::has_public_item_by_user(&db, session_user.id).await? } else { false }; let checklist = build_onboarding_checklist(&OnboardingProgress { profile_done, stripe_done, projects_done, publish_done, }); Ok(OnboardingChecklistPartialTemplate { checklist }) }