Skip to main content

max / makenotwork

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