max / makenotwork
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() { |