Skip to main content

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