koprogo_api/domain/entities/
consent.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Valid consent types matching database CHECK constraint
6const VALID_CONSENT_TYPES: [&str; 2] = ["privacy_policy", "terms"];
7
8/// ConsentRecord - GDPR Art. 7 / Art. 13-14 compliance
9///
10/// Tracks explicit user consent to privacy policy and terms of service.
11/// Each record is immutable (append-only) — new consent creates a new record.
12/// Audit trail includes IP address, user agent, and policy version.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct ConsentRecord {
15    pub id: Uuid,
16    pub user_id: Uuid,
17    pub organization_id: Uuid,
18    pub consent_type: String,
19    pub accepted_at: DateTime<Utc>,
20    pub ip_address: Option<String>,
21    pub user_agent: Option<String>,
22    pub policy_version: String,
23    pub created_at: DateTime<Utc>,
24    pub updated_at: DateTime<Utc>,
25}
26
27impl ConsentRecord {
28    /// Create a new consent record with validation
29    ///
30    /// # Arguments
31    /// - `user_id`: The user giving consent
32    /// - `organization_id`: The organization context
33    /// - `consent_type`: Must be "privacy_policy" or "terms"
34    /// - `ip_address`: Optional IP for audit trail
35    /// - `user_agent`: Optional browser user-agent for audit trail
36    /// - `policy_version`: Version of the policy being accepted (default "1.0")
37    pub fn new(
38        user_id: Uuid,
39        organization_id: Uuid,
40        consent_type: &str,
41        ip_address: Option<String>,
42        user_agent: Option<String>,
43        policy_version: Option<String>,
44    ) -> Result<Self, String> {
45        // Validate consent_type
46        if !VALID_CONSENT_TYPES.contains(&consent_type) {
47            return Err(format!(
48                "Invalid consent type '{}'. Must be one of: {}",
49                consent_type,
50                VALID_CONSENT_TYPES.join(", ")
51            ));
52        }
53
54        let version = policy_version.unwrap_or_else(|| "1.0".to_string());
55        if version.is_empty() {
56            return Err("Policy version cannot be empty".to_string());
57        }
58
59        let now = Utc::now();
60
61        Ok(Self {
62            id: Uuid::new_v4(),
63            user_id,
64            organization_id,
65            consent_type: consent_type.to_string(),
66            accepted_at: now,
67            ip_address,
68            user_agent,
69            policy_version: version,
70            created_at: now,
71            updated_at: now,
72        })
73    }
74
75    /// Check if this consent is for privacy policy
76    pub fn is_privacy_policy(&self) -> bool {
77        self.consent_type == "privacy_policy"
78    }
79
80    /// Check if this consent is for terms of service
81    pub fn is_terms(&self) -> bool {
82        self.consent_type == "terms"
83    }
84}
85
86/// Consent status summary for a user
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88pub struct ConsentStatus {
89    pub privacy_policy_accepted: bool,
90    pub terms_accepted: bool,
91    pub privacy_policy_accepted_at: Option<DateTime<Utc>>,
92    pub terms_accepted_at: Option<DateTime<Utc>>,
93    pub privacy_policy_version: Option<String>,
94    pub terms_version: Option<String>,
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn test_new_privacy_policy_consent() {
103        let user_id = Uuid::new_v4();
104        let org_id = Uuid::new_v4();
105        let record = ConsentRecord::new(
106            user_id,
107            org_id,
108            "privacy_policy",
109            Some("192.168.1.1".to_string()),
110            Some("Mozilla/5.0".to_string()),
111            Some("1.2".to_string()),
112        )
113        .unwrap();
114
115        assert_eq!(record.user_id, user_id);
116        assert_eq!(record.organization_id, org_id);
117        assert_eq!(record.consent_type, "privacy_policy");
118        assert_eq!(record.policy_version, "1.2");
119        assert!(record.is_privacy_policy());
120        assert!(!record.is_terms());
121        assert_eq!(record.ip_address.as_deref(), Some("192.168.1.1"));
122        assert_eq!(record.user_agent.as_deref(), Some("Mozilla/5.0"));
123    }
124
125    #[test]
126    fn test_new_terms_consent() {
127        let record =
128            ConsentRecord::new(Uuid::new_v4(), Uuid::new_v4(), "terms", None, None, None).unwrap();
129
130        assert_eq!(record.consent_type, "terms");
131        assert_eq!(record.policy_version, "1.0"); // default
132        assert!(record.is_terms());
133        assert!(!record.is_privacy_policy());
134    }
135
136    #[test]
137    fn test_invalid_consent_type() {
138        let result = ConsentRecord::new(
139            Uuid::new_v4(),
140            Uuid::new_v4(),
141            "invalid_type",
142            None,
143            None,
144            None,
145        );
146        assert!(result.is_err());
147        assert!(result
148            .unwrap_err()
149            .contains("Invalid consent type 'invalid_type'"));
150    }
151
152    #[test]
153    fn test_empty_policy_version() {
154        let result = ConsentRecord::new(
155            Uuid::new_v4(),
156            Uuid::new_v4(),
157            "privacy_policy",
158            None,
159            None,
160            Some("".to_string()),
161        );
162        assert!(result.is_err());
163        assert!(result
164            .unwrap_err()
165            .contains("Policy version cannot be empty"));
166    }
167
168    #[test]
169    fn test_consent_generates_unique_ids() {
170        let r1 =
171            ConsentRecord::new(Uuid::new_v4(), Uuid::new_v4(), "terms", None, None, None).unwrap();
172        let r2 =
173            ConsentRecord::new(Uuid::new_v4(), Uuid::new_v4(), "terms", None, None, None).unwrap();
174        assert_ne!(r1.id, r2.id);
175    }
176
177    #[test]
178    fn test_consent_status_default() {
179        let status = ConsentStatus::default();
180        assert!(!status.privacy_policy_accepted);
181        assert!(!status.terms_accepted);
182        assert!(status.privacy_policy_accepted_at.is_none());
183        assert!(status.terms_accepted_at.is_none());
184    }
185}