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