//! Integration dashboard tab handlers (Forums, SyncKit, Media). use axum::extract::State; use axum::response::IntoResponse; use crate::{ auth::AuthUser, config::Config, db, error::Result, helpers, templates::{MediaFileRow, UserMediaTabTemplate, UserSyncKitTabTemplate}, types::{ProjectCard, SyncAppRow, apply_top_keys, build_top_keys_map}, }; use sqlx::PgPool; /// Render the HTMX partial for the SyncKit tab. #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_synckit")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_synckit( State(db): State, AuthUser(session_user): AuthUser, ) -> Result { build_synckit(&db, &session_user).await } /// The Cloud Sync section's contents, without the transport around them. /// /// Split out for `47e67540`, the same shape `build_creator` has: the section is /// deep-linkable, so the settings tab renders it inline at first paint rather /// than fetching it, and a developer returning from Stripe lands on their apps. pub(in crate::routes::pages::dashboard) async fn build_synckit( db: &PgPool, session_user: &crate::auth::SessionUser, ) -> Result { let db_apps = db::synckit::get_sync_apps_by_creator(db, session_user.id).await?; let db_projects = db::projects::get_projects_by_user(db, session_user.id).await?; // Batch-fetch stats and item titles (no N+1) let stats_batch = db::synckit::get_sync_app_stats_batch(db, session_user.id).await?; let stats_map: std::collections::HashMap<_, _> = stats_batch .into_iter() .map(|(id, devices, logs)| (id, (devices, logs))) .collect(); let item_ids: Vec = db_apps.iter().filter_map(|a| a.item_id).collect(); let item_titles_batch = db::items::get_item_titles_batch(db, &item_ids).await?; let item_title_map: std::collections::HashMap<_, _> = item_titles_batch.into_iter().collect(); let billing_batch = db::synckit_billing::get_apps_with_billing_by_creator(db, session_user.id).await?; let billing_map: std::collections::HashMap<_, _> = billing_batch.into_iter().map(|b| (b.id, b)).collect(); // For per_key-mode apps, fetch the most-loaded keys to render mini-gauges // under the app-aggregate gauge. One batched query covers every app. let top_keys_map = build_top_keys_map(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 keys_secret_masked = app.keys_secret_prefix.as_ref().map(|p| format!("{p}...")); // Resolve linked project name/slug let (project_name, project_slug) = app .project_id .and_then(|pid| db_projects.iter().find(|p| p.id == pid)) .map_or((None, None), |p| { (Some(p.title.clone()), Some(p.slug.to_string())) }); // Resolve linked item title from batch let item_title = app .item_id .and_then(|iid| item_title_map.get(&iid).cloned()); let billing = billing_map.get(&app.id).map(|b| { let mut view = crate::types::SyncAppBillingView::from_db(b); apply_top_keys(&mut view, b, top_keys_map.get(&b.id)); view }); apps.push(SyncAppRow { id: app.id.to_string(), name: app.name, api_key_masked, api_key_full: String::new(), keys_secret_masked, is_active: app.is_active, device_count, log_entry_count, created_at: app.created_at.format("%b %d, %Y").to_string(), slug: app.slug, project_name, project_slug, item_title, billing, }); } let projects: Vec = db_projects.iter().map(ProjectCard::from_db).collect(); Ok(UserSyncKitTabTemplate { apps, projects }) } /// Render the HTMX partial for the dashboard media tab. #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_media")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_media( State(db): State, State(config): State, AuthUser(session_user): AuthUser, ) -> Result { let cdn_base = config.cdn_base_url.as_str(); let db_files = db::media_files::list_by_user_folder(&db, session_user.id, None).await?; let folders = db::media_files::list_folders(&db, session_user.id).await?; let files: Vec = db_files .into_iter() .map(|f| { let cdn_url = format!("{}/{}", cdn_base, f.s3_key); let markdown_ref = if f.folder.is_empty() { f.filename.clone() } else { f.s3_key .trim_start_matches(&format!("{}/media/", f.user_id)) .to_string() }; MediaFileRow { id: f.id.to_string(), folder: f.folder, filename: f.filename, content_type: f.content_type, file_size: helpers::format_bytes(f.file_size_bytes), media_type: f.media_type, cdn_url, markdown_ref, created_at: f.created_at.format("%b %d, %Y").to_string(), } }) .collect(); let breakdown = db::creator_tiers::get_storage_breakdown(&db, session_user.id).await?; let storage_display = helpers::format_bytes(breakdown.media_bytes); Ok(UserMediaTabTemplate { files, folders, storage_display, }) }