Skip to main content

allsource_core/domain/entities/
access_token.rs

1use crate::{
2    domain::value_objects::{ArticleId, CreatorId, TenantId, TransactionId, WalletAddress},
3    error::Result,
4};
5use chrono::{DateTime, Duration, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9/// Access token ID
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub struct AccessTokenId(Uuid);
12
13impl AccessTokenId {
14    pub fn new() -> Self {
15        Self(Uuid::new_v4())
16    }
17
18    pub fn from_uuid(uuid: Uuid) -> Self {
19        Self(uuid)
20    }
21
22    pub fn parse(value: &str) -> Result<Self> {
23        let uuid = Uuid::parse_str(value).map_err(|e| {
24            crate::error::AllSourceError::InvalidInput(format!(
25                "Invalid access token ID '{}': {}",
26                value, e
27            ))
28        })?;
29        Ok(Self(uuid))
30    }
31
32    pub fn as_uuid(&self) -> Uuid {
33        self.0
34    }
35}
36
37impl Default for AccessTokenId {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl std::fmt::Display for AccessTokenId {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(f, "{}", self.0)
46    }
47}
48
49/// How access was granted
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
51pub enum AccessMethod {
52    /// Paid for directly
53    #[default]
54    Paid,
55    /// Accessed through a bundle
56    Bundle,
57    /// Free access (promotional)
58    Free,
59    /// Subscription access
60    Subscription,
61}
62
63/// Default access duration: 30 days
64const DEFAULT_ACCESS_DAYS: i64 = 30;
65
66/// Domain Entity: AccessToken
67///
68/// Represents a reader's access to a specific article.
69/// Access tokens are issued after successful payment and grant time-limited access.
70///
71/// Domain Rules:
72/// - Token is tied to a specific article and reader wallet
73/// - Token has an expiration date (default 30 days)
74/// - Token can be revoked (e.g., on refund)
75/// - Token cannot be transferred to another wallet
76/// - Expired or revoked tokens do not grant access
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct AccessToken {
79    id: AccessTokenId,
80    tenant_id: TenantId,
81    article_id: ArticleId,
82    creator_id: CreatorId,
83    reader_wallet: WalletAddress,
84    transaction_id: Option<TransactionId>,
85    access_method: AccessMethod,
86    token_hash: String,
87    issued_at: DateTime<Utc>,
88    expires_at: DateTime<Utc>,
89    revoked: bool,
90    revoked_at: Option<DateTime<Utc>>,
91    revocation_reason: Option<String>,
92    last_accessed_at: Option<DateTime<Utc>>,
93    access_count: u32,
94    metadata: serde_json::Value,
95}
96
97impl AccessToken {
98    /// Create a new access token for a paid transaction
99    pub fn new_paid(
100        tenant_id: TenantId,
101        article_id: ArticleId,
102        creator_id: CreatorId,
103        reader_wallet: WalletAddress,
104        transaction_id: TransactionId,
105        token_hash: String,
106    ) -> Result<Self> {
107        Self::validate_token_hash(&token_hash)?;
108
109        let now = Utc::now();
110        let expires_at = now + Duration::days(DEFAULT_ACCESS_DAYS);
111
112        Ok(Self {
113            id: AccessTokenId::new(),
114            tenant_id,
115            article_id,
116            creator_id,
117            reader_wallet,
118            transaction_id: Some(transaction_id),
119            access_method: AccessMethod::Paid,
120            token_hash,
121            issued_at: now,
122            expires_at,
123            revoked: false,
124            revoked_at: None,
125            revocation_reason: None,
126            last_accessed_at: None,
127            access_count: 0,
128            metadata: serde_json::json!({}),
129        })
130    }
131
132    /// Create a new access token with custom duration
133    pub fn new_with_duration(
134        tenant_id: TenantId,
135        article_id: ArticleId,
136        creator_id: CreatorId,
137        reader_wallet: WalletAddress,
138        transaction_id: Option<TransactionId>,
139        access_method: AccessMethod,
140        token_hash: String,
141        duration_days: i64,
142    ) -> Result<Self> {
143        Self::validate_token_hash(&token_hash)?;
144
145        if duration_days <= 0 || duration_days > 365 {
146            return Err(crate::error::AllSourceError::ValidationError(
147                "Access duration must be between 1 and 365 days".to_string(),
148            ));
149        }
150
151        let now = Utc::now();
152        let expires_at = now + Duration::days(duration_days);
153
154        Ok(Self {
155            id: AccessTokenId::new(),
156            tenant_id,
157            article_id,
158            creator_id,
159            reader_wallet,
160            transaction_id,
161            access_method,
162            token_hash,
163            issued_at: now,
164            expires_at,
165            revoked: false,
166            revoked_at: None,
167            revocation_reason: None,
168            last_accessed_at: None,
169            access_count: 0,
170            metadata: serde_json::json!({}),
171        })
172    }
173
174    /// Create a free access token (promotional)
175    pub fn new_free(
176        tenant_id: TenantId,
177        article_id: ArticleId,
178        creator_id: CreatorId,
179        reader_wallet: WalletAddress,
180        token_hash: String,
181        duration_days: i64,
182    ) -> Result<Self> {
183        Self::new_with_duration(
184            tenant_id,
185            article_id,
186            creator_id,
187            reader_wallet,
188            None,
189            AccessMethod::Free,
190            token_hash,
191            duration_days,
192        )
193    }
194
195    /// Reconstruct access token from storage (bypasses validation)
196    #[allow(clippy::too_many_arguments)]
197    pub fn reconstruct(
198        id: AccessTokenId,
199        tenant_id: TenantId,
200        article_id: ArticleId,
201        creator_id: CreatorId,
202        reader_wallet: WalletAddress,
203        transaction_id: Option<TransactionId>,
204        access_method: AccessMethod,
205        token_hash: String,
206        issued_at: DateTime<Utc>,
207        expires_at: DateTime<Utc>,
208        revoked: bool,
209        revoked_at: Option<DateTime<Utc>>,
210        revocation_reason: Option<String>,
211        last_accessed_at: Option<DateTime<Utc>>,
212        access_count: u32,
213        metadata: serde_json::Value,
214    ) -> Self {
215        Self {
216            id,
217            tenant_id,
218            article_id,
219            creator_id,
220            reader_wallet,
221            transaction_id,
222            access_method,
223            token_hash,
224            issued_at,
225            expires_at,
226            revoked,
227            revoked_at,
228            revocation_reason,
229            last_accessed_at,
230            access_count,
231            metadata,
232        }
233    }
234
235    // Getters
236
237    pub fn id(&self) -> &AccessTokenId {
238        &self.id
239    }
240
241    pub fn tenant_id(&self) -> &TenantId {
242        &self.tenant_id
243    }
244
245    pub fn article_id(&self) -> &ArticleId {
246        &self.article_id
247    }
248
249    pub fn creator_id(&self) -> &CreatorId {
250        &self.creator_id
251    }
252
253    pub fn reader_wallet(&self) -> &WalletAddress {
254        &self.reader_wallet
255    }
256
257    pub fn transaction_id(&self) -> Option<&TransactionId> {
258        self.transaction_id.as_ref()
259    }
260
261    pub fn access_method(&self) -> AccessMethod {
262        self.access_method
263    }
264
265    pub fn token_hash(&self) -> &str {
266        &self.token_hash
267    }
268
269    pub fn issued_at(&self) -> DateTime<Utc> {
270        self.issued_at
271    }
272
273    pub fn expires_at(&self) -> DateTime<Utc> {
274        self.expires_at
275    }
276
277    pub fn is_revoked(&self) -> bool {
278        self.revoked
279    }
280
281    pub fn revoked_at(&self) -> Option<DateTime<Utc>> {
282        self.revoked_at
283    }
284
285    pub fn revocation_reason(&self) -> Option<&str> {
286        self.revocation_reason.as_deref()
287    }
288
289    pub fn last_accessed_at(&self) -> Option<DateTime<Utc>> {
290        self.last_accessed_at
291    }
292
293    pub fn access_count(&self) -> u32 {
294        self.access_count
295    }
296
297    pub fn metadata(&self) -> &serde_json::Value {
298        &self.metadata
299    }
300
301    // Domain behavior methods
302
303    /// Check if token is expired
304    pub fn is_expired(&self) -> bool {
305        Utc::now() > self.expires_at
306    }
307
308    /// Check if token grants access (not expired and not revoked)
309    pub fn is_valid(&self) -> bool {
310        !self.revoked && !self.is_expired()
311    }
312
313    /// Check if this token grants access to a specific article for a specific wallet
314    pub fn grants_access_to(&self, article_id: &ArticleId, wallet: &WalletAddress) -> bool {
315        self.is_valid() && &self.article_id == article_id && &self.reader_wallet == wallet
316    }
317
318    /// Get remaining access time in seconds
319    pub fn remaining_seconds(&self) -> i64 {
320        if self.is_expired() {
321            0
322        } else {
323            (self.expires_at - Utc::now()).num_seconds().max(0)
324        }
325    }
326
327    /// Get remaining access time in days
328    pub fn remaining_days(&self) -> i64 {
329        if self.is_expired() {
330            0
331        } else {
332            (self.expires_at - Utc::now()).num_days().max(0)
333        }
334    }
335
336    /// Record an access (reading the article)
337    pub fn record_access(&mut self) {
338        self.last_accessed_at = Some(Utc::now());
339        self.access_count += 1;
340    }
341
342    /// Revoke the access token
343    pub fn revoke(&mut self, reason: &str) -> Result<()> {
344        if self.revoked {
345            return Err(crate::error::AllSourceError::ValidationError(
346                "Token is already revoked".to_string(),
347            ));
348        }
349
350        self.revoked = true;
351        self.revoked_at = Some(Utc::now());
352        self.revocation_reason = Some(reason.to_string());
353        Ok(())
354    }
355
356    /// Extend the expiration date
357    pub fn extend(&mut self, additional_days: i64) -> Result<()> {
358        if self.revoked {
359            return Err(crate::error::AllSourceError::ValidationError(
360                "Cannot extend a revoked token".to_string(),
361            ));
362        }
363
364        if additional_days <= 0 || additional_days > 365 {
365            return Err(crate::error::AllSourceError::ValidationError(
366                "Extension must be between 1 and 365 days".to_string(),
367            ));
368        }
369
370        self.expires_at += Duration::days(additional_days);
371        Ok(())
372    }
373
374    /// Update metadata
375    pub fn update_metadata(&mut self, metadata: serde_json::Value) {
376        self.metadata = metadata;
377    }
378
379    // Validation
380
381    fn validate_token_hash(hash: &str) -> Result<()> {
382        if hash.is_empty() {
383            return Err(crate::error::AllSourceError::InvalidInput(
384                "Token hash cannot be empty".to_string(),
385            ));
386        }
387
388        // Token hash should be reasonable length (typically 64 chars for SHA-256)
389        if hash.len() < 32 || hash.len() > 128 {
390            return Err(crate::error::AllSourceError::InvalidInput(format!(
391                "Invalid token hash length: {}",
392                hash.len()
393            )));
394        }
395
396        Ok(())
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    const VALID_WALLET: &str = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
405    const VALID_TOKEN_HASH: &str =
406        "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd";
407
408    fn test_tenant_id() -> TenantId {
409        TenantId::new("test-tenant".to_string()).unwrap()
410    }
411
412    fn test_article_id() -> ArticleId {
413        ArticleId::new("test-article".to_string()).unwrap()
414    }
415
416    fn test_creator_id() -> CreatorId {
417        CreatorId::new()
418    }
419
420    fn test_wallet() -> WalletAddress {
421        WalletAddress::new(VALID_WALLET.to_string()).unwrap()
422    }
423
424    fn test_transaction_id() -> TransactionId {
425        TransactionId::new()
426    }
427
428    #[test]
429    fn test_create_paid_access_token() {
430        let token = AccessToken::new_paid(
431            test_tenant_id(),
432            test_article_id(),
433            test_creator_id(),
434            test_wallet(),
435            test_transaction_id(),
436            VALID_TOKEN_HASH.to_string(),
437        );
438
439        assert!(token.is_ok());
440        let token = token.unwrap();
441        assert!(token.is_valid());
442        assert!(!token.is_expired());
443        assert!(!token.is_revoked());
444        assert_eq!(token.access_method(), AccessMethod::Paid);
445        assert!(token.transaction_id().is_some());
446    }
447
448    #[test]
449    fn test_create_free_access_token() {
450        let token = AccessToken::new_free(
451            test_tenant_id(),
452            test_article_id(),
453            test_creator_id(),
454            test_wallet(),
455            VALID_TOKEN_HASH.to_string(),
456            7, // 7 days
457        );
458
459        assert!(token.is_ok());
460        let token = token.unwrap();
461        assert!(token.is_valid());
462        assert_eq!(token.access_method(), AccessMethod::Free);
463        assert!(token.transaction_id().is_none());
464    }
465
466    #[test]
467    fn test_reject_empty_token_hash() {
468        let result = AccessToken::new_paid(
469            test_tenant_id(),
470            test_article_id(),
471            test_creator_id(),
472            test_wallet(),
473            test_transaction_id(),
474            "".to_string(),
475        );
476
477        assert!(result.is_err());
478    }
479
480    #[test]
481    fn test_reject_invalid_duration() {
482        let result = AccessToken::new_with_duration(
483            test_tenant_id(),
484            test_article_id(),
485            test_creator_id(),
486            test_wallet(),
487            None,
488            AccessMethod::Free,
489            VALID_TOKEN_HASH.to_string(),
490            0, // Invalid duration
491        );
492
493        assert!(result.is_err());
494
495        let result = AccessToken::new_with_duration(
496            test_tenant_id(),
497            test_article_id(),
498            test_creator_id(),
499            test_wallet(),
500            None,
501            AccessMethod::Free,
502            VALID_TOKEN_HASH.to_string(),
503            400, // Too long
504        );
505
506        assert!(result.is_err());
507    }
508
509    #[test]
510    fn test_grants_access_to() {
511        let article_id = test_article_id();
512        let wallet = test_wallet();
513
514        let token = AccessToken::new_paid(
515            test_tenant_id(),
516            article_id.clone(),
517            test_creator_id(),
518            wallet.clone(),
519            test_transaction_id(),
520            VALID_TOKEN_HASH.to_string(),
521        )
522        .unwrap();
523
524        assert!(token.grants_access_to(&article_id, &wallet));
525
526        // Different article
527        let other_article = ArticleId::new("other-article".to_string()).unwrap();
528        assert!(!token.grants_access_to(&other_article, &wallet));
529
530        // Different wallet
531        let other_wallet =
532            WalletAddress::new("11111111111111111111111111111111".to_string()).unwrap();
533        assert!(!token.grants_access_to(&article_id, &other_wallet));
534    }
535
536    #[test]
537    fn test_revoke_token() {
538        let mut token = AccessToken::new_paid(
539            test_tenant_id(),
540            test_article_id(),
541            test_creator_id(),
542            test_wallet(),
543            test_transaction_id(),
544            VALID_TOKEN_HASH.to_string(),
545        )
546        .unwrap();
547
548        assert!(token.is_valid());
549
550        token.revoke("Refund processed").unwrap();
551
552        assert!(!token.is_valid());
553        assert!(token.is_revoked());
554        assert!(token.revoked_at().is_some());
555        assert_eq!(token.revocation_reason(), Some("Refund processed"));
556    }
557
558    #[test]
559    fn test_cannot_revoke_twice() {
560        let mut token = AccessToken::new_paid(
561            test_tenant_id(),
562            test_article_id(),
563            test_creator_id(),
564            test_wallet(),
565            test_transaction_id(),
566            VALID_TOKEN_HASH.to_string(),
567        )
568        .unwrap();
569
570        token.revoke("First revoke").unwrap();
571        let result = token.revoke("Second revoke");
572        assert!(result.is_err());
573    }
574
575    #[test]
576    fn test_record_access() {
577        let mut token = AccessToken::new_paid(
578            test_tenant_id(),
579            test_article_id(),
580            test_creator_id(),
581            test_wallet(),
582            test_transaction_id(),
583            VALID_TOKEN_HASH.to_string(),
584        )
585        .unwrap();
586
587        assert_eq!(token.access_count(), 0);
588        assert!(token.last_accessed_at().is_none());
589
590        token.record_access();
591
592        assert_eq!(token.access_count(), 1);
593        assert!(token.last_accessed_at().is_some());
594
595        token.record_access();
596        assert_eq!(token.access_count(), 2);
597    }
598
599    #[test]
600    fn test_extend_token() {
601        let mut token = AccessToken::new_paid(
602            test_tenant_id(),
603            test_article_id(),
604            test_creator_id(),
605            test_wallet(),
606            test_transaction_id(),
607            VALID_TOKEN_HASH.to_string(),
608        )
609        .unwrap();
610
611        let original_expiry = token.expires_at();
612        token.extend(7).unwrap();
613
614        assert!(token.expires_at() > original_expiry);
615    }
616
617    #[test]
618    fn test_cannot_extend_revoked_token() {
619        let mut token = AccessToken::new_paid(
620            test_tenant_id(),
621            test_article_id(),
622            test_creator_id(),
623            test_wallet(),
624            test_transaction_id(),
625            VALID_TOKEN_HASH.to_string(),
626        )
627        .unwrap();
628
629        token.revoke("Revoked").unwrap();
630        let result = token.extend(7);
631        assert!(result.is_err());
632    }
633
634    #[test]
635    fn test_remaining_time() {
636        let token = AccessToken::new_paid(
637            test_tenant_id(),
638            test_article_id(),
639            test_creator_id(),
640            test_wallet(),
641            test_transaction_id(),
642            VALID_TOKEN_HASH.to_string(),
643        )
644        .unwrap();
645
646        // Should have approximately 30 days remaining
647        assert!(token.remaining_days() >= 29);
648        assert!(token.remaining_seconds() > 0);
649    }
650
651    #[test]
652    fn test_serde_serialization() {
653        let token = AccessToken::new_paid(
654            test_tenant_id(),
655            test_article_id(),
656            test_creator_id(),
657            test_wallet(),
658            test_transaction_id(),
659            VALID_TOKEN_HASH.to_string(),
660        )
661        .unwrap();
662
663        let json = serde_json::to_string(&token);
664        assert!(json.is_ok());
665
666        let deserialized: AccessToken = serde_json::from_str(&json.unwrap()).unwrap();
667        assert_eq!(deserialized.access_method(), AccessMethod::Paid);
668    }
669}