koprogo_api/domain/entities/
challenge.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Challenge status lifecycle
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum ChallengeStatus {
8    Draft,     // Challenge being created
9    Active,    // Challenge is live
10    Completed, // Challenge period ended
11    Cancelled, // Challenge cancelled
12}
13
14/// Challenge type for different engagement patterns
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub enum ChallengeType {
17    Individual, // Each user competes individually
18    Team,       // Users work together towards shared goal
19    Building,   // Entire building works towards goal
20}
21
22/// Challenge entity - Time-bound goals to encourage community engagement
23///
24/// Represents a specific challenge or contest with a defined timeframe.
25/// Challenges motivate participation through gamification.
26///
27/// # Belgian Context
28/// - Encourages active copropriรฉtรฉ community participation
29/// - Promotes use of platform features (SEL, bookings, etc.)
30/// - Builds community spirit through shared goals
31///
32/// # Business Rules
33/// - title must be 3-100 characters
34/// - description must be 10-1000 characters
35/// - start_date must be < end_date
36/// - target_value must be > 0
37/// - reward_points must be 0-10000
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Challenge {
40    pub id: Uuid,
41    pub organization_id: Uuid,
42    pub building_id: Option<Uuid>, // None = organization-wide
43    pub challenge_type: ChallengeType,
44    pub status: ChallengeStatus,
45    pub title: String, // e.g., "Community Booking Week", "SEL Exchange Marathon"
46    pub description: String, // Challenge details and rules
47    pub icon: String,  // Emoji or icon URL
48    pub start_date: DateTime<Utc>,
49    pub end_date: DateTime<Utc>,
50    pub target_metric: String, // e.g., "bookings_created", "sel_exchanges_completed"
51    pub target_value: i32,     // Target count to achieve
52    pub reward_points: i32,    // Points awarded for completion (0-10000)
53    pub created_at: DateTime<Utc>,
54    pub updated_at: DateTime<Utc>,
55}
56
57impl Challenge {
58    /// Minimum title length
59    pub const MIN_TITLE_LENGTH: usize = 3;
60    /// Maximum title length
61    pub const MAX_TITLE_LENGTH: usize = 100;
62    /// Minimum description length
63    pub const MIN_DESCRIPTION_LENGTH: usize = 10;
64    /// Maximum description length
65    pub const MAX_DESCRIPTION_LENGTH: usize = 1000;
66    /// Maximum reward points
67    pub const MAX_REWARD_POINTS: i32 = 10000;
68
69    /// Create a new challenge
70    ///
71    /// # Validation
72    /// - title must be 3-100 characters
73    /// - description must be 10-1000 characters
74    /// - start_date must be < end_date
75    /// - start_date must be in the future
76    /// - target_value must be > 0
77    /// - reward_points must be 0-10000
78    /// - icon must not be empty
79    pub fn new(
80        organization_id: Uuid,
81        building_id: Option<Uuid>,
82        challenge_type: ChallengeType,
83        title: String,
84        description: String,
85        icon: String,
86        start_date: DateTime<Utc>,
87        end_date: DateTime<Utc>,
88        target_metric: String,
89        target_value: i32,
90        reward_points: i32,
91    ) -> Result<Self, String> {
92        // Validate title
93        if title.len() < Self::MIN_TITLE_LENGTH || title.len() > Self::MAX_TITLE_LENGTH {
94            return Err(format!(
95                "Challenge title must be {}-{} characters",
96                Self::MIN_TITLE_LENGTH,
97                Self::MAX_TITLE_LENGTH
98            ));
99        }
100
101        // Validate description
102        if description.len() < Self::MIN_DESCRIPTION_LENGTH
103            || description.len() > Self::MAX_DESCRIPTION_LENGTH
104        {
105            return Err(format!(
106                "Challenge description must be {}-{} characters",
107                Self::MIN_DESCRIPTION_LENGTH,
108                Self::MAX_DESCRIPTION_LENGTH
109            ));
110        }
111
112        // Validate icon
113        if icon.trim().is_empty() {
114            return Err("Challenge icon cannot be empty".to_string());
115        }
116
117        // Validate dates
118        if start_date >= end_date {
119            return Err("Start date must be before end date".to_string());
120        }
121
122        let now = Utc::now();
123        if start_date <= now {
124            return Err("Challenge start date must be in the future".to_string());
125        }
126
127        // Validate target
128        if target_value <= 0 {
129            return Err("Target value must be greater than 0".to_string());
130        }
131
132        // Validate reward points
133        if reward_points < 0 || reward_points > Self::MAX_REWARD_POINTS {
134            return Err(format!(
135                "Reward points must be 0-{} points",
136                Self::MAX_REWARD_POINTS
137            ));
138        }
139
140        // Validate metric
141        if target_metric.trim().is_empty() {
142            return Err("Target metric cannot be empty".to_string());
143        }
144
145        let now = Utc::now();
146        Ok(Self {
147            id: Uuid::new_v4(),
148            organization_id,
149            building_id,
150            challenge_type,
151            status: ChallengeStatus::Draft,
152            title,
153            description,
154            icon,
155            start_date,
156            end_date,
157            target_metric,
158            target_value,
159            reward_points,
160            created_at: now,
161            updated_at: now,
162        })
163    }
164
165    /// Activate the challenge (Draft โ†’ Active)
166    pub fn activate(&mut self) -> Result<(), String> {
167        match self.status {
168            ChallengeStatus::Draft => {
169                self.status = ChallengeStatus::Active;
170                self.updated_at = Utc::now();
171                Ok(())
172            }
173            ChallengeStatus::Active => Err("Challenge is already active".to_string()),
174            ChallengeStatus::Completed => Err("Cannot activate a completed challenge".to_string()),
175            ChallengeStatus::Cancelled => Err("Cannot activate a cancelled challenge".to_string()),
176        }
177    }
178
179    /// Complete the challenge (Active โ†’ Completed)
180    pub fn complete(&mut self) -> Result<(), String> {
181        match self.status {
182            ChallengeStatus::Active => {
183                self.status = ChallengeStatus::Completed;
184                self.updated_at = Utc::now();
185                Ok(())
186            }
187            ChallengeStatus::Draft => Err("Cannot complete a draft challenge".to_string()),
188            ChallengeStatus::Completed => Err("Challenge is already completed".to_string()),
189            ChallengeStatus::Cancelled => Err("Cannot complete a cancelled challenge".to_string()),
190        }
191    }
192
193    /// Cancel the challenge
194    pub fn cancel(&mut self) -> Result<(), String> {
195        match self.status {
196            ChallengeStatus::Draft | ChallengeStatus::Active => {
197                self.status = ChallengeStatus::Cancelled;
198                self.updated_at = Utc::now();
199                Ok(())
200            }
201            ChallengeStatus::Completed => Err("Cannot cancel a completed challenge".to_string()),
202            ChallengeStatus::Cancelled => Err("Challenge is already cancelled".to_string()),
203        }
204    }
205
206    /// Check if challenge is currently active (now >= start_date AND now < end_date AND status = Active)
207    pub fn is_currently_active(&self) -> bool {
208        let now = Utc::now();
209        self.status == ChallengeStatus::Active && now >= self.start_date && now < self.end_date
210    }
211
212    /// Check if challenge has ended (now >= end_date)
213    pub fn has_ended(&self) -> bool {
214        Utc::now() >= self.end_date
215    }
216
217    /// Calculate duration in days
218    pub fn duration_days(&self) -> i64 {
219        self.end_date
220            .signed_duration_since(self.start_date)
221            .num_days()
222    }
223
224    /// Update challenge details (only allowed for Draft challenges)
225    pub fn update(
226        &mut self,
227        title: Option<String>,
228        description: Option<String>,
229        icon: Option<String>,
230        start_date: Option<DateTime<Utc>>,
231        end_date: Option<DateTime<Utc>>,
232        target_value: Option<i32>,
233        reward_points: Option<i32>,
234    ) -> Result<(), String> {
235        // Only allow updates for Draft challenges
236        if self.status != ChallengeStatus::Draft {
237            return Err("Can only update draft challenges".to_string());
238        }
239
240        // Update title if provided
241        if let Some(t) = title {
242            if t.len() < Self::MIN_TITLE_LENGTH || t.len() > Self::MAX_TITLE_LENGTH {
243                return Err(format!(
244                    "Challenge title must be {}-{} characters",
245                    Self::MIN_TITLE_LENGTH,
246                    Self::MAX_TITLE_LENGTH
247                ));
248            }
249            self.title = t;
250        }
251
252        // Update description if provided
253        if let Some(d) = description {
254            if d.len() < Self::MIN_DESCRIPTION_LENGTH || d.len() > Self::MAX_DESCRIPTION_LENGTH {
255                return Err(format!(
256                    "Challenge description must be {}-{} characters",
257                    Self::MIN_DESCRIPTION_LENGTH,
258                    Self::MAX_DESCRIPTION_LENGTH
259                ));
260            }
261            self.description = d;
262        }
263
264        // Update icon if provided
265        if let Some(i) = icon {
266            if i.trim().is_empty() {
267                return Err("Challenge icon cannot be empty".to_string());
268            }
269            self.icon = i;
270        }
271
272        // Update dates if provided (with validation)
273        if start_date.is_some() || end_date.is_some() {
274            let new_start = start_date.unwrap_or(self.start_date);
275            let new_end = end_date.unwrap_or(self.end_date);
276
277            if new_start >= new_end {
278                return Err("Start date must be before end date".to_string());
279            }
280
281            self.start_date = new_start;
282            self.end_date = new_end;
283        }
284
285        // Update target value if provided
286        if let Some(tv) = target_value {
287            if tv <= 0 {
288                return Err("Target value must be greater than 0".to_string());
289            }
290            self.target_value = tv;
291        }
292
293        // Update reward points if provided
294        if let Some(rp) = reward_points {
295            if rp < 0 || rp > Self::MAX_REWARD_POINTS {
296                return Err(format!(
297                    "Reward points must be 0-{} points",
298                    Self::MAX_REWARD_POINTS
299                ));
300            }
301            self.reward_points = rp;
302        }
303
304        self.updated_at = Utc::now();
305        Ok(())
306    }
307
308    /// Update challenge title
309    pub fn update_title(&mut self, title: String) -> Result<(), String> {
310        self.update(Some(title), None, None, None, None, None, None)
311    }
312
313    /// Update challenge description
314    pub fn update_description(&mut self, description: String) -> Result<(), String> {
315        self.update(None, Some(description), None, None, None, None, None)
316    }
317
318    /// Update challenge icon
319    pub fn update_icon(&mut self, icon: String) -> Result<(), String> {
320        self.update(None, None, Some(icon), None, None, None, None)
321    }
322
323    /// Update challenge start date
324    pub fn update_start_date(&mut self, start_date: DateTime<Utc>) -> Result<(), String> {
325        self.update(None, None, None, Some(start_date), None, None, None)
326    }
327
328    /// Update challenge end date
329    pub fn update_end_date(&mut self, end_date: DateTime<Utc>) -> Result<(), String> {
330        self.update(None, None, None, None, Some(end_date), None, None)
331    }
332
333    /// Update challenge target value
334    pub fn update_target_value(&mut self, target_value: i32) -> Result<(), String> {
335        self.update(None, None, None, None, None, Some(target_value), None)
336    }
337
338    /// Update challenge reward points
339    pub fn update_reward_points(&mut self, reward_points: i32) -> Result<(), String> {
340        self.update(None, None, None, None, None, None, Some(reward_points))
341    }
342}
343
344/// Challenge progress tracking for individual users
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct ChallengeProgress {
347    pub id: Uuid,
348    pub challenge_id: Uuid,
349    pub user_id: Uuid,
350    pub current_value: i32, // Current progress (e.g., 5 bookings out of 10)
351    pub completed: bool,    // Has user completed the challenge?
352    pub completed_at: Option<DateTime<Utc>>,
353    pub created_at: DateTime<Utc>,
354    pub updated_at: DateTime<Utc>,
355}
356
357impl ChallengeProgress {
358    /// Start tracking progress for a challenge
359    pub fn new(challenge_id: Uuid, user_id: Uuid) -> Self {
360        let now = Utc::now();
361        Self {
362            id: Uuid::new_v4(),
363            challenge_id,
364            user_id,
365            current_value: 0,
366            completed: false,
367            completed_at: None,
368            created_at: now,
369            updated_at: now,
370        }
371    }
372
373    /// Increment progress by amount
374    pub fn increment(&mut self, amount: i32) -> Result<(), String> {
375        if self.completed {
376            return Err("Cannot increment progress on completed challenge".to_string());
377        }
378
379        self.current_value += amount;
380        self.updated_at = Utc::now();
381        Ok(())
382    }
383
384    /// Mark challenge as completed
385    pub fn mark_completed(&mut self) -> Result<(), String> {
386        if self.completed {
387            return Err("Challenge is already completed".to_string());
388        }
389
390        self.completed = true;
391        self.completed_at = Some(Utc::now());
392        self.updated_at = Utc::now();
393        Ok(())
394    }
395
396    /// Calculate completion percentage
397    pub fn completion_percentage(&self, target_value: i32) -> f64 {
398        if target_value <= 0 {
399            return 0.0;
400        }
401        (self.current_value as f64 / target_value as f64 * 100.0).min(100.0)
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    fn create_test_challenge() -> Challenge {
410        let organization_id = Uuid::new_v4();
411        let start_date = Utc::now() + chrono::Duration::days(1);
412        let end_date = start_date + chrono::Duration::days(7);
413
414        Challenge::new(
415            organization_id,
416            None,
417            ChallengeType::Individual,
418            "Booking Week".to_string(),
419            "Make 5 resource bookings this week to earn points!".to_string(),
420            "๐Ÿ“…".to_string(),
421            start_date,
422            end_date,
423            "bookings_created".to_string(),
424            5,
425            50,
426        )
427        .unwrap()
428    }
429
430    #[test]
431    fn test_create_challenge_success() {
432        let challenge = create_test_challenge();
433        assert_eq!(challenge.title, "Booking Week");
434        assert_eq!(challenge.challenge_type, ChallengeType::Individual);
435        assert_eq!(challenge.status, ChallengeStatus::Draft);
436        assert_eq!(challenge.target_value, 5);
437        assert_eq!(challenge.reward_points, 50);
438    }
439
440    #[test]
441    fn test_create_challenge_invalid_title() {
442        let organization_id = Uuid::new_v4();
443        let start_date = Utc::now() + chrono::Duration::days(1);
444        let end_date = start_date + chrono::Duration::days(7);
445
446        let result = Challenge::new(
447            organization_id,
448            None,
449            ChallengeType::Individual,
450            "AB".to_string(), // Too short
451            "Make 5 resource bookings this week to earn points!".to_string(),
452            "๐Ÿ“…".to_string(),
453            start_date,
454            end_date,
455            "bookings_created".to_string(),
456            5,
457            50,
458        );
459
460        assert!(result.is_err());
461        assert!(result.unwrap_err().contains("Challenge title must be"));
462    }
463
464    #[test]
465    fn test_create_challenge_invalid_dates() {
466        let organization_id = Uuid::new_v4();
467        let start_date = Utc::now() + chrono::Duration::days(7);
468        let end_date = start_date - chrono::Duration::days(1); // End before start
469
470        let result = Challenge::new(
471            organization_id,
472            None,
473            ChallengeType::Individual,
474            "Booking Week".to_string(),
475            "Make 5 resource bookings this week to earn points!".to_string(),
476            "๐Ÿ“…".to_string(),
477            start_date,
478            end_date,
479            "bookings_created".to_string(),
480            5,
481            50,
482        );
483
484        assert!(result.is_err());
485        assert!(result
486            .unwrap_err()
487            .contains("Start date must be before end date"));
488    }
489
490    #[test]
491    fn test_create_challenge_past_start_date() {
492        let organization_id = Uuid::new_v4();
493        let start_date = Utc::now() - chrono::Duration::days(1); // Past
494        let end_date = start_date + chrono::Duration::days(7);
495
496        let result = Challenge::new(
497            organization_id,
498            None,
499            ChallengeType::Individual,
500            "Booking Week".to_string(),
501            "Make 5 resource bookings this week to earn points!".to_string(),
502            "๐Ÿ“…".to_string(),
503            start_date,
504            end_date,
505            "bookings_created".to_string(),
506            5,
507            50,
508        );
509
510        assert!(result.is_err());
511        assert!(result
512            .unwrap_err()
513            .contains("Challenge start date must be in the future"));
514    }
515
516    #[test]
517    fn test_activate_challenge() {
518        let mut challenge = create_test_challenge();
519        let result = challenge.activate();
520        assert!(result.is_ok());
521        assert_eq!(challenge.status, ChallengeStatus::Active);
522    }
523
524    #[test]
525    fn test_complete_challenge() {
526        let mut challenge = create_test_challenge();
527        challenge.activate().unwrap();
528        let result = challenge.complete();
529        assert!(result.is_ok());
530        assert_eq!(challenge.status, ChallengeStatus::Completed);
531    }
532
533    #[test]
534    fn test_cancel_challenge() {
535        let mut challenge = create_test_challenge();
536        let result = challenge.cancel();
537        assert!(result.is_ok());
538        assert_eq!(challenge.status, ChallengeStatus::Cancelled);
539    }
540
541    #[test]
542    fn test_duration_days() {
543        let challenge = create_test_challenge();
544        assert_eq!(challenge.duration_days(), 7);
545    }
546
547    #[test]
548    fn test_challenge_progress_new() {
549        let challenge_id = Uuid::new_v4();
550        let user_id = Uuid::new_v4();
551        let progress = ChallengeProgress::new(challenge_id, user_id);
552
553        assert_eq!(progress.challenge_id, challenge_id);
554        assert_eq!(progress.user_id, user_id);
555        assert_eq!(progress.current_value, 0);
556        assert!(!progress.completed);
557    }
558
559    #[test]
560    fn test_challenge_progress_increment() {
561        let challenge_id = Uuid::new_v4();
562        let user_id = Uuid::new_v4();
563        let mut progress = ChallengeProgress::new(challenge_id, user_id);
564
565        progress.increment(3).unwrap();
566        assert_eq!(progress.current_value, 3);
567
568        progress.increment(2).unwrap();
569        assert_eq!(progress.current_value, 5);
570    }
571
572    #[test]
573    fn test_challenge_progress_mark_completed() {
574        let challenge_id = Uuid::new_v4();
575        let user_id = Uuid::new_v4();
576        let mut progress = ChallengeProgress::new(challenge_id, user_id);
577
578        progress.mark_completed().unwrap();
579        assert!(progress.completed);
580        assert!(progress.completed_at.is_some());
581    }
582
583    #[test]
584    fn test_challenge_progress_completion_percentage() {
585        let challenge_id = Uuid::new_v4();
586        let user_id = Uuid::new_v4();
587        let mut progress = ChallengeProgress::new(challenge_id, user_id);
588
589        progress.increment(3).unwrap();
590        assert_eq!(progress.completion_percentage(10), 30.0);
591
592        progress.increment(7).unwrap();
593        assert_eq!(progress.completion_percentage(10), 100.0);
594    }
595
596    #[test]
597    fn test_update_challenge_only_draft() {
598        let mut challenge = create_test_challenge();
599        challenge.activate().unwrap();
600
601        let result = challenge.update(
602            Some("Updated Title".to_string()),
603            None,
604            None,
605            None,
606            None,
607            None,
608            None,
609        );
610
611        assert!(result.is_err());
612        assert!(result
613            .unwrap_err()
614            .contains("Can only update draft challenges"));
615    }
616}