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