Skip to main content

max / makenotwork

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