Skip to main content

max / makenotwork

18.6 KB · 509 lines History Blame Raw
1 //! Main dashboard pages: user dashboard, project dashboard, item dashboard.
2
3 use askama::Template as _;
4
5 use crate::extractors::ValidatedQuery;
6 use axum::{
7 extract::{Path, State},
8 response::IntoResponse,
9 };
10 use tower_sessions::Session;
11
12 use crate::{
13 auth::AuthUser,
14 config::Config,
15 db::{self, ItemId, Slug, analytics::TimeRange},
16 error::{AppError, Result, ResultExt},
17 helpers::get_csrf_token,
18 quasi,
19 templates::{
20 DashboardItemTemplate, DashboardProjectTemplate, DashboardUserTemplate,
21 ItemAnalyticsPartialTemplate, ItemOverviewTabTemplate, ItemVersionUploadTemplate,
22 OnboardingChecklistPartialTemplate,
23 },
24 types::{
25 Item, OnboardingChecklist, OnboardingStep, Project, ProjectCard, StatCard, User, Version,
26 },
27 };
28 use sqlx::PgPool;
29
30 use super::{AnalyticsQuery, ItemTabQuery, UserTabQuery, project_tabs, tabs};
31
32 const ONBOARDING_DISMISSED_KEY: &str = "onboarding_dismissed";
33
34 /// Pre-computed completion flags for each onboarding step.
35 struct OnboardingProgress {
36 profile_done: bool,
37 stripe_done: bool,
38 projects_done: bool,
39 publish_done: bool,
40 }
41
42 /// Build the onboarding checklist from pre-computed step flags.
43 fn build_onboarding_checklist(progress: &OnboardingProgress) -> OnboardingChecklist {
44 let OnboardingProgress {
45 profile_done,
46 stripe_done,
47 projects_done,
48 publish_done,
49 } = *progress;
50 let steps = vec![
51 OnboardingStep {
52 label: "Set up your profile: name, bio, and links",
53 done: profile_done,
54 link_href: "/dashboard?tab=settings",
55 link_label: "Go to Profile",
56 },
57 OnboardingStep {
58 label: "Connect Stripe: required to receive payments, 3% processing only",
59 done: stripe_done,
60 link_href: "/dashboard?tab=payments",
61 link_label: "Go to Payments",
62 },
63 OnboardingStep {
64 label: "Create your first project: blog, podcast, course, etc.",
65 done: projects_done,
66 link_href: "/dashboard?tab=projects",
67 link_label: "Go to Projects",
68 },
69 OnboardingStep {
70 label: "Publish your first item: upload files, set pricing, go live",
71 done: publish_done,
72 link_href: "/dashboard?tab=projects",
73 link_label: "Go to Projects",
74 },
75 ];
76 let completed = steps.iter().filter(|s| s.done).count();
77 let total = steps.len();
78 // Guard the division here so the template never does arithmetic that could
79 // panic the render (total is steps.len() = nonzero today, but the guard
80 // keeps a future dynamic step list from turning a 0 into a 500).
81 let progress_pct = (completed * 100).checked_div(total).unwrap_or(0) as u32;
82 OnboardingChecklist {
83 steps,
84 completed,
85 total,
86 progress_pct,
87 }
88 }
89
90 /// Render the main user dashboard with projects and transactions.
91 #[tracing::instrument(skip_all, name = "dashboard::dashboard")]
92 pub(super) async fn dashboard(
93 State(db): State<PgPool>,
94 State(config): State<Config>,
95 State(payments): State<crate::Billing>,
96 session: Session,
97 AuthUser(session_user): AuthUser,
98 ValidatedQuery(query): ValidatedQuery<UserTabQuery>,
99 ) -> Result<impl IntoResponse> {
100 let csrf_token = get_csrf_token(&session).await;
101
102 // These two reads are independent, run them concurrently rather than in
103 // series so the dashboard pays one round-trip's latency, not two (Run 11
104 // Perf MOD tail; revisits Run 10 M6 now that the heavy per-render work is gone).
105 //
106 // It was four until `6b24f2df` step 5: the other two fetched the incoming and
107 // outgoing transactions for a template field nothing read, and the payments
108 // panel that does want them fetches its own.
109 let (db_user, db_projects) = tokio::try_join!(
110 db::users::get_user_by_id(&db, session_user.id),
111 db::projects::get_projects_by_user(&db, session_user.id),
112 )?;
113 let db_user = db_user.ok_or(AppError::NotFound)?;
114
115 let user = User::from(&db_user);
116
117 let projects: Vec<ProjectCard> = db_projects.iter().map(ProjectCard::from_db).collect();
118
119 // Build onboarding checklist for creators who haven't completed all steps
120 let onboarding_dismissed = session
121 .get::<bool>(ONBOARDING_DISMISSED_KEY)
122 .await
123 .ok()
124 .flatten()
125 .unwrap_or(false);
126 let (onboarding, show_checklist_recovery) = if session_user.can_create_projects {
127 let profile_done = db_user.display_name.as_ref().is_some_and(|n| !n.is_empty());
128 let stripe_done = user.stripe_connected;
129 let projects_done = !db_projects.is_empty();
130 let publish_done = if projects_done {
131 db::items::has_public_item_by_user(&db, session_user.id).await?
132 } else {
133 false
134 };
135
136 let all_done = profile_done && stripe_done && projects_done && publish_done;
137 if all_done {
138 (None, false)
139 } else if onboarding_dismissed {
140 (None, true)
141 } else {
142 (
143 Some(build_onboarding_checklist(&OnboardingProgress {
144 profile_done,
145 stripe_done,
146 projects_done,
147 publish_done,
148 })),
149 false,
150 )
151 }
152 } else {
153 (None, false)
154 };
155
156 let suspended = db_user.is_suspended();
157 let suspension_reason = db_user.suspension_reason.clone();
158 let has_pending_appeal =
159 db_user.appeal_submitted_at.is_some() && db_user.appeal_decided_at.is_none();
160 let appeal_decision = db_user.appeal_decision.clone();
161 let appeal_response = db_user.appeal_response.clone();
162
163 // Check for one-time password breach warning (set during signup/password change)
164 let password_warning = session
165 .get::<String>("password_warning")
166 .await
167 .ok()
168 .flatten();
169 if password_warning.is_some() {
170 session.remove::<String>("password_warning").await.ok();
171 }
172
173 // The shown panel arrives with the document rather than a round trip later,
174 // for the reason `9b958e7b` gives, and it is chosen server-side so the six
175 // links that used to hand out a `/dashboard#tab-*` are links the page can
176 // act on before it renders. `6b24f2df` step 5.
177 let deactivated = db_user.is_deactivated();
178 let shown = quasi::user_tabs::shown_at(
179 query.tab.as_deref(),
180 deactivated,
181 session_user.can_create_projects,
182 );
183 // Only the four tabs anything opens on are fillable. Analytics is pressed
184 // rather than linked to, and it answers for itself when its screen is on.
185 let panel =
186 match quasi::user_tabs::route_at(shown, deactivated, session_user.can_create_projects) {
187 "payments" => tabs::build_payments(&db, csrf_token.clone(), &session_user)
188 .await?
189 .render(),
190 "settings" => tabs::build_settings(
191 &db,
192 &config,
193 &payments,
194 csrf_token.clone(),
195 &session_user,
196 query.section.as_deref(),
197 )
198 .await?
199 .render(),
200 // The fill rather than the fragment: the strip wraps this itself.
201 "support" => Ok(quasi::user_support::fill(&session_user.email)),
202 // The fill rather than the fragment: the strip wraps this in the
203 // region itself, so a second one here would nest two ids.
204 _ => Ok(quasi::user_projects::fill(
205 &projects,
206 session_user.can_create_projects,
207 )),
208 }
209 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
210
211 Ok(DashboardUserTemplate {
212 tabs: quasi::user_tabs::html(shown, &panel, deactivated, session_user.can_create_projects),
213 csrf_token,
214 session_user: Some(session_user),
215 user,
216 onboarding,
217 show_checklist_recovery,
218 suspended,
219 suspension_reason,
220 has_pending_appeal,
221 appeal_decision,
222 appeal_response,
223 password_warning,
224 deactivated,
225 creator_paused: db_user.is_creator_paused(),
226 })
227 }
228
229 /// Render the dashboard view for a single owned project.
230 #[tracing::instrument(skip_all, name = "dashboard::dashboard_project")]
231 pub(super) async fn dashboard_project(
232 State(db): State<PgPool>,
233 State(config): State<Config>,
234 session: Session,
235 AuthUser(session_user): AuthUser,
236 Path(slug): Path<String>,
237 ValidatedQuery(query): ValidatedQuery<ItemTabQuery>,
238 ) -> Result<impl IntoResponse> {
239 let csrf_token = get_csrf_token(&session).await;
240 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
241
242 let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug)
243 .await?
244 .ok_or(AppError::NotFound)?;
245
246 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
247 let project = Project::from_db(&db_project, db_items.len() as u32);
248
249 let git_enabled = config.build.git_repos_path.is_some();
250 let synckit_enabled = db_project.features.iter().any(|f| f == "cloud_sync");
251
252 // The shown panel arrives with the document rather than a round trip later,
253 // for the reason `9b958e7b` gives, and it is chosen server-side so the
254 // Stripe return and the overview's Go to Content are links rather than a
255 // hash the page's JS has to act on. `6b24f2df`.
256 let shown = quasi::project_tabs::shown_at(query.tab.as_deref(), git_enabled, synckit_enabled);
257 let panel = match quasi::project_tabs::route_at(shown, git_enabled, synckit_enabled) {
258 // The described panel goes in without the region wrapper its route
259 // answers with: the strip draws that div, and two elements carrying one
260 // id is a target nothing can aim at.
261 "content" => {
262 let content = project_tabs::build_content(&db, &session_user, &db_project).await?;
263 Ok(quasi::project_content::fill(
264 db_project.slug.as_ref(),
265 &content.items,
266 &content.deleted_items,
267 &content.posts,
268 &quasi::project_content::View::default(),
269 ))
270 }
271 "synckit" => project_tabs::build_synckit(&db, &session_user, &db_project)
272 .await?
273 .render(),
274 // The fill rather than the fragment, as for Content above: the strip
275 // draws the region and two elements carrying one id is a target nothing
276 // can aim at.
277 _ => {
278 let overview = project_tabs::build_overview(&db, &session_user, &db_project).await?;
279 Ok(quasi::project_overview::fill(
280 &overview.project_slug,
281 &overview.stats,
282 overview.stripe_connected,
283 overview.has_items,
284 overview.has_published_item,
285 ))
286 }
287 }
288 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
289
290 Ok(DashboardProjectTemplate {
291 csrf_token,
292 session_user: Some(session_user),
293 project,
294 tabs: quasi::project_tabs::html(
295 db_project.slug.as_ref(),
296 shown,
297 &panel,
298 git_enabled,
299 synckit_enabled,
300 ),
301 })
302 }
303
304 /// Render the dashboard shell for a single owned item (tabs loaded via HTMX).
305 #[tracing::instrument(skip_all, name = "dashboard::dashboard_item")]
306 pub(super) async fn dashboard_item(
307 State(db): State<PgPool>,
308 session: Session,
309 AuthUser(session_user): AuthUser,
310 Path(id): Path<String>,
311 ValidatedQuery(query): ValidatedQuery<ItemTabQuery>,
312 ) -> Result<impl IntoResponse> {
313 let csrf_token = get_csrf_token(&session).await;
314
315 let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?;
316
317 let db_item = db::items::get_item_by_id(&db, item_id)
318 .await?
319 .ok_or(AppError::NotFound)?;
320
321 let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
322 .await?
323 .ok_or(AppError::NotFound)?;
324
325 // Verify ownership
326 if db_project.user_id != session_user.id {
327 return Err(AppError::Forbidden);
328 }
329
330 let is_free = db_item.price_cents == 0;
331 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
332 let item = Item::from_db_detail(
333 &db_item,
334 &item_tags,
335 None,
336 None,
337 is_free,
338 true,
339 session_user.settlement_currency,
340 );
341
342 // The shown panel is rendered here rather than fetched. The page used to
343 // give its panel container an `hx-trigger="load"`, so it drew an empty box
344 // and filled it a round trip later; a described strip cannot say that
345 // (`9b958e7b`), and the overview template wants nothing this handler has not
346 // already built. `6b24f2df`.
347 let is_bundle = item.item_type == "bundle";
348 let shown = quasi::item_tabs::shown_at(query.tab.as_deref(), is_bundle);
349 // Only the two tabs anything links to are fillable here. Nothing in the tree
350 // links to details, pricing or sales, measured 2026-08-19, and each of those
351 // wants queries this handler does not make; add one when a link appears
352 // rather than paying for four panels nobody asks for.
353 let panel = match quasi::item_tabs::route_at(shown, is_bundle) {
354 "files" => {
355 let db_versions = db::versions::get_versions_by_item(&db, item_id).await?;
356 let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
357 // Described panel, bespoke uploader inside it. See
358 // `crate::quasi::item_files`.
359 let uploader = ItemVersionUploadTemplate {
360 item: item.clone(),
361 versions: versions.clone(),
362 }
363 .render()
364 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
365 quasi::item_files::fragment(&item.id, &versions, &uploader)
366 }
367 _ => ItemOverviewTabTemplate { item: item.clone() }
368 .render()
369 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?,
370 };
371 let tabs = quasi::item_tabs::html(&item.id, shown, &panel, is_bundle);
372
373 Ok(DashboardItemTemplate {
374 csrf_token,
375 session_user: Some(session_user),
376 item,
377 project_title: db_project.title,
378 project_slug: db_project.slug.to_string(),
379 tabs,
380 })
381 }
382
383 /// Render the HTMX partial for item analytics (stats + revenue chart).
384 #[tracing::instrument(skip_all, name = "dashboard::dashboard_item_analytics")]
385 pub(super) async fn dashboard_item_analytics(
386 State(db): State<PgPool>,
387 AuthUser(session_user): AuthUser,
388 Path(id): Path<String>,
389 ValidatedQuery(query): ValidatedQuery<AnalyticsQuery>,
390 ) -> Result<impl IntoResponse> {
391 let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?;
392
393 let db_item = db::items::get_item_by_id(&db, item_id)
394 .await?
395 .ok_or(AppError::NotFound)?;
396
397 let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
398 .await?
399 .ok_or(AppError::NotFound)?;
400
401 if db_project.user_id != session_user.id {
402 return Err(AppError::Forbidden);
403 }
404
405 let range = query
406 .range
407 .as_deref()
408 .and_then(|s| s.parse::<TimeRange>().ok())
409 .unwrap_or(TimeRange::Days30);
410
411 let buckets =
412 db::analytics::get_revenue_timeseries(&db, session_user.id, None, Some(item_id), &range)
413 .await?;
414
415 let comparison =
416 db::analytics::get_period_comparison(&db, session_user.id, None, Some(item_id), &range)
417 .await?;
418
419 let bars = super::build_chart_bars(&buckets, session_user.settlement_currency);
420
421 let currency = db::users::get_user_by_id(&db, session_user.id)
422 .await?
423 .map(|u| u.settlement_currency)
424 .unwrap_or_default();
425 let revenue_str = comparison.current_revenue_cents.format_revenue(currency);
426
427 let db_versions = db::versions::get_versions_by_item(&db, item_id).await?;
428 let total_downloads: i32 = db_versions.iter().map(|v| v.download_count).sum();
429
430 let stats = vec![
431 StatCard {
432 label: "Revenue".to_string(),
433 value: revenue_str,
434 change: comparison.revenue_change().map(|(t, _)| t),
435 is_positive: comparison.revenue_change().is_none_or(|(_, p)| p),
436 },
437 StatCard {
438 label: "Sales".to_string(),
439 value: comparison.current_sales.to_string(),
440 change: comparison.sales_change().map(|(t, _)| t),
441 is_positive: comparison.sales_change().is_none_or(|(_, p)| p),
442 },
443 StatCard {
444 label: "Downloads".to_string(),
445 value: total_downloads.to_string(),
446 change: None,
447 is_positive: true,
448 },
449 ];
450
451 Ok(ItemAnalyticsPartialTemplate {
452 stats,
453 bars,
454 item_id: item_id.to_string(),
455 active_range: range.to_string(),
456 })
457 }
458
459 /// Dismiss the onboarding checklist for the current session.
460 /// Returns a recovery link so the user can bring it back.
461 #[tracing::instrument(skip_all, name = "dashboard::dismiss_onboarding")]
462 pub(super) async fn dismiss_onboarding(
463 session: Session,
464 AuthUser(_session_user): AuthUser,
465 ) -> Result<impl IntoResponse> {
466 session
467 .insert(ONBOARDING_DISMISSED_KEY, true)
468 .await
469 .context("session insert")?;
470 Ok(axum::response::Html(
471 "<div style=\"padding: 0.75rem 0; text-align: right;\">\
472 <a href=\"#\" hx-post=\"/dashboard/onboarding/restore\" hx-target=\"#onboarding-area\" hx-swap=\"innerHTML\" \
473 style=\"font-size: 0.85rem; opacity: 0.7;\">Show setup checklist</a></div>",
474 ))
475 }
476
477 /// Restore the onboarding checklist after it was dismissed.
478 #[tracing::instrument(skip_all, name = "dashboard::restore_onboarding")]
479 pub(super) async fn restore_onboarding(
480 State(db): State<PgPool>,
481 session: Session,
482 AuthUser(session_user): AuthUser,
483 ) -> Result<impl IntoResponse> {
484 session.remove::<bool>(ONBOARDING_DISMISSED_KEY).await.ok();
485
486 let db_user = db::users::get_user_by_id(&db, session_user.id)
487 .await?
488 .ok_or(AppError::NotFound)?;
489 let db_projects = db::projects::get_projects_by_user(&db, session_user.id).await?;
490
491 let profile_done = db_user.display_name.as_ref().is_some_and(|n| !n.is_empty());
492 let stripe_done = db_user.stripe_account_id.is_some();
493 let projects_done = !db_projects.is_empty();
494 let publish_done = if projects_done {
495 db::items::has_public_item_by_user(&db, session_user.id).await?
496 } else {
497 false
498 };
499
500 let checklist = build_onboarding_checklist(&OnboardingProgress {
501 profile_done,
502 stripe_done,
503 projects_done,
504 publish_done,
505 });
506
507 Ok(OnboardingChecklistPartialTemplate { checklist })
508 }
509