Skip to main content

max / makenotwork

41.8 KB · 1173 lines History Blame Raw
1 //! Project HTMX tab partials.
2
3 use crate::extractors::ValidatedQuery;
4 use axum::extract::{Path, State};
5 use axum::http::HeaderMap;
6 use axum::response::IntoResponse;
7
8 use std::collections::{HashMap, HashSet};
9
10 use crate::{
11 auth::AuthUser,
12 config::Config,
13 db::{self, ItemId, Slug, analytics::TimeRange},
14 error::{AppError, Result},
15 helpers,
16 templates::{
17 LinkedRepoView, ProjectAnalyticsTabTemplate, ProjectBlogTabTemplate,
18 ProjectCodeTabTemplate, ProjectContentTabTemplate, ProjectMembersTabTemplate,
19 ProjectMonetizationTabTemplate, ProjectOverviewTabTemplate, ProjectPromotionsTabTemplate,
20 ProjectSettingsTabTemplate, ProjectSubscriptionsTabTemplate, ProjectSyncKitTabTemplate,
21 RepoCollaboratorView,
22 },
23 types::{
24 BlogPostDashboardRow, ContentItem, Project, ProjectMemberRow, PromoCodeRow, StatCard,
25 SubscriptionTier, SyncAppRow,
26 },
27 };
28 use sqlx::PgPool;
29
30 use super::AnalyticsQuery;
31
32 /// Build the content items list with bundle children nested under their parent.
33 ///
34 /// Unlisted items that belong to a bundle are shown only as children of that bundle,
35 /// not as top-level rows. Listed items always appear at the top level even if they
36 /// are also in a bundle.
37 fn build_content_items_with_bundles(
38 db_items: &[db::DbItem],
39 bundle_map: &[(ItemId, ItemId)],
40 currency: crate::currency::SettlementCurrency,
41 ) -> Vec<ContentItem> {
42 // Build bundle_id → [child_item_id] map
43 let mut children_of: HashMap<ItemId, Vec<ItemId>> = HashMap::new();
44 let mut child_to_bundle: HashMap<ItemId, ItemId> = HashMap::new();
45 for &(bundle_id, child_id) in bundle_map {
46 children_of.entry(bundle_id).or_default().push(child_id);
47 child_to_bundle.insert(child_id, bundle_id);
48 }
49
50 // Index all items by ID for lookup
51 let item_by_id: HashMap<ItemId, &db::DbItem> = db_items.iter().map(|i| (i.id, i)).collect();
52
53 // Items that are unlisted AND belong to a bundle should be hidden from top level
54 let hidden_at_top: HashSet<ItemId> = db_items
55 .iter()
56 .filter(|i| !i.listed && child_to_bundle.contains_key(&i.id))
57 .map(|i| i.id)
58 .collect();
59
60 let mut items = Vec::new();
61 let mut pos = 1u32;
62 for db_item in db_items {
63 if hidden_at_top.contains(&db_item.id) {
64 continue;
65 }
66
67 let mut content_item = ContentItem::from_db(db_item, pos, currency);
68 pos += 1;
69
70 // If this is a bundle, attach its children
71 if let Some(child_ids) = children_of.get(&db_item.id) {
72 for (ci, child_id) in child_ids.iter().enumerate() {
73 if let Some(child_db) = item_by_id.get(child_id) {
74 content_item.children.push(ContentItem::from_db(
75 child_db,
76 (ci + 1) as u32,
77 currency,
78 ));
79 }
80 }
81 }
82
83 items.push(content_item);
84 }
85
86 items
87 }
88
89 /// Resolve a project by slug for the authenticated user and check its ETag.
90 /// Returns `Ok(Err(304))` if the client's cached version is fresh,
91 /// or `Ok(Ok((project, generation)))` if rendering is needed.
92 async fn resolve_project_etag(
93 db: &PgPool,
94 user_id: db::UserId,
95 slug: &str,
96 headers: &HeaderMap,
97 ) -> Result<std::result::Result<(db::DbProject, i64), axum::response::Response>> {
98 let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?;
99 let db_project = db::projects::get_project_by_user_and_slug(db, user_id, &slug)
100 .await?
101 .ok_or(AppError::NotFound)?;
102
103 let generation = db_project.cache_generation;
104 if let Some(not_modified) = helpers::check_etag(headers, generation) {
105 return Ok(Err(not_modified));
106 }
107 Ok(Ok((db_project, generation)))
108 }
109
110 /// Render the HTMX partial for the project overview tab with stats.
111 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_overview")]
112 pub(super) async fn project_tab_overview(
113 State(db): State<PgPool>,
114 AuthUser(session_user): AuthUser,
115 headers: HeaderMap,
116 Path(slug): Path<String>,
117 ) -> Result<axum::response::Response> {
118 let (db_project, generation) =
119 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
120 Ok(pair) => pair,
121 Err(not_modified) => return Ok(not_modified),
122 };
123
124 Ok(helpers::with_etag(
125 generation,
126 build_overview(&db, &session_user, &db_project).await?,
127 ))
128 }
129
130 /// The overview tab's contents, without the transport around them.
131 ///
132 /// Split out of the handler 2026-08-19 so the project page can render this panel
133 /// into its described strip (`6b24f2df`): the strip's shown tab arrives with the
134 /// document, so whoever renders the page needs the same thing the route answers.
135 /// The page handler was already building an identical `stats` vector for a
136 /// template field nothing read.
137 pub(super) async fn build_overview(
138 db: &PgPool,
139 session_user: &crate::auth::SessionUser,
140 db_project: &db::DbProject,
141 ) -> Result<ProjectOverviewTabTemplate> {
142 let db_items = db::items::get_items_by_project(db, db_project.id).await?;
143 let (revenue_cents, sales_count) =
144 db::transactions::get_revenue_by_project(db, db_project.id).await?;
145
146 // Renders every currency the project earned in, largest first. Normally one.
147 let revenue_str = revenue_cents.display(session_user.settlement_currency);
148
149 let stats = vec![
150 StatCard {
151 label: "Total Revenue".to_string(),
152 value: revenue_str,
153 change: None,
154 is_positive: true,
155 },
156 StatCard {
157 label: "Total Sales".to_string(),
158 value: sales_count.to_string(),
159 change: None,
160 is_positive: true,
161 },
162 StatCard {
163 label: "Items".to_string(),
164 value: db_items.len().to_string(),
165 change: None,
166 is_positive: true,
167 },
168 ];
169
170 let db_user = db::users::get_user_by_id(db, session_user.id)
171 .await?
172 .ok_or(AppError::NotFound)?;
173
174 let has_items = !db_items.is_empty();
175 let has_published_item = db_items.iter().any(|i| i.is_public);
176
177 Ok(ProjectOverviewTabTemplate {
178 stats,
179 project_slug: db_project.slug.to_string(),
180 stripe_connected: db_user.stripe_account_id.is_some(),
181 has_items,
182 has_published_item,
183 })
184 }
185
186 /// Render the HTMX partial for the project content/items tab.
187 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_content")]
188 pub(super) async fn project_tab_content(
189 State(db): State<PgPool>,
190 State(config): State<Config>,
191 AuthUser(session_user): AuthUser,
192 headers: HeaderMap,
193 Path(slug): Path<String>,
194 ValidatedQuery(query): ValidatedQuery<ContentQuery>,
195 ) -> Result<axum::response::Response> {
196 let described = config
197 .quasi_screens
198 .enabled(crate::quasi::project_content::SCREEN);
199 let view = if described {
200 query.view()?
201 } else {
202 crate::quasi::project_content::View::default()
203 };
204
205 // The ETag is the project's cache generation, which does not move when a
206 // filter does. A narrowed panel is a different body under the same tag, so
207 // it is answered without one rather than cached as the whole table.
208 if described && !view.is_default() {
209 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
210 let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug)
211 .await?
212 .ok_or(AppError::NotFound)?;
213 return Ok(content_panel(&db, &session_user, &db_project, &view)
214 .await?
215 .into_response());
216 }
217
218 let (db_project, generation) =
219 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
220 Ok(pair) => pair,
221 Err(not_modified) => return Ok(not_modified),
222 };
223
224 if described {
225 return Ok(helpers::with_etag(
226 generation,
227 content_panel(&db, &session_user, &db_project, &view).await?,
228 ));
229 }
230
231 Ok(helpers::with_etag(
232 generation,
233 build_content(&db, &session_user, &db_project).await?,
234 ))
235 }
236
237 /// The content tab's contents, without the transport around them.
238 ///
239 /// Split out 2026-08-19 for the described strip (`6b24f2df`): the project page
240 /// can open on this tab, so it needs what the route answers.
241 pub(super) async fn build_content(
242 db: &PgPool,
243 session_user: &crate::auth::SessionUser,
244 db_project: &db::DbProject,
245 ) -> Result<ProjectContentTabTemplate> {
246 let db_items = db::items::get_items_by_project(db, db_project.id).await?;
247 let bundle_map = db::bundles::get_project_bundle_map(db, db_project.id).await?;
248 let db_deleted = db::items::get_deleted_items_by_project(db, db_project.id).await?;
249 let db_posts = db::blog_posts::get_blog_posts_by_project(db, db_project.id).await?;
250
251 let items =
252 build_content_items_with_bundles(&db_items, &bundle_map, session_user.settlement_currency);
253 let deleted_items: Vec<crate::templates::DeletedItemRow> = db_deleted
254 .iter()
255 .map(|i| crate::templates::DeletedItemRow {
256 id: i.id.to_string(),
257 title: i.title.clone(),
258 deleted_at: i
259 .deleted_at
260 .map(|d| d.format("%b %d, %Y").to_string())
261 .unwrap_or_default(),
262 })
263 .collect();
264
265 let posts: Vec<BlogPostDashboardRow> = db_posts
266 .into_iter()
267 .map(|p| BlogPostDashboardRow {
268 id: p.id.to_string(),
269 title: p.title,
270 slug: p.slug.to_string(),
271 status: if p.published_at.is_some() {
272 "Published".to_string()
273 } else {
274 "Draft".to_string()
275 },
276 status_tone: if p.published_at.is_some() {
277 crate::types::BadgeStatus::Live.tone()
278 } else {
279 crate::types::BadgeStatus::Pending.tone()
280 },
281 published_at: p
282 .published_at
283 .map_or_else(|| "-".to_string(), |d| d.format("%b %d, %Y").to_string()),
284 })
285 .collect();
286
287 Ok(ProjectContentTabTemplate {
288 items,
289 deleted_items,
290 project_slug: db_project.slug.to_string(),
291 project_id: db_project.id.to_string(),
292 posts,
293 })
294 }
295
296 /// Render the HTMX partial for the project analytics tab.
297 /// Analytics data changes constantly, so no ETag caching.
298 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_analytics")]
299 pub(super) async fn project_tab_analytics(
300 State(db): State<PgPool>,
301 AuthUser(session_user): AuthUser,
302 Path(slug): Path<String>,
303 ValidatedQuery(query): ValidatedQuery<AnalyticsQuery>,
304 ) -> Result<impl IntoResponse> {
305 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
306 let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug)
307 .await?
308 .ok_or(AppError::NotFound)?;
309
310 let range = query
311 .range
312 .as_deref()
313 .and_then(|s| s.parse::<TimeRange>().ok())
314 .unwrap_or(TimeRange::Days30);
315
316 let buckets = db::analytics::get_revenue_timeseries(
317 &db,
318 session_user.id,
319 Some(db_project.id),
320 None,
321 &range,
322 )
323 .await?;
324
325 let comparison = db::analytics::get_period_comparison(
326 &db,
327 session_user.id,
328 Some(db_project.id),
329 None,
330 &range,
331 )
332 .await?;
333
334 let bars = super::build_chart_bars(&buckets, session_user.settlement_currency);
335
336 let revenue_str = crate::formatting::format_revenue(
337 comparison.current_revenue_cents.as_i64(),
338 session_user.settlement_currency,
339 );
340
341 // Page view stats for this project
342 let (current_views, prev_views) = db::page_views::get_view_period_comparison(
343 &db,
344 session_user.id,
345 Some(db_project.id),
346 &range,
347 )
348 .await?;
349 let view_change = db::analytics::pct_change(current_views, prev_views);
350
351 let mut stats = vec![
352 StatCard {
353 label: "Views".to_string(),
354 value: current_views.to_string(),
355 change: view_change.as_ref().map(|(t, _)| t.clone()),
356 is_positive: view_change.is_none_or(|(_, p)| p),
357 },
358 StatCard {
359 label: "Revenue".to_string(),
360 value: revenue_str,
361 change: comparison.revenue_change().map(|(t, _)| t),
362 is_positive: comparison.revenue_change().is_none_or(|(_, p)| p),
363 },
364 StatCard {
365 label: "Sales".to_string(),
366 value: comparison.current_sales.to_string(),
367 change: comparison.sales_change().map(|(t, _)| t),
368 is_positive: comparison.sales_change().is_none_or(|(_, p)| p),
369 },
370 StatCard {
371 label: "Followers".to_string(),
372 value: comparison.current_followers.to_string(),
373 change: comparison.followers_change().map(|(t, _)| t),
374 is_positive: comparison.followers_change().is_none_or(|(_, p)| p),
375 },
376 ];
377
378 if current_views > 0 {
379 let conversion = format!(
380 "{:.1}%",
381 comparison.current_sales as f64 / current_views as f64 * 100.0
382 );
383 stats.push(StatCard {
384 label: "Conversion".to_string(),
385 value: conversion,
386 change: None,
387 is_positive: true,
388 });
389 }
390
391 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
392 let items: Vec<ContentItem> = db_items
393 .iter()
394 .enumerate()
395 .map(|(i, item)| {
396 ContentItem::from_db(item, (i + 1) as u32, session_user.settlement_currency)
397 })
398 .collect();
399
400 Ok(ProjectAnalyticsTabTemplate {
401 stats,
402 bars,
403 items,
404 project_slug: db_project.slug.to_string(),
405 active_range: range.to_string(),
406 })
407 }
408
409 /// Render the HTMX partial for the project settings tab.
410 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_settings")]
411 pub(super) async fn project_tab_settings(
412 State(db): State<PgPool>,
413 AuthUser(session_user): AuthUser,
414 headers: HeaderMap,
415 Path(slug): Path<String>,
416 ) -> Result<axum::response::Response> {
417 let (db_project, generation) =
418 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
419 Ok(pair) => pair,
420 Err(not_modified) => return Ok(not_modified),
421 };
422
423 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
424
425 let project = Project::from_db(&db_project, db_items.len() as u32);
426 let category_name = db::categories::get_project_category_name(&db, db_project.id)
427 .await?
428 .unwrap_or_default();
429
430 let project_id = db_project.id.to_string();
431
432 let features = db_project.features.clone();
433 let project_features = db::ProjectFeature::all();
434 let sections = db::project_sections::list_by_project(&db, db_project.id).await?;
435
436 let pricing_model = db_project.pricing_model.to_string();
437 let price_dollars = if db_project.price_cents > 0 {
438 crate::formatting::format_dollars_plain(db_project.price_cents)
439 } else {
440 String::new()
441 };
442 let pwyw_min_dollars = match db_project.pwyw_min_cents {
443 Some(c) if c > 0 => crate::formatting::format_dollars_plain(c),
444 _ => String::new(),
445 };
446
447 Ok(helpers::with_etag(
448 generation,
449 ProjectSettingsTabTemplate {
450 project,
451 category_name,
452 project_id,
453 features,
454 project_features,
455 sections,
456 pricing_model,
457 price_dollars,
458 pwyw_min_dollars,
459 theme_options: crate::theming::theme_options(db_project.theme_id.as_deref()),
460 },
461 ))
462 }
463
464 /// Render the HTMX partial for the project subscriptions tab (tier management).
465 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_subscriptions")]
466 pub(super) async fn project_tab_subscriptions(
467 State(db): State<PgPool>,
468 AuthUser(session_user): AuthUser,
469 headers: HeaderMap,
470 Path(slug): Path<String>,
471 ) -> Result<axum::response::Response> {
472 let (db_project, generation) =
473 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
474 Ok(pair) => pair,
475 Err(not_modified) => return Ok(not_modified),
476 };
477
478 let db_user = db::users::get_user_by_id(&db, session_user.id)
479 .await?
480 .ok_or(AppError::NotFound)?;
481
482 let db_tiers = db::subscriptions::get_all_tiers_by_project(&db, db_project.id).await?;
483 let tiers: Vec<SubscriptionTier> = db_tiers
484 .iter()
485 .map(|t| SubscriptionTier::from_db(t, db_user.settlement_currency))
486 .collect();
487
488 let subscriber_count =
489 db::subscriptions::get_project_subscriber_count(&db, db_project.id).await?;
490
491 Ok(helpers::with_etag(
492 generation,
493 ProjectSubscriptionsTabTemplate {
494 project_id: db_project.id.to_string(),
495 project_slug: db_project.slug.to_string(),
496 tiers,
497 subscriber_count,
498 stripe_connected: db_user.stripe_account_id.is_some(),
499 },
500 ))
501 }
502
503 /// Render the HTMX partial for the project blog tab.
504 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_blog")]
505 pub(super) async fn project_tab_blog(
506 State(db): State<PgPool>,
507 AuthUser(session_user): AuthUser,
508 headers: HeaderMap,
509 Path(slug): Path<String>,
510 ) -> Result<axum::response::Response> {
511 let (db_project, generation) =
512 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
513 Ok(pair) => pair,
514 Err(not_modified) => return Ok(not_modified),
515 };
516
517 let db_posts = db::blog_posts::get_blog_posts_by_project(&db, db_project.id).await?;
518
519 let posts: Vec<BlogPostDashboardRow> = db_posts
520 .into_iter()
521 .map(|p| BlogPostDashboardRow {
522 id: p.id.to_string(),
523 title: p.title,
524 slug: p.slug.to_string(),
525 status: if p.published_at.is_some() {
526 "Published".to_string()
527 } else {
528 "Draft".to_string()
529 },
530 status_tone: if p.published_at.is_some() {
531 crate::types::BadgeStatus::Live.tone()
532 } else {
533 crate::types::BadgeStatus::Pending.tone()
534 },
535 published_at: p
536 .published_at
537 .map_or_else(|| "-".to_string(), |d| d.format("%b %d, %Y").to_string()),
538 })
539 .collect();
540
541 Ok(helpers::with_etag(
542 generation,
543 ProjectBlogTabTemplate {
544 project_id: db_project.id.to_string(),
545 project_slug: db_project.slug.to_string(),
546 posts,
547 },
548 ))
549 }
550
551 /// Render the HTMX partial for the project promotions tab (promo codes).
552 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_promotions")]
553 pub(super) async fn project_tab_promotions(
554 State(db): State<PgPool>,
555 AuthUser(session_user): AuthUser,
556 headers: HeaderMap,
557 Path(slug): Path<String>,
558 ) -> Result<axum::response::Response> {
559 let (db_project, generation) =
560 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
561 Ok(pair) => pair,
562 Err(not_modified) => return Ok(not_modified),
563 };
564
565 let codes = db::promo_codes::get_promo_codes_by_project(&db, db_project.id).await?;
566 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
567
568 let items: Vec<ContentItem> = db_items
569 .iter()
570 .enumerate()
571 .map(|(i, item)| {
572 ContentItem::from_db(item, (i + 1) as u32, session_user.settlement_currency)
573 })
574 .collect();
575
576 Ok(helpers::with_etag(
577 generation,
578 ProjectPromotionsTabTemplate {
579 project_id: db_project.id.to_string(),
580 project_slug: db_project.slug.to_string(),
581 promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
582 items,
583 },
584 ))
585 }
586
587 /// Render the HTMX partial for the project code tab (git repos).
588 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_code")]
589 pub(super) async fn project_tab_code(
590 State(db): State<PgPool>,
591 State(config): State<Config>,
592 AuthUser(session_user): AuthUser,
593 headers: HeaderMap,
594 Path(slug): Path<String>,
595 ) -> Result<axum::response::Response> {
596 let (db_project, generation) =
597 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
598 Ok(pair) => pair,
599 Err(not_modified) => return Ok(not_modified),
600 };
601
602 let git_enabled = config.build.git_repos_path.is_some();
603 // Degrade to an empty list on error, but LOG it, a silent `unwrap_or_default`
604 // renders the Code tab as "no repos" and makes a DB outage look like empty
605 // state with no signal (audit Run 13 Obs).
606 let db_linked_repos = db::git_repos::get_repos_by_project(&db, db_project.id)
607 .await
608 .unwrap_or_else(|e| {
609 tracing::warn!(error = ?e, project_id = %db_project.id, "code tab: failed to load linked repos; rendering as none");
610 Vec::new()
611 });
612 let all_repos = db::git_repos::get_repos_by_user(&db, session_user.id)
613 .await
614 .unwrap_or_else(|e| {
615 tracing::warn!(error = ?e, user_id = %session_user.id, "code tab: failed to load user repos; rendering as none");
616 Vec::new()
617 });
618 let available_repos: Vec<_> = all_repos
619 .into_iter()
620 .filter(|r| r.project_id.is_none())
621 .collect();
622
623 // Build linked repo views with collaborators. One batched query for all
624 // repos' collaborators, grouped in memory, avoids the per-repo N+1.
625 let repo_ids: Vec<_> = db_linked_repos.iter().map(|r| r.id).collect();
626 let mut collabs_by_repo: std::collections::HashMap<_, Vec<RepoCollaboratorView>> =
627 std::collections::HashMap::new();
628 for c in db::repo_collaborators::list_collaborators_for_repos(&db, &repo_ids)
629 .await
630 .unwrap_or_else(|e| {
631 tracing::warn!(error = ?e, "code tab: failed to load repo collaborators; rendering none");
632 Vec::new()
633 })
634 {
635 collabs_by_repo.entry(c.repo_id).or_default().push(RepoCollaboratorView {
636 user_id: c.user_id.to_string(),
637 username: c.username,
638 can_push: c.can_push,
639 });
640 }
641 let linked_repos: Vec<LinkedRepoView> = db_linked_repos
642 .iter()
643 .map(|repo| LinkedRepoView {
644 id: repo.id.to_string(),
645 name: repo.name.clone(),
646 collaborators: collabs_by_repo.remove(&repo.id).unwrap_or_default(),
647 })
648 .collect();
649
650 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
651 let project = Project::from_db(&db_project, db_items.len() as u32);
652
653 Ok(helpers::with_etag(
654 generation,
655 ProjectCodeTabTemplate {
656 project,
657 git_enabled,
658 linked_repos,
659 available_repos,
660 project_id: db_project.id.to_string(),
661 },
662 ))
663 }
664
665 /// Load a project's members as view rows plus the owner's residual split
666 /// percent. Shared by the members tab and the combined monetization tab, the
667 /// mapping was copy-pasted verbatim in both (audit Run 17 Architecture).
668 #[tracing::instrument(skip_all, name = "project_tabs::load_project_members")]
669 async fn load_project_members(
670 db: &PgPool,
671 project_id: db::ProjectId,
672 ) -> Result<(Vec<ProjectMemberRow>, i64)> {
673 let db_members = db::project_members::get_project_members(db, project_id).await?;
674 let members: Vec<ProjectMemberRow> = db_members
675 .iter()
676 .map(|m| ProjectMemberRow {
677 id: m.id.to_string(),
678 user_id: m.user_id.to_string(),
679 username: m.username.clone(),
680 display_name: m.display_name.clone(),
681 role: m.role.to_string(),
682 split_percent: m.split_percent,
683 stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled,
684 accepted: m.is_accepted(),
685 added_at: m.added_at.format("%Y-%m-%d").to_string(),
686 })
687 .collect();
688 let total_member_split = db::project_members::get_total_split_percent(db, project_id).await?;
689 Ok((members, 100 - total_member_split))
690 }
691
692 /// Render the HTMX partial for the project members tab.
693 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_members")]
694 pub(super) async fn project_tab_members(
695 State(db): State<PgPool>,
696 AuthUser(session_user): AuthUser,
697 headers: HeaderMap,
698 Path(slug): Path<String>,
699 ) -> Result<axum::response::Response> {
700 let (db_project, generation) =
701 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
702 Ok(pair) => pair,
703 Err(not_modified) => return Ok(not_modified),
704 };
705
706 let (members, owner_split) = load_project_members(&db, db_project.id).await?;
707
708 Ok(helpers::with_etag(
709 generation,
710 ProjectMembersTabTemplate {
711 project_id: db_project.id.to_string(),
712 project_slug: db_project.slug.to_string(),
713 members,
714 owner_split,
715 },
716 ))
717 }
718
719 /// Combined monetization tab: tiers, promo codes, and team splits.
720 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_monetization")]
721 pub(super) async fn project_tab_monetization(
722 State(db): State<PgPool>,
723 AuthUser(session_user): AuthUser,
724 headers: HeaderMap,
725 Path(slug): Path<String>,
726 ) -> Result<axum::response::Response> {
727 let (db_project, generation) =
728 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
729 Ok(pair) => pair,
730 Err(not_modified) => return Ok(not_modified),
731 };
732
733 let db_user = db::users::get_user_by_id(&db, session_user.id)
734 .await?
735 .ok_or(AppError::NotFound)?;
736
737 let db_tiers = db::subscriptions::get_all_tiers_by_project(&db, db_project.id).await?;
738 let tiers: Vec<SubscriptionTier> = db_tiers
739 .iter()
740 .map(|t| SubscriptionTier::from_db(t, db_user.settlement_currency))
741 .collect();
742 let subscriber_count =
743 db::subscriptions::get_project_subscriber_count(&db, db_project.id).await?;
744
745 let codes = db::promo_codes::get_promo_codes_by_project(&db, db_project.id).await?;
746 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
747 let items: Vec<ContentItem> = db_items
748 .iter()
749 .enumerate()
750 .map(|(i, item)| {
751 ContentItem::from_db(item, (i + 1) as u32, session_user.settlement_currency)
752 })
753 .collect();
754
755 let (members, owner_split) = load_project_members(&db, db_project.id).await?;
756
757 Ok(helpers::with_etag(
758 generation,
759 ProjectMonetizationTabTemplate {
760 project_id: db_project.id.to_string(),
761 project_slug: db_project.slug.to_string(),
762 tiers,
763 subscriber_count,
764 stripe_connected: db_user.stripe_account_id.is_some(),
765 promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
766 items,
767 members,
768 owner_split,
769 },
770 ))
771 }
772
773 /// Render the HTMX partial for SyncKit apps linked to a project.
774 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_synckit")]
775 pub(super) async fn project_tab_synckit(
776 State(db): State<PgPool>,
777 AuthUser(session_user): AuthUser,
778 headers: HeaderMap,
779 Path(slug): Path<String>,
780 ) -> Result<axum::response::Response> {
781 let (db_project, generation) =
782 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
783 Ok(pair) => pair,
784 Err(not_modified) => return Ok(not_modified),
785 };
786
787 Ok(helpers::with_etag(
788 generation,
789 build_synckit(&db, &session_user, &db_project).await?,
790 ))
791 }
792
793 /// The cloud-sync tab's contents, without the transport around them.
794 ///
795 /// Split out 2026-08-19 for the described strip (`6b24f2df`). This one has a
796 /// caller that matters: `routes::synckit::billing` sends a creator back here
797 /// after Stripe, and that link used to be a `#tab-synckit` the page's JS
798 /// restored by clicking.
799 pub(super) async fn build_synckit(
800 db: &PgPool,
801 session_user: &crate::auth::SessionUser,
802 db_project: &db::DbProject,
803 ) -> Result<ProjectSyncKitTabTemplate> {
804 let db_apps = db::synckit::get_sync_apps_by_project(db, db_project.id).await?;
805
806 let stats_batch = db::synckit::get_sync_app_stats_batch(db, session_user.id).await?;
807 let stats_map: std::collections::HashMap<_, _> = stats_batch
808 .into_iter()
809 .map(|(id, devices, logs)| (id, (devices, logs)))
810 .collect();
811
812 let billing_batch =
813 db::synckit_billing::get_apps_with_billing_by_project(db, db_project.id).await?;
814 let billing_map: std::collections::HashMap<_, _> =
815 billing_batch.into_iter().map(|b| (b.id, b)).collect();
816 let top_keys_map = crate::types::build_top_keys_map(db, &billing_map).await?;
817
818 let mut apps = Vec::with_capacity(db_apps.len());
819 for app in &db_apps {
820 let (device_count, log_entry_count) = stats_map.get(&app.id).copied().unwrap_or((0, 0));
821
822 let api_key_masked = format!("{}...", app.api_key_prefix);
823 let keys_secret_masked = app.keys_secret_prefix.as_ref().map(|p| format!("{p}..."));
824
825 let billing = billing_map.get(&app.id).map(|b| {
826 let mut view = crate::types::SyncAppBillingView::from_db(b);
827 crate::types::apply_top_keys(&mut view, b, top_keys_map.get(&b.id));
828 view
829 });
830
831 apps.push(SyncAppRow {
832 id: app.id.to_string(),
833 name: app.name.clone(),
834 api_key_masked,
835 api_key_full: String::new(),
836 keys_secret_masked,
837 is_active: app.is_active,
838 device_count,
839 log_entry_count,
840 created_at: app.created_at.format("%b %d, %Y").to_string(),
841 slug: app.slug.clone(),
842 project_name: None,
843 project_slug: None,
844 item_title: None,
845 billing,
846 });
847 }
848
849 Ok(ProjectSyncKitTabTemplate {
850 apps,
851 project_id: db_project.id.to_string(),
852 project_slug: db_project.slug.to_string(),
853 })
854 }
855
856 // The described Content panel: `crate::quasi::project_content`.
857 //
858 // One route answers both renderings, unlike the four screens quasi mounts for
859 // itself. Those are addressed by a path with nothing in it and could be nested
860 // whole; this one is `/dashboard/project/{slug}/tabs/content`, and a nest strips
861 // its prefix before the inner router sees it, so the slug would be gone by the
862 // time a described handler ran. The switch is read per request instead, which
863 // costs one map lookup and keeps the address in one place.
864
865 /// Which rows the described Content panel is showing, and in what order.
866 ///
867 /// The view is the address (`N12`), so this is every narrowing the shipped
868 /// screen held in a closure. `type` is spelled `kind` here because the wire name
869 /// is a keyword.
870 #[derive(serde::Deserialize)]
871 pub(super) struct ContentQuery {
872 pub q: Option<String>,
873 pub status: Option<String>,
874 #[serde(rename = "type")]
875 pub kind: Option<String>,
876 pub sort: Option<String>,
877 pub direction: Option<String>,
878 pub open: Option<String>,
879 pub ticked: Option<String>,
880 }
881
882 impl ContentQuery {
883 /// The view this address asks for, or a 404 for a sort column that is not
884 /// one. See `View::of`.
885 fn view(&self) -> Result<crate::quasi::project_content::View> {
886 crate::quasi::project_content::View::of(
887 self.q.as_deref(),
888 self.status.as_deref(),
889 self.kind.as_deref(),
890 self.sort.as_deref(),
891 self.direction.as_deref(),
892 self.open.as_deref(),
893 self.ticked.as_deref(),
894 )
895 .ok_or(AppError::NotFound)
896 }
897 }
898
899 /// What a control over the selection sends: the ticks, and the one value the
900 /// control asked for if it asked for one.
901 ///
902 /// `ticked` is quasi's name for a selection member (`Node::TICKED`), repeated
903 /// once per row, which is why this needs the `axum_extra` form extractor. The
904 /// API's own bulk routes read the same ids under `item_ids`; they are a
905 /// different reading of the same set and are left alone, because a described
906 /// write answers with the region it changed and an API route answers with a
907 /// toast.
908 #[derive(serde::Deserialize)]
909 pub(super) struct BulkForm {
910 #[serde(default)]
911 pub ticked: Vec<ItemId>,
912 pub price_dollars: Option<String>,
913 pub tag_slug: Option<String>,
914 }
915
916 /// Everything a described write needs before it writes: the project it is in,
917 /// and that the reader may write to it.
918 async fn writable_project(
919 db: &PgPool,
920 session_user: &crate::auth::SessionUser,
921 slug: &str,
922 ) -> Result<db::DbProject> {
923 session_user.check_not_suspended()?;
924 let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?;
925 db::projects::get_project_by_user_and_slug(db, session_user.id, &slug)
926 .await?
927 .ok_or(AppError::NotFound)
928 }
929
930 /// The described panel, as a route answers it.
931 ///
932 /// One function for the write handlers to end with, so a bulk publish and a
933 /// rename answer the same markup as a filter change rather than each rebuilding
934 /// it.
935 async fn content_panel(
936 db: &PgPool,
937 session_user: &crate::auth::SessionUser,
938 db_project: &db::DbProject,
939 view: &crate::quasi::project_content::View,
940 ) -> Result<axum::response::Html<String>> {
941 let content = build_content(db, session_user, db_project).await?;
942 Ok(axum::response::Html(
943 crate::quasi::project_content::fragment(
944 db_project.slug.as_ref(),
945 &content.items,
946 &content.deleted_items,
947 &content.posts,
948 view,
949 ),
950 ))
951 }
952
953 /// Answer a described write by re-reading the panel it happened in.
954 ///
955 /// Decision 7: the route that changed something is the party that knows what it
956 /// changed, so it answers with that region rather than with a toast the page has
957 /// to act on. One query more than the API route runs, on an action a creator
958 /// takes deliberately.
959 async fn wrote(
960 db: &PgPool,
961 session_user: &crate::auth::SessionUser,
962 db_project: &db::DbProject,
963 view: &crate::quasi::project_content::View,
964 ) -> Result<axum::response::Response> {
965 // The ticks are spent: they named rows that have just changed, and carrying
966 // them into the answer would re-tick a set the write already consumed.
967 let view = crate::quasi::project_content::View {
968 ticked: false,
969 ..view.clone()
970 };
971 Ok(content_panel(db, session_user, db_project, &view)
972 .await?
973 .into_response())
974 }
975
976 /// A control over the selection: publish, unpublish, delete, price or tag.
977 ///
978 /// One handler for the five, because they differ only in the verb and in what
979 /// the control asked for. The ownership check is the query's: every `db::items`
980 /// bulk statement is scoped to the project and to the project's owner, so an id
981 /// from another project matches nothing rather than being an error the reader
982 /// has to read.
983 #[tracing::instrument(skip_all, name = "project_tabs::content_bulk")]
984 pub(super) async fn content_bulk(
985 State(db): State<PgPool>,
986 AuthUser(session_user): AuthUser,
987 Path((slug, verb)): Path<(String, String)>,
988 ValidatedQuery(query): ValidatedQuery<ContentQuery>,
989 crate::extractors::ValidatedHtmlForm(form): crate::extractors::ValidatedHtmlForm<BulkForm>,
990 ) -> Result<axum::response::Response> {
991 let view = query.view()?;
992 let db_project = writable_project(&db, &session_user, &slug).await?;
993
994 if form.ticked.len() > crate::routes::api::items::BULK_ITEM_LIMIT {
995 return Err(AppError::BadRequest(format!(
996 "Too many items (max {})",
997 crate::routes::api::items::BULK_ITEM_LIMIT
998 )));
999 }
1000
1001 // A write over nothing answers the panel rather than erroring. Every
1002 // renderer draws a control over an empty selection as disabled and refuses
1003 // the press, so arriving here with no ticks is a hand-typed request or a
1004 // host without the script -- and neither is worth an error page.
1005 if !form.ticked.is_empty() {
1006 let items = &form.ticked;
1007 let project_id = db_project.id;
1008 let user_id = session_user.id;
1009 match verb.as_str() {
1010 "publish" => {
1011 db::items::bulk_publish(&db, items, project_id, user_id).await?;
1012 }
1013 "unpublish" => {
1014 db::items::bulk_unpublish(&db, items, project_id, user_id).await?;
1015 }
1016 "delete" => {
1017 db::items::bulk_delete(&db, items, project_id, user_id).await?;
1018 }
1019 "price" => {
1020 let raw =
1021 crate::pricing::parse_dollars_to_cents("Price", form.price_dollars.as_deref())?;
1022 let price = db::PriceCents::new(raw)?;
1023 db::items::bulk_update_price(&db, items, project_id, user_id, price).await?;
1024 }
1025 "tag" => {
1026 let slug = form.tag_slug.as_deref().unwrap_or_default().trim();
1027 crate::validation::validate_tag_slug(slug)?;
1028 let tag = db::tags::get_tag_by_slug(&db, slug)
1029 .await?
1030 .ok_or(AppError::NotFound)?;
1031 db::items::bulk_add_tag(&db, items, project_id, user_id, tag.id).await?;
1032 }
1033 _ => return Err(AppError::NotFound),
1034 }
1035 db::projects::bump_cache_generation(&db, project_id).await?;
1036 }
1037
1038 wrote(&db, &session_user, &db_project, &view).await
1039 }
1040
1041 /// What a row control sends when it asks for a value.
1042 #[derive(serde::Deserialize)]
1043 pub(super) struct RowForm {
1044 pub title: Option<String>,
1045 pub direction: Option<String>,
1046 }
1047
1048 /// Move one item up or down the project's own order.
1049 #[tracing::instrument(skip_all, name = "project_tabs::content_move")]
1050 pub(super) async fn content_move(
1051 State(db): State<PgPool>,
1052 AuthUser(session_user): AuthUser,
1053 Path((slug, id)): Path<(String, ItemId)>,
1054 ValidatedQuery(query): ValidatedQuery<ContentQuery>,
1055 crate::extractors::ValidatedHtmlForm(form): crate::extractors::ValidatedHtmlForm<RowForm>,
1056 ) -> Result<axum::response::Response> {
1057 let view = query.view()?;
1058 let db_project = writable_project(&db, &session_user, &slug).await?;
1059
1060 // `move_item` reads the project's items in display order and swaps a pair,
1061 // and answers Ok for a direction it cannot honour, so the ends need no
1062 // special case here.
1063 db::items::move_item(
1064 &db,
1065 db_project.id,
1066 session_user.id,
1067 id,
1068 form.direction.as_deref().unwrap_or_default(),
1069 )
1070 .await?;
1071 db::projects::bump_cache_generation(&db, db_project.id).await?;
1072
1073 wrote(&db, &session_user, &db_project, &view).await
1074 }
1075
1076 /// Publish one draft from its row, which is the bulk verb over a set of one.
1077 #[tracing::instrument(skip_all, name = "project_tabs::content_publish")]
1078 pub(super) async fn content_publish(
1079 State(db): State<PgPool>,
1080 AuthUser(session_user): AuthUser,
1081 Path((slug, id)): Path<(String, ItemId)>,
1082 ValidatedQuery(query): ValidatedQuery<ContentQuery>,
1083 ) -> Result<axum::response::Response> {
1084 let view = query.view()?;
1085 let db_project = writable_project(&db, &session_user, &slug).await?;
1086
1087 db::items::bulk_publish(&db, &[id], db_project.id, session_user.id).await?;
1088 db::projects::bump_cache_generation(&db, db_project.id).await?;
1089
1090 wrote(&db, &session_user, &db_project, &view).await
1091 }
1092
1093 /// Rename one item, which is what the inline editor became.
1094 #[tracing::instrument(skip_all, name = "project_tabs::content_rename")]
1095 pub(super) async fn content_rename(
1096 State(db): State<PgPool>,
1097 AuthUser(session_user): AuthUser,
1098 Path((slug, id)): Path<(String, ItemId)>,
1099 ValidatedQuery(query): ValidatedQuery<ContentQuery>,
1100 crate::extractors::ValidatedHtmlForm(form): crate::extractors::ValidatedHtmlForm<RowForm>,
1101 ) -> Result<axum::response::Response> {
1102 let view = query.view()?;
1103 let db_project = writable_project(&db, &session_user, &slug).await?;
1104
1105 let title = form.title.as_deref().unwrap_or_default().trim();
1106 crate::validation::validate_item_title(title)?;
1107 // The item is the project's, and `update_item` is scoped to the owner. A
1108 // title is every field this write touches; the rest stay as they are.
1109 let item = db::items::get_item_by_id(&db, id)
1110 .await?
1111 .ok_or(AppError::NotFound)?;
1112 if item.project_id != db_project.id {
1113 return Err(AppError::NotFound);
1114 }
1115 db::items::update_item(
1116 &db,
1117 id,
1118 session_user.id,
1119 Some(title),
1120 None,
1121 None,
1122 None,
1123 None,
1124 None,
1125 None,
1126 None,
1127 None,
1128 None,
1129 None,
1130 )
1131 .await?;
1132 db::projects::bump_cache_generation(&db, db_project.id).await?;
1133
1134 wrote(&db, &session_user, &db_project, &view).await
1135 }
1136
1137 /// Bring one soft-deleted item back, inside its seven days.
1138 #[tracing::instrument(skip_all, name = "project_tabs::content_restore")]
1139 pub(super) async fn content_restore(
1140 State(db): State<PgPool>,
1141 AuthUser(session_user): AuthUser,
1142 Path((slug, id)): Path<(String, ItemId)>,
1143 ValidatedQuery(query): ValidatedQuery<ContentQuery>,
1144 ) -> Result<axum::response::Response> {
1145 let view = query.view()?;
1146 let db_project = writable_project(&db, &session_user, &slug).await?;
1147
1148 if !db::items::restore_item(&db, id, session_user.id).await? {
1149 return Err(AppError::NotFound);
1150 }
1151 db::projects::bump_cache_generation(&db, db_project.id).await?;
1152
1153 wrote(&db, &session_user, &db_project, &view).await
1154 }
1155
1156 /// Delete one blog post from the table it shares this tab with.
1157 #[tracing::instrument(skip_all, name = "project_tabs::content_blog_delete")]
1158 pub(super) async fn content_blog_delete(
1159 State(db): State<PgPool>,
1160 AuthUser(session_user): AuthUser,
1161 Path((slug, id)): Path<(String, db::BlogPostId)>,
1162 ValidatedQuery(query): ValidatedQuery<ContentQuery>,
1163 ) -> Result<axum::response::Response> {
1164 let view = query.view()?;
1165 let db_project = writable_project(&db, &session_user, &slug).await?;
1166
1167 if !db::blog_posts::delete_blog_post(&db, id, session_user.id).await? {
1168 return Err(AppError::NotFound);
1169 }
1170
1171 wrote(&db, &session_user, &db_project, &view).await
1172 }
1173