koprogo_api/infrastructure/database/repositories/
challenge_repository_impl.rs

1use crate::application::ports::{ChallengeProgressRepository, ChallengeRepository};
2use crate::domain::entities::{Challenge, ChallengeProgress, ChallengeStatus, ChallengeType};
3use crate::infrastructure::pool::DbPool;
4use async_trait::async_trait;
5use sqlx::Row;
6use uuid::Uuid;
7
8pub struct PostgresChallengeRepository {
9    pool: DbPool,
10}
11
12impl PostgresChallengeRepository {
13    pub fn new(pool: DbPool) -> Self {
14        Self { pool }
15    }
16
17    /// Helper to convert database row to Challenge entity
18    fn row_to_challenge(row: &sqlx::postgres::PgRow) -> Result<Challenge, String> {
19        // Parse ENUMs from database strings
20        let challenge_type_str: String = row
21            .try_get("challenge_type")
22            .map_err(|e| format!("Failed to get challenge_type: {}", e))?;
23        let challenge_type: ChallengeType =
24            serde_json::from_str(&format!("\"{}\"", challenge_type_str))
25                .map_err(|e| format!("Failed to parse challenge_type: {}", e))?;
26
27        let status_str: String = row
28            .try_get("status")
29            .map_err(|e| format!("Failed to get status: {}", e))?;
30        let status: ChallengeStatus = serde_json::from_str(&format!("\"{}\"", status_str))
31            .map_err(|e| format!("Failed to parse status: {}", e))?;
32
33        Ok(Challenge {
34            id: row
35                .try_get("id")
36                .map_err(|e| format!("Failed to get id: {}", e))?,
37            organization_id: row
38                .try_get("organization_id")
39                .map_err(|e| format!("Failed to get organization_id: {}", e))?,
40            building_id: row
41                .try_get("building_id")
42                .map_err(|e| format!("Failed to get building_id: {}", e))?,
43            challenge_type,
44            status,
45            title: row
46                .try_get("title")
47                .map_err(|e| format!("Failed to get title: {}", e))?,
48            description: row
49                .try_get("description")
50                .map_err(|e| format!("Failed to get description: {}", e))?,
51            icon: row
52                .try_get("icon")
53                .map_err(|e| format!("Failed to get icon: {}", e))?,
54            start_date: row
55                .try_get("start_date")
56                .map_err(|e| format!("Failed to get start_date: {}", e))?,
57            end_date: row
58                .try_get("end_date")
59                .map_err(|e| format!("Failed to get end_date: {}", e))?,
60            target_metric: row
61                .try_get("target_metric")
62                .map_err(|e| format!("Failed to get target_metric: {}", e))?,
63            target_value: row
64                .try_get("target_value")
65                .map_err(|e| format!("Failed to get target_value: {}", e))?,
66            reward_points: row
67                .try_get("reward_points")
68                .map_err(|e| format!("Failed to get reward_points: {}", e))?,
69            created_at: row
70                .try_get("created_at")
71                .map_err(|e| format!("Failed to get created_at: {}", e))?,
72            updated_at: row
73                .try_get("updated_at")
74                .map_err(|e| format!("Failed to get updated_at: {}", e))?,
75        })
76    }
77}
78
79#[async_trait]
80impl ChallengeRepository for PostgresChallengeRepository {
81    async fn create(&self, challenge: &Challenge) -> Result<Challenge, String> {
82        // Serialize ENUMs to strings for database
83        let challenge_type_str = serde_json::to_string(&challenge.challenge_type)
84            .map_err(|e| format!("Failed to serialize challenge_type: {}", e))?
85            .trim_matches('"')
86            .to_string();
87
88        let status_str = serde_json::to_string(&challenge.status)
89            .map_err(|e| format!("Failed to serialize status: {}", e))?
90            .trim_matches('"')
91            .to_string();
92
93        sqlx::query(
94            r#"
95            INSERT INTO challenges (
96                id, organization_id, building_id, challenge_type, status, title,
97                description, icon, start_date, end_date, target_metric,
98                target_value, reward_points, created_at, updated_at
99            )
100            VALUES ($1, $2, $3, $4::challenge_type, $5::challenge_status, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
101            "#,
102        )
103        .bind(&challenge.id)
104        .bind(&challenge.organization_id)
105        .bind(&challenge.building_id)
106        .bind(&challenge_type_str)
107        .bind(&status_str)
108        .bind(&challenge.title)
109        .bind(&challenge.description)
110        .bind(&challenge.icon)
111        .bind(&challenge.start_date)
112        .bind(&challenge.end_date)
113        .bind(&challenge.target_metric)
114        .bind(&challenge.target_value)
115        .bind(&challenge.reward_points)
116        .bind(&challenge.created_at)
117        .bind(&challenge.updated_at)
118        .execute(&self.pool)
119        .await
120        .map_err(|e| format!("Failed to create challenge: {}", e))?;
121
122        Ok(challenge.clone())
123    }
124
125    async fn find_by_id(&self, id: Uuid) -> Result<Option<Challenge>, String> {
126        let row = sqlx::query(
127            r#"
128            SELECT id, organization_id, building_id, challenge_type, status, title,
129                   description, icon, start_date, end_date, target_metric,
130                   target_value, reward_points, created_at, updated_at
131            FROM challenges
132            WHERE id = $1
133            "#,
134        )
135        .bind(id)
136        .fetch_optional(&self.pool)
137        .await
138        .map_err(|e| format!("Failed to find challenge by id: {}", e))?;
139
140        row.as_ref().map(Self::row_to_challenge).transpose()
141    }
142
143    async fn find_by_organization(&self, organization_id: Uuid) -> Result<Vec<Challenge>, String> {
144        let rows = sqlx::query(
145            r#"
146            SELECT id, organization_id, building_id, challenge_type, status, title,
147                   description, icon, start_date, end_date, target_metric,
148                   target_value, reward_points, created_at, updated_at
149            FROM challenges
150            WHERE organization_id = $1
151            ORDER BY start_date DESC
152            "#,
153        )
154        .bind(organization_id)
155        .fetch_all(&self.pool)
156        .await
157        .map_err(|e| format!("Failed to find challenges by organization: {}", e))?;
158
159        rows.iter().map(Self::row_to_challenge).collect()
160    }
161
162    async fn find_by_organization_and_status(
163        &self,
164        organization_id: Uuid,
165        status: ChallengeStatus,
166    ) -> Result<Vec<Challenge>, String> {
167        let status_str = serde_json::to_string(&status)
168            .map_err(|e| format!("Failed to serialize status: {}", e))?
169            .trim_matches('"')
170            .to_string();
171
172        let rows = sqlx::query(
173            r#"
174            SELECT id, organization_id, building_id, challenge_type, status, title,
175                   description, icon, start_date, end_date, target_metric,
176                   target_value, reward_points, created_at, updated_at
177            FROM challenges
178            WHERE organization_id = $1
179              AND status = $2::challenge_status
180            ORDER BY start_date DESC
181            "#,
182        )
183        .bind(organization_id)
184        .bind(&status_str)
185        .fetch_all(&self.pool)
186        .await
187        .map_err(|e| format!("Failed to find challenges by status: {}", e))?;
188
189        rows.iter().map(Self::row_to_challenge).collect()
190    }
191
192    async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Challenge>, String> {
193        let rows = sqlx::query(
194            r#"
195            SELECT id, organization_id, building_id, challenge_type, status, title,
196                   description, icon, start_date, end_date, target_metric,
197                   target_value, reward_points, created_at, updated_at
198            FROM challenges
199            WHERE building_id = $1
200            ORDER BY start_date DESC
201            "#,
202        )
203        .bind(building_id)
204        .fetch_all(&self.pool)
205        .await
206        .map_err(|e| format!("Failed to find challenges by building: {}", e))?;
207
208        rows.iter().map(Self::row_to_challenge).collect()
209    }
210
211    async fn find_active(&self, organization_id: Uuid) -> Result<Vec<Challenge>, String> {
212        let rows = sqlx::query(
213            r#"
214            SELECT id, organization_id, building_id, challenge_type, status, title,
215                   description, icon, start_date, end_date, target_metric,
216                   target_value, reward_points, created_at, updated_at
217            FROM challenges
218            WHERE organization_id = $1
219              AND status = 'Active'
220              AND start_date <= NOW()
221              AND end_date > NOW()
222            ORDER BY start_date DESC
223            "#,
224        )
225        .bind(organization_id)
226        .fetch_all(&self.pool)
227        .await
228        .map_err(|e| format!("Failed to find active challenges: {}", e))?;
229
230        rows.iter().map(Self::row_to_challenge).collect()
231    }
232
233    async fn find_ended_not_completed(&self) -> Result<Vec<Challenge>, String> {
234        let rows = sqlx::query(
235            r#"
236            SELECT id, organization_id, building_id, challenge_type, status, title,
237                   description, icon, start_date, end_date, target_metric,
238                   target_value, reward_points, created_at, updated_at
239            FROM challenges
240            WHERE status = 'Active'
241              AND end_date <= NOW()
242            ORDER BY end_date ASC
243            "#,
244        )
245        .fetch_all(&self.pool)
246        .await
247        .map_err(|e| format!("Failed to find ended challenges: {}", e))?;
248
249        rows.iter().map(Self::row_to_challenge).collect()
250    }
251
252    async fn update(&self, challenge: &Challenge) -> Result<Challenge, String> {
253        let challenge_type_str = serde_json::to_string(&challenge.challenge_type)
254            .map_err(|e| format!("Failed to serialize challenge_type: {}", e))?
255            .trim_matches('"')
256            .to_string();
257
258        let status_str = serde_json::to_string(&challenge.status)
259            .map_err(|e| format!("Failed to serialize status: {}", e))?
260            .trim_matches('"')
261            .to_string();
262
263        let result = sqlx::query(
264            r#"
265            UPDATE challenges
266            SET challenge_type = $2::challenge_type,
267                status = $3::challenge_status,
268                title = $4,
269                description = $5,
270                icon = $6,
271                start_date = $7,
272                end_date = $8,
273                target_metric = $9,
274                target_value = $10,
275                reward_points = $11,
276                updated_at = $12
277            WHERE id = $1
278            "#,
279        )
280        .bind(&challenge.id)
281        .bind(&challenge_type_str)
282        .bind(&status_str)
283        .bind(&challenge.title)
284        .bind(&challenge.description)
285        .bind(&challenge.icon)
286        .bind(&challenge.start_date)
287        .bind(&challenge.end_date)
288        .bind(&challenge.target_metric)
289        .bind(&challenge.target_value)
290        .bind(&challenge.reward_points)
291        .bind(&challenge.updated_at)
292        .execute(&self.pool)
293        .await
294        .map_err(|e| format!("Failed to update challenge: {}", e))?;
295
296        if result.rows_affected() == 0 {
297            return Err("Challenge not found".to_string());
298        }
299
300        Ok(challenge.clone())
301    }
302
303    async fn delete(&self, id: Uuid) -> Result<(), String> {
304        let result = sqlx::query(
305            r#"
306            DELETE FROM challenges
307            WHERE id = $1
308            "#,
309        )
310        .bind(id)
311        .execute(&self.pool)
312        .await
313        .map_err(|e| format!("Failed to delete challenge: {}", e))?;
314
315        if result.rows_affected() == 0 {
316            return Err("Challenge not found".to_string());
317        }
318
319        Ok(())
320    }
321
322    async fn count_by_organization(&self, organization_id: Uuid) -> Result<i64, String> {
323        let row = sqlx::query(
324            r#"
325            SELECT COUNT(*) as count
326            FROM challenges
327            WHERE organization_id = $1
328            "#,
329        )
330        .bind(organization_id)
331        .fetch_one(&self.pool)
332        .await
333        .map_err(|e| format!("Failed to count challenges: {}", e))?;
334
335        let count: i64 = row
336            .try_get("count")
337            .map_err(|e| format!("Failed to get count: {}", e))?;
338
339        Ok(count)
340    }
341}
342
343// ============================================================================
344// ChallengeProgressRepository Implementation
345// ============================================================================
346
347pub struct PostgresChallengeProgressRepository {
348    pool: DbPool,
349}
350
351impl PostgresChallengeProgressRepository {
352    pub fn new(pool: DbPool) -> Self {
353        Self { pool }
354    }
355
356    /// Helper to convert database row to ChallengeProgress entity
357    fn row_to_progress(row: &sqlx::postgres::PgRow) -> Result<ChallengeProgress, String> {
358        Ok(ChallengeProgress {
359            id: row
360                .try_get("id")
361                .map_err(|e| format!("Failed to get id: {}", e))?,
362            challenge_id: row
363                .try_get("challenge_id")
364                .map_err(|e| format!("Failed to get challenge_id: {}", e))?,
365            user_id: row
366                .try_get("user_id")
367                .map_err(|e| format!("Failed to get user_id: {}", e))?,
368            current_value: row
369                .try_get("current_value")
370                .map_err(|e| format!("Failed to get current_value: {}", e))?,
371            completed: row
372                .try_get("completed")
373                .map_err(|e| format!("Failed to get completed: {}", e))?,
374            completed_at: row
375                .try_get("completed_at")
376                .map_err(|e| format!("Failed to get completed_at: {}", e))?,
377            created_at: row
378                .try_get("created_at")
379                .map_err(|e| format!("Failed to get created_at: {}", e))?,
380            updated_at: row
381                .try_get("updated_at")
382                .map_err(|e| format!("Failed to get updated_at: {}", e))?,
383        })
384    }
385}
386
387#[async_trait]
388impl ChallengeProgressRepository for PostgresChallengeProgressRepository {
389    async fn create(&self, progress: &ChallengeProgress) -> Result<ChallengeProgress, String> {
390        sqlx::query(
391            r#"
392            INSERT INTO challenge_progress (
393                id, challenge_id, user_id, current_value, completed,
394                completed_at, created_at, updated_at
395            )
396            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
397            "#,
398        )
399        .bind(&progress.id)
400        .bind(&progress.challenge_id)
401        .bind(&progress.user_id)
402        .bind(&progress.current_value)
403        .bind(&progress.completed)
404        .bind(&progress.completed_at)
405        .bind(&progress.created_at)
406        .bind(&progress.updated_at)
407        .execute(&self.pool)
408        .await
409        .map_err(|e| format!("Failed to create challenge progress: {}", e))?;
410
411        Ok(progress.clone())
412    }
413
414    async fn find_by_id(&self, id: Uuid) -> Result<Option<ChallengeProgress>, String> {
415        let row = sqlx::query(
416            r#"
417            SELECT id, challenge_id, user_id, current_value, completed,
418                   completed_at, created_at, updated_at
419            FROM challenge_progress
420            WHERE id = $1
421            "#,
422        )
423        .bind(id)
424        .fetch_optional(&self.pool)
425        .await
426        .map_err(|e| format!("Failed to find challenge progress by id: {}", e))?;
427
428        row.as_ref().map(Self::row_to_progress).transpose()
429    }
430
431    async fn find_by_user_and_challenge(
432        &self,
433        user_id: Uuid,
434        challenge_id: Uuid,
435    ) -> Result<Option<ChallengeProgress>, String> {
436        let row = sqlx::query(
437            r#"
438            SELECT id, challenge_id, user_id, current_value, completed,
439                   completed_at, created_at, updated_at
440            FROM challenge_progress
441            WHERE user_id = $1 AND challenge_id = $2
442            "#,
443        )
444        .bind(user_id)
445        .bind(challenge_id)
446        .fetch_optional(&self.pool)
447        .await
448        .map_err(|e| format!("Failed to find challenge progress: {}", e))?;
449
450        row.as_ref().map(Self::row_to_progress).transpose()
451    }
452
453    async fn find_by_user(&self, user_id: Uuid) -> Result<Vec<ChallengeProgress>, String> {
454        let rows = sqlx::query(
455            r#"
456            SELECT id, challenge_id, user_id, current_value, completed,
457                   completed_at, created_at, updated_at
458            FROM challenge_progress
459            WHERE user_id = $1
460            ORDER BY created_at DESC
461            "#,
462        )
463        .bind(user_id)
464        .fetch_all(&self.pool)
465        .await
466        .map_err(|e| format!("Failed to find progress by user: {}", e))?;
467
468        rows.iter().map(Self::row_to_progress).collect()
469    }
470
471    async fn find_by_challenge(
472        &self,
473        challenge_id: Uuid,
474    ) -> Result<Vec<ChallengeProgress>, String> {
475        let rows = sqlx::query(
476            r#"
477            SELECT id, challenge_id, user_id, current_value, completed,
478                   completed_at, created_at, updated_at
479            FROM challenge_progress
480            WHERE challenge_id = $1
481            ORDER BY current_value DESC
482            "#,
483        )
484        .bind(challenge_id)
485        .fetch_all(&self.pool)
486        .await
487        .map_err(|e| format!("Failed to find progress by challenge: {}", e))?;
488
489        rows.iter().map(Self::row_to_progress).collect()
490    }
491
492    async fn find_active_by_user(&self, user_id: Uuid) -> Result<Vec<ChallengeProgress>, String> {
493        let rows = sqlx::query(
494            r#"
495            SELECT cp.id, cp.challenge_id, cp.user_id, cp.current_value,
496                   cp.completed, cp.completed_at, cp.created_at, cp.updated_at
497            FROM challenge_progress cp
498            JOIN challenges c ON c.id = cp.challenge_id
499            WHERE cp.user_id = $1
500              AND c.status = 'Active'
501              AND cp.completed = FALSE
502            ORDER BY c.end_date ASC
503            "#,
504        )
505        .bind(user_id)
506        .fetch_all(&self.pool)
507        .await
508        .map_err(|e| format!("Failed to find active progress: {}", e))?;
509
510        rows.iter().map(Self::row_to_progress).collect()
511    }
512
513    async fn update(&self, progress: &ChallengeProgress) -> Result<ChallengeProgress, String> {
514        let result = sqlx::query(
515            r#"
516            UPDATE challenge_progress
517            SET current_value = $2,
518                completed = $3,
519                completed_at = $4,
520                updated_at = $5
521            WHERE id = $1
522            "#,
523        )
524        .bind(&progress.id)
525        .bind(&progress.current_value)
526        .bind(&progress.completed)
527        .bind(&progress.completed_at)
528        .bind(&progress.updated_at)
529        .execute(&self.pool)
530        .await
531        .map_err(|e| format!("Failed to update challenge progress: {}", e))?;
532
533        if result.rows_affected() == 0 {
534            return Err("Challenge progress not found".to_string());
535        }
536
537        Ok(progress.clone())
538    }
539
540    async fn count_completed_by_user(&self, user_id: Uuid) -> Result<i64, String> {
541        let row = sqlx::query(
542            r#"
543            SELECT COUNT(*) as count
544            FROM challenge_progress
545            WHERE user_id = $1 AND completed = TRUE
546            "#,
547        )
548        .bind(user_id)
549        .fetch_one(&self.pool)
550        .await
551        .map_err(|e| format!("Failed to count completed challenges: {}", e))?;
552
553        let count: i64 = row
554            .try_get("count")
555            .map_err(|e| format!("Failed to get count: {}", e))?;
556
557        Ok(count)
558    }
559
560    async fn get_leaderboard(
561        &self,
562        organization_id: Uuid,
563        building_id: Option<Uuid>,
564        limit: i64,
565    ) -> Result<Vec<(Uuid, i32)>, String> {
566        // Calculate total points from completed challenges
567        // Filter by organization and optionally by building
568        let rows = if let Some(bldg_id) = building_id {
569            sqlx::query(
570                r#"
571                SELECT cp.user_id, COALESCE(SUM(c.reward_points), 0)::INTEGER as total_points
572                FROM challenge_progress cp
573                JOIN challenges c ON c.id = cp.challenge_id
574                WHERE c.organization_id = $1
575                  AND c.building_id = $2
576                  AND cp.completed = TRUE
577                GROUP BY cp.user_id
578                ORDER BY total_points DESC
579                LIMIT $3
580                "#,
581            )
582            .bind(organization_id)
583            .bind(bldg_id)
584            .bind(limit)
585            .fetch_all(&self.pool)
586            .await
587            .map_err(|e| format!("Failed to get leaderboard: {}", e))?
588        } else {
589            sqlx::query(
590                r#"
591                SELECT cp.user_id, COALESCE(SUM(c.reward_points), 0)::INTEGER as total_points
592                FROM challenge_progress cp
593                JOIN challenges c ON c.id = cp.challenge_id
594                WHERE c.organization_id = $1
595                  AND cp.completed = TRUE
596                GROUP BY cp.user_id
597                ORDER BY total_points DESC
598                LIMIT $2
599                "#,
600            )
601            .bind(organization_id)
602            .bind(limit)
603            .fetch_all(&self.pool)
604            .await
605            .map_err(|e| format!("Failed to get leaderboard: {}", e))?
606        };
607
608        let leaderboard = rows
609            .iter()
610            .map(|row| {
611                let user_id: Uuid = row
612                    .try_get("user_id")
613                    .map_err(|e| format!("Failed to get user_id: {}", e))?;
614                let total_points: i32 = row
615                    .try_get("total_points")
616                    .map_err(|e| format!("Failed to get total_points: {}", e))?;
617                Ok((user_id, total_points))
618            })
619            .collect::<Result<Vec<_>, String>>()?;
620
621        Ok(leaderboard)
622    }
623}