1use crate::{
2 domain::value_objects::{ArticleId, CreatorId, Money, TenantId},
3 error::Result,
4};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
10pub enum ArticleStatus {
11 Draft,
13 #[default]
15 Active,
16 Archived,
18 Deleted,
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct ArticleStats {
25 pub total_purchases: u64,
27 pub total_revenue_cents: u64,
29 pub unique_readers: u64,
31 pub avg_read_duration_seconds: u64,
33 pub avg_scroll_depth: u8,
35 pub conversion_rate: f32,
37}
38
39impl ArticleStats {
40 pub fn new() -> Self {
41 Self::default()
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct PaywallArticle {
58 id: ArticleId,
59 tenant_id: TenantId,
60 creator_id: CreatorId,
61 title: String,
62 url: String,
63 price: Money,
64 description: Option<String>,
65 estimated_reading_time_minutes: Option<u16>,
66 preview_content: Option<String>,
67 status: ArticleStatus,
68 stats: ArticleStats,
69 created_at: DateTime<Utc>,
70 updated_at: DateTime<Utc>,
71 published_at: Option<DateTime<Utc>>,
72 metadata: serde_json::Value,
73}
74
75const MIN_PRICE_CENTS: u64 = 10;
77const MAX_PRICE_CENTS: u64 = 1000;
79
80impl PaywallArticle {
81 pub fn new(
83 id: ArticleId,
84 tenant_id: TenantId,
85 creator_id: CreatorId,
86 title: String,
87 url: String,
88 price_cents: u64,
89 ) -> Result<Self> {
90 Self::validate_title(&title)?;
91 Self::validate_url(&url)?;
92 Self::validate_price(price_cents)?;
93
94 let now = Utc::now();
95 Ok(Self {
96 id,
97 tenant_id,
98 creator_id,
99 title,
100 url,
101 price: Money::usd_cents(price_cents),
102 description: None,
103 estimated_reading_time_minutes: None,
104 preview_content: None,
105 status: ArticleStatus::Active,
106 stats: ArticleStats::new(),
107 created_at: now,
108 updated_at: now,
109 published_at: Some(now),
110 metadata: serde_json::json!({}),
111 })
112 }
113
114 pub fn new_draft(
116 id: ArticleId,
117 tenant_id: TenantId,
118 creator_id: CreatorId,
119 title: String,
120 url: String,
121 price_cents: u64,
122 ) -> Result<Self> {
123 Self::validate_title(&title)?;
124 Self::validate_url(&url)?;
125 Self::validate_price(price_cents)?;
126
127 let now = Utc::now();
128 Ok(Self {
129 id,
130 tenant_id,
131 creator_id,
132 title,
133 url,
134 price: Money::usd_cents(price_cents),
135 description: None,
136 estimated_reading_time_minutes: None,
137 preview_content: None,
138 status: ArticleStatus::Draft,
139 stats: ArticleStats::new(),
140 created_at: now,
141 updated_at: now,
142 published_at: None,
143 metadata: serde_json::json!({}),
144 })
145 }
146
147 #[allow(clippy::too_many_arguments)]
149 pub fn reconstruct(
150 id: ArticleId,
151 tenant_id: TenantId,
152 creator_id: CreatorId,
153 title: String,
154 url: String,
155 price: Money,
156 description: Option<String>,
157 estimated_reading_time_minutes: Option<u16>,
158 preview_content: Option<String>,
159 status: ArticleStatus,
160 stats: ArticleStats,
161 created_at: DateTime<Utc>,
162 updated_at: DateTime<Utc>,
163 published_at: Option<DateTime<Utc>>,
164 metadata: serde_json::Value,
165 ) -> Self {
166 Self {
167 id,
168 tenant_id,
169 creator_id,
170 title,
171 url,
172 price,
173 description,
174 estimated_reading_time_minutes,
175 preview_content,
176 status,
177 stats,
178 created_at,
179 updated_at,
180 published_at,
181 metadata,
182 }
183 }
184
185 pub fn id(&self) -> &ArticleId {
188 &self.id
189 }
190
191 pub fn tenant_id(&self) -> &TenantId {
192 &self.tenant_id
193 }
194
195 pub fn creator_id(&self) -> &CreatorId {
196 &self.creator_id
197 }
198
199 pub fn title(&self) -> &str {
200 &self.title
201 }
202
203 pub fn url(&self) -> &str {
204 &self.url
205 }
206
207 pub fn price(&self) -> &Money {
208 &self.price
209 }
210
211 pub fn price_cents(&self) -> u64 {
212 self.price.amount()
213 }
214
215 pub fn description(&self) -> Option<&str> {
216 self.description.as_deref()
217 }
218
219 pub fn estimated_reading_time_minutes(&self) -> Option<u16> {
220 self.estimated_reading_time_minutes
221 }
222
223 pub fn preview_content(&self) -> Option<&str> {
224 self.preview_content.as_deref()
225 }
226
227 pub fn status(&self) -> ArticleStatus {
228 self.status
229 }
230
231 pub fn stats(&self) -> &ArticleStats {
232 &self.stats
233 }
234
235 pub fn created_at(&self) -> DateTime<Utc> {
236 self.created_at
237 }
238
239 pub fn updated_at(&self) -> DateTime<Utc> {
240 self.updated_at
241 }
242
243 pub fn published_at(&self) -> Option<DateTime<Utc>> {
244 self.published_at
245 }
246
247 pub fn metadata(&self) -> &serde_json::Value {
248 &self.metadata
249 }
250
251 pub fn is_purchasable(&self) -> bool {
255 self.status == ArticleStatus::Active
256 }
257
258 pub fn belongs_to(&self, creator_id: &CreatorId) -> bool {
260 &self.creator_id == creator_id
261 }
262
263 pub fn update_title(&mut self, title: String) -> Result<()> {
265 Self::validate_title(&title)?;
266 self.title = title;
267 self.updated_at = Utc::now();
268 Ok(())
269 }
270
271 pub fn update_url(&mut self, url: String) -> Result<()> {
273 Self::validate_url(&url)?;
274 self.url = url;
275 self.updated_at = Utc::now();
276 Ok(())
277 }
278
279 pub fn update_price(&mut self, price_cents: u64) -> Result<()> {
281 Self::validate_price(price_cents)?;
282 self.price = Money::usd_cents(price_cents);
283 self.updated_at = Utc::now();
284 Ok(())
285 }
286
287 pub fn update_description(&mut self, description: Option<String>) {
289 self.description = description;
290 self.updated_at = Utc::now();
291 }
292
293 pub fn update_reading_time(&mut self, minutes: Option<u16>) {
295 self.estimated_reading_time_minutes = minutes;
296 self.updated_at = Utc::now();
297 }
298
299 pub fn update_preview(&mut self, preview: Option<String>) -> Result<()> {
301 if let Some(ref p) = preview
302 && p.len() > 1000
303 {
304 return Err(crate::error::AllSourceError::ValidationError(
305 "Preview content cannot exceed 1000 characters".to_string(),
306 ));
307 }
308 self.preview_content = preview;
309 self.updated_at = Utc::now();
310 Ok(())
311 }
312
313 pub fn publish(&mut self) -> Result<()> {
315 if self.status != ArticleStatus::Draft {
316 return Err(crate::error::AllSourceError::ValidationError(format!(
317 "Can only publish draft articles, current status: {:?}",
318 self.status
319 )));
320 }
321
322 self.status = ArticleStatus::Active;
323 self.published_at = Some(Utc::now());
324 self.updated_at = Utc::now();
325 Ok(())
326 }
327
328 pub fn archive(&mut self) {
330 self.status = ArticleStatus::Archived;
331 self.updated_at = Utc::now();
332 }
333
334 pub fn restore(&mut self) -> Result<()> {
336 if self.status != ArticleStatus::Archived {
337 return Err(crate::error::AllSourceError::ValidationError(
338 "Can only restore archived articles".to_string(),
339 ));
340 }
341
342 self.status = ArticleStatus::Active;
343 self.updated_at = Utc::now();
344 Ok(())
345 }
346
347 pub fn delete(&mut self) {
349 self.status = ArticleStatus::Deleted;
350 self.updated_at = Utc::now();
351 }
352
353 pub fn record_purchase(&mut self, amount_cents: u64) {
355 self.stats.total_purchases += 1;
356 self.stats.total_revenue_cents += amount_cents;
357 self.updated_at = Utc::now();
358 }
359
360 pub fn record_reader(&mut self) {
362 self.stats.unique_readers += 1;
363 self.updated_at = Utc::now();
364 }
365
366 pub fn update_reading_analytics(&mut self, duration_seconds: u64, scroll_depth: u8) {
368 let total = self.stats.total_purchases.max(1);
370 self.stats.avg_read_duration_seconds =
371 ((self.stats.avg_read_duration_seconds * (total - 1)) + duration_seconds) / total;
372 self.stats.avg_scroll_depth = (((u64::from(self.stats.avg_scroll_depth) * (total - 1))
373 + u64::from(scroll_depth))
374 / total) as u8;
375 self.updated_at = Utc::now();
376 }
377
378 pub fn update_conversion_rate(&mut self, views: u64) {
380 if views > 0 {
381 self.stats.conversion_rate = (self.stats.total_purchases as f32 / views as f32) * 100.0;
382 }
383 self.updated_at = Utc::now();
384 }
385
386 pub fn update_metadata(&mut self, metadata: serde_json::Value) {
388 self.metadata = metadata;
389 self.updated_at = Utc::now();
390 }
391
392 fn validate_title(title: &str) -> Result<()> {
395 if title.is_empty() {
396 return Err(crate::error::AllSourceError::InvalidInput(
397 "Article title cannot be empty".to_string(),
398 ));
399 }
400
401 if title.len() > 500 {
402 return Err(crate::error::AllSourceError::InvalidInput(
403 "Article title cannot exceed 500 characters".to_string(),
404 ));
405 }
406
407 Ok(())
408 }
409
410 fn validate_url(url: &str) -> Result<()> {
411 if url.is_empty() {
412 return Err(crate::error::AllSourceError::InvalidInput(
413 "Article URL cannot be empty".to_string(),
414 ));
415 }
416
417 if !url.starts_with("http://") && !url.starts_with("https://") {
418 return Err(crate::error::AllSourceError::InvalidInput(
419 "Article URL must start with http:// or https://".to_string(),
420 ));
421 }
422
423 if url.len() > 2048 {
424 return Err(crate::error::AllSourceError::InvalidInput(
425 "Article URL cannot exceed 2048 characters".to_string(),
426 ));
427 }
428
429 Ok(())
430 }
431
432 fn validate_price(price_cents: u64) -> Result<()> {
433 if price_cents < MIN_PRICE_CENTS {
434 return Err(crate::error::AllSourceError::ValidationError(format!(
435 "Price must be at least ${:.2}",
436 MIN_PRICE_CENTS as f64 / 100.0
437 )));
438 }
439
440 if price_cents > MAX_PRICE_CENTS {
441 return Err(crate::error::AllSourceError::ValidationError(format!(
442 "Price cannot exceed ${:.2}",
443 MAX_PRICE_CENTS as f64 / 100.0
444 )));
445 }
446
447 Ok(())
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 fn test_tenant_id() -> TenantId {
456 TenantId::new("test-tenant".to_string()).unwrap()
457 }
458
459 fn test_article_id() -> ArticleId {
460 ArticleId::new("test-article".to_string()).unwrap()
461 }
462
463 fn test_creator_id() -> CreatorId {
464 CreatorId::new()
465 }
466
467 #[test]
468 fn test_create_article() {
469 let article = PaywallArticle::new(
470 test_article_id(),
471 test_tenant_id(),
472 test_creator_id(),
473 "My Awesome Article".to_string(),
474 "https://blog.example.com/article".to_string(),
475 50, );
477
478 assert!(article.is_ok());
479 let article = article.unwrap();
480 assert_eq!(article.title(), "My Awesome Article");
481 assert_eq!(article.price_cents(), 50);
482 assert_eq!(article.status(), ArticleStatus::Active);
483 assert!(article.is_purchasable());
484 }
485
486 #[test]
487 fn test_create_draft_article() {
488 let article = PaywallArticle::new_draft(
489 test_article_id(),
490 test_tenant_id(),
491 test_creator_id(),
492 "Draft Article".to_string(),
493 "https://blog.example.com/draft".to_string(),
494 100,
495 );
496
497 assert!(article.is_ok());
498 let article = article.unwrap();
499 assert_eq!(article.status(), ArticleStatus::Draft);
500 assert!(!article.is_purchasable());
501 assert!(article.published_at().is_none());
502 }
503
504 #[test]
505 fn test_reject_empty_title() {
506 let result = PaywallArticle::new(
507 test_article_id(),
508 test_tenant_id(),
509 test_creator_id(),
510 String::new(),
511 "https://blog.example.com/article".to_string(),
512 50,
513 );
514
515 assert!(result.is_err());
516 }
517
518 #[test]
519 fn test_reject_invalid_url() {
520 let result = PaywallArticle::new(
521 test_article_id(),
522 test_tenant_id(),
523 test_creator_id(),
524 "Title".to_string(),
525 "not-a-url".to_string(),
526 50,
527 );
528
529 assert!(result.is_err());
530 }
531
532 #[test]
533 fn test_reject_price_too_low() {
534 let result = PaywallArticle::new(
535 test_article_id(),
536 test_tenant_id(),
537 test_creator_id(),
538 "Title".to_string(),
539 "https://example.com".to_string(),
540 5, );
542
543 assert!(result.is_err());
544 }
545
546 #[test]
547 fn test_reject_price_too_high() {
548 let result = PaywallArticle::new(
549 test_article_id(),
550 test_tenant_id(),
551 test_creator_id(),
552 "Title".to_string(),
553 "https://example.com".to_string(),
554 1500, );
556
557 assert!(result.is_err());
558 }
559
560 #[test]
561 fn test_publish_draft() {
562 let mut article = PaywallArticle::new_draft(
563 test_article_id(),
564 test_tenant_id(),
565 test_creator_id(),
566 "Draft Article".to_string(),
567 "https://example.com".to_string(),
568 50,
569 )
570 .unwrap();
571
572 assert!(!article.is_purchasable());
573
574 let result = article.publish();
575 assert!(result.is_ok());
576 assert!(article.is_purchasable());
577 assert!(article.published_at().is_some());
578 }
579
580 #[test]
581 fn test_cannot_publish_active_article() {
582 let mut article = PaywallArticle::new(
583 test_article_id(),
584 test_tenant_id(),
585 test_creator_id(),
586 "Active Article".to_string(),
587 "https://example.com".to_string(),
588 50,
589 )
590 .unwrap();
591
592 let result = article.publish();
593 assert!(result.is_err());
594 }
595
596 #[test]
597 fn test_archive_and_restore() {
598 let mut article = PaywallArticle::new(
599 test_article_id(),
600 test_tenant_id(),
601 test_creator_id(),
602 "Article".to_string(),
603 "https://example.com".to_string(),
604 50,
605 )
606 .unwrap();
607
608 assert!(article.is_purchasable());
609
610 article.archive();
611 assert_eq!(article.status(), ArticleStatus::Archived);
612 assert!(!article.is_purchasable());
613
614 article.restore().unwrap();
615 assert_eq!(article.status(), ArticleStatus::Active);
616 assert!(article.is_purchasable());
617 }
618
619 #[test]
620 fn test_record_purchase() {
621 let mut article = PaywallArticle::new(
622 test_article_id(),
623 test_tenant_id(),
624 test_creator_id(),
625 "Article".to_string(),
626 "https://example.com".to_string(),
627 50,
628 )
629 .unwrap();
630
631 assert_eq!(article.stats().total_purchases, 0);
632 assert_eq!(article.stats().total_revenue_cents, 0);
633
634 article.record_purchase(50);
635 assert_eq!(article.stats().total_purchases, 1);
636 assert_eq!(article.stats().total_revenue_cents, 50);
637
638 article.record_purchase(50);
639 assert_eq!(article.stats().total_purchases, 2);
640 assert_eq!(article.stats().total_revenue_cents, 100);
641 }
642
643 #[test]
644 fn test_update_price() {
645 let mut article = PaywallArticle::new(
646 test_article_id(),
647 test_tenant_id(),
648 test_creator_id(),
649 "Article".to_string(),
650 "https://example.com".to_string(),
651 50,
652 )
653 .unwrap();
654
655 assert_eq!(article.price_cents(), 50);
656
657 article.update_price(100).unwrap();
658 assert_eq!(article.price_cents(), 100);
659
660 assert!(article.update_price(5).is_err());
662 }
663
664 #[test]
665 fn test_belongs_to() {
666 let creator_id = test_creator_id();
667 let other_creator_id = CreatorId::new();
668
669 let article = PaywallArticle::new(
670 test_article_id(),
671 test_tenant_id(),
672 creator_id,
673 "Article".to_string(),
674 "https://example.com".to_string(),
675 50,
676 )
677 .unwrap();
678
679 assert!(article.belongs_to(&creator_id));
680 assert!(!article.belongs_to(&other_creator_id));
681 }
682
683 #[test]
684 fn test_serde_serialization() {
685 let article = PaywallArticle::new(
686 test_article_id(),
687 test_tenant_id(),
688 test_creator_id(),
689 "Test Article".to_string(),
690 "https://example.com".to_string(),
691 50,
692 )
693 .unwrap();
694
695 let json = serde_json::to_string(&article);
696 assert!(json.is_ok());
697
698 let deserialized: PaywallArticle = serde_json::from_str(&json.unwrap()).unwrap();
699 assert_eq!(deserialized.title(), "Test Article");
700 }
701}