//! Project HTMX tab partials. use crate::extractors::ValidatedQuery; use axum::extract::{Path, State}; use axum::http::HeaderMap; use axum::response::IntoResponse; use std::collections::{HashMap, HashSet}; use crate::{ auth::AuthUser, config::Config, db::{self, ItemId, Slug, analytics::TimeRange}, error::{AppError, Result}, helpers, templates::{ LinkedRepoView, ProjectCodeTabTemplate, ProjectContentTabTemplate, ProjectMonetizationTabTemplate, ProjectOverviewTabTemplate, ProjectSettingsTabTemplate, ProjectSubscriptionsTabTemplate, ProjectSyncKitTabTemplate, RepoCollaboratorView, }, types::{ BlogPostDashboardRow, ContentItem, Project, ProjectMemberRow, PromoCodeRow, StatCard, SubscriptionTier, SyncAppRow, }, }; use sqlx::PgPool; use super::AnalyticsQuery; /// Build the content items list with bundle children nested under their parent. /// /// Unlisted items that belong to a bundle are shown only as children of that bundle, /// not as top-level rows. Listed items always appear at the top level even if they /// are also in a bundle. fn build_content_items_with_bundles( db_items: &[db::DbItem], bundle_map: &[(ItemId, ItemId)], currency: crate::currency::SettlementCurrency, ) -> Vec { // Build bundle_id → [child_item_id] map let mut children_of: HashMap> = HashMap::new(); let mut child_to_bundle: HashMap = HashMap::new(); for &(bundle_id, child_id) in bundle_map { children_of.entry(bundle_id).or_default().push(child_id); child_to_bundle.insert(child_id, bundle_id); } // Index all items by ID for lookup let item_by_id: HashMap = db_items.iter().map(|i| (i.id, i)).collect(); // Items that are unlisted AND belong to a bundle should be hidden from top level let hidden_at_top: HashSet = db_items .iter() .filter(|i| !i.listed && child_to_bundle.contains_key(&i.id)) .map(|i| i.id) .collect(); let mut items = Vec::new(); let mut pos = 1u32; for db_item in db_items { if hidden_at_top.contains(&db_item.id) { continue; } let mut content_item = ContentItem::from_db(db_item, pos, currency); pos += 1; // If this is a bundle, attach its children if let Some(child_ids) = children_of.get(&db_item.id) { for (ci, child_id) in child_ids.iter().enumerate() { if let Some(child_db) = item_by_id.get(child_id) { content_item.children.push(ContentItem::from_db( child_db, (ci + 1) as u32, currency, )); } } } items.push(content_item); } items } /// Resolve a project by slug for the authenticated user and check its ETag. /// Returns `Ok(Err(304))` if the client's cached version is fresh, /// or `Ok(Ok((project, generation)))` if rendering is needed. async fn resolve_project_etag( db: &PgPool, user_id: db::UserId, slug: &str, headers: &HeaderMap, ) -> Result> { let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_project_by_user_and_slug(db, user_id, &slug) .await? .ok_or(AppError::NotFound)?; let generation = db_project.cache_generation; if let Some(not_modified) = helpers::check_etag(headers, generation) { return Ok(Err(not_modified)); } Ok(Ok((db_project, generation))) } /// Render the HTMX partial for the project overview tab with stats. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_overview")] pub(super) async fn project_tab_overview( State(db): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let overview = build_overview(&db, &session_user, &db_project).await?; Ok(helpers::with_etag( generation, axum::response::Html(crate::quasi::project_overview::fragment( &overview.project_slug, &overview.stats, overview.stripe_connected, overview.has_items, overview.has_published_item, )), )) } /// The overview tab's contents, without the transport around them. /// /// Split out of the handler 2026-08-19 so the project page can render this panel /// into its described strip (`6b24f2df`): the strip's shown tab arrives with the /// document, so whoever renders the page needs the same thing the route answers. /// The page handler was already building an identical `stats` vector for a /// template field nothing read. pub(super) async fn build_overview( db: &PgPool, session_user: &crate::auth::SessionUser, db_project: &db::DbProject, ) -> Result { let db_items = db::items::get_items_by_project(db, db_project.id).await?; let (revenue_cents, sales_count) = db::transactions::get_revenue_by_project(db, db_project.id).await?; // Renders every currency the project earned in, largest first. Normally one. let revenue_str = revenue_cents.display(session_user.settlement_currency); let stats = vec![ StatCard { label: "Total Revenue".to_string(), value: revenue_str, change: None, is_positive: true, }, StatCard { label: "Total Sales".to_string(), value: sales_count.to_string(), change: None, is_positive: true, }, StatCard { label: "Items".to_string(), value: db_items.len().to_string(), change: None, is_positive: true, }, ]; let db_user = db::users::get_user_by_id(db, session_user.id) .await? .ok_or(AppError::NotFound)?; let has_items = !db_items.is_empty(); let has_published_item = db_items.iter().any(|i| i.is_public); Ok(ProjectOverviewTabTemplate { stats, project_slug: db_project.slug.to_string(), stripe_connected: db_user.stripe_account_id.is_some(), has_items, has_published_item, }) } /// Render the HTMX partial for the project content/items tab. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_content")] pub(super) async fn project_tab_content( State(db): State, State(_config): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ValidatedQuery(query): ValidatedQuery, ) -> Result { // Always described since `64b33b26`: the filters are a server round trip // (makeover-layout N12), which is why the view is read from the query. let view = query.view()?; // The ETag is the project's cache generation, which does not move when a // filter does. A narrowed panel is a different body under the same tag, so // it is answered without one rather than cached as the whole table. if !view.is_default() { let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug) .await? .ok_or(AppError::NotFound)?; return Ok(content_panel(&db, &session_user, &db_project, &view) .await? .into_response()); } let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; Ok(helpers::with_etag( generation, content_panel(&db, &session_user, &db_project, &view).await?, )) } /// The content tab's contents, without the transport around them. /// /// Split out 2026-08-19 for the described strip (`6b24f2df`): the project page /// can open on this tab, so it needs what the route answers. pub(super) async fn build_content( db: &PgPool, session_user: &crate::auth::SessionUser, db_project: &db::DbProject, ) -> Result { let db_items = db::items::get_items_by_project(db, db_project.id).await?; let bundle_map = db::bundles::get_project_bundle_map(db, db_project.id).await?; let db_deleted = db::items::get_deleted_items_by_project(db, db_project.id).await?; let db_posts = db::blog_posts::get_blog_posts_by_project(db, db_project.id).await?; let items = build_content_items_with_bundles(&db_items, &bundle_map, session_user.settlement_currency); let deleted_items: Vec = db_deleted .iter() .map(|i| crate::templates::DeletedItemRow { id: i.id.to_string(), title: i.title.clone(), deleted_at: i .deleted_at .map(|d| d.format("%b %d, %Y").to_string()) .unwrap_or_default(), }) .collect(); let posts: Vec = db_posts .into_iter() .map(|p| BlogPostDashboardRow { id: p.id.to_string(), title: p.title, slug: p.slug.to_string(), status: if p.published_at.is_some() { "Published".to_string() } else { "Draft".to_string() }, status_tone: if p.published_at.is_some() { crate::types::BadgeStatus::Live.tone() } else { crate::types::BadgeStatus::Pending.tone() }, published_at: p .published_at .map_or_else(|| "-".to_string(), |d| d.format("%b %d, %Y").to_string()), }) .collect(); Ok(ProjectContentTabTemplate { items, deleted_items, project_slug: db_project.slug.to_string(), project_id: db_project.id.to_string(), posts, }) } /// Render the HTMX partial for the project analytics tab. /// Analytics data changes constantly, so no ETag caching. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_analytics")] pub(super) async fn project_tab_analytics( State(db): State, AuthUser(session_user): AuthUser, Path(slug): Path, ValidatedQuery(query): ValidatedQuery, ) -> Result { let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug) .await? .ok_or(AppError::NotFound)?; let range = query .range .as_deref() .and_then(|s| s.parse::().ok()) .unwrap_or(TimeRange::Days30); let buckets = db::analytics::get_revenue_timeseries( &db, session_user.id, Some(db_project.id), None, &range, ) .await?; let comparison = db::analytics::get_period_comparison( &db, session_user.id, Some(db_project.id), None, &range, ) .await?; let bars = super::build_chart_bars(&buckets, session_user.settlement_currency); let revenue_str = crate::formatting::format_revenue( comparison.current_revenue_cents.as_i64(), session_user.settlement_currency, ); // Page view stats for this project let (current_views, prev_views) = db::page_views::get_view_period_comparison( &db, session_user.id, Some(db_project.id), &range, ) .await?; let view_change = db::analytics::pct_change(current_views, prev_views); let mut stats = vec![ StatCard { label: "Views".to_string(), value: current_views.to_string(), change: view_change.as_ref().map(|(t, _)| t.clone()), is_positive: view_change.is_none_or(|(_, p)| p), }, StatCard { label: "Revenue".to_string(), value: revenue_str, change: comparison.revenue_change().map(|(t, _)| t), is_positive: comparison.revenue_change().is_none_or(|(_, p)| p), }, StatCard { label: "Sales".to_string(), value: comparison.current_sales.to_string(), change: comparison.sales_change().map(|(t, _)| t), is_positive: comparison.sales_change().is_none_or(|(_, p)| p), }, StatCard { label: "Followers".to_string(), value: comparison.current_followers.to_string(), change: comparison.followers_change().map(|(t, _)| t), is_positive: comparison.followers_change().is_none_or(|(_, p)| p), }, ]; if current_views > 0 { let conversion = format!( "{:.1}%", comparison.current_sales as f64 / current_views as f64 * 100.0 ); stats.push(StatCard { label: "Conversion".to_string(), value: conversion, change: None, is_positive: true, }); } let db_items = db::items::get_items_by_project(&db, db_project.id).await?; let items: Vec = db_items .iter() .enumerate() .map(|(i, item)| { ContentItem::from_db(item, (i + 1) as u32, session_user.settlement_currency) }) .collect(); Ok(axum::response::Html( crate::quasi::project_analytics::fragment( db_project.slug.as_ref(), &range.to_string(), &stats, &bars, &items, ), )) } /// Render the HTMX partial for the project settings tab. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_settings")] pub(super) async fn project_tab_settings( State(db): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let db_items = db::items::get_items_by_project(&db, db_project.id).await?; let project = Project::from_db(&db_project, db_items.len() as u32); let category_name = db::categories::get_project_category_name(&db, db_project.id) .await? .unwrap_or_default(); let project_id = db_project.id.to_string(); let features = db_project.features.clone(); let project_features = db::ProjectFeature::all(); let sections = db::project_sections::list_by_project(&db, db_project.id).await?; let pricing_model = db_project.pricing_model.to_string(); let price_dollars = if db_project.price_cents > 0 { crate::formatting::format_dollars_plain(db_project.price_cents) } else { String::new() }; let pwyw_min_dollars = match db_project.pwyw_min_cents { Some(c) if c > 0 => crate::formatting::format_dollars_plain(c), _ => String::new(), }; Ok(helpers::with_etag( generation, ProjectSettingsTabTemplate { project, category_name, project_id, features, project_features, sections, pricing_model, price_dollars, pwyw_min_dollars, theme_options: crate::theming::theme_options(db_project.theme_id.as_deref()), }, )) } /// Render the HTMX partial for the project subscriptions tab (tier management). #[tracing::instrument(skip_all, name = "project_tabs::project_tab_subscriptions")] pub(super) async fn project_tab_subscriptions( State(db): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let db_user = db::users::get_user_by_id(&db, session_user.id) .await? .ok_or(AppError::NotFound)?; let db_tiers = db::subscriptions::get_all_tiers_by_project(&db, db_project.id).await?; let tiers: Vec = db_tiers .iter() .map(|t| SubscriptionTier::from_db(t, db_user.settlement_currency)) .collect(); let subscriber_count = db::subscriptions::get_project_subscriber_count(&db, db_project.id).await?; Ok(helpers::with_etag( generation, ProjectSubscriptionsTabTemplate { project_id: db_project.id.to_string(), project_slug: db_project.slug.to_string(), tiers, subscriber_count, stripe_connected: db_user.stripe_account_id.is_some(), }, )) } /// Render the HTMX partial for the project code tab (git repos). #[tracing::instrument(skip_all, name = "project_tabs::project_tab_code")] pub(super) async fn project_tab_code( State(db): State, State(config): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let git_enabled = config.build.git_repos_path.is_some(); // Degrade to an empty list on error, but LOG it, a silent `unwrap_or_default` // renders the Code tab as "no repos" and makes a DB outage look like empty // state with no signal (audit Run 13 Obs). let db_linked_repos = db::git_repos::get_repos_by_project(&db, db_project.id) .await .unwrap_or_else(|e| { tracing::warn!(error = ?e, project_id = %db_project.id, "code tab: failed to load linked repos; rendering as none"); Vec::new() }); let all_repos = db::git_repos::get_repos_by_user(&db, session_user.id) .await .unwrap_or_else(|e| { tracing::warn!(error = ?e, user_id = %session_user.id, "code tab: failed to load user repos; rendering as none"); Vec::new() }); let available_repos: Vec<_> = all_repos .into_iter() .filter(|r| r.project_id.is_none()) .collect(); // Build linked repo views with collaborators. One batched query for all // repos' collaborators, grouped in memory, avoids the per-repo N+1. let repo_ids: Vec<_> = db_linked_repos.iter().map(|r| r.id).collect(); let mut collabs_by_repo: std::collections::HashMap<_, Vec> = std::collections::HashMap::new(); for c in db::repo_collaborators::list_collaborators_for_repos(&db, &repo_ids) .await .unwrap_or_else(|e| { tracing::warn!(error = ?e, "code tab: failed to load repo collaborators; rendering none"); Vec::new() }) { collabs_by_repo.entry(c.repo_id).or_default().push(RepoCollaboratorView { user_id: c.user_id.to_string(), username: c.username, can_push: c.can_push, }); } let linked_repos: Vec = db_linked_repos .iter() .map(|repo| LinkedRepoView { id: repo.id.to_string(), name: repo.name.clone(), collaborators: collabs_by_repo.remove(&repo.id).unwrap_or_default(), }) .collect(); let db_items = db::items::get_items_by_project(&db, db_project.id).await?; let project = Project::from_db(&db_project, db_items.len() as u32); Ok(helpers::with_etag( generation, ProjectCodeTabTemplate { project, git_enabled, linked_repos, available_repos, project_id: db_project.id.to_string(), }, )) } /// Load a project's members as view rows plus the owner's residual split /// percent. Shared by the members tab and the combined monetization tab, the /// mapping was copy-pasted verbatim in both (audit Run 17 Architecture). #[tracing::instrument(skip_all, name = "project_tabs::load_project_members")] async fn load_project_members( db: &PgPool, project_id: db::ProjectId, ) -> Result<(Vec, i64)> { let db_members = db::project_members::get_project_members(db, project_id).await?; let members: Vec = db_members .iter() .map(|m| ProjectMemberRow { id: m.id.to_string(), user_id: m.user_id.to_string(), username: m.username.clone(), display_name: m.display_name.clone(), role: m.role.to_string(), split_percent: m.split_percent, stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled, accepted: m.is_accepted(), added_at: m.added_at.format("%Y-%m-%d").to_string(), }) .collect(); let total_member_split = db::project_members::get_total_split_percent(db, project_id).await?; Ok((members, 100 - total_member_split)) } /// Render the HTMX partial for the project members tab. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_members")] pub(super) async fn project_tab_members( State(db): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let (members, owner_split) = load_project_members(&db, db_project.id).await?; Ok(helpers::with_etag( generation, axum::response::Html(crate::quasi::project_members::section( &members, owner_split, &db_project.id.to_string(), )), )) } /// Combined monetization tab: tiers, promo codes, and team splits. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_monetization")] pub(super) async fn project_tab_monetization( State(db): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; let db_user = db::users::get_user_by_id(&db, session_user.id) .await? .ok_or(AppError::NotFound)?; let db_tiers = db::subscriptions::get_all_tiers_by_project(&db, db_project.id).await?; let tiers: Vec = db_tiers .iter() .map(|t| SubscriptionTier::from_db(t, db_user.settlement_currency)) .collect(); let subscriber_count = db::subscriptions::get_project_subscriber_count(&db, db_project.id).await?; let codes = db::promo_codes::get_promo_codes_by_project(&db, db_project.id).await?; let db_items = db::items::get_items_by_project(&db, db_project.id).await?; let items: Vec = db_items .iter() .enumerate() .map(|(i, item)| { ContentItem::from_db(item, (i + 1) as u32, session_user.settlement_currency) }) .collect(); let (members, owner_split) = load_project_members(&db, db_project.id).await?; Ok(helpers::with_etag( generation, ProjectMonetizationTabTemplate { project_id: db_project.id.to_string(), project_slug: db_project.slug.to_string(), tiers, subscriber_count, stripe_connected: db_user.stripe_account_id.is_some(), promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(), items, members, owner_split, }, )) } /// Render the HTMX partial for SyncKit apps linked to a project. #[tracing::instrument(skip_all, name = "project_tabs::project_tab_synckit")] pub(super) async fn project_tab_synckit( State(db): State, AuthUser(session_user): AuthUser, headers: HeaderMap, Path(slug): Path, ) -> Result { let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? { Ok(pair) => pair, Err(not_modified) => return Ok(not_modified), }; Ok(helpers::with_etag( generation, build_synckit(&db, &session_user, &db_project).await?, )) } /// The cloud-sync tab's contents, without the transport around them. /// /// Split out 2026-08-19 for the described strip (`6b24f2df`). This one has a /// caller that matters: `routes::synckit::billing` sends a creator back here /// after Stripe, and that link used to be a `#tab-synckit` the page's JS /// restored by clicking. pub(super) async fn build_synckit( db: &PgPool, session_user: &crate::auth::SessionUser, db_project: &db::DbProject, ) -> Result { let db_apps = db::synckit::get_sync_apps_by_project(db, db_project.id).await?; let stats_batch = db::synckit::get_sync_app_stats_batch(db, session_user.id).await?; let stats_map: std::collections::HashMap<_, _> = stats_batch .into_iter() .map(|(id, devices, logs)| (id, (devices, logs))) .collect(); let billing_batch = db::synckit_billing::get_apps_with_billing_by_project(db, db_project.id).await?; let billing_map: std::collections::HashMap<_, _> = billing_batch.into_iter().map(|b| (b.id, b)).collect(); let top_keys_map = crate::types::build_top_keys_map(db, &billing_map).await?; let mut apps = Vec::with_capacity(db_apps.len()); for app in &db_apps { let (device_count, log_entry_count) = stats_map.get(&app.id).copied().unwrap_or((0, 0)); let api_key_masked = format!("{}...", app.api_key_prefix); let keys_secret_masked = app.keys_secret_prefix.as_ref().map(|p| format!("{p}...")); let billing = billing_map.get(&app.id).map(|b| { let mut view = crate::types::SyncAppBillingView::from_db(b); crate::types::apply_top_keys(&mut view, b, top_keys_map.get(&b.id)); view }); apps.push(SyncAppRow { id: app.id.to_string(), name: app.name.clone(), api_key_masked, api_key_full: String::new(), keys_secret_masked, is_active: app.is_active, device_count, log_entry_count, created_at: app.created_at.format("%b %d, %Y").to_string(), slug: app.slug.clone(), project_name: None, project_slug: None, item_title: None, billing, }); } Ok(ProjectSyncKitTabTemplate { apps, project_id: db_project.id.to_string(), project_slug: db_project.slug.to_string(), }) } // The described Content panel: `crate::quasi::project_content`. // // One route answers both renderings, unlike the four screens quasi mounts for // itself. Those are addressed by a path with nothing in it and could be nested // whole; this one is `/dashboard/project/{slug}/tabs/content`, and a nest strips // its prefix before the inner router sees it, so the slug would be gone by the // time a described handler ran. The switch is read per request instead, which // costs one map lookup and keeps the address in one place. /// Which rows the described Content panel is showing, and in what order. /// /// The view is the address (`N12`), so this is every narrowing the shipped /// screen held in a closure. `type` is spelled `kind` here because the wire name /// is a keyword. #[derive(serde::Deserialize)] pub(super) struct ContentQuery { pub q: Option, pub status: Option, #[serde(rename = "type")] pub kind: Option, pub sort: Option, pub direction: Option, pub open: Option, pub ticked: Option, } impl ContentQuery { /// The view this address asks for, or a 404 for a sort column that is not /// one. See `View::of`. fn view(&self) -> Result { crate::quasi::project_content::View::of( self.q.as_deref(), self.status.as_deref(), self.kind.as_deref(), self.sort.as_deref(), self.direction.as_deref(), self.open.as_deref(), self.ticked.as_deref(), ) .ok_or(AppError::NotFound) } } /// What a control over the selection sends: the ticks, and the one value the /// control asked for if it asked for one. /// /// `ticked` is quasi's name for a selection member (`Node::TICKED`), repeated /// once per row, which is why this needs the `axum_extra` form extractor. The /// API's own bulk routes read the same ids under `item_ids`; they are a /// different reading of the same set and are left alone, because a described /// write answers with the region it changed and an API route answers with a /// toast. #[derive(serde::Deserialize)] pub(super) struct BulkForm { #[serde(default)] pub ticked: Vec, pub price_dollars: Option, pub tag_slug: Option, } /// Everything a described write needs before it writes: the project it is in, /// and that the reader may write to it. async fn writable_project( db: &PgPool, session_user: &crate::auth::SessionUser, slug: &str, ) -> Result { session_user.check_not_suspended()?; let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?; db::projects::get_project_by_user_and_slug(db, session_user.id, &slug) .await? .ok_or(AppError::NotFound) } /// The described panel, as a route answers it. /// /// One function for the write handlers to end with, so a bulk publish and a /// rename answer the same markup as a filter change rather than each rebuilding /// it. async fn content_panel( db: &PgPool, session_user: &crate::auth::SessionUser, db_project: &db::DbProject, view: &crate::quasi::project_content::View, ) -> Result> { let content = build_content(db, session_user, db_project).await?; Ok(axum::response::Html( crate::quasi::project_content::fragment( db_project.slug.as_ref(), &content.items, &content.deleted_items, &content.posts, view, ), )) } /// Answer a described write by re-reading the panel it happened in. /// /// Decision 7: the route that changed something is the party that knows what it /// changed, so it answers with that region rather than with a toast the page has /// to act on. One query more than the API route runs, on an action a creator /// takes deliberately. async fn wrote( db: &PgPool, session_user: &crate::auth::SessionUser, db_project: &db::DbProject, view: &crate::quasi::project_content::View, ) -> Result { // The ticks are spent: they named rows that have just changed, and carrying // them into the answer would re-tick a set the write already consumed. let view = crate::quasi::project_content::View { ticked: false, ..view.clone() }; Ok(content_panel(db, session_user, db_project, &view) .await? .into_response()) } /// A control over the selection: publish, unpublish, delete, price or tag. /// /// One handler for the five, because they differ only in the verb and in what /// the control asked for. The ownership check is the query's: every `db::items` /// bulk statement is scoped to the project and to the project's owner, so an id /// from another project matches nothing rather than being an error the reader /// has to read. #[tracing::instrument(skip_all, name = "project_tabs::content_bulk")] pub(super) async fn content_bulk( State(db): State, AuthUser(session_user): AuthUser, Path((slug, verb)): Path<(String, String)>, ValidatedQuery(query): ValidatedQuery, crate::extractors::ValidatedHtmlForm(form): crate::extractors::ValidatedHtmlForm, ) -> Result { let view = query.view()?; let db_project = writable_project(&db, &session_user, &slug).await?; if form.ticked.len() > crate::routes::api::items::BULK_ITEM_LIMIT { return Err(AppError::BadRequest(format!( "Too many items (max {})", crate::routes::api::items::BULK_ITEM_LIMIT ))); } // A write over nothing answers the panel rather than erroring. Every // renderer draws a control over an empty selection as disabled and refuses // the press, so arriving here with no ticks is a hand-typed request or a // host without the script -- and neither is worth an error page. if !form.ticked.is_empty() { let items = &form.ticked; let project_id = db_project.id; let user_id = session_user.id; match verb.as_str() { "publish" => { db::items::bulk_publish(&db, items, project_id, user_id).await?; } "unpublish" => { db::items::bulk_unpublish(&db, items, project_id, user_id).await?; } "delete" => { db::items::bulk_delete(&db, items, project_id, user_id).await?; } "price" => { let raw = crate::pricing::parse_dollars_to_cents("Price", form.price_dollars.as_deref())?; let price = db::PriceCents::new(raw)?; db::items::bulk_update_price(&db, items, project_id, user_id, price).await?; } "tag" => { let slug = form.tag_slug.as_deref().unwrap_or_default().trim(); crate::validation::validate_tag_slug(slug)?; let tag = db::tags::get_tag_by_slug(&db, slug) .await? .ok_or(AppError::NotFound)?; db::items::bulk_add_tag(&db, items, project_id, user_id, tag.id).await?; } _ => return Err(AppError::NotFound), } db::projects::bump_cache_generation(&db, project_id).await?; } wrote(&db, &session_user, &db_project, &view).await } /// What a row control sends when it asks for a value. #[derive(serde::Deserialize)] pub(super) struct RowForm { pub title: Option, pub direction: Option, } /// Move one item up or down the project's own order. #[tracing::instrument(skip_all, name = "project_tabs::content_move")] pub(super) async fn content_move( State(db): State, AuthUser(session_user): AuthUser, Path((slug, id)): Path<(String, ItemId)>, ValidatedQuery(query): ValidatedQuery, crate::extractors::ValidatedHtmlForm(form): crate::extractors::ValidatedHtmlForm, ) -> Result { let view = query.view()?; let db_project = writable_project(&db, &session_user, &slug).await?; // `move_item` reads the project's items in display order and swaps a pair, // and answers Ok for a direction it cannot honour, so the ends need no // special case here. db::items::move_item( &db, db_project.id, session_user.id, id, form.direction.as_deref().unwrap_or_default(), ) .await?; db::projects::bump_cache_generation(&db, db_project.id).await?; wrote(&db, &session_user, &db_project, &view).await } /// Publish one draft from its row, which is the bulk verb over a set of one. #[tracing::instrument(skip_all, name = "project_tabs::content_publish")] pub(super) async fn content_publish( State(db): State, AuthUser(session_user): AuthUser, Path((slug, id)): Path<(String, ItemId)>, ValidatedQuery(query): ValidatedQuery, ) -> Result { let view = query.view()?; let db_project = writable_project(&db, &session_user, &slug).await?; db::items::bulk_publish(&db, &[id], db_project.id, session_user.id).await?; db::projects::bump_cache_generation(&db, db_project.id).await?; wrote(&db, &session_user, &db_project, &view).await } /// Rename one item, which is what the inline editor became. #[tracing::instrument(skip_all, name = "project_tabs::content_rename")] pub(super) async fn content_rename( State(db): State, AuthUser(session_user): AuthUser, Path((slug, id)): Path<(String, ItemId)>, ValidatedQuery(query): ValidatedQuery, crate::extractors::ValidatedHtmlForm(form): crate::extractors::ValidatedHtmlForm, ) -> Result { let view = query.view()?; let db_project = writable_project(&db, &session_user, &slug).await?; let title = form.title.as_deref().unwrap_or_default().trim(); crate::validation::validate_item_title(title)?; // The item is the project's, and `update_item` is scoped to the owner. A // title is every field this write touches; the rest stay as they are. let item = db::items::get_item_by_id(&db, id) .await? .ok_or(AppError::NotFound)?; if item.project_id != db_project.id { return Err(AppError::NotFound); } db::items::update_item( &db, id, session_user.id, Some(title), None, None, None, None, None, None, None, None, None, None, ) .await?; db::projects::bump_cache_generation(&db, db_project.id).await?; wrote(&db, &session_user, &db_project, &view).await } /// Bring one soft-deleted item back, inside its seven days. #[tracing::instrument(skip_all, name = "project_tabs::content_restore")] pub(super) async fn content_restore( State(db): State, AuthUser(session_user): AuthUser, Path((slug, id)): Path<(String, ItemId)>, ValidatedQuery(query): ValidatedQuery, ) -> Result { let view = query.view()?; let db_project = writable_project(&db, &session_user, &slug).await?; if !db::items::restore_item(&db, id, session_user.id).await? { return Err(AppError::NotFound); } db::projects::bump_cache_generation(&db, db_project.id).await?; wrote(&db, &session_user, &db_project, &view).await } /// Delete one blog post from the table it shares this tab with. #[tracing::instrument(skip_all, name = "project_tabs::content_blog_delete")] pub(super) async fn content_blog_delete( State(db): State, AuthUser(session_user): AuthUser, Path((slug, id)): Path<(String, db::BlogPostId)>, ValidatedQuery(query): ValidatedQuery, ) -> Result { let view = query.view()?; let db_project = writable_project(&db, &session_user, &slug).await?; if !db::blog_posts::delete_blog_post(&db, id, session_user.id).await? { return Err(AppError::NotFound); } wrote(&db, &session_user, &db_project, &view).await }