koprogo_api/domain/entities/
achievement.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Achievement category for organizational purposes
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum AchievementCategory {
8    Community,  // Community participation achievements
9    Sel,        // SEL (Local Exchange) achievements
10    Booking,    // Resource booking achievements
11    Sharing,    // Object sharing achievements
12    Skills,     // Skills directory achievements
13    Notice,     // Notice board achievements
14    Governance, // Meeting/voting participation achievements
15    Milestone,  // Platform usage milestones
16}
17
18/// Achievement tier for progression
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
20pub enum AchievementTier {
21    Bronze,   // Entry-level achievements
22    Silver,   // Intermediate achievements
23    Gold,     // Advanced achievements
24    Platinum, // Expert-level achievements
25    Diamond,  // Exceptional achievements
26}
27
28/// Achievement entity - Defines badges/achievements users can earn
29///
30/// Represents a specific accomplishment or milestone in the platform.
31/// Achievements encourage community participation and engagement.
32///
33/// # Belgian Context
34/// - Promotes active participation in copropriΓ©tΓ© management
35/// - Encourages use of community features (SEL, notice board, bookings)
36/// - Recognizes contributions to building community
37///
38/// # Business Rules
39/// - name must be 3-100 characters
40/// - description must be 10-500 characters
41/// - icon must be valid emoji or URL
42/// - points_value must be 0-1000
43/// - requirements stored as JSON for flexibility
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Achievement {
46    pub id: Uuid,
47    pub organization_id: Uuid,
48    pub category: AchievementCategory,
49    pub tier: AchievementTier,
50    pub name: String, // e.g., "SEL Pioneer", "First Booking", "Community Helper"
51    pub description: String, // What this achievement represents
52    pub icon: String, // Emoji or icon URL
53    pub points_value: i32, // Points awarded when earned (0-1000)
54    pub requirements: String, // JSON criteria (e.g., {"action": "sel_exchange", "count": 10})
55    pub is_secret: bool, // Hidden until earned
56    pub is_repeatable: bool, // Can be earned multiple times
57    pub display_order: i32, // For sorting in UI
58    pub created_at: DateTime<Utc>,
59    pub updated_at: DateTime<Utc>,
60}
61
62impl Achievement {
63    /// Minimum name length
64    pub const MIN_NAME_LENGTH: usize = 3;
65    /// Maximum name length
66    pub const MAX_NAME_LENGTH: usize = 100;
67    /// Minimum description length
68    pub const MIN_DESCRIPTION_LENGTH: usize = 10;
69    /// Maximum description length
70    pub const MAX_DESCRIPTION_LENGTH: usize = 500;
71    /// Maximum points value
72    pub const MAX_POINTS_VALUE: i32 = 1000;
73
74    /// Create a new achievement
75    ///
76    /// # Validation
77    /// - name must be 3-100 characters
78    /// - description must be 10-500 characters
79    /// - icon must not be empty
80    /// - points_value must be 0-1000
81    /// - requirements must not be empty
82    pub fn new(
83        organization_id: Uuid,
84        category: AchievementCategory,
85        tier: AchievementTier,
86        name: String,
87        description: String,
88        icon: String,
89        points_value: i32,
90        requirements: String,
91        is_secret: bool,
92        is_repeatable: bool,
93        display_order: i32,
94    ) -> Result<Self, String> {
95        // Validate name
96        if name.len() < Self::MIN_NAME_LENGTH || name.len() > Self::MAX_NAME_LENGTH {
97            return Err(format!(
98                "Achievement name must be {}-{} characters",
99                Self::MIN_NAME_LENGTH,
100                Self::MAX_NAME_LENGTH
101            ));
102        }
103
104        // Validate description
105        if description.len() < Self::MIN_DESCRIPTION_LENGTH
106            || description.len() > Self::MAX_DESCRIPTION_LENGTH
107        {
108            return Err(format!(
109                "Achievement description must be {}-{} characters",
110                Self::MIN_DESCRIPTION_LENGTH,
111                Self::MAX_DESCRIPTION_LENGTH
112            ));
113        }
114
115        // Validate icon
116        if icon.trim().is_empty() {
117            return Err("Achievement icon cannot be empty".to_string());
118        }
119
120        // Validate points
121        if points_value < 0 || points_value > Self::MAX_POINTS_VALUE {
122            return Err(format!(
123                "Points value must be 0-{} points",
124                Self::MAX_POINTS_VALUE
125            ));
126        }
127
128        // Validate requirements
129        if requirements.trim().is_empty() {
130            return Err("Achievement requirements cannot be empty".to_string());
131        }
132
133        let now = Utc::now();
134        Ok(Self {
135            id: Uuid::new_v4(),
136            organization_id,
137            category,
138            tier,
139            name,
140            description,
141            icon,
142            points_value,
143            requirements,
144            is_secret,
145            is_repeatable,
146            display_order,
147            created_at: now,
148            updated_at: now,
149        })
150    }
151
152    /// Update achievement details
153    pub fn update(
154        &mut self,
155        name: Option<String>,
156        description: Option<String>,
157        icon: Option<String>,
158        points_value: Option<i32>,
159        requirements: Option<String>,
160        is_secret: Option<bool>,
161        is_repeatable: Option<bool>,
162        display_order: Option<i32>,
163    ) -> Result<(), String> {
164        // Update name if provided
165        if let Some(n) = name {
166            if n.len() < Self::MIN_NAME_LENGTH || n.len() > Self::MAX_NAME_LENGTH {
167                return Err(format!(
168                    "Achievement name must be {}-{} characters",
169                    Self::MIN_NAME_LENGTH,
170                    Self::MAX_NAME_LENGTH
171                ));
172            }
173            self.name = n;
174        }
175
176        // Update description if provided
177        if let Some(d) = description {
178            if d.len() < Self::MIN_DESCRIPTION_LENGTH || d.len() > Self::MAX_DESCRIPTION_LENGTH {
179                return Err(format!(
180                    "Achievement description must be {}-{} characters",
181                    Self::MIN_DESCRIPTION_LENGTH,
182                    Self::MAX_DESCRIPTION_LENGTH
183                ));
184            }
185            self.description = d;
186        }
187
188        // Update icon if provided
189        if let Some(i) = icon {
190            if i.trim().is_empty() {
191                return Err("Achievement icon cannot be empty".to_string());
192            }
193            self.icon = i;
194        }
195
196        // Update points if provided
197        if let Some(p) = points_value {
198            if p < 0 || p > Self::MAX_POINTS_VALUE {
199                return Err(format!(
200                    "Points value must be 0-{} points",
201                    Self::MAX_POINTS_VALUE
202                ));
203            }
204            self.points_value = p;
205        }
206
207        // Update requirements if provided
208        if let Some(r) = requirements {
209            if r.trim().is_empty() {
210                return Err("Achievement requirements cannot be empty".to_string());
211            }
212            self.requirements = r;
213        }
214
215        // Update flags
216        if let Some(s) = is_secret {
217            self.is_secret = s;
218        }
219        if let Some(r) = is_repeatable {
220            self.is_repeatable = r;
221        }
222        if let Some(o) = display_order {
223            self.display_order = o;
224        }
225
226        self.updated_at = Utc::now();
227        Ok(())
228    }
229
230    /// Calculate points for tier (helper for auto-calculation)
231    pub fn default_points_for_tier(tier: &AchievementTier) -> i32 {
232        match tier {
233            AchievementTier::Bronze => 10,
234            AchievementTier::Silver => 25,
235            AchievementTier::Gold => 50,
236            AchievementTier::Platinum => 100,
237            AchievementTier::Diamond => 250,
238        }
239    }
240
241    /// Update achievement name
242    pub fn update_name(&mut self, name: String) -> Result<(), String> {
243        self.update(Some(name), None, None, None, None, None, None, None)
244    }
245
246    /// Update achievement description
247    pub fn update_description(&mut self, description: String) -> Result<(), String> {
248        self.update(None, Some(description), None, None, None, None, None, None)
249    }
250
251    /// Update achievement icon
252    pub fn update_icon(&mut self, icon: String) -> Result<(), String> {
253        self.update(None, None, Some(icon), None, None, None, None, None)
254    }
255
256    /// Update achievement points value
257    pub fn update_points_value(&mut self, points_value: i32) -> Result<(), String> {
258        self.update(None, None, None, Some(points_value), None, None, None, None)
259    }
260
261    /// Update achievement requirements
262    pub fn update_requirements(&mut self, requirements: String) -> Result<(), String> {
263        self.update(None, None, None, None, Some(requirements), None, None, None)
264    }
265}
266
267/// User achievement record - Tracks which achievements a user has earned
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct UserAchievement {
270    pub id: Uuid,
271    pub user_id: Uuid,
272    pub achievement_id: Uuid,
273    pub earned_at: DateTime<Utc>,
274    pub progress_data: Option<String>, // JSON for tracking progress towards repeatable achievements
275    pub times_earned: i32,             // For repeatable achievements
276}
277
278impl UserAchievement {
279    /// Award achievement to user
280    pub fn new(user_id: Uuid, achievement_id: Uuid, progress_data: Option<String>) -> Self {
281        Self {
282            id: Uuid::new_v4(),
283            user_id,
284            achievement_id,
285            earned_at: Utc::now(),
286            progress_data,
287            times_earned: 1,
288        }
289    }
290
291    /// Increment times earned (for repeatable achievements)
292    pub fn increment_earned(&mut self) {
293        self.times_earned += 1;
294    }
295
296    /// Repeat earn achievement (alias for increment_earned)
297    pub fn repeat_earn(&mut self) -> Result<(), String> {
298        self.increment_earned();
299        Ok(())
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn create_test_achievement() -> Achievement {
308        let organization_id = Uuid::new_v4();
309        Achievement::new(
310            organization_id,
311            AchievementCategory::Community,
312            AchievementTier::Bronze,
313            "First Booking".to_string(),
314            "Made your first resource booking".to_string(),
315            "πŸŽ‰".to_string(),
316            10,
317            r#"{"action": "booking_created", "count": 1}"#.to_string(),
318            false,
319            false,
320            1,
321        )
322        .unwrap()
323    }
324
325    #[test]
326    fn test_create_achievement_success() {
327        let achievement = create_test_achievement();
328        assert_eq!(achievement.name, "First Booking");
329        assert_eq!(achievement.category, AchievementCategory::Community);
330        assert_eq!(achievement.tier, AchievementTier::Bronze);
331        assert_eq!(achievement.points_value, 10);
332        assert!(!achievement.is_secret);
333        assert!(!achievement.is_repeatable);
334    }
335
336    #[test]
337    fn test_create_achievement_invalid_name() {
338        let organization_id = Uuid::new_v4();
339        let result = Achievement::new(
340            organization_id,
341            AchievementCategory::Community,
342            AchievementTier::Bronze,
343            "AB".to_string(), // Too short
344            "Made your first resource booking".to_string(),
345            "πŸŽ‰".to_string(),
346            10,
347            r#"{"action": "booking_created", "count": 1}"#.to_string(),
348            false,
349            false,
350            1,
351        );
352
353        assert!(result.is_err());
354        assert!(result.unwrap_err().contains("Achievement name must be"));
355    }
356
357    #[test]
358    fn test_create_achievement_invalid_description() {
359        let organization_id = Uuid::new_v4();
360        let result = Achievement::new(
361            organization_id,
362            AchievementCategory::Community,
363            AchievementTier::Bronze,
364            "First Booking".to_string(),
365            "Short".to_string(), // Too short
366            "πŸŽ‰".to_string(),
367            10,
368            r#"{"action": "booking_created", "count": 1}"#.to_string(),
369            false,
370            false,
371            1,
372        );
373
374        assert!(result.is_err());
375        assert!(result
376            .unwrap_err()
377            .contains("Achievement description must be"));
378    }
379
380    #[test]
381    fn test_create_achievement_invalid_icon() {
382        let organization_id = Uuid::new_v4();
383        let result = Achievement::new(
384            organization_id,
385            AchievementCategory::Community,
386            AchievementTier::Bronze,
387            "First Booking".to_string(),
388            "Made your first resource booking".to_string(),
389            "".to_string(), // Empty icon
390            10,
391            r#"{"action": "booking_created", "count": 1}"#.to_string(),
392            false,
393            false,
394            1,
395        );
396
397        assert!(result.is_err());
398        assert!(result
399            .unwrap_err()
400            .contains("Achievement icon cannot be empty"));
401    }
402
403    #[test]
404    fn test_create_achievement_invalid_points() {
405        let organization_id = Uuid::new_v4();
406        let result = Achievement::new(
407            organization_id,
408            AchievementCategory::Community,
409            AchievementTier::Bronze,
410            "First Booking".to_string(),
411            "Made your first resource booking".to_string(),
412            "πŸŽ‰".to_string(),
413            2000, // Exceeds max
414            r#"{"action": "booking_created", "count": 1}"#.to_string(),
415            false,
416            false,
417            1,
418        );
419
420        assert!(result.is_err());
421        assert!(result.unwrap_err().contains("Points value must be"));
422    }
423
424    #[test]
425    fn test_create_achievement_invalid_requirements() {
426        let organization_id = Uuid::new_v4();
427        let result = Achievement::new(
428            organization_id,
429            AchievementCategory::Community,
430            AchievementTier::Bronze,
431            "First Booking".to_string(),
432            "Made your first resource booking".to_string(),
433            "πŸŽ‰".to_string(),
434            10,
435            "".to_string(), // Empty requirements
436            false,
437            false,
438            1,
439        );
440
441        assert!(result.is_err());
442        assert!(result
443            .unwrap_err()
444            .contains("Achievement requirements cannot be empty"));
445    }
446
447    #[test]
448    fn test_update_achievement_success() {
449        let mut achievement = create_test_achievement();
450        let result = achievement.update(
451            Some("Updated Name".to_string()),
452            Some("Updated description for this achievement".to_string()),
453            Some("πŸ†".to_string()),
454            Some(25),
455            None,
456            None,
457            None,
458            Some(10),
459        );
460
461        assert!(result.is_ok());
462        assert_eq!(achievement.name, "Updated Name");
463        assert_eq!(achievement.icon, "πŸ†");
464        assert_eq!(achievement.points_value, 25);
465        assert_eq!(achievement.display_order, 10);
466    }
467
468    #[test]
469    fn test_update_achievement_invalid_name() {
470        let mut achievement = create_test_achievement();
471        let result = achievement.update(
472            Some("AB".to_string()), // Too short
473            None,
474            None,
475            None,
476            None,
477            None,
478            None,
479            None,
480        );
481
482        assert!(result.is_err());
483        assert!(result.unwrap_err().contains("Achievement name must be"));
484    }
485
486    #[test]
487    fn test_default_points_for_tier() {
488        assert_eq!(
489            Achievement::default_points_for_tier(&AchievementTier::Bronze),
490            10
491        );
492        assert_eq!(
493            Achievement::default_points_for_tier(&AchievementTier::Silver),
494            25
495        );
496        assert_eq!(
497            Achievement::default_points_for_tier(&AchievementTier::Gold),
498            50
499        );
500        assert_eq!(
501            Achievement::default_points_for_tier(&AchievementTier::Platinum),
502            100
503        );
504        assert_eq!(
505            Achievement::default_points_for_tier(&AchievementTier::Diamond),
506            250
507        );
508    }
509
510    #[test]
511    fn test_user_achievement_new() {
512        let user_id = Uuid::new_v4();
513        let achievement_id = Uuid::new_v4();
514        let user_achievement = UserAchievement::new(user_id, achievement_id, None);
515
516        assert_eq!(user_achievement.user_id, user_id);
517        assert_eq!(user_achievement.achievement_id, achievement_id);
518        assert_eq!(user_achievement.times_earned, 1);
519        assert!(user_achievement.progress_data.is_none());
520    }
521
522    #[test]
523    fn test_user_achievement_increment() {
524        let user_id = Uuid::new_v4();
525        let achievement_id = Uuid::new_v4();
526        let mut user_achievement = UserAchievement::new(user_id, achievement_id, None);
527
528        user_achievement.increment_earned();
529        assert_eq!(user_achievement.times_earned, 2);
530
531        user_achievement.increment_earned();
532        assert_eq!(user_achievement.times_earned, 3);
533    }
534
535    #[test]
536    fn test_achievement_tier_ordering() {
537        assert!(AchievementTier::Bronze < AchievementTier::Silver);
538        assert!(AchievementTier::Silver < AchievementTier::Gold);
539        assert!(AchievementTier::Gold < AchievementTier::Platinum);
540        assert!(AchievementTier::Platinum < AchievementTier::Diamond);
541    }
542}