Skip to main content

max / makenotwork

23.6 KB · 636 lines History Blame Raw
1 //! Data export handlers for projects (JSON), transactions (CSV),
2 //! followers/subscribers (CSV), and content files (ZIP).
3
4 mod content;
5 pub(super) use content::export_content;
6
7 use axum::{
8 extract::State,
9 http::header::HeaderMap,
10 response::{IntoResponse, Response},
11 };
12 use crate::{
13 auth::AuthUser,
14 db,
15 error::{Result, ResultExt},
16 helpers::{is_htmx_request, sanitize_csv_cell},
17 templates::{ExportDownloadTemplate, FormStatusTemplate},
18 AppState,
19 };
20
21 /// Return an inline error message for HTMX export requests instead of
22 /// letting the error propagate to the JSON error layer (which would swap
23 /// raw JSON text into the status div).
24 pub(crate) fn export_error_html(message: &str) -> Response {
25 axum::response::Html(
26 FormStatusTemplate {
27 success: false,
28 message: message.to_string(),
29 }
30 .render_string(),
31 )
32 .into_response()
33 }
34
35 /// Build an HTTP response for a downloadable file attachment.
36 fn download_response(content: Vec<u8>, filename: &str, content_type: &str) -> Result<Response> {
37 Response::builder()
38 .header("Content-Type", content_type)
39 .header(
40 "Content-Disposition",
41 format!("attachment; filename=\"{filename}\""),
42 )
43 .body(content.into())
44 .context("build download response")
45 }
46
47 // =============================================================================
48 // Export API
49 // =============================================================================
50
51 /// Export all projects and items as a downloadable JSON file.
52 #[tracing::instrument(skip_all, name = "exports::export_projects")]
53 pub(super) async fn export_projects(
54 State(state): State<AppState>,
55 headers: HeaderMap,
56 AuthUser(user): AuthUser,
57 ) -> Result<Response> {
58 let is_htmx = is_htmx_request(&headers);
59
60 // Get all projects and all items in 2 queries (not N+1)
61 let projects = db::projects::get_projects_by_user(&state.db, user.id).await?;
62 let all_items = db::items::get_items_by_user(&state.db, user.id).await?;
63 let all_item_ids: Vec<db::ItemId> = all_items.iter().map(|i| i.id).collect();
64 let tags_map = db::tags::get_tags_for_items(&state.db, &all_item_ids).await?;
65
66 // Batch-load per-item data (chapters, versions, license keys, item-scoped promo codes)
67 let chapters_map = db::chapters::get_chapters_by_items(&state.db, &all_item_ids).await?;
68 let versions_map = db::versions::get_versions_by_items(&state.db, &all_item_ids).await?;
69 let license_keys_map = db::license_keys::get_license_keys_by_items(&state.db, &all_item_ids).await?;
70 let item_promo_codes_map = db::promo_codes::get_promo_codes_by_items(&state.db, &all_item_ids).await?;
71
72 // Promo codes are creator-scoped, fetch once
73 let promo_codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?;
74 let promo_codes_data: Vec<serde_json::Value> = promo_codes.iter().map(|pc| {
75 serde_json::json!({
76 "code": pc.code,
77 "code_purpose": pc.code_purpose.to_string(),
78 "discount_type": pc.discount_type.map(|dt| dt.to_string()),
79 "discount_value": pc.discount_value,
80 "min_price_cents": pc.min_price_cents,
81 "trial_days": pc.trial_days,
82 "max_uses": pc.max_uses,
83 "use_count": pc.use_count,
84 "expires_at": pc.expires_at,
85 "item_id": pc.item_id,
86 "project_id": pc.project_id,
87 "tier_id": pc.tier_id,
88 "created_at": pc.created_at,
89 })
90 }).collect();
91
92 // Group items by project_id
93 let mut items_by_project: std::collections::HashMap<db::ProjectId, Vec<&db::DbItem>> =
94 std::collections::HashMap::new();
95 for item in &all_items {
96 items_by_project.entry(item.project_id).or_default().push(item);
97 }
98
99 // Batch-load per-project data (blog posts, bundle maps)
100 let project_ids: Vec<db::ProjectId> = projects.iter().map(|p| p.id).collect();
101 let blog_posts_map = db::blog_posts::get_blog_posts_by_projects(&state.db, &project_ids).await?;
102 let bundle_pairs = db::bundles::get_bundle_maps_by_projects(&state.db, &project_ids).await?;
103 let mut bundle_map: std::collections::HashMap<db::ItemId, Vec<db::ItemId>> =
104 std::collections::HashMap::new();
105 for (bundle_id, child_id) in &bundle_pairs {
106 bundle_map.entry(*bundle_id).or_default().push(*child_id);
107 }
108
109 let mut export_data = Vec::new();
110 for project in &projects {
111 let items = items_by_project.get(&project.id).map(|v| v.as_slice()).unwrap_or(&[]);
112
113 let mut items_data = Vec::new();
114 for item in items {
115 let tag_names: Vec<&str> = tags_map
116 .get(&item.id)
117 .map(|tags| tags.iter().map(|t| t.tag_name.as_str()).collect())
118 .unwrap_or_default();
119
120 // Content-type-specific fields
121 let content_fields = match item.content() {
122 db::ContentData::Text { ref body, word_count, reading_time_minutes } => {
123 serde_json::json!({
124 "body": body,
125 "word_count": word_count,
126 "reading_time_minutes": reading_time_minutes,
127 })
128 }
129 db::ContentData::Audio { duration_seconds, episode_number, .. } => {
130 serde_json::json!({
131 "duration_seconds": duration_seconds,
132 "episode_number": episode_number,
133 })
134 }
135 db::ContentData::Video { duration_seconds, width, height, .. } => {
136 serde_json::json!({
137 "duration_seconds": duration_seconds,
138 "width": width,
139 "height": height,
140 })
141 }
142 db::ContentData::Other => serde_json::json!({}),
143 };
144
145 // Chapters (from batch map)
146 let chapters_data: Vec<serde_json::Value> = chapters_map
147 .get(&item.id)
148 .map(|chapters| chapters.iter().map(|ch| {
149 serde_json::json!({
150 "title": ch.title,
151 "start_seconds": ch.start_seconds,
152 "sort_order": ch.sort_order,
153 })
154 }).collect())
155 .unwrap_or_default();
156
157 // Versions (from batch map)
158 let versions_data: Vec<serde_json::Value> = versions_map
159 .get(&item.id)
160 .map(|versions| versions.iter().map(|v| {
161 serde_json::json!({
162 "version_number": v.version_number,
163 "changelog": v.changelog,
164 "file_name": v.file_name,
165 "file_size_bytes": v.file_size_bytes,
166 "is_current": v.is_current,
167 "download_count": v.download_count,
168 "created_at": v.created_at,
169 })
170 }).collect())
171 .unwrap_or_default();
172
173 // License keys (from batch map)
174 let license_keys_data: Vec<serde_json::Value> = license_keys_map
175 .get(&item.id)
176 .map(|keys| keys.iter().map(|lk| {
177 serde_json::json!({
178 "key_code": lk.key_code,
179 "max_activations": lk.max_activations,
180 "activation_count": lk.activation_count,
181 "revoked_at": lk.revoked_at,
182 "created_at": lk.created_at,
183 })
184 }).collect())
185 .unwrap_or_default();
186
187 // Item-scoped promo codes (from batch map)
188 let item_promo_codes_data: Vec<serde_json::Value> = item_promo_codes_map
189 .get(&item.id)
190 .map(|codes| codes.iter().map(|pc| {
191 serde_json::json!({
192 "code": pc.code,
193 "code_purpose": pc.code_purpose.to_string(),
194 "max_uses": pc.max_uses,
195 "use_count": pc.use_count,
196 "expires_at": pc.expires_at,
197 "created_at": pc.created_at,
198 })
199 }).collect())
200 .unwrap_or_default();
201
202 let mut item_json = serde_json::json!({
203 "id": item.id,
204 "title": item.title,
205 "description": item.description,
206 "item_type": item.item_type,
207 "price_cents": item.price_cents,
208 "is_public": item.is_public,
209 "tags": tag_names,
210 "play_count": item.play_count,
211 "download_count": item.download_count,
212 "created_at": item.created_at,
213 "chapters": chapters_data,
214 "versions": versions_data,
215 "license_keys": license_keys_data,
216 "promo_codes": item_promo_codes_data,
217 });
218
219 // Merge content-type fields into item JSON
220 if let Some(obj) = content_fields.as_object() {
221 for (k, v) in obj {
222 item_json[k] = v.clone();
223 }
224 }
225
226 // Include child item IDs for bundles
227 if item.item_type == db::ItemType::Bundle
228 && let Some(child_ids) = bundle_map.get(&item.id)
229 {
230 item_json["bundle_items"] = serde_json::json!(child_ids);
231 }
232
233 items_data.push(item_json);
234 }
235
236 // Blog posts (from batch map)
237 let blog_posts_data: Vec<serde_json::Value> = blog_posts_map
238 .get(&project.id)
239 .map(|posts| posts.iter().map(|post| {
240 serde_json::json!({
241 "id": post.id,
242 "title": post.title,
243 "slug": post.slug,
244 "body_markdown": post.body_markdown,
245 "published_at": post.published_at,
246 "created_at": post.created_at,
247 "updated_at": post.updated_at,
248 })
249 }).collect())
250 .unwrap_or_default();
251
252 export_data.push(serde_json::json!({
253 "id": project.id,
254 "slug": project.slug,
255 "title": project.title,
256 "description": project.description,
257 "project_type": project.project_type,
258 "is_public": project.is_public,
259 "created_at": project.created_at,
260 "items": items_data,
261 "blog_posts": blog_posts_data,
262 }));
263 }
264
265 // Collections (batch-loaded to avoid N+1)
266 let collections = db::collections::get_collections_by_user(&state.db, user.id).await?;
267 let collection_ids: Vec<db::CollectionId> = collections.iter().map(|c| c.id).collect();
268 let collection_items_map = db::collections::get_item_ids_by_collections(&state.db, &collection_ids).await?;
269 let mut collections_data = Vec::new();
270 for c in &collections {
271 let item_ids = collection_items_map.get(&c.id).cloned().unwrap_or_default();
272 collections_data.push(serde_json::json!({
273 "id": c.id,
274 "slug": c.slug,
275 "title": c.title,
276 "description": c.description,
277 "is_public": c.is_public,
278 "item_ids": item_ids,
279 "created_at": c.created_at,
280 }));
281 }
282
283 // Custom domain
284 let custom_domain = db::custom_domains::get_custom_domain_by_user(&state.db, user.id).await?;
285 let custom_domain_data = custom_domain.map(|d| serde_json::json!({
286 "domain": d.domain,
287 "verified": d.verified,
288 }));
289
290 let json_content = serde_json::to_string_pretty(&serde_json::json!({
291 "exported_at": chrono::Utc::now().to_rfc3339(),
292 "projects": export_data,
293 "promo_codes": promo_codes_data,
294 "collections": collections_data,
295 "custom_domain": custom_domain_data,
296 })).unwrap_or_else(|_| "{}".to_string());
297
298 if is_htmx {
299 let data_uri = format!(
300 "data:application/json;charset=utf-8,{}",
301 urlencoding::encode(&json_content)
302 );
303 return Ok(ExportDownloadTemplate {
304 data_uri,
305 filename: "makenot-work-projects.json".to_string(),
306 }.into_response());
307 }
308
309 download_response(json_content.into_bytes(), "makenot-work-projects.json", "application/json")
310 }
311
312 /// Export all sales transactions as a downloadable CSV file.
313 #[tracing::instrument(skip_all, name = "exports::export_sales")]
314 pub(super) async fn export_sales(
315 State(state): State<AppState>,
316 headers: HeaderMap,
317 AuthUser(user): AuthUser,
318 ) -> Result<Response> {
319 let is_htmx = is_htmx_request(&headers);
320
321 // Get all sales with conditional buyer email
322 let transactions = db::transactions::get_seller_transactions_for_export(&state.db, user.id).await?;
323
324 let mut csv_content = String::from("Date,Item ID,Item Title,Amount,Status,Buyer Email\n");
325 for tx in &transactions {
326 let item_title = tx.item_title.as_deref().unwrap_or("[Deleted]");
327 let item_id_str = tx.item_id
328 .map(|id| id.to_string())
329 .unwrap_or_else(|| "[Deleted]".to_string());
330 let buyer_email = tx.buyer_email.as_deref().unwrap_or("");
331
332 csv_content.push_str(&format!(
333 "{},{},{},{:.2},{},{}\n",
334 tx.created_at.format("%Y-%m-%d %H:%M:%S"),
335 item_id_str,
336 sanitize_csv_cell(item_title),
337 tx.amount_cents.as_f64() / 100.0,
338 sanitize_csv_cell(&tx.status.to_string()),
339 sanitize_csv_cell(buyer_email)
340 ));
341 }
342
343 if is_htmx {
344 let data_uri = format!(
345 "data:text/csv;charset=utf-8,{}",
346 urlencoding::encode(&csv_content)
347 );
348 return Ok(ExportDownloadTemplate {
349 data_uri,
350 filename: "makenot-work-sales.csv".to_string(),
351 }.into_response());
352 }
353
354 download_response(csv_content.into_bytes(), "makenot-work-sales.csv", "text/csv")
355 }
356
357 /// Export revenue splits as a downloadable CSV file.
358 #[tracing::instrument(skip_all, name = "exports::export_splits")]
359 pub(super) async fn export_splits(
360 State(state): State<AppState>,
361 headers: HeaderMap,
362 AuthUser(user): AuthUser,
363 ) -> Result<Response> {
364 let is_htmx = is_htmx_request(&headers);
365
366 let splits = db::project_members::get_splits_for_export(&state.db, user.id).await?;
367
368 let mut csv_content = String::from("Date,Type,Direction,Recipient,Amount,Split %\n");
369 for split in &splits {
370 let direction = if split.recipient_id == user.id { "incoming" } else { "outgoing" };
371 csv_content.push_str(&format!(
372 "{},{},{},{},{:.2},{}\n",
373 split.created_at.format("%Y-%m-%d %H:%M:%S"),
374 sanitize_csv_cell(&split.source_type),
375 direction,
376 sanitize_csv_cell(&split.recipient_username),
377 split.amount_cents.as_f64() / 100.0,
378 split.split_percent,
379 ));
380 }
381
382 if is_htmx {
383 let data_uri = format!(
384 "data:text/csv;charset=utf-8,{}",
385 urlencoding::encode(&csv_content)
386 );
387 return Ok(ExportDownloadTemplate {
388 data_uri,
389 filename: "makenot-work-splits.csv".to_string(),
390 }.into_response());
391 }
392
393 download_response(csv_content.into_bytes(), "makenot-work-splits.csv", "text/csv")
394 }
395
396 /// Export all purchase transactions as a downloadable CSV file.
397 #[tracing::instrument(skip_all, name = "exports::export_purchases")]
398 pub(super) async fn export_purchases(
399 State(state): State<AppState>,
400 headers: HeaderMap,
401 AuthUser(user): AuthUser,
402 ) -> Result<Response> {
403 let is_htmx = is_htmx_request(&headers);
404
405 // Get all purchases (transactions where user is the buyer)
406 let transactions = db::transactions::get_transactions_by_buyer(&state.db, user.id, None).await?;
407
408 // Batch-fetch titles for transactions missing the denormalized item_title
409 let missing_title_ids: Vec<db::ItemId> = transactions.iter()
410 .filter(|tx| tx.item_title.is_none())
411 .filter_map(|tx| tx.item_id)
412 .collect();
413 let title_lookup: std::collections::HashMap<db::ItemId, String> =
414 db::items::get_item_titles_batch(&state.db, &missing_title_ids)
415 .await?
416 .into_iter()
417 .collect();
418
419 let mut csv_content = String::from("Date,Item ID,Item Title,Amount,Status\n");
420 for tx in &transactions {
421 let item_title = if let Some(title) = &tx.item_title {
422 title.clone()
423 } else if let Some(item_id) = tx.item_id {
424 title_lookup.get(&item_id).cloned().unwrap_or_else(|| "[Deleted]".to_string())
425 } else {
426 "[Deleted]".to_string()
427 };
428
429 let item_id_str = tx.item_id
430 .map(|id| id.to_string())
431 .unwrap_or_else(|| "[Deleted]".to_string());
432
433 csv_content.push_str(&format!(
434 "{},{},{},{:.2},{}\n",
435 tx.created_at.format("%Y-%m-%d %H:%M:%S"),
436 item_id_str,
437 sanitize_csv_cell(&item_title),
438 tx.amount_cents.as_f64() / 100.0,
439 sanitize_csv_cell(&tx.status.to_string())
440 ));
441 }
442
443 if is_htmx {
444 let data_uri = format!(
445 "data:text/csv;charset=utf-8,{}",
446 urlencoding::encode(&csv_content)
447 );
448 return Ok(ExportDownloadTemplate {
449 data_uri,
450 filename: "makenot-work-purchases.csv".to_string(),
451 }.into_response());
452 }
453
454 download_response(csv_content.into_bytes(), "makenot-work-purchases.csv", "text/csv")
455 }
456
457 /// Export followers and subscribers as a downloadable CSV file.
458 #[tracing::instrument(skip_all, name = "exports::export_followers")]
459 pub(super) async fn export_followers(
460 State(state): State<AppState>,
461 headers: HeaderMap,
462 AuthUser(user): AuthUser,
463 ) -> Result<Response> {
464 let is_htmx = is_htmx_request(&headers);
465
466 let followers = db::follows::get_followers_for_export(&state.db, user.id).await?;
467 let subscribers = db::subscriptions::get_project_subscribers_for_export(&state.db, user.id).await?;
468
469 let mut csv_content = String::from("Section,Username,Display Name,Email,Type,Status,Since\n");
470
471 for f in &followers {
472 csv_content.push_str(&format!(
473 "Follower,{},{},{},{},{},{}\n",
474 sanitize_csv_cell(&f.username),
475 sanitize_csv_cell(f.display_name.as_deref().unwrap_or("")),
476 sanitize_csv_cell(f.email.as_deref().unwrap_or("")),
477 f.target_type,
478 "",
479 f.created_at.format("%Y-%m-%d %H:%M:%S"),
480 ));
481 }
482
483 for s in &subscribers {
484 csv_content.push_str(&format!(
485 "Subscriber,{},{},{},{},{},{}\n",
486 sanitize_csv_cell(&s.username),
487 sanitize_csv_cell(s.display_name.as_deref().unwrap_or("")),
488 "",
489 sanitize_csv_cell(&s.tier_name),
490 s.status,
491 s.created_at.format("%Y-%m-%d %H:%M:%S"),
492 ));
493 }
494
495 if is_htmx {
496 let data_uri = format!(
497 "data:text/csv;charset=utf-8,{}",
498 urlencoding::encode(&csv_content)
499 );
500 return Ok(ExportDownloadTemplate {
501 data_uri,
502 filename: "makenot-work-followers.csv".to_string(),
503 }.into_response());
504 }
505
506 download_response(csv_content.into_bytes(), "makenot-work-followers.csv", "text/csv")
507 }
508
509 /// Export subscriptions as a downloadable CSV file with full detail.
510 #[tracing::instrument(skip_all, name = "exports::export_subscriptions")]
511 pub(super) async fn export_subscriptions(
512 State(state): State<AppState>,
513 headers: HeaderMap,
514 AuthUser(user): AuthUser,
515 ) -> Result<Response> {
516 let is_htmx = is_htmx_request(&headers);
517
518 let subscriptions =
519 db::subscriptions::get_subscriptions_for_export(&state.db, user.id).await?;
520
521 let mut csv_content = String::from(
522 "Project,Tier,Price,Username,Status,Period Start,Period End,Canceled At,Created At\n",
523 );
524 for s in &subscriptions {
525 let fmt_opt =
526 |dt: Option<chrono::DateTime<chrono::Utc>>| -> String {
527 dt.map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string())
528 .unwrap_or_default()
529 };
530
531 csv_content.push_str(&format!(
532 "{},{},{:.2},{},{},{},{},{},{}\n",
533 sanitize_csv_cell(&s.project_title),
534 sanitize_csv_cell(&s.tier_name),
535 s.price_cents as f64 / 100.0,
536 sanitize_csv_cell(&s.username),
537 sanitize_csv_cell(&s.status.to_string()),
538 fmt_opt(s.current_period_start),
539 fmt_opt(s.current_period_end),
540 fmt_opt(s.canceled_at),
541 s.created_at.format("%Y-%m-%d %H:%M:%S"),
542 ));
543 }
544
545 if is_htmx {
546 let data_uri = format!(
547 "data:text/csv;charset=utf-8,{}",
548 urlencoding::encode(&csv_content)
549 );
550 return Ok(ExportDownloadTemplate {
551 data_uri,
552 filename: "makenot-work-subscriptions.csv".to_string(),
553 }
554 .into_response());
555 }
556
557 download_response(csv_content.into_bytes(), "makenot-work-subscriptions.csv", "text/csv")
558 }
559
560 /// Export buyer contacts (who opted to share their email) as CSV.
561 #[tracing::instrument(skip_all, name = "exports::export_contacts")]
562 pub(super) async fn export_contacts(
563 State(state): State<AppState>,
564 headers: HeaderMap,
565 AuthUser(user): AuthUser,
566 ) -> Result<Response> {
567 let is_htmx = is_htmx_request(&headers);
568 let contacts = db::transactions::get_seller_contacts(&state.db, user.id).await?;
569
570 let mut csv_content = String::from("Username,Email,Purchases,Total Spent,Last Purchase\n");
571 for c in &contacts {
572 csv_content.push_str(&format!(
573 "{},{},{},{:.2},{}\n",
574 sanitize_csv_cell(&c.username),
575 sanitize_csv_cell(&c.email),
576 c.total_purchases,
577 c.total_spent_cents as f64 / 100.0,
578 c.last_purchase_at.format("%Y-%m-%d"),
579 ));
580 }
581
582 if is_htmx {
583 let data_uri = format!(
584 "data:text/csv;charset=utf-8,{}",
585 urlencoding::encode(&csv_content)
586 );
587 return Ok(ExportDownloadTemplate {
588 data_uri,
589 filename: "makenot-work-contacts.csv".to_string(),
590 }.into_response());
591 }
592
593 download_response(csv_content.into_bytes(), "makenot-work-contacts.csv", "text/csv")
594 }
595
596 #[cfg(test)]
597 mod tests {
598 use super::*;
599 use axum::http::StatusCode;
600 use axum::body::to_bytes;
601
602 #[test]
603 fn download_response_sets_content_type() {
604 let resp = download_response(b"hello".to_vec(), "test.csv", "text/csv").unwrap();
605 assert_eq!(resp.headers().get("Content-Type").unwrap(), "text/csv");
606 }
607
608 #[test]
609 fn download_response_sets_content_disposition() {
610 let resp = download_response(b"data".to_vec(), "export.json", "application/json").unwrap();
611 let disp = resp.headers().get("Content-Disposition").unwrap().to_str().unwrap();
612 assert_eq!(disp, "attachment; filename=\"export.json\"");
613 }
614
615 #[test]
616 fn download_response_status_200() {
617 let resp = download_response(vec![], "empty.csv", "text/csv").unwrap();
618 assert_eq!(resp.status(), StatusCode::OK);
619 }
620
621 #[tokio::test]
622 async fn download_response_body_matches() {
623 let content = b"col1,col2\na,b\n".to_vec();
624 let resp = download_response(content.clone(), "f.csv", "text/csv").unwrap();
625 let body = to_bytes(resp.into_body(), 1024).await.unwrap();
626 assert_eq!(body.as_ref(), content.as_slice());
627 }
628
629 #[test]
630 fn download_response_filename_with_spaces() {
631 let resp = download_response(b"x".to_vec(), "my export.csv", "text/csv").unwrap();
632 let disp = resp.headers().get("Content-Disposition").unwrap().to_str().unwrap();
633 assert!(disp.contains("my export.csv"));
634 }
635 }
636