koprogo_api/domain/entities/
gdpr_objection.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// GDPR Article 21 - Right to Object
6///
7/// Represents a user's objection to processing of their personal data
8/// for specific purposes (marketing, profiling, legitimate interests).
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct GdprObjectionRequest {
11    pub id: Uuid,
12    pub user_id: Uuid,
13    pub organization_id: Option<Uuid>,
14    pub requested_at: DateTime<Utc>,
15    pub status: ObjectionStatus,
16    pub objection_type: ObjectionType,
17    pub processing_purposes: Vec<ProcessingPurpose>,
18    pub justification: Option<String>,
19    pub processed_at: Option<DateTime<Utc>>,
20    pub processed_by: Option<Uuid>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub enum ObjectionStatus {
25    Pending,
26    Accepted, // Objection upheld, processing stopped
27    Rejected, // Objection rejected (compelling legitimate grounds)
28    Partial,  // Some purposes accepted, others rejected
29}
30
31/// Types of objection under Article 21
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub enum ObjectionType {
34    /// Article 21(1) - objection to processing based on legitimate interests
35    LegitimateInterests,
36    /// Article 21(2) - objection to direct marketing (absolute right)
37    DirectMarketing,
38    /// Article 21(3) - objection to profiling
39    Profiling,
40    /// Article 21(4) - objection to automated decision-making
41    AutomatedDecisionMaking,
42    /// Article 21(6) - objection to scientific/historical research
43    ScientificResearch,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct ProcessingPurpose {
48    pub purpose: String,
49    pub accepted: Option<bool>, // None = pending, Some(true) = accepted, Some(false) = rejected
50}
51
52impl GdprObjectionRequest {
53    /// Create a new objection request
54    pub fn new(
55        user_id: Uuid,
56        organization_id: Option<Uuid>,
57        objection_type: ObjectionType,
58        processing_purposes: Vec<String>,
59        justification: Option<String>,
60    ) -> Self {
61        Self {
62            id: Uuid::new_v4(),
63            user_id,
64            organization_id,
65            requested_at: Utc::now(),
66            status: ObjectionStatus::Pending,
67            objection_type,
68            processing_purposes: processing_purposes
69                .into_iter()
70                .map(|purpose| ProcessingPurpose {
71                    purpose,
72                    accepted: None,
73                })
74                .collect(),
75            justification,
76            processed_at: None,
77            processed_by: None,
78        }
79    }
80
81    /// Accept the objection (stop all processing)
82    pub fn accept(&mut self, admin_id: Uuid) {
83        self.status = ObjectionStatus::Accepted;
84        for purpose in &mut self.processing_purposes {
85            purpose.accepted = Some(true);
86        }
87        self.processed_at = Some(Utc::now());
88        self.processed_by = Some(admin_id);
89    }
90
91    /// Reject the objection (continue processing)
92    pub fn reject(&mut self, admin_id: Uuid) {
93        self.status = ObjectionStatus::Rejected;
94        for purpose in &mut self.processing_purposes {
95            purpose.accepted = Some(false);
96        }
97        self.processed_at = Some(Utc::now());
98        self.processed_by = Some(admin_id);
99    }
100
101    /// Accept some purposes, reject others
102    pub fn partial_accept(&mut self, admin_id: Uuid, accepted_purposes: Vec<String>) {
103        self.status = ObjectionStatus::Partial;
104        for purpose in &mut self.processing_purposes {
105            purpose.accepted = Some(accepted_purposes.contains(&purpose.purpose));
106        }
107        self.processed_at = Some(Utc::now());
108        self.processed_by = Some(admin_id);
109    }
110
111    /// Check if this is a direct marketing objection (absolute right)
112    pub fn is_marketing_objection(&self) -> bool {
113        matches!(self.objection_type, ObjectionType::DirectMarketing)
114    }
115
116    /// Check if objection is pending
117    pub fn is_pending(&self) -> bool {
118        matches!(self.status, ObjectionStatus::Pending)
119    }
120
121    /// Get list of accepted objections
122    pub fn get_accepted_purposes(&self) -> Vec<String> {
123        self.processing_purposes
124            .iter()
125            .filter(|p| p.accepted == Some(true))
126            .map(|p| p.purpose.clone())
127            .collect()
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_create_objection_request() {
137        let user_id = Uuid::new_v4();
138        let org_id = Uuid::new_v4();
139        let purposes = vec!["email_marketing".to_string(), "sms_marketing".to_string()];
140
141        let request = GdprObjectionRequest::new(
142            user_id,
143            Some(org_id),
144            ObjectionType::DirectMarketing,
145            purposes.clone(),
146            Some("I don't want to receive marketing emails".to_string()),
147        );
148
149        assert_eq!(request.user_id, user_id);
150        assert_eq!(request.organization_id, Some(org_id));
151        assert!(request.is_pending());
152        assert!(request.is_marketing_objection());
153        assert_eq!(request.processing_purposes.len(), 2);
154    }
155
156    #[test]
157    fn test_accept_objection() {
158        let user_id = Uuid::new_v4();
159        let admin_id = Uuid::new_v4();
160        let purposes = vec!["profiling".to_string()];
161
162        let mut request =
163            GdprObjectionRequest::new(user_id, None, ObjectionType::Profiling, purposes, None);
164
165        request.accept(admin_id);
166
167        assert_eq!(request.status, ObjectionStatus::Accepted);
168        assert!(request
169            .processing_purposes
170            .iter()
171            .all(|p| p.accepted == Some(true)));
172        assert_eq!(request.get_accepted_purposes().len(), 1);
173    }
174
175    #[test]
176    fn test_reject_objection() {
177        let user_id = Uuid::new_v4();
178        let admin_id = Uuid::new_v4();
179        let purposes = vec!["legitimate_interest".to_string()];
180
181        let mut request = GdprObjectionRequest::new(
182            user_id,
183            None,
184            ObjectionType::LegitimateInterests,
185            purposes,
186            Some("Compelling legitimate grounds".to_string()),
187        );
188
189        request.reject(admin_id);
190
191        assert_eq!(request.status, ObjectionStatus::Rejected);
192        assert!(request
193            .processing_purposes
194            .iter()
195            .all(|p| p.accepted == Some(false)));
196        assert_eq!(request.get_accepted_purposes().len(), 0);
197    }
198
199    #[test]
200    fn test_partial_accept() {
201        let user_id = Uuid::new_v4();
202        let admin_id = Uuid::new_v4();
203        let purposes = vec![
204            "email_marketing".to_string(),
205            "analytics".to_string(),
206            "sms_marketing".to_string(),
207        ];
208
209        let mut request = GdprObjectionRequest::new(
210            user_id,
211            None,
212            ObjectionType::DirectMarketing,
213            purposes,
214            None,
215        );
216
217        request.partial_accept(
218            admin_id,
219            vec!["email_marketing".to_string(), "sms_marketing".to_string()],
220        );
221
222        assert_eq!(request.status, ObjectionStatus::Partial);
223        assert_eq!(request.get_accepted_purposes().len(), 2);
224        assert!(request
225            .get_accepted_purposes()
226            .contains(&"email_marketing".to_string()));
227        assert!(!request
228            .get_accepted_purposes()
229            .contains(&"analytics".to_string()));
230    }
231
232    #[test]
233    fn test_marketing_objection_detection() {
234        let user_id = Uuid::new_v4();
235        let purposes = vec!["marketing".to_string()];
236
237        let marketing_request = GdprObjectionRequest::new(
238            user_id,
239            None,
240            ObjectionType::DirectMarketing,
241            purposes.clone(),
242            None,
243        );
244
245        let profiling_request =
246            GdprObjectionRequest::new(user_id, None, ObjectionType::Profiling, purposes, None);
247
248        assert!(marketing_request.is_marketing_objection());
249        assert!(!profiling_request.is_marketing_objection());
250    }
251}