Skip to main content

allsource_core/domain/entities/
access_token.rs

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