//! Integration dashboard tab handlers (Forums, SyncKit, Media). use axum::extract::State; use axum::response::IntoResponse; use crate::{ auth::AuthUser, db, error::{AppError, Result}, helpers, templates::*, types::*, AppState, }; /// Render the HTMX partial for the Forums tab (Multithreaded community memberships). #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_forums")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_forums( State(state): State, AuthUser(session_user): AuthUser, ) -> Result { let mt_base_url = state .config .mt_base_url .as_ref() .ok_or(AppError::NotFound)?; let url = format!("{}/api/user/{}/summary", mt_base_url, session_user.id); let resp = reqwest::Client::new() .get(&url) .timeout(std::time::Duration::from_secs(5)) .send() .await .map_err(|e| { tracing::warn!(error = ?e, "failed to fetch MT user summary"); AppError::Internal(anyhow::anyhow!("MT API unavailable")) })?; if !resp.status().is_success() { return Ok(UserForumsTabTemplate { memberships: vec![], mt_base_url: mt_base_url.clone(), } .into_response()); } let json: serde_json::Value = resp.json().await.map_err(|e| { tracing::warn!(error = ?e, "failed to parse MT summary response"); AppError::Internal(anyhow::anyhow!("MT API response invalid")) })?; let memberships = json["memberships"] .as_array() .map(|arr| { arr.iter() .filter_map(|m| { let community_slug = m["community_slug"].as_str()?; let username = &session_user.username; Some(ForumMembership { community_name: m["community_name"].as_str()?.to_string(), profile_url: format!( "{}/p/{}/u/{}", mt_base_url, community_slug, username ), role: m["role"].as_str()?.to_string(), joined: m["joined_at"] .as_str() .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) .map(|dt| dt.format("%b %d, %Y").to_string()) .unwrap_or_default(), post_count: m["post_count"].as_i64().unwrap_or(0), }) }) .collect() }) .unwrap_or_default(); Ok(UserForumsTabTemplate { memberships, mt_base_url: mt_base_url.clone(), } .into_response()) } /// 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(state): State, AuthUser(session_user): AuthUser, ) -> Result { let db_apps = db::synckit::get_sync_apps_by_creator(&state.db, session_user.id).await?; let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?; // Batch-fetch stats and item titles (no N+1) 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 item_ids: Vec = db_apps.iter().filter_map(|a| a.item_id).collect(); let item_titles_batch = db::items::get_item_titles_batch(&state.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(&state.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(&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); // 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(|p| (Some(p.title.clone()), Some(p.slug.to_string()))) .unwrap_or((None, None)); // 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(), 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(state): State, AuthUser(session_user): AuthUser, ) -> Result { let cdn_base = state .config .cdn_base_url .as_deref() .unwrap_or("https://cdn.makenot.work"); let db_files = db::media_files::list_by_user_folder(&state.db, session_user.id, None).await?; let folders = db::media_files::list_folders(&state.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(&state.db, session_user.id).await?; let storage_display = helpers::format_bytes(breakdown.media_bytes); Ok(UserMediaTabTemplate { files, folders, storage_display, }) }