Skip to main content

max / makenotwork

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