Skip to main content

max / makenotwork

19.2 KB · 545 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 pub cover_bytes: i64,
269 pub download_bytes: i64,
270 pub insertion_bytes: i64,
271 pub video_bytes: i64,
272 pub media_bytes: i64,
273 pub total_bytes: i64,
274 }
275
276 #[cfg(test)]
277 mod tests {
278 use super::*;
279
280 fn make_item(item_type: super::super::super::ItemType) -> DbItem {
281 DbItem {
282 id: ItemId::nil(),
283 project_id: ProjectId::nil(),
284 title: "test".to_string(),
285 description: None,
286 price_cents: 0,
287 item_type,
288 thumbnail_url: None,
289 is_public: true,
290 sort_order: 0,
291 created_at: Utc::now(),
292 updated_at: Utc::now(),
293 body: Some("hello world".to_string()),
294 word_count: Some(2),
295 reading_time_minutes: Some(1),
296 audio_url: Some("https://example.com/audio.mp3".to_string()),
297 duration_seconds: Some(120),
298 cover_image_url: Some("https://example.com/cover.jpg".to_string()),
299 episode_number: Some(5),
300 audio_s3_key: Some("audio/test.mp3".to_string()),
301 cover_s3_key: Some("covers/test.jpg".to_string()),
302 enable_license_keys: false,
303 default_max_activations: None,
304 sales_count: 0,
305 play_count: 0,
306 unique_play_count: 0,
307 download_count: 0,
308 pwyw_enabled: false,
309 pwyw_min_cents: None,
310 scan_status: super::super::super::FileScanStatus::Pending,
311 release_announced_at: None,
312 publish_at: None,
313 mt_thread_id: None,
314 web_only: false,
315 audio_file_size_bytes: None,
316 cover_file_size_bytes: None,
317 slug: "test".to_string(),
318 listed: true,
319 license_preset: None,
320 custom_license_text: None,
321 ai_tier: super::super::super::AiTier::Handmade,
322 ai_disclosure: None,
323 video_s3_key: None,
324 video_file_size_bytes: None,
325 video_duration_seconds: None,
326 video_width: None,
327 video_height: None,
328 removed_by_admin: false,
329 removal_reason: None,
330 removed_at: None,
331 deleted_at: None,
332 }
333 }
334
335 #[test]
336 fn content_text_variant() {
337 let item = make_item(super::super::super::ItemType::Text);
338 match item.content() {
339 ContentData::Text { body, word_count, reading_time_minutes } => {
340 assert_eq!(body.as_deref(), Some("hello world"));
341 assert_eq!(word_count, Some(2));
342 assert_eq!(reading_time_minutes, Some(1));
343 }
344 _ => panic!("expected Text variant"),
345 }
346 }
347
348 #[test]
349 fn content_audio_variant() {
350 let item = make_item(super::super::super::ItemType::Audio);
351 match item.content() {
352 ContentData::Audio { audio_s3_key, duration_seconds, episode_number, .. } => {
353 assert_eq!(audio_s3_key.as_deref(), Some("audio/test.mp3"));
354 assert_eq!(duration_seconds, Some(120));
355 assert_eq!(episode_number, Some(5));
356 }
357 _ => panic!("expected Audio variant"),
358 }
359 }
360
361 #[test]
362 fn content_video_variant() {
363 let mut item = make_item(super::super::super::ItemType::Video);
364 item.video_s3_key = Some("video/test.mp4".to_string());
365 item.video_duration_seconds = Some(300);
366 item.video_width = Some(1920);
367 item.video_height = Some(1080);
368 match item.content() {
369 ContentData::Video { video_s3_key, duration_seconds, width, height, .. } => {
370 assert_eq!(video_s3_key.as_deref(), Some("video/test.mp4"));
371 assert_eq!(duration_seconds, Some(300));
372 assert_eq!(width, Some(1920));
373 assert_eq!(height, Some(1080));
374 }
375 _ => panic!("expected Video variant"),
376 }
377 }
378
379 #[test]
380 fn content_other_variant() {
381 let item = make_item(super::super::super::ItemType::Digital);
382 assert!(matches!(item.content(), ContentData::Other));
383 }
384
385 #[test]
386 fn has_s3_content_true() {
387 let item = make_item(super::super::super::ItemType::Audio);
388 assert!(item.has_s3_content());
389 }
390
391 #[test]
392 fn has_s3_content_false() {
393 let mut item = make_item(super::super::super::ItemType::Text);
394 item.audio_s3_key = None;
395 item.cover_s3_key = None;
396 assert!(!item.has_s3_content());
397 }
398
399 #[test]
400 fn has_s3_content_video() {
401 let mut item = make_item(super::super::super::ItemType::Video);
402 item.audio_s3_key = None;
403 item.cover_s3_key = None;
404 item.video_s3_key = Some("video/test.mp4".to_string());
405 assert!(item.has_s3_content());
406 }
407
408 #[test]
409 fn has_s3_content_cover_only() {
410 let mut item = make_item(super::super::super::ItemType::Audio);
411 item.audio_s3_key = None;
412 item.video_s3_key = None;
413 // cover_s3_key is still Some from make_item
414 assert!(item.has_s3_content());
415 }
416
417 #[test]
418 fn has_s3_content_all_none() {
419 let mut item = make_item(super::super::super::ItemType::Digital);
420 item.audio_s3_key = None;
421 item.cover_s3_key = None;
422 item.video_s3_key = None;
423 assert!(!item.has_s3_content());
424 }
425
426 #[test]
427 fn content_other_for_all_non_media_types() {
428 for item_type in [
429 super::super::super::ItemType::Image,
430 super::super::super::ItemType::Plugin,
431 super::super::super::ItemType::Preset,
432 super::super::super::ItemType::Sample,
433 super::super::super::ItemType::Course,
434 super::super::super::ItemType::Template,
435 super::super::super::ItemType::Digital,
436 ] {
437 let item = make_item(item_type);
438 assert!(
439 matches!(item.content(), ContentData::Other),
440 "expected Other for {:?}",
441 item_type
442 );
443 }
444 }
445
446 #[test]
447 fn content_text_with_none_fields() {
448 let mut item = make_item(super::super::super::ItemType::Text);
449 item.body = None;
450 item.word_count = None;
451 item.reading_time_minutes = None;
452 match item.content() {
453 ContentData::Text { body, word_count, reading_time_minutes } => {
454 assert!(body.is_none());
455 assert!(word_count.is_none());
456 assert!(reading_time_minutes.is_none());
457 }
458 _ => panic!("expected Text variant"),
459 }
460 }
461
462 #[test]
463 fn content_audio_with_none_fields() {
464 let mut item = make_item(super::super::super::ItemType::Audio);
465 item.audio_url = None;
466 item.audio_s3_key = None;
467 item.cover_s3_key = None;
468 item.cover_image_url = None;
469 item.duration_seconds = None;
470 item.episode_number = None;
471 match item.content() {
472 ContentData::Audio {
473 audio_url,
474 audio_s3_key,
475 cover_s3_key,
476 cover_image_url,
477 duration_seconds,
478 episode_number,
479 } => {
480 assert!(audio_url.is_none());
481 assert!(audio_s3_key.is_none());
482 assert!(cover_s3_key.is_none());
483 assert!(cover_image_url.is_none());
484 assert!(duration_seconds.is_none());
485 assert!(episode_number.is_none());
486 }
487 _ => panic!("expected Audio variant"),
488 }
489 }
490
491 #[test]
492 fn content_video_with_none_fields() {
493 let mut item = make_item(super::super::super::ItemType::Video);
494 item.video_s3_key = None;
495 item.cover_s3_key = None;
496 item.cover_image_url = None;
497 item.video_duration_seconds = None;
498 item.video_width = None;
499 item.video_height = None;
500 match item.content() {
501 ContentData::Video {
502 video_s3_key,
503 cover_s3_key,
504 cover_image_url,
505 duration_seconds,
506 width,
507 height,
508 } => {
509 assert!(video_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!(width.is_none());
514 assert!(height.is_none());
515 }
516 _ => panic!("expected Video variant"),
517 }
518 }
519
520 #[test]
521 fn content_video_uses_video_duration_not_audio_duration() {
522 let mut item = make_item(super::super::super::ItemType::Video);
523 item.duration_seconds = Some(999); // audio duration
524 item.video_duration_seconds = Some(42); // video duration
525 match item.content() {
526 ContentData::Video { duration_seconds, .. } => {
527 assert_eq!(duration_seconds, Some(42));
528 }
529 _ => panic!("expected Video variant"),
530 }
531 }
532
533 #[test]
534 fn storage_breakdown_default_is_zero() {
535 let sb = StorageBreakdown::default();
536 assert_eq!(sb.audio_bytes, 0);
537 assert_eq!(sb.cover_bytes, 0);
538 assert_eq!(sb.download_bytes, 0);
539 assert_eq!(sb.insertion_bytes, 0);
540 assert_eq!(sb.video_bytes, 0);
541 assert_eq!(sb.media_bytes, 0);
542 assert_eq!(sb.total_bytes, 0);
543 }
544 }
545