//! Data export handlers for projects (JSON), transactions (CSV), //! followers/subscribers (CSV), and content files (ZIP). mod content; pub(super) use content::export_content; use axum::{ extract::State, http::header::HeaderMap, response::{IntoResponse, Response}, }; use crate::{ auth::AuthUser, db, error::{Result, ResultExt}, helpers::{is_htmx_request, sanitize_csv_cell}, templates::{ExportDownloadTemplate, FormStatusTemplate}, AppState, }; /// Return an inline error message for HTMX export requests instead of /// letting the error propagate to the JSON error layer (which would swap /// raw JSON text into the status div). pub(crate) fn export_error_html(message: &str) -> Response { axum::response::Html( FormStatusTemplate { success: false, message: message.to_string(), } .render_string(), ) .into_response() } /// Build an HTTP response for a downloadable file attachment. fn download_response(content: Vec, filename: &str, content_type: &str) -> Result { Response::builder() .header("Content-Type", content_type) .header( "Content-Disposition", format!("attachment; filename=\"{filename}\""), ) .body(content.into()) .context("build download response") } // ============================================================================= // Export API // ============================================================================= /// Export all projects and items as a downloadable JSON file. #[tracing::instrument(skip_all, name = "exports::export_projects")] pub(super) async fn export_projects( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Result { let is_htmx = is_htmx_request(&headers); // Get all projects and all items in 2 queries (not N+1) let projects = db::projects::get_projects_by_user(&state.db, user.id).await?; let all_items = db::items::get_items_by_user(&state.db, user.id).await?; let all_item_ids: Vec = all_items.iter().map(|i| i.id).collect(); let tags_map = db::tags::get_tags_for_items(&state.db, &all_item_ids).await?; // Batch-load per-item data (chapters, versions, license keys, item-scoped promo codes) let chapters_map = db::chapters::get_chapters_by_items(&state.db, &all_item_ids).await?; let versions_map = db::versions::get_versions_by_items(&state.db, &all_item_ids).await?; let license_keys_map = db::license_keys::get_license_keys_by_items(&state.db, &all_item_ids).await?; let item_promo_codes_map = db::promo_codes::get_promo_codes_by_items(&state.db, &all_item_ids).await?; // Promo codes are creator-scoped, fetch once let promo_codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?; let promo_codes_data: Vec = promo_codes.iter().map(|pc| { serde_json::json!({ "code": pc.code, "code_purpose": pc.code_purpose.to_string(), "discount_type": pc.discount_type.map(|dt| dt.to_string()), "discount_value": pc.discount_value, "min_price_cents": pc.min_price_cents, "trial_days": pc.trial_days, "max_uses": pc.max_uses, "use_count": pc.use_count, "expires_at": pc.expires_at, "item_id": pc.item_id, "project_id": pc.project_id, "tier_id": pc.tier_id, "created_at": pc.created_at, }) }).collect(); // Group items by project_id let mut items_by_project: std::collections::HashMap> = std::collections::HashMap::new(); for item in &all_items { items_by_project.entry(item.project_id).or_default().push(item); } // Batch-load per-project data (blog posts, bundle maps) let project_ids: Vec = projects.iter().map(|p| p.id).collect(); let blog_posts_map = db::blog_posts::get_blog_posts_by_projects(&state.db, &project_ids).await?; let bundle_pairs = db::bundles::get_bundle_maps_by_projects(&state.db, &project_ids).await?; let mut bundle_map: std::collections::HashMap> = std::collections::HashMap::new(); for (bundle_id, child_id) in &bundle_pairs { bundle_map.entry(*bundle_id).or_default().push(*child_id); } let mut export_data = Vec::new(); for project in &projects { let items = items_by_project.get(&project.id).map(|v| v.as_slice()).unwrap_or(&[]); let mut items_data = Vec::new(); for item in items { let tag_names: Vec<&str> = tags_map .get(&item.id) .map(|tags| tags.iter().map(|t| t.tag_name.as_str()).collect()) .unwrap_or_default(); // Content-type-specific fields let content_fields = match item.content() { db::ContentData::Text { ref body, word_count, reading_time_minutes } => { serde_json::json!({ "body": body, "word_count": word_count, "reading_time_minutes": reading_time_minutes, }) } db::ContentData::Audio { duration_seconds, episode_number, .. } => { serde_json::json!({ "duration_seconds": duration_seconds, "episode_number": episode_number, }) } db::ContentData::Video { duration_seconds, width, height, .. } => { serde_json::json!({ "duration_seconds": duration_seconds, "width": width, "height": height, }) } db::ContentData::Other => serde_json::json!({}), }; // Chapters (from batch map) let chapters_data: Vec = chapters_map .get(&item.id) .map(|chapters| chapters.iter().map(|ch| { serde_json::json!({ "title": ch.title, "start_seconds": ch.start_seconds, "sort_order": ch.sort_order, }) }).collect()) .unwrap_or_default(); // Versions (from batch map) let versions_data: Vec = versions_map .get(&item.id) .map(|versions| versions.iter().map(|v| { serde_json::json!({ "version_number": v.version_number, "changelog": v.changelog, "file_name": v.file_name, "file_size_bytes": v.file_size_bytes, "is_current": v.is_current, "download_count": v.download_count, "created_at": v.created_at, }) }).collect()) .unwrap_or_default(); // License keys (from batch map) let license_keys_data: Vec = license_keys_map .get(&item.id) .map(|keys| keys.iter().map(|lk| { serde_json::json!({ "key_code": lk.key_code, "max_activations": lk.max_activations, "activation_count": lk.activation_count, "revoked_at": lk.revoked_at, "created_at": lk.created_at, }) }).collect()) .unwrap_or_default(); // Item-scoped promo codes (from batch map) let item_promo_codes_data: Vec = item_promo_codes_map .get(&item.id) .map(|codes| codes.iter().map(|pc| { serde_json::json!({ "code": pc.code, "code_purpose": pc.code_purpose.to_string(), "max_uses": pc.max_uses, "use_count": pc.use_count, "expires_at": pc.expires_at, "created_at": pc.created_at, }) }).collect()) .unwrap_or_default(); let mut item_json = serde_json::json!({ "id": item.id, "title": item.title, "description": item.description, "item_type": item.item_type, "price_cents": item.price_cents, "is_public": item.is_public, "tags": tag_names, "play_count": item.play_count, "download_count": item.download_count, "created_at": item.created_at, "chapters": chapters_data, "versions": versions_data, "license_keys": license_keys_data, "promo_codes": item_promo_codes_data, }); // Merge content-type fields into item JSON if let Some(obj) = content_fields.as_object() { for (k, v) in obj { item_json[k] = v.clone(); } } // Include child item IDs for bundles if item.item_type == db::ItemType::Bundle && let Some(child_ids) = bundle_map.get(&item.id) { item_json["bundle_items"] = serde_json::json!(child_ids); } items_data.push(item_json); } // Blog posts (from batch map) let blog_posts_data: Vec = blog_posts_map .get(&project.id) .map(|posts| posts.iter().map(|post| { serde_json::json!({ "id": post.id, "title": post.title, "slug": post.slug, "body_markdown": post.body_markdown, "published_at": post.published_at, "created_at": post.created_at, "updated_at": post.updated_at, }) }).collect()) .unwrap_or_default(); export_data.push(serde_json::json!({ "id": project.id, "slug": project.slug, "title": project.title, "description": project.description, "project_type": project.project_type, "is_public": project.is_public, "created_at": project.created_at, "items": items_data, "blog_posts": blog_posts_data, })); } // Collections (batch-loaded to avoid N+1) let collections = db::collections::get_collections_by_user(&state.db, user.id).await?; let collection_ids: Vec = collections.iter().map(|c| c.id).collect(); let collection_items_map = db::collections::get_item_ids_by_collections(&state.db, &collection_ids).await?; let mut collections_data = Vec::new(); for c in &collections { let item_ids = collection_items_map.get(&c.id).cloned().unwrap_or_default(); collections_data.push(serde_json::json!({ "id": c.id, "slug": c.slug, "title": c.title, "description": c.description, "is_public": c.is_public, "item_ids": item_ids, "created_at": c.created_at, })); } // Custom domain let custom_domain = db::custom_domains::get_custom_domain_by_user(&state.db, user.id).await?; let custom_domain_data = custom_domain.map(|d| serde_json::json!({ "domain": d.domain, "verified": d.verified, })); let json_content = serde_json::to_string_pretty(&serde_json::json!({ "exported_at": chrono::Utc::now().to_rfc3339(), "projects": export_data, "promo_codes": promo_codes_data, "collections": collections_data, "custom_domain": custom_domain_data, })).unwrap_or_else(|_| "{}".to_string()); if is_htmx { let data_uri = format!( "data:application/json;charset=utf-8,{}", urlencoding::encode(&json_content) ); return Ok(ExportDownloadTemplate { data_uri, filename: "makenot-work-projects.json".to_string(), }.into_response()); } download_response(json_content.into_bytes(), "makenot-work-projects.json", "application/json") } /// Export all sales transactions as a downloadable CSV file. #[tracing::instrument(skip_all, name = "exports::export_sales")] pub(super) async fn export_sales( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Result { let is_htmx = is_htmx_request(&headers); // Get all sales with conditional buyer email let transactions = db::transactions::get_seller_transactions_for_export(&state.db, user.id).await?; let mut csv_content = String::from("Date,Item ID,Item Title,Amount,Status,Buyer Email\n"); for tx in &transactions { let item_title = tx.item_title.as_deref().unwrap_or("[Deleted]"); let item_id_str = tx.item_id .map(|id| id.to_string()) .unwrap_or_else(|| "[Deleted]".to_string()); let buyer_email = tx.buyer_email.as_deref().unwrap_or(""); csv_content.push_str(&format!( "{},{},{},{:.2},{},{}\n", tx.created_at.format("%Y-%m-%d %H:%M:%S"), item_id_str, sanitize_csv_cell(item_title), tx.amount_cents.as_f64() / 100.0, sanitize_csv_cell(&tx.status.to_string()), sanitize_csv_cell(buyer_email) )); } if is_htmx { let data_uri = format!( "data:text/csv;charset=utf-8,{}", urlencoding::encode(&csv_content) ); return Ok(ExportDownloadTemplate { data_uri, filename: "makenot-work-sales.csv".to_string(), }.into_response()); } download_response(csv_content.into_bytes(), "makenot-work-sales.csv", "text/csv") } /// Export revenue splits as a downloadable CSV file. #[tracing::instrument(skip_all, name = "exports::export_splits")] pub(super) async fn export_splits( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Result { let is_htmx = is_htmx_request(&headers); let splits = db::project_members::get_splits_for_export(&state.db, user.id).await?; let mut csv_content = String::from("Date,Type,Direction,Recipient,Amount,Split %\n"); for split in &splits { let direction = if split.recipient_id == user.id { "incoming" } else { "outgoing" }; csv_content.push_str(&format!( "{},{},{},{},{:.2},{}\n", split.created_at.format("%Y-%m-%d %H:%M:%S"), sanitize_csv_cell(&split.source_type), direction, sanitize_csv_cell(&split.recipient_username), split.amount_cents.as_f64() / 100.0, split.split_percent, )); } if is_htmx { let data_uri = format!( "data:text/csv;charset=utf-8,{}", urlencoding::encode(&csv_content) ); return Ok(ExportDownloadTemplate { data_uri, filename: "makenot-work-splits.csv".to_string(), }.into_response()); } download_response(csv_content.into_bytes(), "makenot-work-splits.csv", "text/csv") } /// Export all purchase transactions as a downloadable CSV file. #[tracing::instrument(skip_all, name = "exports::export_purchases")] pub(super) async fn export_purchases( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Result { let is_htmx = is_htmx_request(&headers); // Get all purchases (transactions where user is the buyer) let transactions = db::transactions::get_transactions_by_buyer(&state.db, user.id, None).await?; // Batch-fetch titles for transactions missing the denormalized item_title let missing_title_ids: Vec = transactions.iter() .filter(|tx| tx.item_title.is_none()) .filter_map(|tx| tx.item_id) .collect(); let title_lookup: std::collections::HashMap = db::items::get_item_titles_batch(&state.db, &missing_title_ids) .await? .into_iter() .collect(); let mut csv_content = String::from("Date,Item ID,Item Title,Amount,Status\n"); for tx in &transactions { let item_title = if let Some(title) = &tx.item_title { title.clone() } else if let Some(item_id) = tx.item_id { title_lookup.get(&item_id).cloned().unwrap_or_else(|| "[Deleted]".to_string()) } else { "[Deleted]".to_string() }; let item_id_str = tx.item_id .map(|id| id.to_string()) .unwrap_or_else(|| "[Deleted]".to_string()); csv_content.push_str(&format!( "{},{},{},{:.2},{}\n", tx.created_at.format("%Y-%m-%d %H:%M:%S"), item_id_str, sanitize_csv_cell(&item_title), tx.amount_cents.as_f64() / 100.0, sanitize_csv_cell(&tx.status.to_string()) )); } if is_htmx { let data_uri = format!( "data:text/csv;charset=utf-8,{}", urlencoding::encode(&csv_content) ); return Ok(ExportDownloadTemplate { data_uri, filename: "makenot-work-purchases.csv".to_string(), }.into_response()); } download_response(csv_content.into_bytes(), "makenot-work-purchases.csv", "text/csv") } /// Export followers and subscribers as a downloadable CSV file. #[tracing::instrument(skip_all, name = "exports::export_followers")] pub(super) async fn export_followers( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Result { let is_htmx = is_htmx_request(&headers); let followers = db::follows::get_followers_for_export(&state.db, user.id).await?; let subscribers = db::subscriptions::get_project_subscribers_for_export(&state.db, user.id).await?; let mut csv_content = String::from("Section,Username,Display Name,Email,Type,Status,Since\n"); for f in &followers { csv_content.push_str(&format!( "Follower,{},{},{},{},{},{}\n", sanitize_csv_cell(&f.username), sanitize_csv_cell(f.display_name.as_deref().unwrap_or("")), sanitize_csv_cell(f.email.as_deref().unwrap_or("")), f.target_type, "", f.created_at.format("%Y-%m-%d %H:%M:%S"), )); } for s in &subscribers { csv_content.push_str(&format!( "Subscriber,{},{},{},{},{},{}\n", sanitize_csv_cell(&s.username), sanitize_csv_cell(s.display_name.as_deref().unwrap_or("")), "", sanitize_csv_cell(&s.tier_name), s.status, s.created_at.format("%Y-%m-%d %H:%M:%S"), )); } if is_htmx { let data_uri = format!( "data:text/csv;charset=utf-8,{}", urlencoding::encode(&csv_content) ); return Ok(ExportDownloadTemplate { data_uri, filename: "makenot-work-followers.csv".to_string(), }.into_response()); } download_response(csv_content.into_bytes(), "makenot-work-followers.csv", "text/csv") } /// Export subscriptions as a downloadable CSV file with full detail. #[tracing::instrument(skip_all, name = "exports::export_subscriptions")] pub(super) async fn export_subscriptions( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Result { let is_htmx = is_htmx_request(&headers); let subscriptions = db::subscriptions::get_subscriptions_for_export(&state.db, user.id).await?; let mut csv_content = String::from( "Project,Tier,Price,Username,Status,Period Start,Period End,Canceled At,Created At\n", ); for s in &subscriptions { let fmt_opt = |dt: Option>| -> String { dt.map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_default() }; csv_content.push_str(&format!( "{},{},{:.2},{},{},{},{},{},{}\n", sanitize_csv_cell(&s.project_title), sanitize_csv_cell(&s.tier_name), s.price_cents as f64 / 100.0, sanitize_csv_cell(&s.username), sanitize_csv_cell(&s.status.to_string()), fmt_opt(s.current_period_start), fmt_opt(s.current_period_end), fmt_opt(s.canceled_at), s.created_at.format("%Y-%m-%d %H:%M:%S"), )); } if is_htmx { let data_uri = format!( "data:text/csv;charset=utf-8,{}", urlencoding::encode(&csv_content) ); return Ok(ExportDownloadTemplate { data_uri, filename: "makenot-work-subscriptions.csv".to_string(), } .into_response()); } download_response(csv_content.into_bytes(), "makenot-work-subscriptions.csv", "text/csv") } /// Export buyer contacts (who opted to share their email) as CSV. #[tracing::instrument(skip_all, name = "exports::export_contacts")] pub(super) async fn export_contacts( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Result { let is_htmx = is_htmx_request(&headers); let contacts = db::transactions::get_seller_contacts(&state.db, user.id).await?; let mut csv_content = String::from("Username,Email,Purchases,Total Spent,Last Purchase\n"); for c in &contacts { csv_content.push_str(&format!( "{},{},{},{:.2},{}\n", sanitize_csv_cell(&c.username), sanitize_csv_cell(&c.email), c.total_purchases, c.total_spent_cents as f64 / 100.0, c.last_purchase_at.format("%Y-%m-%d"), )); } if is_htmx { let data_uri = format!( "data:text/csv;charset=utf-8,{}", urlencoding::encode(&csv_content) ); return Ok(ExportDownloadTemplate { data_uri, filename: "makenot-work-contacts.csv".to_string(), }.into_response()); } download_response(csv_content.into_bytes(), "makenot-work-contacts.csv", "text/csv") } #[cfg(test)] mod tests { use super::*; use axum::http::StatusCode; use axum::body::to_bytes; #[test] fn download_response_sets_content_type() { let resp = download_response(b"hello".to_vec(), "test.csv", "text/csv").unwrap(); assert_eq!(resp.headers().get("Content-Type").unwrap(), "text/csv"); } #[test] fn download_response_sets_content_disposition() { let resp = download_response(b"data".to_vec(), "export.json", "application/json").unwrap(); let disp = resp.headers().get("Content-Disposition").unwrap().to_str().unwrap(); assert_eq!(disp, "attachment; filename=\"export.json\""); } #[test] fn download_response_status_200() { let resp = download_response(vec![], "empty.csv", "text/csv").unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn download_response_body_matches() { let content = b"col1,col2\na,b\n".to_vec(); let resp = download_response(content.clone(), "f.csv", "text/csv").unwrap(); let body = to_bytes(resp.into_body(), 1024).await.unwrap(); assert_eq!(body.as_ref(), content.as_slice()); } #[test] fn download_response_filename_with_spaces() { let resp = download_response(b"x".to_vec(), "my export.csv", "text/csv").unwrap(); let disp = resp.headers().get("Content-Disposition").unwrap().to_str().unwrap(); assert!(disp.contains("my export.csv")); } }