Skip to main content

max / makenotwork

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