Skip to main content

max / makenotwork

19.5 KB · 550 lines History Blame Raw
1 //! Item, version, chapter, and section models.
2
3 use chrono::{DateTime, Utc};
4 use serde::Serialize;
5 use sqlx::FromRow;
6
7 use super::super::id_types::*;
8
9 /// A purchasable or free item within a project.
10 #[derive(Debug, Clone, FromRow, Serialize)]
11 pub struct DbItem {
12 /// Database primary key.
13 pub id: ItemId,
14 /// Parent project ID.
15 pub project_id: ProjectId,
16 /// Display title.
17 pub title: String,
18 /// Optional longer description.
19 pub description: Option<String>,
20 /// Price in cents (0 = free).
21 pub price_cents: i32,
22 /// Content type (audio, text, video, etc.).
23 pub item_type: super::super::ItemType,
24 /// URL to thumbnail image.
25 pub thumbnail_url: Option<String>,
26 /// Whether this item is publicly visible.
27 pub is_public: bool,
28 /// Position within the project's item list.
29 pub sort_order: i32,
30 /// When the item was created.
31 pub created_at: DateTime<Utc>,
32 /// When the item was last modified.
33 pub updated_at: DateTime<Utc>,
34 // Text content fields (for articles/essays)
35 /// Markdown/HTML body for text items.
36 pub body: Option<String>,
37 /// Computed word count of the body.
38 pub word_count: Option<i32>,
39 /// Estimated reading time in minutes.
40 pub reading_time_minutes: Option<i32>,
41 // Audio content fields (for podcasts/audio)
42 /// Public URL to the audio file.
43 pub audio_url: Option<String>,
44 /// Audio duration in seconds.
45 pub duration_seconds: Option<i32>,
46 /// URL to the item's cover image.
47 pub cover_image_url: Option<String>,
48 /// Episode number for podcast/series items.
49 pub episode_number: Option<i32>,
50 // S3 storage keys (optional, for S3-hosted content)
51 /// S3 object key for the audio file.
52 pub audio_s3_key: Option<String>,
53 /// S3 object key for the cover image.
54 pub cover_s3_key: Option<String>,
55 // License key settings
56 /// Whether license keys are enabled for this item.
57 pub enable_license_keys: bool,
58 /// Default max activations for new keys (NULL = unlimited).
59 pub default_max_activations: Option<i32>,
60 /// Denormalized count of completed purchases.
61 pub sales_count: i32,
62 /// Number of audio/video stream requests (total, including replays).
63 pub play_count: i32,
64 /// Number of unique authenticated listeners.
65 pub unique_play_count: i32,
66 /// Aggregate download count across all versions.
67 pub download_count: i32,
68 /// Whether Pay What You Want pricing is enabled.
69 pub pwyw_enabled: bool,
70 /// Minimum price in cents when PWYW is enabled (floor).
71 pub pwyw_min_cents: Option<i32>,
72 /// Malware scan status for uploaded files.
73 pub scan_status: super::super::FileScanStatus,
74 /// When a release announcement was sent (prevents re-announcement on unpublish/republish).
75 pub release_announced_at: Option<DateTime<Utc>>,
76 /// Scheduled publication time (item stays draft until this time, then the scheduler publishes it).
77 pub publish_at: Option<DateTime<Utc>>,
78 /// Linked MT discussion thread ID (None if not yet created or MT unavailable).
79 pub mt_thread_id: Option<MtThreadId>,
80 /// Whether this item should only be published on the web (skip email announcements).
81 pub web_only: bool,
82 /// Size of the audio file in bytes (populated on upload confirm).
83 pub audio_file_size_bytes: Option<i64>,
84 /// Size of the cover image in bytes (populated on upload confirm).
85 pub cover_file_size_bytes: Option<i64>,
86 /// URL-safe slug unique per project (for custom domain pretty URLs).
87 pub slug: String,
88 /// Whether this item appears on the project page (unlisted items are bundle-only).
89 pub listed: bool,
90 /// License preset key (e.g. "mit", "cc_by_4", "custom"). None = no license.
91 pub license_preset: Option<String>,
92 /// Custom license text, used when license_preset = "custom".
93 pub custom_license_text: Option<String>,
94 /// AI classification tier (handmade, assisted, generated).
95 pub ai_tier: super::super::AiTier,
96 /// Mandatory disclosure text for the `assisted` tier.
97 pub ai_disclosure: Option<String>,
98 // Video content fields
99 /// S3 object key for the video file.
100 pub video_s3_key: Option<String>,
101 /// Size of the video file in bytes.
102 pub video_file_size_bytes: Option<i64>,
103 /// Video duration in seconds.
104 pub video_duration_seconds: Option<i32>,
105 /// Video width in pixels.
106 pub video_width: Option<i32>,
107 /// Video height in pixels.
108 pub video_height: Option<i32>,
109 /// Whether this item was removed by an admin (enforcement ladder step 2).
110 pub removed_by_admin: bool,
111 /// Admin-provided reason for removal (shown to the creator).
112 pub removal_reason: Option<String>,
113 /// When the admin removed this item.
114 pub removed_at: Option<DateTime<Utc>>,
115 /// When the creator soft-deleted this item (NULL = not deleted, purged after 7 days).
116 pub deleted_at: Option<DateTime<Utc>>,
117 }
118
119 /// Content-type-specific data extracted from a `DbItem`.
120 ///
121 /// The flat `DbItem` struct has all content fields as `Option<T>` (required
122 /// by sqlx). This enum provides type-safe access: a `Text` item's fields
123 /// are grouped together, an `Audio` item's fields are grouped together,
124 /// and everything else is `Other`.
125 #[derive(Debug, Clone)]
126 pub enum ContentData {
127 Text {
128 body: Option<String>,
129 word_count: Option<i32>,
130 reading_time_minutes: Option<i32>,
131 },
132 Audio {
133 audio_url: Option<String>,
134 audio_s3_key: Option<String>,
135 cover_s3_key: Option<String>,
136 cover_image_url: Option<String>,
137 duration_seconds: Option<i32>,
138 episode_number: Option<i32>,
139 },
140 Video {
141 video_s3_key: Option<String>,
142 cover_s3_key: Option<String>,
143 cover_image_url: Option<String>,
144 duration_seconds: Option<i32>,
145 width: Option<i32>,
146 height: Option<i32>,
147 },
148 Other,
149 }
150
151 impl DbItem {
152 /// Extract content-type-specific fields as a discriminated enum.
153 pub fn content(&self) -> ContentData {
154 match self.item_type {
155 super::super::ItemType::Text => ContentData::Text {
156 body: self.body.clone(),
157 word_count: self.word_count,
158 reading_time_minutes: self.reading_time_minutes,
159 },
160 super::super::ItemType::Audio => ContentData::Audio {
161 audio_url: self.audio_url.clone(),
162 audio_s3_key: self.audio_s3_key.clone(),
163 cover_s3_key: self.cover_s3_key.clone(),
164 cover_image_url: self.cover_image_url.clone(),
165 duration_seconds: self.duration_seconds,
166 episode_number: self.episode_number,
167 },
168 super::super::ItemType::Video => ContentData::Video {
169 video_s3_key: self.video_s3_key.clone(),
170 cover_s3_key: self.cover_s3_key.clone(),
171 cover_image_url: self.cover_image_url.clone(),
172 duration_seconds: self.video_duration_seconds,
173 width: self.video_width,
174 height: self.video_height,
175 },
176 _ => ContentData::Other,
177 }
178 }
179
180 /// Whether this item has any S3-hosted content (audio, video, or cover image).
181 pub fn has_s3_content(&self) -> bool {
182 self.audio_s3_key.is_some() || self.cover_s3_key.is_some() || self.video_s3_key.is_some()
183 }
184 }
185
186 /// A versioned release of a downloadable item.
187 #[derive(Debug, Clone, FromRow, Serialize)]
188 pub struct DbVersion {
189 /// Database primary key.
190 pub id: VersionId,
191 /// Parent item ID.
192 pub item_id: ItemId,
193 /// Semver-style version string (e.g. "1.0.0").
194 pub version_number: String,
195 /// Optional release notes.
196 pub changelog: Option<String>,
197 /// Public download URL.
198 pub file_url: Option<String>,
199 /// File size in bytes.
200 pub file_size_bytes: Option<i64>,
201 /// Original uploaded file name.
202 pub file_name: Option<String>,
203 /// Number of times this version has been downloaded.
204 pub download_count: i32,
205 /// Whether this is the active/latest version.
206 pub is_current: bool,
207 /// When this version was created.
208 pub created_at: DateTime<Utc>,
209 // S3 storage key (optional, for S3-hosted content)
210 /// S3 object key for the version's file.
211 pub s3_key: Option<String>,
212 /// Malware scan status for uploaded files.
213 pub scan_status: super::super::FileScanStatus,
214 /// Optional short label (e.g. "macOS (arm)", "Linux (x86_64)").
215 pub label: Option<String>,
216 }
217
218 /// A chapter marker within an audio item.
219 #[derive(Debug, Clone, FromRow, Serialize)]
220 pub struct DbChapter {
221 /// Database primary key.
222 pub id: ChapterId,
223 /// Parent audio item ID.
224 pub item_id: ItemId,
225 /// Chapter title.
226 pub title: String,
227 /// Offset in seconds where this chapter begins.
228 pub start_seconds: f32,
229 /// Display order among sibling chapters.
230 pub sort_order: i32,
231 /// When this chapter was created.
232 pub created_at: DateTime<Utc>,
233 }
234
235 /// A tabbed content section within an item.
236 #[derive(Debug, Clone, FromRow, Serialize)]
237 pub struct DbItemSection {
238 /// Database primary key.
239 pub id: ItemSectionId,
240 /// Parent item ID.
241 pub item_id: ItemId,
242 /// Section tab title.
243 pub title: String,
244 /// URL-safe slug (unique per item).
245 pub slug: String,
246 /// Markdown body content.
247 pub body: String,
248 /// Display order among sibling sections.
249 pub sort_order: i32,
250 /// When this section was created.
251 pub created_at: DateTime<Utc>,
252 /// When this section was last modified.
253 pub updated_at: DateTime<Utc>,
254 }
255
256 /// File sizes for an item's audio, video, and cover uploads (for storage decrement on delete).
257 #[derive(Debug, Clone)]
258 pub struct ItemFileSizes {
259 pub audio_file_size_bytes: Option<i64>,
260 pub cover_file_size_bytes: Option<i64>,
261 pub video_file_size_bytes: Option<i64>,
262 }
263
264 /// Per-category storage breakdown for the creator dashboard.
265 #[derive(Debug, Clone, Default)]
266 pub struct StorageBreakdown {
267 pub audio_bytes: i64,
268 /// Item cover images plus project cover images (both charge storage).
269 pub cover_bytes: i64,
270 pub download_bytes: i64,
271 pub insertion_bytes: i64,
272 pub video_bytes: i64,
273 pub media_bytes: i64,
274 /// Gallery carousel images on items and projects (`item_images` /
275 /// `project_images`). Charged at confirm, decremented on delete.
276 pub gallery_bytes: i64,
277 pub total_bytes: i64,
278 }
279
280 #[cfg(test)]
281 mod tests {
282 use super::*;
283
284 fn make_item(item_type: super::super::super::ItemType) -> DbItem {
285 DbItem {
286 id: ItemId::nil(),
287 project_id: ProjectId::nil(),
288 title: "test".to_string(),
289 description: None,
290 price_cents: 0,
291 item_type,
292 thumbnail_url: None,
293 is_public: true,
294 sort_order: 0,
295 created_at: Utc::now(),
296 updated_at: Utc::now(),
297 body: Some("hello world".to_string()),
298 word_count: Some(2),
299 reading_time_minutes: Some(1),
300 audio_url: Some("https://example.com/audio.mp3".to_string()),
301 duration_seconds: Some(120),
302 cover_image_url: Some("https://example.com/cover.jpg".to_string()),
303 episode_number: Some(5),
304 audio_s3_key: Some("audio/test.mp3".to_string()),
305 cover_s3_key: Some("covers/test.jpg".to_string()),
306 enable_license_keys: false,
307 default_max_activations: None,
308 sales_count: 0,
309 play_count: 0,
310 unique_play_count: 0,
311 download_count: 0,
312 pwyw_enabled: false,
313 pwyw_min_cents: None,
314 scan_status: super::super::super::FileScanStatus::Pending,
315 release_announced_at: None,
316 publish_at: None,
317 mt_thread_id: None,
318 web_only: false,
319 audio_file_size_bytes: None,
320 cover_file_size_bytes: None,
321 slug: "test".to_string(),
322 listed: true,
323 license_preset: None,
324 custom_license_text: None,
325 ai_tier: super::super::super::AiTier::Handmade,
326 ai_disclosure: None,
327 video_s3_key: None,
328 video_file_size_bytes: None,
329 video_duration_seconds: None,
330 video_width: None,
331 video_height: None,
332 removed_by_admin: false,
333 removal_reason: None,
334 removed_at: None,
335 deleted_at: None,
336 }
337 }
338
339 #[test]
340 fn content_text_variant() {
341 let item = make_item(super::super::super::ItemType::Text);
342 match item.content() {
343 ContentData::Text { body, word_count, reading_time_minutes } => {
344 assert_eq!(body.as_deref(), Some("hello world"));
345 assert_eq!(word_count, Some(2));
346 assert_eq!(reading_time_minutes, Some(1));
347 }
348 _ => panic!("expected Text variant"),
349 }
350 }
351
352 #[test]
353 fn content_audio_variant() {
354 let item = make_item(super::super::super::ItemType::Audio);
355 match item.content() {
356 ContentData::Audio { audio_s3_key, duration_seconds, episode_number, .. } => {
357 assert_eq!(audio_s3_key.as_deref(), Some("audio/test.mp3"));
358 assert_eq!(duration_seconds, Some(120));
359 assert_eq!(episode_number, Some(5));
360 }
361 _ => panic!("expected Audio variant"),
362 }
363 }
364
365 #[test]
366 fn content_video_variant() {
367 let mut item = make_item(super::super::super::ItemType::Video);
368 item.video_s3_key = Some("video/test.mp4".to_string());
369 item.video_duration_seconds = Some(300);
370 item.video_width = Some(1920);
371 item.video_height = Some(1080);
372 match item.content() {
373 ContentData::Video { video_s3_key, duration_seconds, width, height, .. } => {
374 assert_eq!(video_s3_key.as_deref(), Some("video/test.mp4"));
375 assert_eq!(duration_seconds, Some(300));
376 assert_eq!(width, Some(1920));
377 assert_eq!(height, Some(1080));
378 }
379 _ => panic!("expected Video variant"),
380 }
381 }
382
383 #[test]
384 fn content_other_variant() {
385 let item = make_item(super::super::super::ItemType::Digital);
386 assert!(matches!(item.content(), ContentData::Other));
387 }
388
389 #[test]
390 fn has_s3_content_true() {
391 let item = make_item(super::super::super::ItemType::Audio);
392 assert!(item.has_s3_content());
393 }
394
395 #[test]
396 fn has_s3_content_false() {
397 let mut item = make_item(super::super::super::ItemType::Text);
398 item.audio_s3_key = None;
399 item.cover_s3_key = None;
400 assert!(!item.has_s3_content());
401 }
402
403 #[test]
404 fn has_s3_content_video() {
405 let mut item = make_item(super::super::super::ItemType::Video);
406 item.audio_s3_key = None;
407 item.cover_s3_key = None;
408 item.video_s3_key = Some("video/test.mp4".to_string());
409 assert!(item.has_s3_content());
410 }
411
412 #[test]
413 fn has_s3_content_cover_only() {
414 let mut item = make_item(super::super::super::ItemType::Audio);
415 item.audio_s3_key = None;
416 item.video_s3_key = None;
417 // cover_s3_key is still Some from make_item
418 assert!(item.has_s3_content());
419 }
420
421 #[test]
422 fn has_s3_content_all_none() {
423 let mut item = make_item(super::super::super::ItemType::Digital);
424 item.audio_s3_key = None;
425 item.cover_s3_key = None;
426 item.video_s3_key = None;
427 assert!(!item.has_s3_content());
428 }
429
430 #[test]
431 fn content_other_for_all_non_media_types() {
432 for item_type in [
433 super::super::super::ItemType::Image,
434 super::super::super::ItemType::Plugin,
435 super::super::super::ItemType::Preset,
436 super::super::super::ItemType::Sample,
437 super::super::super::ItemType::Course,
438 super::super::super::ItemType::Template,
439 super::super::super::ItemType::Digital,
440 ] {
441 let item = make_item(item_type);
442 assert!(
443 matches!(item.content(), ContentData::Other),
444 "expected Other for {:?}",
445 item_type
446 );
447 }
448 }
449
450 #[test]
451 fn content_text_with_none_fields() {
452 let mut item = make_item(super::super::super::ItemType::Text);
453 item.body = None;
454 item.word_count = None;
455 item.reading_time_minutes = None;
456 match item.content() {
457 ContentData::Text { body, word_count, reading_time_minutes } => {
458 assert!(body.is_none());
459 assert!(word_count.is_none());
460 assert!(reading_time_minutes.is_none());
461 }
462 _ => panic!("expected Text variant"),
463 }
464 }
465
466 #[test]
467 fn content_audio_with_none_fields() {
468 let mut item = make_item(super::super::super::ItemType::Audio);
469 item.audio_url = None;
470 item.audio_s3_key = None;
471 item.cover_s3_key = None;
472 item.cover_image_url = None;
473 item.duration_seconds = None;
474 item.episode_number = None;
475 match item.content() {
476 ContentData::Audio {
477 audio_url,
478 audio_s3_key,
479 cover_s3_key,
480 cover_image_url,
481 duration_seconds,
482 episode_number,
483 } => {
484 assert!(audio_url.is_none());
485 assert!(audio_s3_key.is_none());
486 assert!(cover_s3_key.is_none());
487 assert!(cover_image_url.is_none());
488 assert!(duration_seconds.is_none());
489 assert!(episode_number.is_none());
490 }
491 _ => panic!("expected Audio variant"),
492 }
493 }
494
495 #[test]
496 fn content_video_with_none_fields() {
497 let mut item = make_item(super::super::super::ItemType::Video);
498 item.video_s3_key = None;
499 item.cover_s3_key = None;
500 item.cover_image_url = None;
501 item.video_duration_seconds = None;
502 item.video_width = None;
503 item.video_height = None;
504 match item.content() {
505 ContentData::Video {
506 video_s3_key,
507 cover_s3_key,
508 cover_image_url,
509 duration_seconds,
510 width,
511 height,
512 } => {
513 assert!(video_s3_key.is_none());
514 assert!(cover_s3_key.is_none());
515 assert!(cover_image_url.is_none());
516 assert!(duration_seconds.is_none());
517 assert!(width.is_none());
518 assert!(height.is_none());
519 }
520 _ => panic!("expected Video variant"),
521 }
522 }
523
524 #[test]
525 fn content_video_uses_video_duration_not_audio_duration() {
526 let mut item = make_item(super::super::super::ItemType::Video);
527 item.duration_seconds = Some(999); // audio duration
528 item.video_duration_seconds = Some(42); // video duration
529 match item.content() {
530 ContentData::Video { duration_seconds, .. } => {
531 assert_eq!(duration_seconds, Some(42));
532 }
533 _ => panic!("expected Video variant"),
534 }
535 }
536
537 #[test]
538 fn storage_breakdown_default_is_zero() {
539 let sb = StorageBreakdown::default();
540 assert_eq!(sb.audio_bytes, 0);
541 assert_eq!(sb.cover_bytes, 0);
542 assert_eq!(sb.download_bytes, 0);
543 assert_eq!(sb.insertion_bytes, 0);
544 assert_eq!(sb.video_bytes, 0);
545 assert_eq!(sb.media_bytes, 0);
546 assert_eq!(sb.gallery_bytes, 0);
547 assert_eq!(sb.total_bytes, 0);
548 }
549 }
550