//! Item, version, chapter, and section models. use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::FromRow; use super::super::id_types::*; /// A purchasable or free item within a project. #[derive(Debug, Clone, FromRow, Serialize)] pub struct DbItem { /// Database primary key. pub id: ItemId, /// Parent project ID. pub project_id: ProjectId, /// Display title. pub title: String, /// Optional longer description. pub description: Option, /// Price in cents (0 = free). pub price_cents: i32, /// Content type (audio, text, video, etc.). pub item_type: super::super::ItemType, /// URL to thumbnail image. pub thumbnail_url: Option, /// Whether this item is publicly visible. pub is_public: bool, /// Position within the project's item list. pub sort_order: i32, /// When the item was created. pub created_at: DateTime, /// When the item was last modified. pub updated_at: DateTime, // Text content fields (for articles/essays) /// Markdown/HTML body for text items. pub body: Option, /// Computed word count of the body. pub word_count: Option, /// Estimated reading time in minutes. pub reading_time_minutes: Option, // Audio content fields (for podcasts/audio) /// Public URL to the audio file. pub audio_url: Option, /// Audio duration in seconds. pub duration_seconds: Option, /// URL to the item's cover image. pub cover_image_url: Option, /// Episode number for podcast/series items. pub episode_number: Option, // S3 storage keys (optional, for S3-hosted content) /// S3 object key for the audio file. pub audio_s3_key: Option, /// S3 object key for the cover image. pub cover_s3_key: Option, // License key settings /// Whether license keys are enabled for this item. pub enable_license_keys: bool, /// Default max activations for new keys (NULL = unlimited). pub default_max_activations: Option, /// Denormalized count of completed purchases. pub sales_count: i32, /// Number of audio/video stream requests (total, including replays). pub play_count: i32, /// Number of unique authenticated listeners. pub unique_play_count: i32, /// Aggregate download count across all versions. pub download_count: i32, /// Whether Pay What You Want pricing is enabled. pub pwyw_enabled: bool, /// Minimum price in cents when PWYW is enabled (floor). pub pwyw_min_cents: Option, /// Malware scan status for uploaded files. pub scan_status: super::super::FileScanStatus, /// When a release announcement was sent (prevents re-announcement on unpublish/republish). pub release_announced_at: Option>, /// Scheduled publication time (item stays draft until this time, then the scheduler publishes it). pub publish_at: Option>, /// Linked MT discussion thread ID (None if not yet created or MT unavailable). pub mt_thread_id: Option, /// Whether this item should only be published on the web (skip email announcements). pub web_only: bool, /// Size of the audio file in bytes (populated on upload confirm). pub audio_file_size_bytes: Option, /// Size of the cover image in bytes (populated on upload confirm). pub cover_file_size_bytes: Option, /// URL-safe slug unique per project (for custom domain pretty URLs). pub slug: String, /// Whether this item appears on the project page (unlisted items are bundle-only). pub listed: bool, /// License preset key (e.g. "mit", "cc_by_4", "custom"). None = no license. pub license_preset: Option, /// Custom license text, used when license_preset = "custom". pub custom_license_text: Option, /// AI classification tier (handmade, assisted, generated). pub ai_tier: super::super::AiTier, /// Mandatory disclosure text for the `assisted` tier. pub ai_disclosure: Option, // Video content fields /// S3 object key for the video file. pub video_s3_key: Option, /// Size of the video file in bytes. pub video_file_size_bytes: Option, /// Video duration in seconds. pub video_duration_seconds: Option, /// Video width in pixels. pub video_width: Option, /// Video height in pixels. pub video_height: Option, /// Whether this item was removed by an admin (enforcement ladder step 2). pub removed_by_admin: bool, /// Admin-provided reason for removal (shown to the creator). pub removal_reason: Option, /// When the admin removed this item. pub removed_at: Option>, /// When the creator soft-deleted this item (NULL = not deleted, purged after 7 days). pub deleted_at: Option>, } /// Content-type-specific data extracted from a `DbItem`. /// /// The flat `DbItem` struct has all content fields as `Option` (required /// by sqlx). This enum provides type-safe access: a `Text` item's fields /// are grouped together, an `Audio` item's fields are grouped together, /// and everything else is `Other`. #[derive(Debug, Clone)] pub enum ContentData { Text { body: Option, word_count: Option, reading_time_minutes: Option, }, Audio { audio_url: Option, audio_s3_key: Option, cover_s3_key: Option, cover_image_url: Option, duration_seconds: Option, episode_number: Option, }, Video { video_s3_key: Option, cover_s3_key: Option, cover_image_url: Option, duration_seconds: Option, width: Option, height: Option, }, Other, } impl DbItem { /// Extract content-type-specific fields as a discriminated enum. pub fn content(&self) -> ContentData { match self.item_type { super::super::ItemType::Text => ContentData::Text { body: self.body.clone(), word_count: self.word_count, reading_time_minutes: self.reading_time_minutes, }, super::super::ItemType::Audio => ContentData::Audio { audio_url: self.audio_url.clone(), audio_s3_key: self.audio_s3_key.clone(), cover_s3_key: self.cover_s3_key.clone(), cover_image_url: self.cover_image_url.clone(), duration_seconds: self.duration_seconds, episode_number: self.episode_number, }, super::super::ItemType::Video => ContentData::Video { video_s3_key: self.video_s3_key.clone(), cover_s3_key: self.cover_s3_key.clone(), cover_image_url: self.cover_image_url.clone(), duration_seconds: self.video_duration_seconds, width: self.video_width, height: self.video_height, }, _ => ContentData::Other, } } /// Whether this item has any S3-hosted content (audio, video, or cover image). pub fn has_s3_content(&self) -> bool { self.audio_s3_key.is_some() || self.cover_s3_key.is_some() || self.video_s3_key.is_some() } } /// A versioned release of a downloadable item. #[derive(Debug, Clone, FromRow, Serialize)] pub struct DbVersion { /// Database primary key. pub id: VersionId, /// Parent item ID. pub item_id: ItemId, /// Semver-style version string (e.g. "1.0.0"). pub version_number: String, /// Optional release notes. pub changelog: Option, /// Public download URL. pub file_url: Option, /// File size in bytes. pub file_size_bytes: Option, /// Original uploaded file name. pub file_name: Option, /// Number of times this version has been downloaded. pub download_count: i32, /// Whether this is the active/latest version. pub is_current: bool, /// When this version was created. pub created_at: DateTime, // S3 storage key (optional, for S3-hosted content) /// S3 object key for the version's file. pub s3_key: Option, /// Malware scan status for uploaded files. pub scan_status: super::super::FileScanStatus, /// Optional short label (e.g. "macOS (arm)", "Linux (x86_64)"). pub label: Option, } /// A chapter marker within an audio item. #[derive(Debug, Clone, FromRow, Serialize)] pub struct DbChapter { /// Database primary key. pub id: ChapterId, /// Parent audio item ID. pub item_id: ItemId, /// Chapter title. pub title: String, /// Offset in seconds where this chapter begins. pub start_seconds: f32, /// Display order among sibling chapters. pub sort_order: i32, /// When this chapter was created. pub created_at: DateTime, } /// A tabbed content section within an item. #[derive(Debug, Clone, FromRow, Serialize)] pub struct DbItemSection { /// Database primary key. pub id: ItemSectionId, /// Parent item ID. pub item_id: ItemId, /// Section tab title. pub title: String, /// URL-safe slug (unique per item). pub slug: String, /// Markdown body content. pub body: String, /// Display order among sibling sections. pub sort_order: i32, /// When this section was created. pub created_at: DateTime, /// When this section was last modified. pub updated_at: DateTime, } /// File sizes for an item's audio, video, and cover uploads (for storage decrement on delete). #[derive(Debug, Clone)] pub struct ItemFileSizes { pub audio_file_size_bytes: Option, pub cover_file_size_bytes: Option, pub video_file_size_bytes: Option, } /// Per-category storage breakdown for the creator dashboard. #[derive(Debug, Clone, Default)] pub struct StorageBreakdown { pub audio_bytes: i64, /// Item cover images plus project cover images (both charge storage). pub cover_bytes: i64, pub download_bytes: i64, pub insertion_bytes: i64, pub video_bytes: i64, pub media_bytes: i64, /// Gallery carousel images on items and projects (`item_images` / /// `project_images`). Charged at confirm, decremented on delete. pub gallery_bytes: i64, pub total_bytes: i64, } #[cfg(test)] mod tests { use super::*; fn make_item(item_type: super::super::super::ItemType) -> DbItem { DbItem { id: ItemId::nil(), project_id: ProjectId::nil(), title: "test".to_string(), description: None, price_cents: 0, item_type, thumbnail_url: None, is_public: true, sort_order: 0, created_at: Utc::now(), updated_at: Utc::now(), body: Some("hello world".to_string()), word_count: Some(2), reading_time_minutes: Some(1), audio_url: Some("https://example.com/audio.mp3".to_string()), duration_seconds: Some(120), cover_image_url: Some("https://example.com/cover.jpg".to_string()), episode_number: Some(5), audio_s3_key: Some("audio/test.mp3".to_string()), cover_s3_key: Some("covers/test.jpg".to_string()), enable_license_keys: false, default_max_activations: None, sales_count: 0, play_count: 0, unique_play_count: 0, download_count: 0, pwyw_enabled: false, pwyw_min_cents: None, scan_status: super::super::super::FileScanStatus::Pending, release_announced_at: None, publish_at: None, mt_thread_id: None, web_only: false, audio_file_size_bytes: None, cover_file_size_bytes: None, slug: "test".to_string(), listed: true, license_preset: None, custom_license_text: None, ai_tier: super::super::super::AiTier::Handmade, ai_disclosure: None, video_s3_key: None, video_file_size_bytes: None, video_duration_seconds: None, video_width: None, video_height: None, removed_by_admin: false, removal_reason: None, removed_at: None, deleted_at: None, } } #[test] fn content_text_variant() { let item = make_item(super::super::super::ItemType::Text); match item.content() { ContentData::Text { body, word_count, reading_time_minutes } => { assert_eq!(body.as_deref(), Some("hello world")); assert_eq!(word_count, Some(2)); assert_eq!(reading_time_minutes, Some(1)); } _ => panic!("expected Text variant"), } } #[test] fn content_audio_variant() { let item = make_item(super::super::super::ItemType::Audio); match item.content() { ContentData::Audio { audio_s3_key, duration_seconds, episode_number, .. } => { assert_eq!(audio_s3_key.as_deref(), Some("audio/test.mp3")); assert_eq!(duration_seconds, Some(120)); assert_eq!(episode_number, Some(5)); } _ => panic!("expected Audio variant"), } } #[test] fn content_video_variant() { let mut item = make_item(super::super::super::ItemType::Video); item.video_s3_key = Some("video/test.mp4".to_string()); item.video_duration_seconds = Some(300); item.video_width = Some(1920); item.video_height = Some(1080); match item.content() { ContentData::Video { video_s3_key, duration_seconds, width, height, .. } => { assert_eq!(video_s3_key.as_deref(), Some("video/test.mp4")); assert_eq!(duration_seconds, Some(300)); assert_eq!(width, Some(1920)); assert_eq!(height, Some(1080)); } _ => panic!("expected Video variant"), } } #[test] fn content_other_variant() { let item = make_item(super::super::super::ItemType::Digital); assert!(matches!(item.content(), ContentData::Other)); } #[test] fn has_s3_content_true() { let item = make_item(super::super::super::ItemType::Audio); assert!(item.has_s3_content()); } #[test] fn has_s3_content_false() { let mut item = make_item(super::super::super::ItemType::Text); item.audio_s3_key = None; item.cover_s3_key = None; assert!(!item.has_s3_content()); } #[test] fn has_s3_content_video() { let mut item = make_item(super::super::super::ItemType::Video); item.audio_s3_key = None; item.cover_s3_key = None; item.video_s3_key = Some("video/test.mp4".to_string()); assert!(item.has_s3_content()); } #[test] fn has_s3_content_cover_only() { let mut item = make_item(super::super::super::ItemType::Audio); item.audio_s3_key = None; item.video_s3_key = None; // cover_s3_key is still Some from make_item assert!(item.has_s3_content()); } #[test] fn has_s3_content_all_none() { let mut item = make_item(super::super::super::ItemType::Digital); item.audio_s3_key = None; item.cover_s3_key = None; item.video_s3_key = None; assert!(!item.has_s3_content()); } #[test] fn content_other_for_all_non_media_types() { for item_type in [ super::super::super::ItemType::Image, super::super::super::ItemType::Plugin, super::super::super::ItemType::Preset, super::super::super::ItemType::Sample, super::super::super::ItemType::Course, super::super::super::ItemType::Template, super::super::super::ItemType::Digital, ] { let item = make_item(item_type); assert!( matches!(item.content(), ContentData::Other), "expected Other for {:?}", item_type ); } } #[test] fn content_text_with_none_fields() { let mut item = make_item(super::super::super::ItemType::Text); item.body = None; item.word_count = None; item.reading_time_minutes = None; match item.content() { ContentData::Text { body, word_count, reading_time_minutes } => { assert!(body.is_none()); assert!(word_count.is_none()); assert!(reading_time_minutes.is_none()); } _ => panic!("expected Text variant"), } } #[test] fn content_audio_with_none_fields() { let mut item = make_item(super::super::super::ItemType::Audio); item.audio_url = None; item.audio_s3_key = None; item.cover_s3_key = None; item.cover_image_url = None; item.duration_seconds = None; item.episode_number = None; match item.content() { ContentData::Audio { audio_url, audio_s3_key, cover_s3_key, cover_image_url, duration_seconds, episode_number, } => { assert!(audio_url.is_none()); assert!(audio_s3_key.is_none()); assert!(cover_s3_key.is_none()); assert!(cover_image_url.is_none()); assert!(duration_seconds.is_none()); assert!(episode_number.is_none()); } _ => panic!("expected Audio variant"), } } #[test] fn content_video_with_none_fields() { let mut item = make_item(super::super::super::ItemType::Video); item.video_s3_key = None; item.cover_s3_key = None; item.cover_image_url = None; item.video_duration_seconds = None; item.video_width = None; item.video_height = None; match item.content() { ContentData::Video { video_s3_key, cover_s3_key, cover_image_url, duration_seconds, width, height, } => { assert!(video_s3_key.is_none()); assert!(cover_s3_key.is_none()); assert!(cover_image_url.is_none()); assert!(duration_seconds.is_none()); assert!(width.is_none()); assert!(height.is_none()); } _ => panic!("expected Video variant"), } } #[test] fn content_video_uses_video_duration_not_audio_duration() { let mut item = make_item(super::super::super::ItemType::Video); item.duration_seconds = Some(999); // audio duration item.video_duration_seconds = Some(42); // video duration match item.content() { ContentData::Video { duration_seconds, .. } => { assert_eq!(duration_seconds, Some(42)); } _ => panic!("expected Video variant"), } } #[test] fn storage_breakdown_default_is_zero() { let sb = StorageBreakdown::default(); assert_eq!(sb.audio_bytes, 0); assert_eq!(sb.cover_bytes, 0); assert_eq!(sb.download_bytes, 0); assert_eq!(sb.insertion_bytes, 0); assert_eq!(sb.video_bytes, 0); assert_eq!(sb.media_bytes, 0); assert_eq!(sb.gallery_bytes, 0); assert_eq!(sb.total_bytes, 0); } }