Skip to main content

max / makenotwork

7.6 KB · 211 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::{AppError, Result},
11 helpers,
12 templates::{
13 ForumMembership, MediaFileRow, UserForumsTabTemplate, UserMediaTabTemplate,
14 UserSyncKitTabTemplate,
15 },
16 types::{ProjectCard, SyncAppRow, apply_top_keys, build_top_keys_map},
17 };
18 use sqlx::PgPool;
19
20 /// Render the HTMX partial for the Forums tab (Multithreaded community memberships).
21 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_forums")]
22 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_forums(
23 State(config): State<Config>,
24 AuthUser(session_user): AuthUser,
25 ) -> Result<impl IntoResponse> {
26 let mt_base_url = config
27 .integrations
28 .mt_base_url
29 .as_ref()
30 .ok_or(AppError::NotFound)?;
31
32 let url = format!("{}/api/user/{}/summary", mt_base_url, session_user.id);
33
34 let resp = crate::helpers::HTTP_CLIENT
35 .get(&url)
36 .timeout(std::time::Duration::from_secs(5))
37 .send()
38 .await
39 .map_err(|e| {
40 tracing::warn!(error = ?e, "failed to fetch MT user summary");
41 AppError::Internal(anyhow::anyhow!("MT API unavailable"))
42 })?;
43
44 if !resp.status().is_success() {
45 return Ok(UserForumsTabTemplate {
46 memberships: vec![],
47 mt_base_url: mt_base_url.clone(),
48 }
49 .into_response());
50 }
51
52 let json: serde_json::Value = resp.json().await.map_err(|e| {
53 tracing::warn!(error = ?e, "failed to parse MT summary response");
54 AppError::Internal(anyhow::anyhow!("MT API response invalid"))
55 })?;
56
57 let memberships = json["memberships"]
58 .as_array()
59 .map(|arr| {
60 arr.iter()
61 .filter_map(|m| {
62 let community_slug = m["community_slug"].as_str()?;
63 let username = &session_user.username;
64 Some(ForumMembership {
65 community_name: m["community_name"].as_str()?.to_string(),
66 profile_url: format!("{mt_base_url}/p/{community_slug}/u/{username}"),
67 role: m["role"].as_str()?.to_string(),
68 joined: m["joined_at"]
69 .as_str()
70 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
71 .map(|dt| dt.format("%b %d, %Y").to_string())
72 .unwrap_or_default(),
73 post_count: m["post_count"].as_i64().unwrap_or(0),
74 })
75 })
76 .collect()
77 })
78 .unwrap_or_default();
79
80 Ok(UserForumsTabTemplate {
81 memberships,
82 mt_base_url: mt_base_url.clone(),
83 }
84 .into_response())
85 }
86
87 /// Render the HTMX partial for the SyncKit tab.
88 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_synckit")]
89 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_synckit(
90 State(db): State<PgPool>,
91 AuthUser(session_user): AuthUser,
92 ) -> Result<impl IntoResponse> {
93 let db_apps = db::synckit::get_sync_apps_by_creator(&db, session_user.id).await?;
94 let db_projects = db::projects::get_projects_by_user(&db, session_user.id).await?;
95
96 // Batch-fetch stats and item titles (no N+1)
97 let stats_batch = db::synckit::get_sync_app_stats_batch(&db, session_user.id).await?;
98 let stats_map: std::collections::HashMap<_, _> = stats_batch
99 .into_iter()
100 .map(|(id, devices, logs)| (id, (devices, logs)))
101 .collect();
102
103 let item_ids: Vec<db::ItemId> = db_apps.iter().filter_map(|a| a.item_id).collect();
104 let item_titles_batch = db::items::get_item_titles_batch(&db, &item_ids).await?;
105 let item_title_map: std::collections::HashMap<_, _> = item_titles_batch.into_iter().collect();
106
107 let billing_batch =
108 db::synckit_billing::get_apps_with_billing_by_creator(&db, session_user.id).await?;
109 let billing_map: std::collections::HashMap<_, _> =
110 billing_batch.into_iter().map(|b| (b.id, b)).collect();
111
112 // For per_key-mode apps, fetch the most-loaded keys to render mini-gauges
113 // under the app-aggregate gauge. One batched query covers every app.
114 let top_keys_map = build_top_keys_map(&db, &billing_map).await?;
115
116 let mut apps = Vec::with_capacity(db_apps.len());
117 for app in db_apps {
118 let (device_count, log_entry_count) = stats_map.get(&app.id).copied().unwrap_or((0, 0));
119
120 let api_key_masked = format!("{}...", app.api_key_prefix);
121 let keys_secret_masked = app.keys_secret_prefix.as_ref().map(|p| format!("{p}..."));
122
123 // Resolve linked project name/slug
124 let (project_name, project_slug) = app
125 .project_id
126 .and_then(|pid| db_projects.iter().find(|p| p.id == pid))
127 .map_or((None, None), |p| {
128 (Some(p.title.clone()), Some(p.slug.to_string()))
129 });
130
131 // Resolve linked item title from batch
132 let item_title = app
133 .item_id
134 .and_then(|iid| item_title_map.get(&iid).cloned());
135
136 let billing = billing_map.get(&app.id).map(|b| {
137 let mut view = crate::types::SyncAppBillingView::from_db(b);
138 apply_top_keys(&mut view, b, top_keys_map.get(&b.id));
139 view
140 });
141
142 apps.push(SyncAppRow {
143 id: app.id.to_string(),
144 name: app.name,
145 api_key_masked,
146 api_key_full: String::new(),
147 keys_secret_masked,
148 is_active: app.is_active,
149 device_count,
150 log_entry_count,
151 created_at: app.created_at.format("%b %d, %Y").to_string(),
152 slug: app.slug,
153 project_name,
154 project_slug,
155 item_title,
156 billing,
157 });
158 }
159
160 let projects: Vec<ProjectCard> = db_projects.iter().map(ProjectCard::from_db).collect();
161
162 Ok(UserSyncKitTabTemplate { apps, projects })
163 }
164
165 /// Render the HTMX partial for the dashboard media tab.
166 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_media")]
167 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_media(
168 State(db): State<PgPool>,
169 State(config): State<Config>,
170 AuthUser(session_user): AuthUser,
171 ) -> Result<impl IntoResponse> {
172 let cdn_base = config.cdn_base_url.as_str();
173
174 let db_files = db::media_files::list_by_user_folder(&db, session_user.id, None).await?;
175 let folders = db::media_files::list_folders(&db, session_user.id).await?;
176
177 let files: Vec<MediaFileRow> = db_files
178 .into_iter()
179 .map(|f| {
180 let cdn_url = format!("{}/{}", cdn_base, f.s3_key);
181 let markdown_ref = if f.folder.is_empty() {
182 f.filename.clone()
183 } else {
184 f.s3_key
185 .trim_start_matches(&format!("{}/media/", f.user_id))
186 .to_string()
187 };
188 MediaFileRow {
189 id: f.id.to_string(),
190 folder: f.folder,
191 filename: f.filename,
192 content_type: f.content_type,
193 file_size: helpers::format_bytes(f.file_size_bytes),
194 media_type: f.media_type,
195 cdn_url,
196 markdown_ref,
197 created_at: f.created_at.format("%b %d, %Y").to_string(),
198 }
199 })
200 .collect();
201
202 let breakdown = db::creator_tiers::get_storage_breakdown(&db, session_user.id).await?;
203 let storage_display = helpers::format_bytes(breakdown.media_bytes);
204
205 Ok(UserMediaTabTemplate {
206 files,
207 folders,
208 storage_display,
209 })
210 }
211