Skip to main content

max / makenotwork

Creator trust audit: AI tiers, export fixes, doc corrections, availability commitment Implement per-item AI tier system (Handmade/Assisted/Generated) with migration, enum, DB queries, API routes, discover filters, and template UI including disclosure textarea for Assisted tier. Fix export gaps: add follower emails (with consent) to CSV, add play/download counts to project JSON, add per-project content export via project_id query param. Clarify payout records are in Stripe. Correct doc inaccuracies: SameSite Strict->Lax in security.md, remove Sentry from privacy policy, fix ZIP export "planned" to "available", remove Stripe "standard account" label, fix appeal timeline mismatch. Raise storage limits to match docs (50/250/500/500 GB). Add pricing table to creators page. Add Status link to site footer. Update monitoring docs to describe actual health dashboard. Add 99.5% availability guarantee with 99.9% as beta exit target. Mark subscription pause on suspension as not yet enforced. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-25 23:31 UTC
Commit: a6b721f1ba49b4d860b4fe4f1be0585aab2a4e43
Parent: 61ee4ef
31 files changed, +384 insertions, -50 deletions
@@ -3385,7 +3385,7 @@ dependencies = [
3385 3385
3386 3386 [[package]]
3387 3387 name = "makenotwork"
3388 - version = "0.3.26"
3388 + version = "0.4.0"
3389 3389 dependencies = [
3390 3390 "anyhow",
3391 3391 "argon2",
@@ -0,0 +1,3 @@
1 + -- Add AI tier classification to items
2 + ALTER TABLE items ADD COLUMN IF NOT EXISTS ai_tier TEXT NOT NULL DEFAULT 'handmade';
3 + ALTER TABLE items ADD COLUMN IF NOT EXISTS ai_disclosure TEXT;
@@ -105,6 +105,19 @@ No browsing profiles. No behavioral tracking. No selling data. Verifiable in the
105 105
106 106 ---
107 107
108 + ## Availability
109 +
110 + **Guarantee:** We target 99.5% uptime (roughly 44 hours of downtime per year or less).
111 +
112 + - Current uptime is published live at [makenot.work/health](https://makenot.work/health), including 24-hour and 7-day percentages.
113 + - The platform is monitored by two independent systems: an internal background monitor with email alerts, and an external monitor (PoM) running on separate infrastructure.
114 + - The server automatically restarts within seconds if a crash occurs.
115 + - Daily database backups are replicated to a separate machine.
116 +
117 + We are a single-server, single-operator platform. That means we cannot yet guarantee the sub-9-hours-per-year downtime that 99.9% availability requires — that needs redundancy or 24/7 on-call coverage. We are honest about this rather than promising something we can't reliably deliver. See Planned Guarantees below for our path to 99.9%.
118 +
119 + ---
120 +
108 121 ## Enforcement
109 122
110 123 If you think we've broken any of these:
@@ -130,6 +143,12 @@ Any content that has existed on the platform for 12 months or more (not includin
130 143 - The creator cannot upload new content without reactivating their subscription.
131 144 - The creator can still export their data and delete their account at any time.
132 145
146 + ### 99.9% Availability
147 +
148 + *One of our targets for officially leaving beta.*
149 +
150 + Reaching 99.9% average uptime (under 9 hours of downtime per year) requires infrastructure changes we are actively working toward: database replication, zero-downtime deployments, and either a second operator or automated failover. We will not claim 99.9% until we can sustain it consistently.
151 +
133 152 ### Moderation Appeals
134 153
135 154 *As a one-person operation, we cannot yet implement independent appeal review. Until we have the team to support it, moderation decisions are made directly and in good faith.*
@@ -82,7 +82,7 @@ For moderate violations or patterns of minor violations:
82 82
83 83 During suspension:
84 84 - Your content remains but is hidden from fans
85 - - Subscriptions are paused (fans aren't charged)
85 + - Fan subscriptions to your content are paused (fans aren't charged) *(not yet enforced -- implementation in progress)*
86 86 - You can't upload or modify content
87 87
88 88 ### Permanent Termination
@@ -33,7 +33,7 @@ Your monthly Makenot.work subscription ($10-40) is separate:
33 33
34 34 ## You're the Merchant of Record
35 35
36 - We use the payment processor's standard account model. This means:
36 + When you connect payments, the processor creates an account for you. This means:
37 37
38 38 - **You are the merchant** - Fans are paying you, not us
39 39 - **You set prices** - Including pay-what-you-want options
@@ -55,7 +55,6 @@ We retain IP addresses for 30 days, then delete them.
55 55 We share data only with:
56 56
57 57 - **Payment processor**: Payment processing (see [Infrastructure & Vendors](../tech/infrastructure.md) for current provider)
58 - - **Sentry**: Error tracking and crash reporting. Error logs may include your email address, user ID, or request context to help us diagnose and fix bugs. Sentry's terms prohibit using this data for marketing, profiling, or any purpose beyond error resolution.
59 58 - **Infrastructure providers**: Hosting, CDN (they process but don't access your data)
60 59 - **Legal authorities**: Only when legally required, and we'll notify you unless prohibited
61 60
@@ -1,15 +1,17 @@
1 1 # Monitoring
2 2
3 - ## Health Endpoint
3 + ## Status Page
4 4
5 - `makenot.work/health` returns a JSON status page covering:
5 + [makenot.work/health](https://makenot.work/health) is a live status dashboard showing:
6 6
7 - - Application uptime and version
8 - - Database connectivity
9 - - S3 storage connectivity
10 - - Email delivery service status
7 + - Overall status (Operational / Degraded / Issues Detected)
8 + - 24-hour and 7-day uptime percentages
9 + - Per-service status: database, sessions, S3 storage, Stripe payments, email, SyncKit
10 + - External monitoring data from PoM (response times, route availability, incidents)
11 + - Recent check history and incident log
12 + - Live endpoint tests (public URLs and database queries)
11 13
12 - The endpoint is public. Anyone can check whether the platform is operational.
14 + The page is public. Anyone can check it at any time. A JSON API is also available at `/api/health` for programmatic monitoring.
13 15
14 16 ## PoM (Production Operations Monitor)
15 17
@@ -19,13 +19,13 @@ This isn't a feature—it's a core principle.
19 19 - Release dates
20 20
21 21 ### Audience Data
22 - - Fan contact emails (when shared via purchase opt-in; included in sales CSV)
22 + - Fan contact emails (when shared via purchase opt-in; included in sales CSV and follower CSV)
23 23
24 24 ### Financial
25 - - Complete transaction history
26 - - Payout records
27 - - Subscription data
25 + - Complete transaction history (sales CSV export)
28 26 - Revenue by item
27 + - Subscription data
28 + - Payout records (via your Stripe Express dashboard — MNW never holds funds, so Stripe is the source of truth for payout timing and amounts)
29 29
30 30 ### Analytics
31 31 - Play counts and download counts (included in project JSON export)
@@ -57,7 +57,7 @@ The export includes all items, blog posts, versions, chapters, license keys, dow
57 57
58 58 ### File Export
59 59
60 - Direct file downloads (S3-hosted audio, software, etc.) are available through the item dashboard. Full ZIP archive export is planned.
60 + Direct file downloads (S3-hosted audio, software, etc.) are available through the item dashboard. Full ZIP archive export is available from Settings > Data, including all uploaded files and a manifest.
61 61
62 62 ## Using Your Export
63 63
@@ -21,7 +21,7 @@ Security is infrastructure, not a feature. Here's how we protect your account an
21 21 - View active sessions in Settings > Security
22 22 - Revoke any session remotely
23 23 - New device/location triggers email notification
24 - - Cookies: HttpOnly, Secure, SameSite=Strict
24 + - Cookies: HttpOnly, Secure, SameSite=Lax (Lax rather than Strict to support OAuth redirect flows)
25 25 - Session rotation on privilege change
26 26
27 27 ---
@@ -4,7 +4,7 @@
4 4
5 5 use sqlx::{FromRow, PgPool};
6 6
7 - use super::enums::{DiscoverSort, ItemType};
7 + use super::enums::{AiTier, DiscoverSort, ItemType};
8 8 use super::models::*;
9 9 use crate::error::Result;
10 10
@@ -21,6 +21,7 @@ pub struct DiscoverFilters<'a> {
21 21 pub min_price: Option<i32>,
22 22 pub max_price: Option<i32>,
23 23 pub sort_by: Option<DiscoverSort>,
24 + pub ai_tier: Option<AiTier>,
24 25 }
25 26
26 27 // Shared SQL fragments for fuzzy search (trigram + ILIKE fallback).
@@ -82,7 +83,7 @@ fn is_short_query(term: &str) -> bool {
82 83 }
83 84
84 85 /// Append discover-item filter clauses to a dynamic query.
85 - /// Parameter positions: $1=search, $2=item_type, $3=min_price, $4=max_price, $5=tag, $6=label.
86 + /// Parameter positions: $1=search, $2=item_type, $3=min_price, $4=max_price, $5=tag, $6=label, $7=ai_tier.
86 87 ///
87 88 /// When `short_query` is `true`, the ILIKE-only clause is used instead of the
88 89 /// full trigram + ILIKE clause (trigram matching is unreliable for 1-2 char terms).
@@ -127,9 +128,12 @@ fn append_item_discover_filters(
127 128 )"#,
128 129 );
129 130 }
131 + if filters.ai_tier.is_some() {
132 + query.push_str(" AND i.ai_tier = $7");
133 + }
130 134 }
131 135
132 - /// Bind the 6 discover-filter parameters ($1-$6) to a sqlx query.
136 + /// Bind the 7 discover-filter parameters ($1-$7) to a sqlx query.
133 137 macro_rules! bind_item_discover_filters {
134 138 ($q:expr, $filters:expr, $search_term:expr) => {
135 139 $q.bind($search_term.unwrap_or(""))
@@ -138,6 +142,7 @@ macro_rules! bind_item_discover_filters {
138 142 .bind($filters.max_price.unwrap_or(i32::MAX))
139 143 .bind($filters.tag.unwrap_or(""))
140 144 .bind($filters.label.unwrap_or(""))
145 + .bind($filters.ai_tier.map(|t| t.to_string()).unwrap_or_default())
141 146 };
142 147 }
143 148
@@ -263,7 +268,7 @@ pub async fn discover_items(
263 268 }
264 269 };
265 270
266 - query.push_str(&format!(" ORDER BY {} LIMIT $7 OFFSET $8", order));
271 + query.push_str(&format!(" ORDER BY {} LIMIT $8 OFFSET $9", order));
267 272
268 273 let items = bind_item_discover_filters!(
269 274 sqlx::query_as::<_, DbDiscoverItemRow>(&query),
@@ -515,10 +515,10 @@ impl CreatorTier {
515 515 /// Maximum total storage in bytes.
516 516 pub fn max_storage_bytes(&self) -> i64 {
517 517 match self {
518 - Self::Basic => 500 * 1024 * 1024, // 500 MB
519 - Self::SmallFiles => 10 * 1024 * 1024 * 1024, // 10 GB
520 - Self::BigFiles => 50 * 1024 * 1024 * 1024, // 50 GB
521 - Self::Streaming => 200 * 1024 * 1024 * 1024, // 200 GB
518 + Self::Basic => 50 * 1024 * 1024 * 1024, // 50 GB
519 + Self::SmallFiles => 250 * 1024 * 1024 * 1024, // 250 GB
520 + Self::BigFiles => 500 * 1024 * 1024 * 1024, // 500 GB
521 + Self::Streaming => 500 * 1024 * 1024 * 1024, // 500 GB
522 522 }
523 523 }
524 524
@@ -529,6 +529,32 @@ impl CreatorTier {
529 529 }
530 530 }
531 531
532 + // ── AI Tiers ──
533 +
534 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
535 + #[serde(rename_all = "snake_case")]
536 + pub enum AiTier {
537 + Handmade,
538 + Assisted,
539 + Generated,
540 + }
541 +
542 + impl_str_enum!(AiTier {
543 + Handmade => "handmade",
544 + Assisted => "assisted",
545 + Generated => "generated",
546 + });
547 +
548 + impl AiTier {
549 + pub fn label(&self) -> &'static str {
550 + match self {
551 + Self::Handmade => "Handmade",
552 + Self::Assisted => "Assisted",
553 + Self::Generated => "Generated",
554 + }
555 + }
556 + }
557 +
532 558 // ── Project Features ──
533 559
534 560 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -1031,10 +1057,10 @@ mod tests {
1031 1057
1032 1058 #[test]
1033 1059 fn creator_tier_storage_limits() {
1034 - assert_eq!(CreatorTier::Basic.max_storage_bytes(), 500 * 1024 * 1024);
1035 - assert_eq!(CreatorTier::SmallFiles.max_storage_bytes(), 10 * 1024 * 1024 * 1024);
1036 - assert_eq!(CreatorTier::BigFiles.max_storage_bytes(), 50 * 1024 * 1024 * 1024);
1037 - assert_eq!(CreatorTier::Streaming.max_storage_bytes(), 200 * 1024 * 1024 * 1024);
1060 + assert_eq!(CreatorTier::Basic.max_storage_bytes(), 50 * 1024 * 1024 * 1024);
1061 + assert_eq!(CreatorTier::SmallFiles.max_storage_bytes(), 250 * 1024 * 1024 * 1024);
1062 + assert_eq!(CreatorTier::BigFiles.max_storage_bytes(), 500 * 1024 * 1024 * 1024);
1063 + assert_eq!(CreatorTier::Streaming.max_storage_bytes(), 500 * 1024 * 1024 * 1024);
1038 1064 }
1039 1065
1040 1066 #[test]
@@ -1313,6 +1339,21 @@ mod tests {
1313 1339 }
1314 1340
1315 1341 #[test]
1342 + fn ai_tier_round_trip() {
1343 + assert_eq!(AiTier::Handmade.to_string(), "handmade");
1344 + assert_eq!("assisted".parse::<AiTier>().unwrap(), AiTier::Assisted);
1345 + assert_eq!("generated".parse::<AiTier>().unwrap(), AiTier::Generated);
1346 + assert!("bogus".parse::<AiTier>().is_err());
1347 + }
1348 +
1349 + #[test]
1350 + fn ai_tier_label() {
1351 + assert_eq!(AiTier::Handmade.label(), "Handmade");
1352 + assert_eq!(AiTier::Assisted.label(), "Assisted");
1353 + assert_eq!(AiTier::Generated.label(), "Generated");
1354 + }
1355 +
1356 + #[test]
1316 1357 fn import_source_round_trip() {
1317 1358 assert_eq!(ImportSource::GenericCsv.to_string(), "generic_csv");
1318 1359 assert_eq!("substack".parse::<ImportSource>().unwrap(), ImportSource::Substack);
@@ -235,7 +235,22 @@ pub async fn get_followers_for_export(
235 235 ) -> Result<Vec<FollowerExportRow>> {
236 236 let rows = sqlx::query_as::<_, FollowerExportRow>(
237 237 r#"
238 - SELECT u.username, u.display_name, f.target_type, f.created_at
238 + SELECT
239 + u.username,
240 + u.display_name,
241 + f.target_type,
242 + f.created_at,
243 + CASE WHEN EXISTS (
244 + SELECT 1 FROM transactions t
245 + WHERE t.buyer_id = f.follower_id
246 + AND t.seller_id = $1
247 + AND t.status = 'completed'
248 + AND t.share_contact = true
249 + AND NOT EXISTS (
250 + SELECT 1 FROM contact_revocations cr
251 + WHERE cr.buyer_id = f.follower_id AND cr.seller_id = $1
252 + )
253 + ) THEN u.email ELSE NULL END AS email
239 254 FROM follows f
240 255 JOIN users u ON u.id = f.follower_id
241 256 WHERE (f.target_type = 'user' AND f.target_id = $1)
@@ -2,7 +2,7 @@
2 2
3 3 use sqlx::PgPool;
4 4
5 - use super::enums::ItemType;
5 + use super::enums::{AiTier, ItemType};
6 6 use super::models::*;
7 7 use super::{ItemId, ProjectId, UserId};
8 8 use crate::error::Result;
@@ -19,6 +19,8 @@ pub async fn create_item(
19 19 description: Option<&str>,
20 20 price_cents: i32,
21 21 item_type: ItemType,
22 + ai_tier: AiTier,
23 + ai_disclosure: Option<&str>,
22 24 ) -> Result<DbItem> {
23 25 let mut slug = crate::helpers::slugify(title);
24 26
@@ -41,8 +43,8 @@ pub async fn create_item(
41 43 let item = loop {
42 44 match sqlx::query_as::<_, DbItem>(
43 45 r#"
44 - INSERT INTO items (project_id, title, description, price_cents, item_type, slug)
45 - VALUES ($1, $2, $3, $4, $5, $6)
46 + INSERT INTO items (project_id, title, description, price_cents, item_type, slug, ai_tier, ai_disclosure)
47 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
46 48 RETURNING *
47 49 "#,
48 50 )
@@ -52,6 +54,8 @@ pub async fn create_item(
52 54 .bind(price_cents)
53 55 .bind(item_type)
54 56 .bind(&slug)
57 + .bind(ai_tier)
58 + .bind(ai_disclosure)
55 59 .fetch_one(pool)
56 60 .await
57 61 {
@@ -210,12 +214,19 @@ pub async fn update_item(
210 214 pwyw_min_cents: Option<i32>,
211 215 publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
212 216 web_only: Option<bool>,
217 + ai_tier: Option<AiTier>,
218 + ai_disclosure: Option<Option<&str>>,
213 219 ) -> Result<DbItem> {
214 220 // Flatten the double-Option: if outer is None, pass current DB value (via SQL CASE).
215 221 // $10 = whether to update publish_at, $11 = the new value (NULL to clear).
216 222 let update_publish_at = publish_at.is_some();
217 223 let publish_at_value = publish_at.flatten();
218 224
225 + // ai_disclosure uses the same double-Option pattern as publish_at:
226 + // None = no change, Some(None) = clear, Some(Some(text)) = set.
227 + let update_ai_disclosure = ai_disclosure.is_some();
228 + let ai_disclosure_value = ai_disclosure.flatten();
229 +
219 230 let item = sqlx::query_as::<_, DbItem>(
220 231 r#"
221 232 UPDATE items
@@ -227,7 +238,9 @@ pub async fn update_item(
227 238 pwyw_enabled = COALESCE($8, pwyw_enabled),
228 239 pwyw_min_cents = COALESCE($9, pwyw_min_cents),
229 240 publish_at = CASE WHEN $10 THEN $11 ELSE publish_at END,
230 - web_only = COALESCE($12, web_only)
241 + web_only = COALESCE($12, web_only),
242 + ai_tier = COALESCE($13, ai_tier),
243 + ai_disclosure = CASE WHEN $14 THEN $15 ELSE ai_disclosure END
231 244 WHERE id = $1
232 245 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
233 246 RETURNING *
@@ -245,6 +258,9 @@ pub async fn update_item(
245 258 .bind(update_publish_at)
246 259 .bind(publish_at_value)
247 260 .bind(web_only)
261 + .bind(ai_tier)
262 + .bind(update_ai_disclosure)
263 + .bind(ai_disclosure_value)
248 264 .fetch_one(pool)
249 265 .await?;
250 266
@@ -523,7 +539,7 @@ pub async fn set_mt_thread_id(
523 539 pub async fn get_user_s3_keys(pool: &PgPool, user_id: UserId) -> Result<Vec<ItemS3KeyRow>> {
524 540 let rows = sqlx::query_as::<_, ItemS3KeyRow>(
525 541 r#"
526 - SELECT i.title, p.slug AS project_slug, i.audio_s3_key, i.cover_s3_key, i.video_s3_key
542 + SELECT i.title, p.id AS project_id, p.slug AS project_slug, i.audio_s3_key, i.cover_s3_key, i.video_s3_key
527 543 FROM items i JOIN projects p ON i.project_id = p.id
528 544 WHERE p.user_id = $1 AND (i.audio_s3_key IS NOT NULL OR i.cover_s3_key IS NOT NULL OR i.video_s3_key IS NOT NULL)
529 545 ORDER BY p.slug, i.sort_order
@@ -89,6 +89,10 @@ pub struct DbItem {
89 89 pub license_preset: Option<String>,
90 90 /// Custom license text, used when license_preset = "custom".
91 91 pub custom_license_text: Option<String>,
92 + /// AI classification tier (handmade, assisted, generated).
93 + pub ai_tier: super::super::AiTier,
94 + /// Mandatory disclosure text for the `assisted` tier.
95 + pub ai_disclosure: Option<String>,
92 96 // Video content fields
93 97 /// S3 object key for the video file.
94 98 pub video_s3_key: Option<String>,
@@ -301,6 +305,8 @@ mod tests {
301 305 listed: true,
302 306 license_preset: None,
303 307 custom_license_text: None,
308 + ai_tier: super::super::super::AiTier::Handmade,
309 + ai_disclosure: None,
304 310 video_s3_key: None,
305 311 video_file_size_bytes: None,
306 312 video_duration_seconds: None,
@@ -117,6 +117,7 @@ pub struct DbImportJob {
117 117 #[derive(Debug, Clone, FromRow)]
118 118 pub struct ItemS3KeyRow {
119 119 pub title: String,
120 + pub project_id: super::super::ProjectId,
120 121 pub project_slug: Slug,
121 122 pub audio_s3_key: Option<String>,
122 123 pub cover_s3_key: Option<String>,
@@ -130,5 +131,6 @@ pub struct VersionS3KeyRow {
130 131 pub file_name: Option<String>,
131 132 pub version_number: String,
132 133 pub item_title: String,
134 + pub project_id: super::super::ProjectId,
133 135 pub project_slug: Slug,
134 136 }
@@ -104,12 +104,17 @@ pub struct DbUserSubscriptionRow {
104 104 // ── Export query models ──
105 105
106 106 /// A follower row for CSV export.
107 + ///
108 + /// The `email` field is only populated when the follower has a completed
109 + /// purchase with `share_contact = true` and no active contact revocation.
107 110 #[derive(Debug, Clone, FromRow)]
108 111 pub struct FollowerExportRow {
109 112 pub username: String,
110 113 pub display_name: Option<String>,
111 114 pub target_type: super::super::FollowTargetType,
112 115 pub created_at: DateTime<Utc>,
116 + /// Shared email (only when buyer opted in and has not revoked).
117 + pub email: Option<String>,
113 118 }
114 119
115 120 /// A subscriber row for CSV export.
@@ -99,7 +99,7 @@ pub async fn get_user_version_s3_keys(
99 99 ) -> Result<Vec<VersionS3KeyRow>> {
100 100 let rows = sqlx::query_as::<_, VersionS3KeyRow>(
101 101 r#"
102 - SELECT v.s3_key, v.file_name, v.version_number, i.title AS item_title, p.slug AS project_slug
102 + SELECT v.s3_key, v.file_name, v.version_number, i.title AS item_title, p.id AS project_id, p.slug AS project_slug
103 103 FROM versions v
104 104 JOIN items i ON v.item_id = i.id
105 105 JOIN projects p ON i.project_id = p.id
@@ -164,6 +164,8 @@ async fn import_item(
164 164 description.as_deref(),
165 165 price,
166 166 ItemType::Digital, // Default type for imports
167 + db::AiTier::Handmade,
168 + None,
167 169 )
168 170 .await?;
169 171
@@ -677,6 +677,8 @@ mod tests {
677 677 listed: true,
678 678 license_preset: None,
679 679 custom_license_text: None,
680 + ai_tier: db::AiTier::Handmade,
681 + ai_disclosure: None,
680 682 }
681 683 }
682 684
@@ -4,10 +4,11 @@
4 4 use std::io::{Cursor, Write};
5 5
6 6 use axum::{
7 - extract::State,
7 + extract::{Query, State},
8 8 http::header::HeaderMap,
9 9 response::{IntoResponse, Response},
10 10 };
11 + use serde::Deserialize;
11 12 use zip::write::SimpleFileOptions;
12 13
13 14 use crate::{
@@ -158,6 +159,8 @@ pub(super) async fn export_projects(
158 159 "price_cents": item.price_cents,
159 160 "is_public": item.is_public,
160 161 "tags": tag_names,
162 + "play_count": item.play_count,
163 + "download_count": item.download_count,
161 164 "created_at": item.created_at,
162 165 "chapters": chapters_data,
163 166 "versions": versions_data,
@@ -388,13 +391,14 @@ pub(super) async fn export_followers(
388 391 let followers = db::follows::get_followers_for_export(&state.db, user.id).await?;
389 392 let subscribers = db::subscriptions::get_project_subscribers_for_export(&state.db, user.id).await?;
390 393
391 - let mut csv_content = String::from("Section,Username,Display Name,Type,Status,Since\n");
394 + let mut csv_content = String::from("Section,Username,Display Name,Email,Type,Status,Since\n");
392 395
393 396 for f in &followers {
394 397 csv_content.push_str(&format!(
395 - "Follower,{},{},{},{},{}\n",
398 + "Follower,{},{},{},{},{},{}\n",
396 399 sanitize_csv_cell(&f.username),
397 400 sanitize_csv_cell(f.display_name.as_deref().unwrap_or("")),
401 + sanitize_csv_cell(f.email.as_deref().unwrap_or("")),
398 402 f.target_type,
399 403 "",
400 404 f.created_at.format("%Y-%m-%d %H:%M:%S"),
@@ -403,9 +407,10 @@ pub(super) async fn export_followers(
403 407
404 408 for s in &subscribers {
405 409 csv_content.push_str(&format!(
406 - "Subscriber,{},{},{},{},{}\n",
410 + "Subscriber,{},{},{},{},{},{}\n",
407 411 sanitize_csv_cell(&s.username),
408 412 sanitize_csv_cell(s.display_name.as_deref().unwrap_or("")),
413 + "",
409 414 sanitize_csv_cell(&s.tier_name),
410 415 s.status,
411 416 s.created_at.format("%Y-%m-%d %H:%M:%S"),
@@ -430,15 +435,27 @@ pub(super) async fn export_followers(
430 435 .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build response: {}", e)))
431 436 }
432 437
433 - /// Export all content files as a ZIP archive uploaded to S3.
438 + /// Query parameters for the content export endpoint.
439 + #[derive(Deserialize)]
440 + pub(super) struct ContentExportQuery {
441 + /// When set, only export files from this project (allows batch export
442 + /// of large catalogs that exceed the 500-file limit).
443 + pub project_id: Option<db::ProjectId>,
444 + }
445 +
446 + /// Export content files as a ZIP archive uploaded to S3.
434 447 ///
435 448 /// Collects audio, covers, version downloads, and insertion clips,
436 449 /// bundles them with a README.txt manifest, uploads to S3 as a
437 450 /// temporary export, and returns a presigned download link.
451 + ///
452 + /// Pass `?project_id=<uuid>` to limit the export to a single project
453 + /// (insertions are user-scoped and always excluded from per-project exports).
438 454 #[tracing::instrument(skip_all, name = "exports::export_content")]
439 455 pub(super) async fn export_content(
440 456 State(state): State<AppState>,
441 457 headers: HeaderMap,
458 + Query(query): Query<ContentExportQuery>,
442 459 AuthUser(user): AuthUser,
443 460 ) -> Result<Response> {
444 461 let is_htmx = is_htmx_request(&headers);
@@ -450,12 +467,16 @@ pub(super) async fn export_content(
450 467 // Collect all S3 keys from items, versions, and insertions
451 468 let item_keys = db::items::get_user_s3_keys(&state.db, user.id).await?;
452 469 let version_keys = db::versions::get_user_version_s3_keys(&state.db, user.id).await?;
453 - let insertions = db::content_insertions::list_insertions(&state.db, user.id).await?;
454 470
455 471 // Build the list of (s3_key, zip_path) pairs
456 472 let mut files: Vec<(String, String)> = Vec::new();
457 473
458 474 for item in &item_keys {
475 + if let Some(pid) = query.project_id {
476 + if item.project_id != pid {
477 + continue;
478 + }
479 + }
459 480 let slug = item.project_slug.as_str();
460 481 let title = sanitize_filename(&item.title);
461 482 if let Some(ref key) = item.audio_s3_key {
@@ -473,6 +494,11 @@ pub(super) async fn export_content(
473 494 }
474 495
475 496 for ver in &version_keys {
497 + if let Some(pid) = query.project_id {
498 + if ver.project_id != pid {
499 + continue;
500 + }
501 + }
476 502 if let Some(ref key) = ver.s3_key {
477 503 let slug = ver.project_slug.as_str();
478 504 let title = sanitize_filename(&ver.item_title);
@@ -481,10 +507,15 @@ pub(super) async fn export_content(
481 507 }
482 508 }
483 509
484 - for ins in &insertions {
485 - let ext = extension_from_key(&ins.storage_key);
486 - let title = sanitize_filename(&ins.title);
487 - files.push((ins.storage_key.clone(), format!("insertions/{}.{}", title, ext)));
510 + // Insertions are user-scoped (not project-scoped), so only include
511 + // them when exporting all content (no project_id filter).
512 + if query.project_id.is_none() {
513 + let insertions = db::content_insertions::list_insertions(&state.db, user.id).await?;
514 + for ins in &insertions {
515 + let ext = extension_from_key(&ins.storage_key);
516 + let title = sanitize_filename(&ins.title);
517 + files.push((ins.storage_key.clone(), format!("insertions/{}.{}", title, ext)));
518 + }
488 519 }
489 520
490 521 if files.is_empty() {