//! Project HTMX tab partials. use axum::extract::{Path, Query, State}; use axum::http::HeaderMap; use axum::response::IntoResponse; use std::collections::{HashMap, HashSet}; use crate::{ auth::AuthUser, db::{self, analytics::TimeRange, ItemId, Slug}, error::{AppError, Result}, helpers, templates::*, types::*, AppState, }; use super::AnalyticsQuery; /// Build the content items list with bundle children nested under their parent. /// /// Unlisted items that belong to a bundle are shown only as children of that bundle, /// not as top-level rows. Listed items always appear at the top level even if they /// are also in a bundle. fn build_content_items_with_bundles( db_items: &[db::DbItem], bundle_map: &[(ItemId, ItemId)], ) -> Vec { // Build bundle_id → [child_item_id] map let mut children_of: HashMap> = HashMap::new(); let mut child_to_bundle: HashMap = HashMap::new(); for &(bundle_id, child_id) in bundle_map { children_of.entry(bundle_id).or_default().push(child_id); child_to_bundle.insert(child_id, bundle_id); } // Index all items by ID for lookup let item_by_id: HashMap = db_items.iter().map(|i| (i.id, i)).collect(); // Items that are unlisted AND belong to a bundle should be hidden from top level let hidden_at_top: HashSet = db_items .iter() .filter(|i| !i.listed && child_to_bundle.contains_key(&i.id)) .map(|i| i.id) .collect(); let mut items = Vec::new(); let mut pos = 1u32; for db_item in db_items { if hidden_at_top.contains(&db_item.id) { continue; } let mut content_item = ContentItem::from_db(db_item, pos); pos += 1; // If this is a bundle, attach its children if let Some(child_ids) = children_of.get(&db_item.id) { for (ci, child_id) in child_ids.iter().enumerate() { if let Some(child_db) = item_by_id.get(child_id) { content_item.children.push(ContentItem::from_db(child_db, (ci + 1) as u32)); } } } items.push(content_item); } items } /// Resolve a project by slug for the authenticated user and check its ETag. /// Returns `Ok(Err(304))` if the client's cached version is fresh, /// or `Ok(Ok((project, generation)))` if rendering is needed. async fn resolve_project_etag( state: &AppState, user_id: db::UserId, slug: &str, headers: &HeaderMap, ) -> Result> { let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_project_by_user_and_slug(&state.db, user_id, &slug) .await? .ok_or(AppError::NotFound)?; let generation = db_project.cache_generation; if let Some(not_modified) = helpers::check_etag(headers, generation) { return Ok(Err(not_modified)); } Ok(Ok((db_project, generation))) } /// Render the HTMX partial for the project overview tab with stats. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_overview")] pub(super) async fn project_tab_overview( State(state): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?; let (revenue_cents, sales_count) = db::transactions::get_revenue_by_project(&state.db, db_project.id).await?; let revenue_str = format!("${}.{:02}", revenue_cents / 100, revenue_cents % 100); let stats = vec![ StatCard { label: "Total Revenue".to_string(), value: revenue_str, 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 db_user = db::users::get_user_by_id(&state.db, session_user.id) .await? .ok_or(AppError::NotFound)?; let has_items = !db_items.is_empty(); let has_published_item = db_items.iter().any(|i| i.is_public); Ok(helpers::with_etag(generation, ProjectOverviewTabTemplate { stats, project_slug: db_project.slug.to_string(), stripe_connected: db_user.stripe_account_id.is_some(), has_items, has_published_item, })) } /// Render the HTMX partial for the project content/items tab. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_content")] pub(super) async fn project_tab_content( State(state): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?; let bundle_map = db::bundles::get_project_bundle_map(&state.db, db_project.id).await?; let db_deleted = db::items::get_deleted_items_by_project(&state.db, db_project.id).await?; let db_posts = db::blog_posts::get_blog_posts_by_project(&state.db, db_project.id).await?; let items = build_content_items_with_bundles(&db_items, &bundle_map); let deleted_items: Vec = db_deleted .iter() .map(|i| crate::templates::DeletedItemRow { id: i.id.to_string(), title: i.title.clone(), deleted_at: i.deleted_at .map(|d| d.format("%b %d, %Y").to_string()) .unwrap_or_default(), }) .collect(); let posts: Vec = db_posts .into_iter() .map(|p| BlogPostDashboardRow { id: p.id.to_string(), title: p.title, slug: p.slug.to_string(), status: if p.published_at.is_some() { "Published".to_string() } else { "Draft".to_string() }, published_at: p.published_at .map(|d| d.format("%b %d, %Y").to_string()) .unwrap_or_else(|| "-".to_string()), }) .collect(); Ok(helpers::with_etag(generation, ProjectContentTabTemplate { items, deleted_items, project_slug: db_project.slug.to_string(), project_id: db_project.id.to_string(), posts, })) } /// Render the HTMX partial for the project analytics tab. /// Analytics data changes constantly, so no ETag caching. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_analytics")] pub(super) async fn project_tab_analytics( State(state): State, AuthUser(session_user): AuthUser, Path(slug): Path, Query(query): Query, ) -> Result { let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_project_by_user_and_slug(&state.db, session_user.id, &slug) .await? .ok_or(AppError::NotFound)?; let range = query .range .as_deref() .and_then(|s| s.parse::().ok()) .unwrap_or(TimeRange::Days30); let buckets = db::analytics::get_revenue_timeseries( &state.db, session_user.id, Some(db_project.id), None, &range, ) .await?; let comparison = db::analytics::get_period_comparison( &state.db, session_user.id, Some(db_project.id), None, &range, ) .await?; let bars = super::build_chart_bars(&buckets); let revenue_str = format!( "${}.{:02}", comparison.current_revenue_cents / 100, comparison.current_revenue_cents % 100 ); // Page view stats for this project let (current_views, prev_views) = db::page_views::get_view_period_comparison( &state.db, session_user.id, Some(db_project.id), &range, ) .await?; let view_change = db::analytics::pct_change(current_views, prev_views); let mut stats = vec![ StatCard { label: "Views".to_string(), value: current_views.to_string(), change: view_change.as_ref().map(|(t, _)| t.clone()), is_positive: view_change.map(|(_, p)| p).unwrap_or(true), }, StatCard { label: "Revenue".to_string(), value: revenue_str, change: comparison.revenue_change().map(|(t, _)| t), is_positive: comparison.revenue_change().map(|(_, p)| p).unwrap_or(true), }, StatCard { label: "Sales".to_string(), value: comparison.current_sales.to_string(), change: comparison.sales_change().map(|(t, _)| t), is_positive: comparison.sales_change().map(|(_, p)| p).unwrap_or(true), }, StatCard { label: "Followers".to_string(), value: comparison.current_followers.to_string(), change: comparison.followers_change().map(|(t, _)| t), is_positive: comparison.followers_change().map(|(_, p)| p).unwrap_or(true), }, ]; if current_views > 0 { let conversion = format!( "{:.1}%", comparison.current_sales as f64 / current_views as f64 * 100.0 ); stats.push(StatCard { label: "Conversion".to_string(), value: conversion, change: None, is_positive: true, }); } let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?; let items: Vec = db_items .iter() .enumerate() .map(|(i, item)| ContentItem::from_db(item, (i + 1) as u32)) .collect(); Ok(ProjectAnalyticsTabTemplate { stats, bars, items, project_slug: db_project.slug.to_string(), active_range: range.to_string(), }) } /// Render the HTMX partial for the project settings tab. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_settings")] pub(super) async fn project_tab_settings( State(state): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?; let project = Project::from_db(&db_project, db_items.len() as u32); let category_name = db::categories::get_project_category_name(&state.db, db_project.id) .await? .unwrap_or_default(); let project_id = db_project.id.to_string(); let features = db_project.features.clone(); let project_features = db::ProjectFeature::all(); let sections = db::project_sections::list_by_project(&state.db, db_project.id).await?; let pricing_model = db_project.pricing_model.to_string(); let price_dollars = if db_project.price_cents > 0 { format!("{:.2}", db_project.price_cents as f64 / 100.0) } else { String::new() }; let pwyw_min_dollars = match db_project.pwyw_min_cents { Some(c) if c > 0 => format!("{:.2}", c as f64 / 100.0), _ => String::new(), }; Ok(helpers::with_etag(generation, ProjectSettingsTabTemplate { project, category_name, project_id, features, project_features, sections, pricing_model, price_dollars, pwyw_min_dollars, })) } /// Render the HTMX partial for the project subscriptions tab (tier management). #[tracing::instrument(skip_all, name = "project_tabs::project_tab_subscriptions")] pub(super) async fn project_tab_subscriptions( State(state): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let db_user = db::users::get_user_by_id(&state.db, session_user.id) .await? .ok_or(AppError::NotFound)?; let db_tiers = db::subscriptions::get_all_tiers_by_project(&state.db, db_project.id).await?; let tiers: Vec = db_tiers.iter().map(SubscriptionTier::from).collect(); let subscriber_count = db::subscriptions::get_project_subscriber_count(&state.db, db_project.id).await?; Ok(helpers::with_etag(generation, ProjectSubscriptionsTabTemplate { project_id: db_project.id.to_string(), project_slug: db_project.slug.to_string(), tiers, subscriber_count, stripe_connected: db_user.stripe_account_id.is_some(), })) } /// Render the HTMX partial for the project blog tab. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_blog")] pub(super) async fn project_tab_blog( State(state): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let db_posts = db::blog_posts::get_blog_posts_by_project(&state.db, db_project.id).await?; let posts: Vec = db_posts .into_iter() .map(|p| BlogPostDashboardRow { id: p.id.to_string(), title: p.title, slug: p.slug.to_string(), status: if p.published_at.is_some() { "Published".to_string() } else { "Draft".to_string() }, published_at: p.published_at .map(|d| d.format("%b %d, %Y").to_string()) .unwrap_or_else(|| "-".to_string()), }) .collect(); Ok(helpers::with_etag(generation, ProjectBlogTabTemplate { project_id: db_project.id.to_string(), project_slug: db_project.slug.to_string(), posts, })) } /// Render the HTMX partial for the project promotions tab (promo codes). #[tracing::instrument(skip_all, name = "project_tabs::project_tab_promotions")] pub(super) async fn project_tab_promotions( State(state): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let codes = db::promo_codes::get_promo_codes_by_project(&state.db, db_project.id).await?; let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?; let items: Vec = db_items .iter() .enumerate() .map(|(i, item)| ContentItem::from_db(item, (i + 1) as u32)) .collect(); Ok(helpers::with_etag(generation, ProjectPromotionsTabTemplate { project_id: db_project.id.to_string(), project_slug: db_project.slug.to_string(), promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(), items, })) } /// Render the HTMX partial for the project code tab (git repos). #[tracing::instrument(skip_all, name = "project_tabs::project_tab_code")] pub(super) async fn project_tab_code( State(state): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let git_enabled = state.config.git_repos_path.is_some(); let db_linked_repos = db::git_repos::get_repos_by_project(&state.db, db_project.id).await.unwrap_or_default(); let all_repos = db::git_repos::get_repos_by_user(&state.db, session_user.id).await.unwrap_or_default(); let available_repos: Vec<_> = all_repos.into_iter().filter(|r| r.project_id.is_none()).collect(); // Build linked repo views with collaborators let mut linked_repos = Vec::with_capacity(db_linked_repos.len()); for repo in &db_linked_repos { let collabs = db::repo_collaborators::list_collaborators(&state.db, repo.id) .await .unwrap_or_default(); linked_repos.push(LinkedRepoView { id: repo.id.to_string(), name: repo.name.clone(), collaborators: collabs .iter() .map(|c| RepoCollaboratorView { user_id: c.user_id.to_string(), username: c.username.clone(), can_push: c.can_push, }) .collect(), }); } let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?; let project = Project::from_db(&db_project, db_items.len() as u32); Ok(helpers::with_etag(generation, ProjectCodeTabTemplate { project, git_enabled, linked_repos, available_repos, project_id: db_project.id.to_string(), })) } /// Render the HTMX partial for the project members tab. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_members")] pub(super) async fn project_tab_members( State(state): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let db_members = db::project_members::get_project_members(&state.db, db_project.id).await?; let members: Vec = db_members .iter() .map(|m| ProjectMemberRow { id: m.id.to_string(), user_id: m.user_id.to_string(), username: m.username.clone(), display_name: m.display_name.clone(), role: m.role.to_string(), split_percent: m.split_percent, stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled, added_at: m.added_at.format("%Y-%m-%d").to_string(), }) .collect(); let total_member_split = db::project_members::get_total_split_percent(&state.db, db_project.id).await?; let owner_split = 100 - total_member_split; Ok(helpers::with_etag(generation, ProjectMembersTabTemplate { project_id: db_project.id.to_string(), project_slug: db_project.slug.to_string(), members, owner_split, })) } /// Combined monetization tab: tiers, promo codes, and team splits. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_monetization")] pub(super) async fn project_tab_monetization( State(state): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let db_user = db::users::get_user_by_id(&state.db, session_user.id) .await? .ok_or(AppError::NotFound)?; // Tiers let db_tiers = db::subscriptions::get_all_tiers_by_project(&state.db, db_project.id).await?; let tiers: Vec = db_tiers.iter().map(SubscriptionTier::from).collect(); let subscriber_count = db::subscriptions::get_project_subscriber_count(&state.db, db_project.id).await?; // Promo codes let codes = db::promo_codes::get_promo_codes_by_project(&state.db, db_project.id).await?; let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?; let items: Vec = db_items .iter() .enumerate() .map(|(i, item)| ContentItem::from_db(item, (i + 1) as u32)) .collect(); // Members let db_members = db::project_members::get_project_members(&state.db, db_project.id).await?; let members: Vec = db_members .iter() .map(|m| ProjectMemberRow { id: m.id.to_string(), user_id: m.user_id.to_string(), username: m.username.clone(), display_name: m.display_name.clone(), role: m.role.to_string(), split_percent: m.split_percent, stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled, added_at: m.added_at.format("%Y-%m-%d").to_string(), }) .collect(); let total_member_split = db::project_members::get_total_split_percent(&state.db, db_project.id).await?; let owner_split = 100 - total_member_split; Ok(helpers::with_etag(generation, ProjectMonetizationTabTemplate { project_id: db_project.id.to_string(), project_slug: db_project.slug.to_string(), tiers, subscriber_count, stripe_connected: db_user.stripe_account_id.is_some(), promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(), items, members, owner_split, })) } /// Render the HTMX partial for SyncKit apps linked to a project. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_synckit")] pub(super) async fn project_tab_synckit( State(state): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let db_apps = db::synckit::get_sync_apps_by_project(&state.db, db_project.id).await?; let stats_batch = db::synckit::get_sync_app_stats_batch(&state.db, session_user.id).await?; let stats_map: std::collections::HashMap<_, _> = stats_batch .into_iter() .map(|(id, devices, logs)| (id, (devices, logs))) .collect(); let billing_batch = db::synckit_billing::get_apps_with_billing_by_project(&state.db, db_project.id).await?; let billing_map: std::collections::HashMap<_, _> = billing_batch .into_iter() .map(|b| (b.id, b)) .collect(); let top_keys_map = crate::types::build_top_keys_map(&state.db, &billing_map).await?; let mut apps = Vec::with_capacity(db_apps.len()); for app in &db_apps { let (device_count, log_entry_count) = stats_map .get(&app.id) .copied() .unwrap_or((0, 0)); let api_key_masked = format!("{}...", &app.api_key_prefix); let billing = billing_map.get(&app.id).map(|b| { let mut view = crate::types::SyncAppBillingView::from_db(b); crate::types::apply_top_keys(&mut view, b, top_keys_map.get(&b.id)); view }); apps.push(SyncAppRow { id: app.id.to_string(), name: app.name.clone(), api_key_masked, api_key_full: String::new(), is_active: app.is_active, device_count, log_entry_count, created_at: app.created_at.format("%b %d, %Y").to_string(), slug: app.slug.clone(), project_name: None, project_slug: None, item_title: None, billing, }); } Ok(helpers::with_etag(generation, ProjectSyncKitTabTemplate { apps, project_id: db_project.id.to_string(), })) }