1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum ChallengeStatus {
8 Draft, Active, Completed, Cancelled, }
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub enum ChallengeType {
17 Individual, Team, Building, }
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Challenge {
40 pub id: Uuid,
41 pub organization_id: Uuid,
42 pub building_id: Option<Uuid>, pub challenge_type: ChallengeType,
44 pub status: ChallengeStatus,
45 pub title: String, pub description: String, pub icon: String, pub start_date: DateTime<Utc>,
49 pub end_date: DateTime<Utc>,
50 pub target_metric: String, pub target_value: i32, pub reward_points: i32, pub created_at: DateTime<Utc>,
54 pub updated_at: DateTime<Utc>,
55}
56
57impl Challenge {
58 pub const MIN_TITLE_LENGTH: usize = 3;
60 pub const MAX_TITLE_LENGTH: usize = 100;
62 pub const MIN_DESCRIPTION_LENGTH: usize = 10;
64 pub const MAX_DESCRIPTION_LENGTH: usize = 1000;
66 pub const MAX_REWARD_POINTS: i32 = 10000;
68
69 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 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 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 if icon.trim().is_empty() {
114 return Err("Challenge icon cannot be empty".to_string());
115 }
116
117 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 if target_value <= 0 {
129 return Err("Target value must be greater than 0".to_string());
130 }
131
132 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 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 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 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 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 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 pub fn has_ended(&self) -> bool {
214 Utc::now() >= self.end_date
215 }
216
217 pub fn duration_days(&self) -> i64 {
219 self.end_date
220 .signed_duration_since(self.start_date)
221 .num_days()
222 }
223
224 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 if self.status != ChallengeStatus::Draft {
237 return Err("Can only update draft challenges".to_string());
238 }
239
240 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 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 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 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 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 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 pub fn update_title(&mut self, title: String) -> Result<(), String> {
310 self.update(Some(title), None, None, None, None, None, None)
311 }
312
313 pub fn update_description(&mut self, description: String) -> Result<(), String> {
315 self.update(None, Some(description), None, None, None, None, None)
316 }
317
318 pub fn update_icon(&mut self, icon: String) -> Result<(), String> {
320 self.update(None, None, Some(icon), None, None, None, None)
321 }
322
323 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 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 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 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#[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, pub completed: bool, pub completed_at: Option<DateTime<Utc>>,
353 pub created_at: DateTime<Utc>,
354 pub updated_at: DateTime<Utc>,
355}
356
357impl ChallengeProgress {
358 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 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 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 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(), "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); 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); 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}