Skip to main content

max / makenotwork

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