Skip to main content

max / makenotwork

15.3 KB · 457 lines History Blame Raw
1 //! Main dashboard pages: user dashboard, project dashboard, item dashboard.
2
3 use crate::extractors::ValidatedQuery;
4 use axum::{
5 extract::{Path, State},
6 response::IntoResponse,
7 };
8 use tower_sessions::Session;
9
10 use crate::{
11 auth::AuthUser,
12 config::Config,
13 constants::DASHBOARD_TRANSACTION_LIMIT,
14 db::{self, ItemId, Slug, analytics::TimeRange},
15 error::{AppError, Result, ResultExt},
16 helpers::get_csrf_token,
17 templates::{
18 DashboardItemTemplate, DashboardProjectTemplate, DashboardUserTemplate,
19 ItemAnalyticsPartialTemplate, OnboardingChecklistPartialTemplate,
20 },
21 types::{
22 ContentItem, Item, OnboardingChecklist, OnboardingStep, Project, ProjectCard, StatCard,
23 User,
24 },
25 };
26 use sqlx::PgPool;
27
28 use super::AnalyticsQuery;
29
30 const ONBOARDING_DISMISSED_KEY: &str = "onboarding_dismissed";
31
32 /// Pre-computed completion flags for each onboarding step.
33 struct OnboardingProgress {
34 profile_done: bool,
35 stripe_done: bool,
36 projects_done: bool,
37 publish_done: bool,
38 }
39
40 /// Build the onboarding checklist from pre-computed step flags.
41 fn build_onboarding_checklist(progress: &OnboardingProgress) -> OnboardingChecklist {
42 let OnboardingProgress {
43 profile_done,
44 stripe_done,
45 projects_done,
46 publish_done,
47 } = *progress;
48 let steps = vec![
49 OnboardingStep {
50 label: "Set up your profile: name, bio, and links",
51 done: profile_done,
52 link_tab: "tab-profile",
53 link_label: "Go to Profile",
54 },
55 OnboardingStep {
56 label: "Connect Stripe: required to receive payments, 3% processing only",
57 done: stripe_done,
58 link_tab: "tab-payments",
59 link_label: "Go to Payments",
60 },
61 OnboardingStep {
62 label: "Create your first project: blog, podcast, course, etc.",
63 done: projects_done,
64 link_tab: "tab-projects",
65 link_label: "Go to Projects",
66 },
67 OnboardingStep {
68 label: "Publish your first item: upload files, set pricing, go live",
69 done: publish_done,
70 link_tab: "tab-projects",
71 link_label: "Go to Projects",
72 },
73 ];
74 let completed = steps.iter().filter(|s| s.done).count();
75 let total = steps.len();
76 // Guard the division here so the template never does arithmetic that could
77 // panic the render (total is steps.len() = nonzero today, but the guard
78 // keeps a future dynamic step list from turning a 0 into a 500).
79 let progress_pct = (completed * 100).checked_div(total).unwrap_or(0) as u32;
80 OnboardingChecklist {
81 steps,
82 completed,
83 total,
84 progress_pct,
85 }
86 }
87
88 /// Render the main user dashboard with projects and transactions.
89 #[tracing::instrument(skip_all, name = "dashboard::dashboard")]
90 pub(super) async fn dashboard(
91 State(db): State<PgPool>,
92 State(config): State<Config>,
93 session: Session,
94 AuthUser(session_user): AuthUser,
95 ) -> Result<impl IntoResponse> {
96 let csrf_token = get_csrf_token(&session).await;
97
98 // These four reads are independent, run them concurrently rather than in
99 // series so the dashboard pays one round-trip's latency, not four (Run 11
100 // Perf MOD tail; revisits Run 10 M6 now that the heavy per-render work is gone).
101 let (db_user, db_projects, incoming_txs, outgoing_txs) = tokio::try_join!(
102 db::users::get_user_by_id(&db, session_user.id),
103 db::projects::get_projects_by_user(&db, session_user.id),
104 db::transactions::get_transactions_by_seller(
105 &db,
106 session_user.id,
107 Some(DASHBOARD_TRANSACTION_LIMIT)
108 ),
109 db::transactions::get_transactions_by_buyer(
110 &db,
111 session_user.id,
112 Some(DASHBOARD_TRANSACTION_LIMIT)
113 ),
114 )?;
115 let db_user = db_user.ok_or(AppError::NotFound)?;
116
117 let user = User::from(&db_user);
118 let transactions = super::collect_transactions(&incoming_txs, &outgoing_txs);
119
120 let projects: Vec<ProjectCard> = db_projects.iter().map(ProjectCard::from_db).collect();
121
122 // Build onboarding checklist for creators who haven't completed all steps
123 let onboarding_dismissed = session
124 .get::<bool>(ONBOARDING_DISMISSED_KEY)
125 .await
126 .ok()
127 .flatten()
128 .unwrap_or(false);
129 let (onboarding, show_checklist_recovery) = if session_user.can_create_projects {
130 let profile_done = db_user.display_name.as_ref().is_some_and(|n| !n.is_empty());
131 let stripe_done = user.stripe_connected;
132 let projects_done = !db_projects.is_empty();
133 let publish_done = if projects_done {
134 db::items::has_public_item_by_user(&db, session_user.id).await?
135 } else {
136 false
137 };
138
139 let all_done = profile_done && stripe_done && projects_done && publish_done;
140 if all_done {
141 (None, false)
142 } else if onboarding_dismissed {
143 (None, true)
144 } else {
145 (
146 Some(build_onboarding_checklist(&OnboardingProgress {
147 profile_done,
148 stripe_done,
149 projects_done,
150 publish_done,
151 })),
152 false,
153 )
154 }
155 } else {
156 (None, false)
157 };
158
159 // Check if user has MT forum memberships (for Forums tab visibility)
160 // Forums-tab visibility is config-gated (matching the landing and user-settings
161 // call sites): show the tab whenever MT is configured; the tab lazy-loads the
162 // user's actual memberships on open. Previously this blocked every dashboard
163 // render on a synchronous 3s-timeout MT round-trip (ultra-fuzz Run 10 Perf S6).
164 let has_mt_memberships = config.integrations.mt_base_url.is_some();
165
166 let suspended = db_user.is_suspended();
167 let suspension_reason = db_user.suspension_reason.clone();
168 let has_pending_appeal =
169 db_user.appeal_submitted_at.is_some() && db_user.appeal_decided_at.is_none();
170 let appeal_decision = db_user.appeal_decision.clone();
171 let appeal_response = db_user.appeal_response.clone();
172
173 // Check for one-time password breach warning (set during signup/password change)
174 let password_warning = session
175 .get::<String>("password_warning")
176 .await
177 .ok()
178 .flatten();
179 if password_warning.is_some() {
180 session.remove::<String>("password_warning").await.ok();
181 }
182
183 Ok(DashboardUserTemplate {
184 csrf_token,
185 session_user: Some(session_user),
186 user,
187 transactions,
188 projects,
189 onboarding,
190 show_checklist_recovery,
191 has_mt_memberships,
192 suspended,
193 suspension_reason,
194 has_pending_appeal,
195 appeal_decision,
196 appeal_response,
197 password_warning,
198 deactivated: db_user.is_deactivated(),
199 creator_paused: db_user.is_creator_paused(),
200 git_enabled: config.build.git_repos_path.is_some(),
201 })
202 }
203
204 /// Render the dashboard view for a single owned project.
205 #[tracing::instrument(skip_all, name = "dashboard::dashboard_project")]
206 pub(super) async fn dashboard_project(
207 State(db): State<PgPool>,
208 State(config): State<Config>,
209 session: Session,
210 AuthUser(session_user): AuthUser,
211 Path(slug): Path<String>,
212 ) -> Result<impl IntoResponse> {
213 let csrf_token = get_csrf_token(&session).await;
214 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
215
216 let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug)
217 .await?
218 .ok_or(AppError::NotFound)?;
219
220 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
221 let (revenue_cents, sales_count) =
222 db::transactions::get_revenue_by_project(&db, db_project.id).await?;
223 // Read the currency from the row rather than the session: a session copy
224 // would keep rendering the old symbol until the creator logged out, which is
225 // precisely the window in which they most need to see the change.
226 let currency = db::users::get_user_by_id(&db, session_user.id)
227 .await?
228 .map(|u| u.settlement_currency)
229 .unwrap_or_default();
230
231 let project = Project::from_db(&db_project, db_items.len() as u32);
232
233 let stats = vec![
234 StatCard {
235 label: "Total Revenue".to_string(),
236 value: revenue_cents.display(currency),
237 change: None,
238 is_positive: true,
239 },
240 StatCard {
241 label: "Total Sales".to_string(),
242 value: sales_count.to_string(),
243 change: None,
244 is_positive: true,
245 },
246 StatCard {
247 label: "Items".to_string(),
248 value: db_items.len().to_string(),
249 change: None,
250 is_positive: true,
251 },
252 ];
253
254 let items: Vec<ContentItem> = db_items
255 .iter()
256 .enumerate()
257 .map(|(i, item)| {
258 ContentItem::from_db(item, (i + 1) as u32, session_user.settlement_currency)
259 })
260 .collect();
261
262 let db_user = db::users::get_user_by_id(&db, session_user.id)
263 .await?
264 .ok_or(AppError::NotFound)?;
265
266 let has_blog = db_project.features.iter().any(|f| f == "blog");
267 let synckit_enabled = db_project.features.iter().any(|f| f == "cloud_sync");
268
269 let git_enabled = config.build.git_repos_path.is_some();
270
271 Ok(DashboardProjectTemplate {
272 csrf_token,
273 session_user: Some(session_user.clone()),
274 project,
275 creator_username: session_user.username.to_string(),
276 stats,
277 items,
278 stripe_connected: db_user.stripe_account_id.is_some(),
279 has_blog,
280 git_enabled,
281 synckit_enabled,
282 })
283 }
284
285 /// Render the dashboard shell for a single owned item (tabs loaded via HTMX).
286 #[tracing::instrument(skip_all, name = "dashboard::dashboard_item")]
287 pub(super) async fn dashboard_item(
288 State(db): State<PgPool>,
289 session: Session,
290 AuthUser(session_user): AuthUser,
291 Path(id): Path<String>,
292 ) -> Result<impl IntoResponse> {
293 let csrf_token = get_csrf_token(&session).await;
294
295 let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?;
296
297 let db_item = db::items::get_item_by_id(&db, item_id)
298 .await?
299 .ok_or(AppError::NotFound)?;
300
301 let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
302 .await?
303 .ok_or(AppError::NotFound)?;
304
305 // Verify ownership
306 if db_project.user_id != session_user.id {
307 return Err(AppError::Forbidden);
308 }
309
310 let is_free = db_item.price_cents == 0;
311 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
312 let item = Item::from_db_detail(
313 &db_item,
314 &item_tags,
315 None,
316 None,
317 is_free,
318 true,
319 session_user.settlement_currency,
320 );
321
322 Ok(DashboardItemTemplate {
323 csrf_token,
324 session_user: Some(session_user),
325 item,
326 project_title: db_project.title,
327 project_slug: db_project.slug.to_string(),
328 })
329 }
330
331 /// Render the HTMX partial for item analytics (stats + revenue chart).
332 #[tracing::instrument(skip_all, name = "dashboard::dashboard_item_analytics")]
333 pub(super) async fn dashboard_item_analytics(
334 State(db): State<PgPool>,
335 AuthUser(session_user): AuthUser,
336 Path(id): Path<String>,
337 ValidatedQuery(query): ValidatedQuery<AnalyticsQuery>,
338 ) -> Result<impl IntoResponse> {
339 let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?;
340
341 let db_item = db::items::get_item_by_id(&db, item_id)
342 .await?
343 .ok_or(AppError::NotFound)?;
344
345 let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
346 .await?
347 .ok_or(AppError::NotFound)?;
348
349 if db_project.user_id != session_user.id {
350 return Err(AppError::Forbidden);
351 }
352
353 let range = query
354 .range
355 .as_deref()
356 .and_then(|s| s.parse::<TimeRange>().ok())
357 .unwrap_or(TimeRange::Days30);
358
359 let buckets =
360 db::analytics::get_revenue_timeseries(&db, session_user.id, None, Some(item_id), &range)
361 .await?;
362
363 let comparison =
364 db::analytics::get_period_comparison(&db, session_user.id, None, Some(item_id), &range)
365 .await?;
366
367 let bars = super::build_chart_bars(&buckets, session_user.settlement_currency);
368
369 let currency = db::users::get_user_by_id(&db, session_user.id)
370 .await?
371 .map(|u| u.settlement_currency)
372 .unwrap_or_default();
373 let revenue_str = comparison.current_revenue_cents.format_revenue(currency);
374
375 let db_versions = db::versions::get_versions_by_item(&db, item_id).await?;
376 let total_downloads: i32 = db_versions.iter().map(|v| v.download_count).sum();
377
378 let stats = vec![
379 StatCard {
380 label: "Revenue".to_string(),
381 value: revenue_str,
382 change: comparison.revenue_change().map(|(t, _)| t),
383 is_positive: comparison.revenue_change().is_none_or(|(_, p)| p),
384 },
385 StatCard {
386 label: "Sales".to_string(),
387 value: comparison.current_sales.to_string(),
388 change: comparison.sales_change().map(|(t, _)| t),
389 is_positive: comparison.sales_change().is_none_or(|(_, p)| p),
390 },
391 StatCard {
392 label: "Downloads".to_string(),
393 value: total_downloads.to_string(),
394 change: None,
395 is_positive: true,
396 },
397 ];
398
399 Ok(ItemAnalyticsPartialTemplate {
400 stats,
401 bars,
402 item_id: item_id.to_string(),
403 active_range: range.to_string(),
404 })
405 }
406
407 /// Dismiss the onboarding checklist for the current session.
408 /// Returns a recovery link so the user can bring it back.
409 #[tracing::instrument(skip_all, name = "dashboard::dismiss_onboarding")]
410 pub(super) async fn dismiss_onboarding(
411 session: Session,
412 AuthUser(_session_user): AuthUser,
413 ) -> Result<impl IntoResponse> {
414 session
415 .insert(ONBOARDING_DISMISSED_KEY, true)
416 .await
417 .context("session insert")?;
418 Ok(axum::response::Html(
419 "<div style=\"padding: 0.75rem 0; text-align: right;\">\
420 <a href=\"#\" hx-post=\"/dashboard/onboarding/restore\" hx-target=\"#onboarding-area\" hx-swap=\"innerHTML\" \
421 style=\"font-size: 0.85rem; opacity: 0.7;\">Show setup checklist</a></div>",
422 ))
423 }
424
425 /// Restore the onboarding checklist after it was dismissed.
426 #[tracing::instrument(skip_all, name = "dashboard::restore_onboarding")]
427 pub(super) async fn restore_onboarding(
428 State(db): State<PgPool>,
429 session: Session,
430 AuthUser(session_user): AuthUser,
431 ) -> Result<impl IntoResponse> {
432 session.remove::<bool>(ONBOARDING_DISMISSED_KEY).await.ok();
433
434 let db_user = db::users::get_user_by_id(&db, session_user.id)
435 .await?
436 .ok_or(AppError::NotFound)?;
437 let db_projects = db::projects::get_projects_by_user(&db, session_user.id).await?;
438
439 let profile_done = db_user.display_name.as_ref().is_some_and(|n| !n.is_empty());
440 let stripe_done = db_user.stripe_account_id.is_some();
441 let projects_done = !db_projects.is_empty();
442 let publish_done = if projects_done {
443 db::items::has_public_item_by_user(&db, session_user.id).await?
444 } else {
445 false
446 };
447
448 let checklist = build_onboarding_checklist(&OnboardingProgress {
449 profile_done,
450 stripe_done,
451 projects_done,
452 publish_done,
453 });
454
455 Ok(OnboardingChecklistPartialTemplate { checklist })
456 }
457