koprogo_api/application/dto/
gamification_dto.rs

1use crate::domain::entities::{
2    Achievement, AchievementCategory, AchievementTier, ChallengeProgress, ChallengeStatus,
3    ChallengeType, UserAchievement,
4};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9// ============================================================================
10// Achievement DTOs
11// ============================================================================
12
13/// DTO for creating a new achievement
14#[derive(Debug, Serialize, Deserialize, Clone)]
15pub struct CreateAchievementDto {
16    pub organization_id: Uuid,
17    pub category: AchievementCategory,
18    pub tier: AchievementTier,
19    pub name: String,
20    pub description: String,
21    pub icon: String,
22    pub points_value: i32,
23    pub requirements: String, // JSON criteria
24    pub is_secret: bool,
25    pub is_repeatable: bool,
26    pub display_order: i32,
27}
28
29/// DTO for updating an achievement
30#[derive(Debug, Serialize, Deserialize, Clone)]
31pub struct UpdateAchievementDto {
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub name: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub description: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub icon: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub points_value: Option<i32>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub requirements: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub is_secret: Option<bool>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub is_repeatable: Option<bool>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub display_order: Option<i32>,
48}
49
50/// Response DTO for Achievement
51#[derive(Debug, Serialize, Deserialize, Clone)]
52pub struct AchievementResponseDto {
53    pub id: Uuid,
54    pub organization_id: Uuid,
55    pub category: AchievementCategory,
56    pub tier: AchievementTier,
57    pub name: String,
58    pub description: String,
59    pub icon: String,
60    pub points_value: i32,
61    pub requirements: String,
62    pub is_secret: bool,
63    pub is_repeatable: bool,
64    pub display_order: i32,
65    pub created_at: DateTime<Utc>,
66    pub updated_at: DateTime<Utc>,
67}
68
69impl From<Achievement> for AchievementResponseDto {
70    fn from(achievement: Achievement) -> Self {
71        Self {
72            id: achievement.id,
73            organization_id: achievement.organization_id,
74            category: achievement.category,
75            tier: achievement.tier,
76            name: achievement.name,
77            description: achievement.description,
78            icon: achievement.icon,
79            points_value: achievement.points_value,
80            requirements: achievement.requirements,
81            is_secret: achievement.is_secret,
82            is_repeatable: achievement.is_repeatable,
83            display_order: achievement.display_order,
84            created_at: achievement.created_at,
85            updated_at: achievement.updated_at,
86        }
87    }
88}
89
90// ============================================================================
91// UserAchievement DTOs
92// ============================================================================
93
94/// Response DTO for UserAchievement with enriched achievement data
95#[derive(Debug, Serialize, Deserialize, Clone)]
96pub struct UserAchievementResponseDto {
97    pub id: Uuid,
98    pub user_id: Uuid,
99    pub achievement_id: Uuid,
100    pub earned_at: DateTime<Utc>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub progress_data: Option<String>,
103    pub times_earned: i32,
104    // Enriched data
105    pub achievement: AchievementResponseDto,
106}
107
108impl UserAchievementResponseDto {
109    pub fn from_entities(user_achievement: UserAchievement, achievement: Achievement) -> Self {
110        Self {
111            id: user_achievement.id,
112            user_id: user_achievement.user_id,
113            achievement_id: user_achievement.achievement_id,
114            earned_at: user_achievement.earned_at,
115            progress_data: user_achievement.progress_data,
116            times_earned: user_achievement.times_earned,
117            achievement: AchievementResponseDto::from(achievement),
118        }
119    }
120}
121
122// ============================================================================
123// Challenge DTOs
124// ============================================================================
125
126/// DTO for creating a new challenge
127#[derive(Debug, Serialize, Deserialize, Clone)]
128pub struct CreateChallengeDto {
129    pub organization_id: Uuid,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub building_id: Option<Uuid>,
132    pub challenge_type: ChallengeType,
133    pub title: String,
134    pub description: String,
135    pub icon: String,
136    pub start_date: DateTime<Utc>,
137    pub end_date: DateTime<Utc>,
138    pub target_metric: String,
139    pub target_value: i32,
140    pub reward_points: i32,
141}
142
143/// DTO for updating a challenge (Draft only)
144#[derive(Debug, Serialize, Deserialize, Clone)]
145pub struct UpdateChallengeDto {
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub title: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub description: Option<String>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub icon: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub start_date: Option<DateTime<Utc>>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub end_date: Option<DateTime<Utc>>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub target_value: Option<i32>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub reward_points: Option<i32>,
160}
161
162/// Response DTO for Challenge
163#[derive(Debug, Serialize, Deserialize, Clone)]
164pub struct ChallengeResponseDto {
165    pub id: Uuid,
166    pub organization_id: Uuid,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub building_id: Option<Uuid>,
169    pub challenge_type: ChallengeType,
170    pub status: ChallengeStatus,
171    pub title: String,
172    pub description: String,
173    pub icon: String,
174    pub start_date: DateTime<Utc>,
175    pub end_date: DateTime<Utc>,
176    pub target_metric: String,
177    pub target_value: i32,
178    pub reward_points: i32,
179    pub created_at: DateTime<Utc>,
180    pub updated_at: DateTime<Utc>,
181    // Computed fields
182    pub duration_days: i64,
183    pub is_currently_active: bool,
184    pub has_ended: bool,
185}
186
187impl From<crate::domain::entities::Challenge> for ChallengeResponseDto {
188    fn from(challenge: crate::domain::entities::Challenge) -> Self {
189        Self {
190            id: challenge.id,
191            organization_id: challenge.organization_id,
192            building_id: challenge.building_id,
193            challenge_type: challenge.challenge_type.clone(),
194            status: challenge.status.clone(),
195            title: challenge.title.clone(),
196            description: challenge.description.clone(),
197            icon: challenge.icon.clone(),
198            start_date: challenge.start_date,
199            end_date: challenge.end_date,
200            target_metric: challenge.target_metric.clone(),
201            target_value: challenge.target_value,
202            reward_points: challenge.reward_points,
203            created_at: challenge.created_at,
204            updated_at: challenge.updated_at,
205            // Computed fields
206            duration_days: challenge.duration_days(),
207            is_currently_active: challenge.is_currently_active(),
208            has_ended: challenge.has_ended(),
209        }
210    }
211}
212
213// ============================================================================
214// ChallengeProgress DTOs
215// ============================================================================
216
217/// Response DTO for ChallengeProgress with enriched challenge data
218#[derive(Debug, Serialize, Deserialize, Clone)]
219pub struct ChallengeProgressResponseDto {
220    pub id: Uuid,
221    pub challenge_id: Uuid,
222    pub user_id: Uuid,
223    pub current_value: i32,
224    pub completed: bool,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub completed_at: Option<DateTime<Utc>>,
227    pub created_at: DateTime<Utc>,
228    pub updated_at: DateTime<Utc>,
229    // Enriched data
230    pub challenge: ChallengeResponseDto,
231    // Computed fields
232    pub completion_percentage: f64,
233}
234
235impl ChallengeProgressResponseDto {
236    pub fn from_entities(
237        progress: ChallengeProgress,
238        challenge: crate::domain::entities::Challenge,
239    ) -> Self {
240        let completion_percentage = progress.completion_percentage(challenge.target_value);
241        Self {
242            id: progress.id,
243            challenge_id: progress.challenge_id,
244            user_id: progress.user_id,
245            current_value: progress.current_value,
246            completed: progress.completed,
247            completed_at: progress.completed_at,
248            created_at: progress.created_at,
249            updated_at: progress.updated_at,
250            challenge: ChallengeResponseDto::from(challenge),
251            completion_percentage,
252        }
253    }
254}
255
256// ============================================================================
257// Leaderboard DTOs
258// ============================================================================
259
260/// Leaderboard entry for user ranking
261#[derive(Debug, Serialize, Deserialize, Clone)]
262pub struct LeaderboardEntryDto {
263    pub user_id: Uuid,
264    pub username: String, // Enriched from User
265    pub total_points: i32,
266    pub achievements_count: i32,
267    pub challenges_completed: i32,
268    pub rank: i32,
269}
270
271/// Leaderboard response with top users
272#[derive(Debug, Serialize, Deserialize, Clone)]
273pub struct LeaderboardResponseDto {
274    pub organization_id: Uuid,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub building_id: Option<Uuid>,
277    pub entries: Vec<LeaderboardEntryDto>,
278    pub total_users: i32,
279}
280
281/// User gamification stats
282#[derive(Debug, Serialize, Deserialize, Clone)]
283pub struct UserGamificationStatsDto {
284    pub user_id: Uuid,
285    pub total_points: i32,
286    pub achievements_earned: i32,
287    pub achievements_available: i32,
288    pub challenges_completed: i32,
289    pub challenges_active: i32,
290    pub rank: Option<i32>,
291    pub recent_achievements: Vec<UserAchievementResponseDto>, // Last 5
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_achievement_response_dto_from_entity() {
300        let organization_id = Uuid::new_v4();
301        let achievement = Achievement::new(
302            organization_id,
303            AchievementCategory::Community,
304            AchievementTier::Bronze,
305            "First Booking".to_string(),
306            "Made your first resource booking".to_string(),
307            "🎉".to_string(),
308            10,
309            r#"{"action": "booking_created", "count": 1}"#.to_string(),
310            false,
311            false,
312            1,
313        )
314        .unwrap();
315
316        let dto = AchievementResponseDto::from(achievement.clone());
317
318        assert_eq!(dto.id, achievement.id);
319        assert_eq!(dto.name, "First Booking");
320        assert_eq!(dto.category, AchievementCategory::Community);
321        assert_eq!(dto.tier, AchievementTier::Bronze);
322        assert_eq!(dto.points_value, 10);
323    }
324
325    #[test]
326    fn test_challenge_response_dto_computed_fields() {
327        let organization_id = Uuid::new_v4();
328        let start_date = Utc::now() + chrono::Duration::days(1);
329        let end_date = start_date + chrono::Duration::days(7);
330
331        let challenge = crate::domain::entities::Challenge::new(
332            organization_id,
333            None,
334            ChallengeType::Individual,
335            "Booking Week".to_string(),
336            "Make 5 resource bookings this week to earn points!".to_string(),
337            "📅".to_string(),
338            start_date,
339            end_date,
340            "bookings_created".to_string(),
341            5,
342            50,
343        )
344        .unwrap();
345
346        let dto = ChallengeResponseDto::from(challenge);
347
348        assert_eq!(dto.duration_days, 7);
349        assert!(!dto.is_currently_active); // Not started yet
350        assert!(!dto.has_ended);
351    }
352
353    #[test]
354    fn test_challenge_progress_completion_percentage() {
355        let organization_id = Uuid::new_v4();
356        let start_date = Utc::now() + chrono::Duration::days(1);
357        let end_date = start_date + chrono::Duration::days(7);
358
359        let challenge = crate::domain::entities::Challenge::new(
360            organization_id,
361            None,
362            ChallengeType::Individual,
363            "Booking Week".to_string(),
364            "Make 5 resource bookings this week to earn points!".to_string(),
365            "📅".to_string(),
366            start_date,
367            end_date,
368            "bookings_created".to_string(),
369            10,
370            50,
371        )
372        .unwrap();
373
374        let challenge_id = challenge.id;
375        let user_id = Uuid::new_v4();
376        let mut progress = ChallengeProgress::new(challenge_id, user_id);
377        progress.increment(3).unwrap();
378
379        let dto = ChallengeProgressResponseDto::from_entities(progress, challenge);
380
381        assert_eq!(dto.completion_percentage, 30.0);
382        assert!(!dto.completed);
383    }
384}