Skip to main content

max / makenotwork

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