Skip to main content

max / makenotwork

27.2 KB · 782 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 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
125 let (revenue_cents, sales_count) =
126 db::transactions::get_revenue_by_project(&db, db_project.id).await?;
127
128 // Renders every currency the project earned in, largest first. Normally one.
129 let revenue_str = revenue_cents.display(session_user.settlement_currency);
130
131 let stats = vec![
132 StatCard {
133 label: "Total Revenue".to_string(),
134 value: revenue_str,
135 change: None,
136 is_positive: true,
137 },
138 StatCard {
139 label: "Total Sales".to_string(),
140 value: sales_count.to_string(),
141 change: None,
142 is_positive: true,
143 },
144 StatCard {
145 label: "Items".to_string(),
146 value: db_items.len().to_string(),
147 change: None,
148 is_positive: true,
149 },
150 ];
151
152 let db_user = db::users::get_user_by_id(&db, session_user.id)
153 .await?
154 .ok_or(AppError::NotFound)?;
155
156 let has_items = !db_items.is_empty();
157 let has_published_item = db_items.iter().any(|i| i.is_public);
158
159 Ok(helpers::with_etag(
160 generation,
161 ProjectOverviewTabTemplate {
162 stats,
163 project_slug: db_project.slug.to_string(),
164 stripe_connected: db_user.stripe_account_id.is_some(),
165 has_items,
166 has_published_item,
167 },
168 ))
169 }
170
171 /// Render the HTMX partial for the project content/items tab.
172 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_content")]
173 pub(super) async fn project_tab_content(
174 State(db): State<PgPool>,
175 AuthUser(session_user): AuthUser,
176 headers: HeaderMap,
177 Path(slug): Path<String>,
178 ) -> Result<axum::response::Response> {
179 let (db_project, generation) =
180 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
181 Ok(pair) => pair,
182 Err(not_modified) => return Ok(not_modified),
183 };
184
185 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
186 let bundle_map = db::bundles::get_project_bundle_map(&db, db_project.id).await?;
187 let db_deleted = db::items::get_deleted_items_by_project(&db, db_project.id).await?;
188 let db_posts = db::blog_posts::get_blog_posts_by_project(&db, db_project.id).await?;
189
190 let items =
191 build_content_items_with_bundles(&db_items, &bundle_map, session_user.settlement_currency);
192 let deleted_items: Vec<crate::templates::DeletedItemRow> = db_deleted
193 .iter()
194 .map(|i| crate::templates::DeletedItemRow {
195 id: i.id.to_string(),
196 title: i.title.clone(),
197 deleted_at: i
198 .deleted_at
199 .map(|d| d.format("%b %d, %Y").to_string())
200 .unwrap_or_default(),
201 })
202 .collect();
203
204 let posts: Vec<BlogPostDashboardRow> = db_posts
205 .into_iter()
206 .map(|p| BlogPostDashboardRow {
207 id: p.id.to_string(),
208 title: p.title,
209 slug: p.slug.to_string(),
210 status: if p.published_at.is_some() {
211 "Published".to_string()
212 } else {
213 "Draft".to_string()
214 },
215 status_tone: if p.published_at.is_some() {
216 crate::types::BadgeStatus::Live.tone()
217 } else {
218 crate::types::BadgeStatus::Pending.tone()
219 },
220 published_at: p
221 .published_at
222 .map_or_else(|| "-".to_string(), |d| d.format("%b %d, %Y").to_string()),
223 })
224 .collect();
225
226 Ok(helpers::with_etag(
227 generation,
228 ProjectContentTabTemplate {
229 items,
230 deleted_items,
231 project_slug: db_project.slug.to_string(),
232 project_id: db_project.id.to_string(),
233 posts,
234 },
235 ))
236 }
237
238 /// Render the HTMX partial for the project analytics tab.
239 /// Analytics data changes constantly, so no ETag caching.
240 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_analytics")]
241 pub(super) async fn project_tab_analytics(
242 State(db): State<PgPool>,
243 AuthUser(session_user): AuthUser,
244 Path(slug): Path<String>,
245 ValidatedQuery(query): ValidatedQuery<AnalyticsQuery>,
246 ) -> Result<impl IntoResponse> {
247 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
248 let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug)
249 .await?
250 .ok_or(AppError::NotFound)?;
251
252 let range = query
253 .range
254 .as_deref()
255 .and_then(|s| s.parse::<TimeRange>().ok())
256 .unwrap_or(TimeRange::Days30);
257
258 let buckets = db::analytics::get_revenue_timeseries(
259 &db,
260 session_user.id,
261 Some(db_project.id),
262 None,
263 &range,
264 )
265 .await?;
266
267 let comparison = db::analytics::get_period_comparison(
268 &db,
269 session_user.id,
270 Some(db_project.id),
271 None,
272 &range,
273 )
274 .await?;
275
276 let bars = super::build_chart_bars(&buckets, session_user.settlement_currency);
277
278 let revenue_str = crate::formatting::format_revenue(
279 comparison.current_revenue_cents.as_i64(),
280 session_user.settlement_currency,
281 );
282
283 // Page view stats for this project
284 let (current_views, prev_views) = db::page_views::get_view_period_comparison(
285 &db,
286 session_user.id,
287 Some(db_project.id),
288 &range,
289 )
290 .await?;
291 let view_change = db::analytics::pct_change(current_views, prev_views);
292
293 let mut stats = vec![
294 StatCard {
295 label: "Views".to_string(),
296 value: current_views.to_string(),
297 change: view_change.as_ref().map(|(t, _)| t.clone()),
298 is_positive: view_change.is_none_or(|(_, p)| p),
299 },
300 StatCard {
301 label: "Revenue".to_string(),
302 value: revenue_str,
303 change: comparison.revenue_change().map(|(t, _)| t),
304 is_positive: comparison.revenue_change().is_none_or(|(_, p)| p),
305 },
306 StatCard {
307 label: "Sales".to_string(),
308 value: comparison.current_sales.to_string(),
309 change: comparison.sales_change().map(|(t, _)| t),
310 is_positive: comparison.sales_change().is_none_or(|(_, p)| p),
311 },
312 StatCard {
313 label: "Followers".to_string(),
314 value: comparison.current_followers.to_string(),
315 change: comparison.followers_change().map(|(t, _)| t),
316 is_positive: comparison.followers_change().is_none_or(|(_, p)| p),
317 },
318 ];
319
320 if current_views > 0 {
321 let conversion = format!(
322 "{:.1}%",
323 comparison.current_sales as f64 / current_views as f64 * 100.0
324 );
325 stats.push(StatCard {
326 label: "Conversion".to_string(),
327 value: conversion,
328 change: None,
329 is_positive: true,
330 });
331 }
332
333 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
334 let items: Vec<ContentItem> = db_items
335 .iter()
336 .enumerate()
337 .map(|(i, item)| {
338 ContentItem::from_db(item, (i + 1) as u32, session_user.settlement_currency)
339 })
340 .collect();
341
342 Ok(ProjectAnalyticsTabTemplate {
343 stats,
344 bars,
345 items,
346 project_slug: db_project.slug.to_string(),
347 active_range: range.to_string(),
348 })
349 }
350
351 /// Render the HTMX partial for the project settings tab.
352 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_settings")]
353 pub(super) async fn project_tab_settings(
354 State(db): State<PgPool>,
355 AuthUser(session_user): AuthUser,
356 headers: HeaderMap,
357 Path(slug): Path<String>,
358 ) -> Result<axum::response::Response> {
359 let (db_project, generation) =
360 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
361 Ok(pair) => pair,
362 Err(not_modified) => return Ok(not_modified),
363 };
364
365 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
366
367 let project = Project::from_db(&db_project, db_items.len() as u32);
368 let category_name = db::categories::get_project_category_name(&db, db_project.id)
369 .await?
370 .unwrap_or_default();
371
372 let project_id = db_project.id.to_string();
373
374 let features = db_project.features.clone();
375 let project_features = db::ProjectFeature::all();
376 let sections = db::project_sections::list_by_project(&db, db_project.id).await?;
377
378 let pricing_model = db_project.pricing_model.to_string();
379 let price_dollars = if db_project.price_cents > 0 {
380 crate::formatting::format_dollars_plain(db_project.price_cents)
381 } else {
382 String::new()
383 };
384 let pwyw_min_dollars = match db_project.pwyw_min_cents {
385 Some(c) if c > 0 => crate::formatting::format_dollars_plain(c),
386 _ => String::new(),
387 };
388
389 Ok(helpers::with_etag(
390 generation,
391 ProjectSettingsTabTemplate {
392 project,
393 category_name,
394 project_id,
395 features,
396 project_features,
397 sections,
398 pricing_model,
399 price_dollars,
400 pwyw_min_dollars,
401 theme_options: crate::theming::theme_options(db_project.theme_id.as_deref()),
402 },
403 ))
404 }
405
406 /// Render the HTMX partial for the project subscriptions tab (tier management).
407 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_subscriptions")]
408 pub(super) async fn project_tab_subscriptions(
409 State(db): State<PgPool>,
410 AuthUser(session_user): AuthUser,
411 headers: HeaderMap,
412 Path(slug): Path<String>,
413 ) -> Result<axum::response::Response> {
414 let (db_project, generation) =
415 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
416 Ok(pair) => pair,
417 Err(not_modified) => return Ok(not_modified),
418 };
419
420 let db_user = db::users::get_user_by_id(&db, session_user.id)
421 .await?
422 .ok_or(AppError::NotFound)?;
423
424 let db_tiers = db::subscriptions::get_all_tiers_by_project(&db, db_project.id).await?;
425 let tiers: Vec<SubscriptionTier> = db_tiers
426 .iter()
427 .map(|t| SubscriptionTier::from_db(t, db_user.settlement_currency))
428 .collect();
429
430 let subscriber_count =
431 db::subscriptions::get_project_subscriber_count(&db, db_project.id).await?;
432
433 Ok(helpers::with_etag(
434 generation,
435 ProjectSubscriptionsTabTemplate {
436 project_id: db_project.id.to_string(),
437 project_slug: db_project.slug.to_string(),
438 tiers,
439 subscriber_count,
440 stripe_connected: db_user.stripe_account_id.is_some(),
441 },
442 ))
443 }
444
445 /// Render the HTMX partial for the project blog tab.
446 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_blog")]
447 pub(super) async fn project_tab_blog(
448 State(db): State<PgPool>,
449 AuthUser(session_user): AuthUser,
450 headers: HeaderMap,
451 Path(slug): Path<String>,
452 ) -> Result<axum::response::Response> {
453 let (db_project, generation) =
454 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
455 Ok(pair) => pair,
456 Err(not_modified) => return Ok(not_modified),
457 };
458
459 let db_posts = db::blog_posts::get_blog_posts_by_project(&db, db_project.id).await?;
460
461 let posts: Vec<BlogPostDashboardRow> = db_posts
462 .into_iter()
463 .map(|p| BlogPostDashboardRow {
464 id: p.id.to_string(),
465 title: p.title,
466 slug: p.slug.to_string(),
467 status: if p.published_at.is_some() {
468 "Published".to_string()
469 } else {
470 "Draft".to_string()
471 },
472 status_tone: if p.published_at.is_some() {
473 crate::types::BadgeStatus::Live.tone()
474 } else {
475 crate::types::BadgeStatus::Pending.tone()
476 },
477 published_at: p
478 .published_at
479 .map_or_else(|| "-".to_string(), |d| d.format("%b %d, %Y").to_string()),
480 })
481 .collect();
482
483 Ok(helpers::with_etag(
484 generation,
485 ProjectBlogTabTemplate {
486 project_id: db_project.id.to_string(),
487 project_slug: db_project.slug.to_string(),
488 posts,
489 },
490 ))
491 }
492
493 /// Render the HTMX partial for the project promotions tab (promo codes).
494 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_promotions")]
495 pub(super) async fn project_tab_promotions(
496 State(db): State<PgPool>,
497 AuthUser(session_user): AuthUser,
498 headers: HeaderMap,
499 Path(slug): Path<String>,
500 ) -> Result<axum::response::Response> {
501 let (db_project, generation) =
502 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
503 Ok(pair) => pair,
504 Err(not_modified) => return Ok(not_modified),
505 };
506
507 let codes = db::promo_codes::get_promo_codes_by_project(&db, db_project.id).await?;
508 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
509
510 let items: Vec<ContentItem> = db_items
511 .iter()
512 .enumerate()
513 .map(|(i, item)| {
514 ContentItem::from_db(item, (i + 1) as u32, session_user.settlement_currency)
515 })
516 .collect();
517
518 Ok(helpers::with_etag(
519 generation,
520 ProjectPromotionsTabTemplate {
521 project_id: db_project.id.to_string(),
522 project_slug: db_project.slug.to_string(),
523 promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
524 items,
525 },
526 ))
527 }
528
529 /// Render the HTMX partial for the project code tab (git repos).
530 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_code")]
531 pub(super) async fn project_tab_code(
532 State(db): State<PgPool>,
533 State(config): State<Config>,
534 AuthUser(session_user): AuthUser,
535 headers: HeaderMap,
536 Path(slug): Path<String>,
537 ) -> Result<axum::response::Response> {
538 let (db_project, generation) =
539 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
540 Ok(pair) => pair,
541 Err(not_modified) => return Ok(not_modified),
542 };
543
544 let git_enabled = config.build.git_repos_path.is_some();
545 // Degrade to an empty list on error, but LOG it, a silent `unwrap_or_default`
546 // renders the Code tab as "no repos" and makes a DB outage look like empty
547 // state with no signal (audit Run 13 Obs).
548 let db_linked_repos = db::git_repos::get_repos_by_project(&db, db_project.id)
549 .await
550 .unwrap_or_else(|e| {
551 tracing::warn!(error = ?e, project_id = %db_project.id, "code tab: failed to load linked repos; rendering as none");
552 Vec::new()
553 });
554 let all_repos = db::git_repos::get_repos_by_user(&db, session_user.id)
555 .await
556 .unwrap_or_else(|e| {
557 tracing::warn!(error = ?e, user_id = %session_user.id, "code tab: failed to load user repos; rendering as none");
558 Vec::new()
559 });
560 let available_repos: Vec<_> = all_repos
561 .into_iter()
562 .filter(|r| r.project_id.is_none())
563 .collect();
564
565 // Build linked repo views with collaborators. One batched query for all
566 // repos' collaborators, grouped in memory, avoids the per-repo N+1.
567 let repo_ids: Vec<_> = db_linked_repos.iter().map(|r| r.id).collect();
568 let mut collabs_by_repo: std::collections::HashMap<_, Vec<RepoCollaboratorView>> =
569 std::collections::HashMap::new();
570 for c in db::repo_collaborators::list_collaborators_for_repos(&db, &repo_ids)
571 .await
572 .unwrap_or_else(|e| {
573 tracing::warn!(error = ?e, "code tab: failed to load repo collaborators; rendering none");
574 Vec::new()
575 })
576 {
577 collabs_by_repo.entry(c.repo_id).or_default().push(RepoCollaboratorView {
578 user_id: c.user_id.to_string(),
579 username: c.username,
580 can_push: c.can_push,
581 });
582 }
583 let linked_repos: Vec<LinkedRepoView> = db_linked_repos
584 .iter()
585 .map(|repo| LinkedRepoView {
586 id: repo.id.to_string(),
587 name: repo.name.clone(),
588 collaborators: collabs_by_repo.remove(&repo.id).unwrap_or_default(),
589 })
590 .collect();
591
592 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
593 let project = Project::from_db(&db_project, db_items.len() as u32);
594
595 Ok(helpers::with_etag(
596 generation,
597 ProjectCodeTabTemplate {
598 project,
599 git_enabled,
600 linked_repos,
601 available_repos,
602 project_id: db_project.id.to_string(),
603 },
604 ))
605 }
606
607 /// Load a project's members as view rows plus the owner's residual split
608 /// percent. Shared by the members tab and the combined monetization tab, the
609 /// mapping was copy-pasted verbatim in both (audit Run 17 Architecture).
610 #[tracing::instrument(skip_all, name = "project_tabs::load_project_members")]
611 async fn load_project_members(
612 db: &PgPool,
613 project_id: db::ProjectId,
614 ) -> Result<(Vec<ProjectMemberRow>, i64)> {
615 let db_members = db::project_members::get_project_members(db, project_id).await?;
616 let members: Vec<ProjectMemberRow> = db_members
617 .iter()
618 .map(|m| ProjectMemberRow {
619 id: m.id.to_string(),
620 user_id: m.user_id.to_string(),
621 username: m.username.clone(),
622 display_name: m.display_name.clone(),
623 role: m.role.to_string(),
624 split_percent: m.split_percent,
625 stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled,
626 accepted: m.is_accepted(),
627 added_at: m.added_at.format("%Y-%m-%d").to_string(),
628 })
629 .collect();
630 let total_member_split = db::project_members::get_total_split_percent(db, project_id).await?;
631 Ok((members, 100 - total_member_split))
632 }
633
634 /// Render the HTMX partial for the project members tab.
635 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_members")]
636 pub(super) async fn project_tab_members(
637 State(db): State<PgPool>,
638 AuthUser(session_user): AuthUser,
639 headers: HeaderMap,
640 Path(slug): Path<String>,
641 ) -> Result<axum::response::Response> {
642 let (db_project, generation) =
643 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
644 Ok(pair) => pair,
645 Err(not_modified) => return Ok(not_modified),
646 };
647
648 let (members, owner_split) = load_project_members(&db, db_project.id).await?;
649
650 Ok(helpers::with_etag(
651 generation,
652 ProjectMembersTabTemplate {
653 project_id: db_project.id.to_string(),
654 project_slug: db_project.slug.to_string(),
655 members,
656 owner_split,
657 },
658 ))
659 }
660
661 /// Combined monetization tab: tiers, promo codes, and team splits.
662 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_monetization")]
663 pub(super) async fn project_tab_monetization(
664 State(db): State<PgPool>,
665 AuthUser(session_user): AuthUser,
666 headers: HeaderMap,
667 Path(slug): Path<String>,
668 ) -> Result<axum::response::Response> {
669 let (db_project, generation) =
670 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
671 Ok(pair) => pair,
672 Err(not_modified) => return Ok(not_modified),
673 };
674
675 let db_user = db::users::get_user_by_id(&db, session_user.id)
676 .await?
677 .ok_or(AppError::NotFound)?;
678
679 let db_tiers = db::subscriptions::get_all_tiers_by_project(&db, db_project.id).await?;
680 let tiers: Vec<SubscriptionTier> = db_tiers
681 .iter()
682 .map(|t| SubscriptionTier::from_db(t, db_user.settlement_currency))
683 .collect();
684 let subscriber_count =
685 db::subscriptions::get_project_subscriber_count(&db, db_project.id).await?;
686
687 let codes = db::promo_codes::get_promo_codes_by_project(&db, db_project.id).await?;
688 let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
689 let items: Vec<ContentItem> = db_items
690 .iter()
691 .enumerate()
692 .map(|(i, item)| {
693 ContentItem::from_db(item, (i + 1) as u32, session_user.settlement_currency)
694 })
695 .collect();
696
697 let (members, owner_split) = load_project_members(&db, db_project.id).await?;
698
699 Ok(helpers::with_etag(
700 generation,
701 ProjectMonetizationTabTemplate {
702 project_id: db_project.id.to_string(),
703 project_slug: db_project.slug.to_string(),
704 tiers,
705 subscriber_count,
706 stripe_connected: db_user.stripe_account_id.is_some(),
707 promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
708 items,
709 members,
710 owner_split,
711 },
712 ))
713 }
714
715 /// Render the HTMX partial for SyncKit apps linked to a project.
716 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_synckit")]
717 pub(super) async fn project_tab_synckit(
718 State(db): State<PgPool>,
719 AuthUser(session_user): AuthUser,
720 headers: HeaderMap,
721 Path(slug): Path<String>,
722 ) -> Result<axum::response::Response> {
723 let (db_project, generation) =
724 match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
725 Ok(pair) => pair,
726 Err(not_modified) => return Ok(not_modified),
727 };
728
729 let db_apps = db::synckit::get_sync_apps_by_project(&db, db_project.id).await?;
730
731 let stats_batch = db::synckit::get_sync_app_stats_batch(&db, session_user.id).await?;
732 let stats_map: std::collections::HashMap<_, _> = stats_batch
733 .into_iter()
734 .map(|(id, devices, logs)| (id, (devices, logs)))
735 .collect();
736
737 let billing_batch =
738 db::synckit_billing::get_apps_with_billing_by_project(&db, db_project.id).await?;
739 let billing_map: std::collections::HashMap<_, _> =
740 billing_batch.into_iter().map(|b| (b.id, b)).collect();
741 let top_keys_map = crate::types::build_top_keys_map(&db, &billing_map).await?;
742
743 let mut apps = Vec::with_capacity(db_apps.len());
744 for app in &db_apps {
745 let (device_count, log_entry_count) = stats_map.get(&app.id).copied().unwrap_or((0, 0));
746
747 let api_key_masked = format!("{}...", app.api_key_prefix);
748 let keys_secret_masked = app.keys_secret_prefix.as_ref().map(|p| format!("{p}..."));
749
750 let billing = billing_map.get(&app.id).map(|b| {
751 let mut view = crate::types::SyncAppBillingView::from_db(b);
752 crate::types::apply_top_keys(&mut view, b, top_keys_map.get(&b.id));
753 view
754 });
755
756 apps.push(SyncAppRow {
757 id: app.id.to_string(),
758 name: app.name.clone(),
759 api_key_masked,
760 api_key_full: String::new(),
761 keys_secret_masked,
762 is_active: app.is_active,
763 device_count,
764 log_entry_count,
765 created_at: app.created_at.format("%b %d, %Y").to_string(),
766 slug: app.slug.clone(),
767 project_name: None,
768 project_slug: None,
769 item_title: None,
770 billing,
771 });
772 }
773
774 Ok(helpers::with_etag(
775 generation,
776 ProjectSyncKitTabTemplate {
777 apps,
778 project_id: db_project.id.to_string(),
779 },
780 ))
781 }
782