Skip to main content

max / makenotwork

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