Skip to main content

max / makenotwork

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