//! Main dashboard pages: user dashboard, project dashboard, item dashboard. use askama::Template as _; use crate::extractors::ValidatedQuery; use axum::{ extract::{Path, State}, response::IntoResponse, }; use tower_sessions::Session; use crate::{ auth::AuthUser, config::Config, db::{self, ItemId, Slug, analytics::TimeRange}, error::{AppError, Result, ResultExt}, helpers::get_csrf_token, quasi, templates::{ DashboardItemTemplate, DashboardProjectTemplate, DashboardUserTemplate, ItemAnalyticsPartialTemplate, ItemFilesTabTemplate, ItemOverviewTabTemplate, OnboardingChecklistPartialTemplate, }, types::{ Item, OnboardingChecklist, OnboardingStep, Project, ProjectCard, StatCard, User, Version, }, }; use sqlx::PgPool; use super::{AnalyticsQuery, ItemTabQuery, UserTabQuery, project_tabs, tabs}; 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_href: "/dashboard?tab=settings", link_label: "Go to Profile", }, OnboardingStep { label: "Connect Stripe: required to receive payments, 3% processing only", done: stripe_done, link_href: "/dashboard?tab=payments", link_label: "Go to Payments", }, OnboardingStep { label: "Create your first project: blog, podcast, course, etc.", done: projects_done, link_href: "/dashboard?tab=projects", link_label: "Go to Projects", }, OnboardingStep { label: "Publish your first item: upload files, set pricing, go live", done: publish_done, link_href: "/dashboard?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, State(payments): State, session: Session, AuthUser(session_user): AuthUser, ValidatedQuery(query): ValidatedQuery, ) -> Result { let csrf_token = get_csrf_token(&session).await; // These two reads are independent, run them concurrently rather than in // series so the dashboard pays one round-trip's latency, not two (Run 11 // Perf MOD tail; revisits Run 10 M6 now that the heavy per-render work is gone). // // It was four until `6b24f2df` step 5: the other two fetched the incoming and // outgoing transactions for a template field nothing read, and the payments // panel that does want them fetches its own. let (db_user, db_projects) = tokio::try_join!( db::users::get_user_by_id(&db, session_user.id), db::projects::get_projects_by_user(&db, session_user.id), )?; let db_user = db_user.ok_or(AppError::NotFound)?; let user = User::from(&db_user); 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) }; 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(); } // The shown panel arrives with the document rather than a round trip later, // for the reason `9b958e7b` gives, and it is chosen server-side so the six // links that used to hand out a `/dashboard#tab-*` are links the page can // act on before it renders. `6b24f2df` step 5. let deactivated = db_user.is_deactivated(); let shown = quasi::user_tabs::shown_at( query.tab.as_deref(), deactivated, session_user.can_create_projects, ); // Only the four tabs anything opens on are fillable. Analytics is pressed // rather than linked to, and it answers for itself when its screen is on. let panel = match quasi::user_tabs::route_at(shown, deactivated, session_user.can_create_projects) { "payments" => tabs::build_payments(&db, csrf_token.clone(), &session_user) .await? .render(), "settings" => tabs::build_settings( &db, &config, &payments, csrf_token.clone(), &session_user, query.section.as_deref(), ) .await? .render(), "support" => tabs::build_support(&session_user).render(), _ => tabs::build_projects(projects, &session_user).render(), } .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?; Ok(DashboardUserTemplate { tabs: quasi::user_tabs::html( &config.quasi_screens, shown, &panel, deactivated, session_user.can_create_projects, ), csrf_token, session_user: Some(session_user), user, onboarding, show_checklist_recovery, suspended, suspension_reason, has_pending_appeal, appeal_decision, appeal_response, password_warning, deactivated, creator_paused: db_user.is_creator_paused(), }) } /// 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, ValidatedQuery(query): ValidatedQuery, ) -> 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 project = Project::from_db(&db_project, db_items.len() as u32); let git_enabled = config.build.git_repos_path.is_some(); let synckit_enabled = db_project.features.iter().any(|f| f == "cloud_sync"); // The shown panel arrives with the document rather than a round trip later, // for the reason `9b958e7b` gives, and it is chosen server-side so the // Stripe return and the overview's Go to Content are links rather than a // hash the page's JS has to act on. `6b24f2df`. let shown = quasi::project_tabs::shown_at(query.tab.as_deref(), git_enabled, synckit_enabled); let panel = match quasi::project_tabs::route_at(shown, git_enabled, synckit_enabled) { // The described panel goes in without the region wrapper its route // answers with: the strip draws that div, and two elements carrying one // id is a target nothing can aim at. "content" if config.quasi_screens.enabled(quasi::project_content::SCREEN) => { let content = project_tabs::build_content(&db, &session_user, &db_project).await?; Ok(quasi::project_content::fill( db_project.slug.as_ref(), &content.items, &content.deleted_items, &content.posts, &quasi::project_content::View::default(), )) } "content" => project_tabs::build_content(&db, &session_user, &db_project) .await? .render(), "synckit" => project_tabs::build_synckit(&db, &session_user, &db_project) .await? .render(), _ => project_tabs::build_overview(&db, &session_user, &db_project) .await? .render(), } .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?; Ok(DashboardProjectTemplate { csrf_token, session_user: Some(session_user), project, tabs: quasi::project_tabs::html( db_project.slug.as_ref(), shown, &panel, 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, ValidatedQuery(query): ValidatedQuery, ) -> 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, ); // The shown panel is rendered here rather than fetched. The page used to // give its panel container an `hx-trigger="load"`, so it drew an empty box // and filled it a round trip later; a described strip cannot say that // (`9b958e7b`), and the overview template wants nothing this handler has not // already built. `6b24f2df`. let is_bundle = item.item_type == "bundle"; let shown = quasi::item_tabs::shown_at(query.tab.as_deref(), is_bundle); // Only the two tabs anything links to are fillable here. Nothing in the tree // links to details, pricing or sales, measured 2026-08-19, and each of those // wants queries this handler does not make; add one when a link appears // rather than paying for four panels nobody asks for. let panel = match quasi::item_tabs::route_at(shown, is_bundle) { "files" => { let db_versions = db::versions::get_versions_by_item(&db, item_id).await?; let versions: Vec = db_versions.iter().map(Version::from_db).collect(); ItemFilesTabTemplate { item: item.clone(), versions, } .render() } _ => ItemOverviewTabTemplate { item: item.clone() }.render(), } .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?; let tabs = quasi::item_tabs::html(&item.id, shown, &panel, is_bundle); Ok(DashboardItemTemplate { csrf_token, session_user: Some(session_user), item, project_title: db_project.title, project_slug: db_project.slug.to_string(), tabs, }) } /// 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 }) }