Skip to main content

max / makenotwork

5.7 KB · 153 lines History Blame Raw
1 //! Integration dashboard tab handlers (Forums, SyncKit, Media).
2
3 use axum::extract::State;
4 use axum::response::IntoResponse;
5
6 use crate::{
7 auth::AuthUser,
8 config::Config,
9 db,
10 error::Result,
11 helpers,
12 templates::{MediaFileRow, UserMediaTabTemplate, UserSyncKitTabTemplate},
13 types::{ProjectCard, SyncAppRow, apply_top_keys, build_top_keys_map},
14 };
15 use sqlx::PgPool;
16
17 /// Render the HTMX partial for the SyncKit tab.
18 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_synckit")]
19 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_synckit(
20 State(db): State<PgPool>,
21 AuthUser(session_user): AuthUser,
22 ) -> Result<impl IntoResponse> {
23 build_synckit(&db, &session_user).await
24 }
25
26 /// The Cloud Sync section's contents, without the transport around them.
27 ///
28 /// Split out for `47e67540`, the same shape `build_creator` has: the section is
29 /// deep-linkable, so the settings tab renders it inline at first paint rather
30 /// than fetching it, and a developer returning from Stripe lands on their apps.
31 pub(in crate::routes::pages::dashboard) async fn build_synckit(
32 db: &PgPool,
33 session_user: &crate::auth::SessionUser,
34 ) -> Result<UserSyncKitTabTemplate> {
35 let db_apps = db::synckit::get_sync_apps_by_creator(db, session_user.id).await?;
36 let db_projects = db::projects::get_projects_by_user(db, session_user.id).await?;
37
38 // Batch-fetch stats and item titles (no N+1)
39 let stats_batch = db::synckit::get_sync_app_stats_batch(db, session_user.id).await?;
40 let stats_map: std::collections::HashMap<_, _> = stats_batch
41 .into_iter()
42 .map(|(id, devices, logs)| (id, (devices, logs)))
43 .collect();
44
45 let item_ids: Vec<db::ItemId> = db_apps.iter().filter_map(|a| a.item_id).collect();
46 let item_titles_batch = db::items::get_item_titles_batch(db, &item_ids).await?;
47 let item_title_map: std::collections::HashMap<_, _> = item_titles_batch.into_iter().collect();
48
49 let billing_batch =
50 db::synckit_billing::get_apps_with_billing_by_creator(db, session_user.id).await?;
51 let billing_map: std::collections::HashMap<_, _> =
52 billing_batch.into_iter().map(|b| (b.id, b)).collect();
53
54 // For per_key-mode apps, fetch the most-loaded keys to render mini-gauges
55 // under the app-aggregate gauge. One batched query covers every app.
56 let top_keys_map = build_top_keys_map(db, &billing_map).await?;
57
58 let mut apps = Vec::with_capacity(db_apps.len());
59 for app in db_apps {
60 let (device_count, log_entry_count) = stats_map.get(&app.id).copied().unwrap_or((0, 0));
61
62 let api_key_masked = format!("{}...", app.api_key_prefix);
63 let keys_secret_masked = app.keys_secret_prefix.as_ref().map(|p| format!("{p}..."));
64
65 // Resolve linked project name/slug
66 let (project_name, project_slug) = app
67 .project_id
68 .and_then(|pid| db_projects.iter().find(|p| p.id == pid))
69 .map_or((None, None), |p| {
70 (Some(p.title.clone()), Some(p.slug.to_string()))
71 });
72
73 // Resolve linked item title from batch
74 let item_title = app
75 .item_id
76 .and_then(|iid| item_title_map.get(&iid).cloned());
77
78 let billing = billing_map.get(&app.id).map(|b| {
79 let mut view = crate::types::SyncAppBillingView::from_db(b);
80 apply_top_keys(&mut view, b, top_keys_map.get(&b.id));
81 view
82 });
83
84 apps.push(SyncAppRow {
85 id: app.id.to_string(),
86 name: app.name,
87 api_key_masked,
88 api_key_full: String::new(),
89 keys_secret_masked,
90 is_active: app.is_active,
91 device_count,
92 log_entry_count,
93 created_at: app.created_at.format("%b %d, %Y").to_string(),
94 slug: app.slug,
95 project_name,
96 project_slug,
97 item_title,
98 billing,
99 });
100 }
101
102 let projects: Vec<ProjectCard> = db_projects.iter().map(ProjectCard::from_db).collect();
103
104 Ok(UserSyncKitTabTemplate { apps, projects })
105 }
106
107 /// Render the HTMX partial for the dashboard media tab.
108 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_media")]
109 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_media(
110 State(db): State<PgPool>,
111 State(config): State<Config>,
112 AuthUser(session_user): AuthUser,
113 ) -> Result<impl IntoResponse> {
114 let cdn_base = config.cdn_base_url.as_str();
115
116 let db_files = db::media_files::list_by_user_folder(&db, session_user.id, None).await?;
117 let folders = db::media_files::list_folders(&db, session_user.id).await?;
118
119 let files: Vec<MediaFileRow> = db_files
120 .into_iter()
121 .map(|f| {
122 let cdn_url = format!("{}/{}", cdn_base, f.s3_key);
123 let markdown_ref = if f.folder.is_empty() {
124 f.filename.clone()
125 } else {
126 f.s3_key
127 .trim_start_matches(&format!("{}/media/", f.user_id))
128 .to_string()
129 };
130 MediaFileRow {
131 id: f.id.to_string(),
132 folder: f.folder,
133 filename: f.filename,
134 content_type: f.content_type,
135 file_size: helpers::format_bytes(f.file_size_bytes),
136 media_type: f.media_type,
137 cdn_url,
138 markdown_ref,
139 created_at: f.created_at.format("%b %d, %Y").to_string(),
140 }
141 })
142 .collect();
143
144 let breakdown = db::creator_tiers::get_storage_breakdown(&db, session_user.id).await?;
145 let storage_display = helpers::format_bytes(breakdown.media_bytes);
146
147 Ok(UserMediaTabTemplate {
148 files,
149 folders,
150 storage_display,
151 })
152 }
153