koprogo_api/infrastructure/database/repositories/
achievement_repository_impl.rs

1use crate::application::ports::{AchievementRepository, UserAchievementRepository};
2use crate::domain::entities::{Achievement, AchievementCategory, AchievementTier, UserAchievement};
3use crate::infrastructure::pool::DbPool;
4use async_trait::async_trait;
5use sqlx::Row;
6use uuid::Uuid;
7
8pub struct PostgresAchievementRepository {
9    pool: DbPool,
10}
11
12impl PostgresAchievementRepository {
13    pub fn new(pool: DbPool) -> Self {
14        Self { pool }
15    }
16
17    /// Helper to convert database row to Achievement entity
18    fn row_to_achievement(row: &sqlx::postgres::PgRow) -> Result<Achievement, String> {
19        // Parse ENUMs from database strings
20        let category_str: String = row
21            .try_get("category")
22            .map_err(|e| format!("Failed to get category: {}", e))?;
23        let category: AchievementCategory = serde_json::from_str(&format!("\"{}\"", category_str))
24            .map_err(|e| format!("Failed to parse category: {}", e))?;
25
26        let tier_str: String = row
27            .try_get("tier")
28            .map_err(|e| format!("Failed to get tier: {}", e))?;
29        let tier: AchievementTier = serde_json::from_str(&format!("\"{}\"", tier_str))
30            .map_err(|e| format!("Failed to parse tier: {}", e))?;
31
32        Ok(Achievement {
33            id: row
34                .try_get("id")
35                .map_err(|e| format!("Failed to get id: {}", e))?,
36            organization_id: row
37                .try_get("organization_id")
38                .map_err(|e| format!("Failed to get organization_id: {}", e))?,
39            category,
40            tier,
41            name: row
42                .try_get("name")
43                .map_err(|e| format!("Failed to get name: {}", e))?,
44            description: row
45                .try_get("description")
46                .map_err(|e| format!("Failed to get description: {}", e))?,
47            icon: row
48                .try_get("icon")
49                .map_err(|e| format!("Failed to get icon: {}", e))?,
50            points_value: row
51                .try_get("points_value")
52                .map_err(|e| format!("Failed to get points_value: {}", e))?,
53            requirements: row
54                .try_get("requirements")
55                .map_err(|e| format!("Failed to get requirements: {}", e))?,
56            is_secret: row
57                .try_get("is_secret")
58                .map_err(|e| format!("Failed to get is_secret: {}", e))?,
59            is_repeatable: row
60                .try_get("is_repeatable")
61                .map_err(|e| format!("Failed to get is_repeatable: {}", e))?,
62            display_order: row
63                .try_get("display_order")
64                .map_err(|e| format!("Failed to get display_order: {}", e))?,
65            created_at: row
66                .try_get("created_at")
67                .map_err(|e| format!("Failed to get created_at: {}", e))?,
68            updated_at: row
69                .try_get("updated_at")
70                .map_err(|e| format!("Failed to get updated_at: {}", e))?,
71        })
72    }
73}
74
75#[async_trait]
76impl AchievementRepository for PostgresAchievementRepository {
77    async fn create(&self, achievement: &Achievement) -> Result<Achievement, String> {
78        // Serialize ENUMs to strings for database
79        let category_str = serde_json::to_string(&achievement.category)
80            .map_err(|e| format!("Failed to serialize category: {}", e))?
81            .trim_matches('"')
82            .to_string();
83
84        let tier_str = serde_json::to_string(&achievement.tier)
85            .map_err(|e| format!("Failed to serialize tier: {}", e))?
86            .trim_matches('"')
87            .to_string();
88
89        sqlx::query(
90            r#"
91            INSERT INTO achievements (
92                id, organization_id, category, tier, name, description, icon,
93                points_value, requirements, is_secret, is_repeatable, display_order,
94                created_at, updated_at
95            )
96            VALUES ($1, $2, $3::achievement_category, $4::achievement_tier, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
97            "#,
98        )
99        .bind(&achievement.id)
100        .bind(&achievement.organization_id)
101        .bind(&category_str)
102        .bind(&tier_str)
103        .bind(&achievement.name)
104        .bind(&achievement.description)
105        .bind(&achievement.icon)
106        .bind(&achievement.points_value)
107        .bind(&achievement.requirements)
108        .bind(&achievement.is_secret)
109        .bind(&achievement.is_repeatable)
110        .bind(&achievement.display_order)
111        .bind(&achievement.created_at)
112        .bind(&achievement.updated_at)
113        .execute(&self.pool)
114        .await
115        .map_err(|e| format!("Failed to create achievement: {}", e))?;
116
117        Ok(achievement.clone())
118    }
119
120    async fn find_by_id(&self, id: Uuid) -> Result<Option<Achievement>, String> {
121        let row = sqlx::query(
122            r#"
123            SELECT id, organization_id, category, tier, name, description, icon,
124                   points_value, requirements, is_secret, is_repeatable, display_order,
125                   created_at, updated_at
126            FROM achievements
127            WHERE id = $1
128            "#,
129        )
130        .bind(id)
131        .fetch_optional(&self.pool)
132        .await
133        .map_err(|e| format!("Failed to find achievement by id: {}", e))?;
134
135        row.as_ref().map(Self::row_to_achievement).transpose()
136    }
137
138    async fn find_by_organization(
139        &self,
140        organization_id: Uuid,
141    ) -> Result<Vec<Achievement>, String> {
142        let rows = sqlx::query(
143            r#"
144            SELECT id, organization_id, category, tier, name, description, icon,
145                   points_value, requirements, is_secret, is_repeatable, display_order,
146                   created_at, updated_at
147            FROM achievements
148            WHERE organization_id = $1
149            ORDER BY display_order ASC, tier ASC, name ASC
150            "#,
151        )
152        .bind(organization_id)
153        .fetch_all(&self.pool)
154        .await
155        .map_err(|e| format!("Failed to find achievements by organization: {}", e))?;
156
157        rows.iter().map(Self::row_to_achievement).collect()
158    }
159
160    async fn find_by_organization_and_category(
161        &self,
162        organization_id: Uuid,
163        category: AchievementCategory,
164    ) -> Result<Vec<Achievement>, String> {
165        let category_str = serde_json::to_string(&category)
166            .map_err(|e| format!("Failed to serialize category: {}", e))?
167            .trim_matches('"')
168            .to_string();
169
170        let rows = sqlx::query(
171            r#"
172            SELECT id, organization_id, category, tier, name, description, icon,
173                   points_value, requirements, is_secret, is_repeatable, display_order,
174                   created_at, updated_at
175            FROM achievements
176            WHERE organization_id = $1
177              AND category = $2::achievement_category
178            ORDER BY display_order ASC, tier ASC, name ASC
179            "#,
180        )
181        .bind(organization_id)
182        .bind(&category_str)
183        .fetch_all(&self.pool)
184        .await
185        .map_err(|e| format!("Failed to find achievements by category: {}", e))?;
186
187        rows.iter().map(Self::row_to_achievement).collect()
188    }
189
190    async fn find_visible_for_user(
191        &self,
192        organization_id: Uuid,
193        user_id: Uuid,
194    ) -> Result<Vec<Achievement>, String> {
195        // Return all non-secret achievements OR secret achievements the user has earned
196        let rows = sqlx::query(
197            r#"
198            SELECT DISTINCT a.id, a.organization_id, a.category, a.tier, a.name,
199                   a.description, a.icon, a.points_value, a.requirements,
200                   a.is_secret, a.is_repeatable, a.display_order,
201                   a.created_at, a.updated_at
202            FROM achievements a
203            LEFT JOIN user_achievements ua ON ua.achievement_id = a.id AND ua.user_id = $2
204            WHERE a.organization_id = $1
205              AND (a.is_secret = FALSE OR ua.id IS NOT NULL)
206            ORDER BY a.display_order ASC, a.tier ASC, a.name ASC
207            "#,
208        )
209        .bind(organization_id)
210        .bind(user_id)
211        .fetch_all(&self.pool)
212        .await
213        .map_err(|e| format!("Failed to find visible achievements: {}", e))?;
214
215        rows.iter().map(Self::row_to_achievement).collect()
216    }
217
218    async fn update(&self, achievement: &Achievement) -> Result<Achievement, String> {
219        let category_str = serde_json::to_string(&achievement.category)
220            .map_err(|e| format!("Failed to serialize category: {}", e))?
221            .trim_matches('"')
222            .to_string();
223
224        let tier_str = serde_json::to_string(&achievement.tier)
225            .map_err(|e| format!("Failed to serialize tier: {}", e))?
226            .trim_matches('"')
227            .to_string();
228
229        let result = sqlx::query(
230            r#"
231            UPDATE achievements
232            SET category = $2::achievement_category,
233                tier = $3::achievement_tier,
234                name = $4,
235                description = $5,
236                icon = $6,
237                points_value = $7,
238                requirements = $8,
239                is_secret = $9,
240                is_repeatable = $10,
241                display_order = $11,
242                updated_at = $12
243            WHERE id = $1
244            "#,
245        )
246        .bind(&achievement.id)
247        .bind(&category_str)
248        .bind(&tier_str)
249        .bind(&achievement.name)
250        .bind(&achievement.description)
251        .bind(&achievement.icon)
252        .bind(&achievement.points_value)
253        .bind(&achievement.requirements)
254        .bind(&achievement.is_secret)
255        .bind(&achievement.is_repeatable)
256        .bind(&achievement.display_order)
257        .bind(&achievement.updated_at)
258        .execute(&self.pool)
259        .await
260        .map_err(|e| format!("Failed to update achievement: {}", e))?;
261
262        if result.rows_affected() == 0 {
263            return Err("Achievement not found".to_string());
264        }
265
266        Ok(achievement.clone())
267    }
268
269    async fn delete(&self, id: Uuid) -> Result<(), String> {
270        let result = sqlx::query(
271            r#"
272            DELETE FROM achievements
273            WHERE id = $1
274            "#,
275        )
276        .bind(id)
277        .execute(&self.pool)
278        .await
279        .map_err(|e| format!("Failed to delete achievement: {}", e))?;
280
281        if result.rows_affected() == 0 {
282            return Err("Achievement not found".to_string());
283        }
284
285        Ok(())
286    }
287
288    async fn count_by_organization(&self, organization_id: Uuid) -> Result<i64, String> {
289        let row = sqlx::query(
290            r#"
291            SELECT COUNT(*) as count
292            FROM achievements
293            WHERE organization_id = $1
294            "#,
295        )
296        .bind(organization_id)
297        .fetch_one(&self.pool)
298        .await
299        .map_err(|e| format!("Failed to count achievements: {}", e))?;
300
301        let count: i64 = row
302            .try_get("count")
303            .map_err(|e| format!("Failed to get count: {}", e))?;
304
305        Ok(count)
306    }
307}
308
309// ============================================================================
310// UserAchievementRepository Implementation
311// ============================================================================
312
313pub struct PostgresUserAchievementRepository {
314    pool: DbPool,
315}
316
317impl PostgresUserAchievementRepository {
318    pub fn new(pool: DbPool) -> Self {
319        Self { pool }
320    }
321
322    /// Helper to convert database row to UserAchievement entity
323    fn row_to_user_achievement(row: &sqlx::postgres::PgRow) -> Result<UserAchievement, String> {
324        Ok(UserAchievement {
325            id: row
326                .try_get("id")
327                .map_err(|e| format!("Failed to get id: {}", e))?,
328            user_id: row
329                .try_get("user_id")
330                .map_err(|e| format!("Failed to get user_id: {}", e))?,
331            achievement_id: row
332                .try_get("achievement_id")
333                .map_err(|e| format!("Failed to get achievement_id: {}", e))?,
334            earned_at: row
335                .try_get("earned_at")
336                .map_err(|e| format!("Failed to get earned_at: {}", e))?,
337            progress_data: row
338                .try_get("progress_data")
339                .map_err(|e| format!("Failed to get progress_data: {}", e))?,
340            times_earned: row
341                .try_get("times_earned")
342                .map_err(|e| format!("Failed to get times_earned: {}", e))?,
343        })
344    }
345}
346
347#[async_trait]
348impl UserAchievementRepository for PostgresUserAchievementRepository {
349    async fn create(&self, user_achievement: &UserAchievement) -> Result<UserAchievement, String> {
350        sqlx::query(
351            r#"
352            INSERT INTO user_achievements (
353                id, user_id, achievement_id, earned_at, progress_data, times_earned
354            )
355            VALUES ($1, $2, $3, $4, $5, $6)
356            "#,
357        )
358        .bind(&user_achievement.id)
359        .bind(&user_achievement.user_id)
360        .bind(&user_achievement.achievement_id)
361        .bind(&user_achievement.earned_at)
362        .bind(&user_achievement.progress_data)
363        .bind(&user_achievement.times_earned)
364        .execute(&self.pool)
365        .await
366        .map_err(|e| format!("Failed to create user achievement: {}", e))?;
367
368        Ok(user_achievement.clone())
369    }
370
371    async fn find_by_id(&self, id: Uuid) -> Result<Option<UserAchievement>, String> {
372        let row = sqlx::query(
373            r#"
374            SELECT id, user_id, achievement_id, earned_at, progress_data, times_earned
375            FROM user_achievements
376            WHERE id = $1
377            "#,
378        )
379        .bind(id)
380        .fetch_optional(&self.pool)
381        .await
382        .map_err(|e| format!("Failed to find user achievement by id: {}", e))?;
383
384        row.as_ref().map(Self::row_to_user_achievement).transpose()
385    }
386
387    async fn find_by_user(&self, user_id: Uuid) -> Result<Vec<UserAchievement>, String> {
388        let rows = sqlx::query(
389            r#"
390            SELECT id, user_id, achievement_id, earned_at, progress_data, times_earned
391            FROM user_achievements
392            WHERE user_id = $1
393            ORDER BY earned_at DESC
394            "#,
395        )
396        .bind(user_id)
397        .fetch_all(&self.pool)
398        .await
399        .map_err(|e| format!("Failed to find user achievements: {}", e))?;
400
401        rows.iter().map(Self::row_to_user_achievement).collect()
402    }
403
404    async fn find_by_user_and_achievement(
405        &self,
406        user_id: Uuid,
407        achievement_id: Uuid,
408    ) -> Result<Option<UserAchievement>, String> {
409        let row = sqlx::query(
410            r#"
411            SELECT id, user_id, achievement_id, earned_at, progress_data, times_earned
412            FROM user_achievements
413            WHERE user_id = $1 AND achievement_id = $2
414            "#,
415        )
416        .bind(user_id)
417        .bind(achievement_id)
418        .fetch_optional(&self.pool)
419        .await
420        .map_err(|e| format!("Failed to find user achievement: {}", e))?;
421
422        row.as_ref().map(Self::row_to_user_achievement).transpose()
423    }
424
425    async fn update(&self, user_achievement: &UserAchievement) -> Result<UserAchievement, String> {
426        let result = sqlx::query(
427            r#"
428            UPDATE user_achievements
429            SET progress_data = $2,
430                times_earned = $3
431            WHERE id = $1
432            "#,
433        )
434        .bind(&user_achievement.id)
435        .bind(&user_achievement.progress_data)
436        .bind(&user_achievement.times_earned)
437        .execute(&self.pool)
438        .await
439        .map_err(|e| format!("Failed to update user achievement: {}", e))?;
440
441        if result.rows_affected() == 0 {
442            return Err("User achievement not found".to_string());
443        }
444
445        Ok(user_achievement.clone())
446    }
447
448    async fn calculate_total_points(&self, user_id: Uuid) -> Result<i32, String> {
449        let row = sqlx::query(
450            r#"
451            SELECT COALESCE(SUM(a.points_value * ua.times_earned), 0) as total_points
452            FROM user_achievements ua
453            JOIN achievements a ON a.id = ua.achievement_id
454            WHERE ua.user_id = $1
455            "#,
456        )
457        .bind(user_id)
458        .fetch_one(&self.pool)
459        .await
460        .map_err(|e| format!("Failed to calculate total points: {}", e))?;
461
462        let total_points: i64 = row
463            .try_get("total_points")
464            .map_err(|e| format!("Failed to get total_points: {}", e))?;
465
466        Ok(total_points as i32)
467    }
468
469    async fn count_by_user(&self, user_id: Uuid) -> Result<i64, String> {
470        let row = sqlx::query(
471            r#"
472            SELECT COUNT(*) as count
473            FROM user_achievements
474            WHERE user_id = $1
475            "#,
476        )
477        .bind(user_id)
478        .fetch_one(&self.pool)
479        .await
480        .map_err(|e| format!("Failed to count user achievements: {}", e))?;
481
482        let count: i64 = row
483            .try_get("count")
484            .map_err(|e| format!("Failed to get count: {}", e))?;
485
486        Ok(count)
487    }
488
489    async fn find_recent_by_user(
490        &self,
491        user_id: Uuid,
492        limit: i64,
493    ) -> Result<Vec<UserAchievement>, String> {
494        let rows = sqlx::query(
495            r#"
496            SELECT id, user_id, achievement_id, earned_at, progress_data, times_earned
497            FROM user_achievements
498            WHERE user_id = $1
499            ORDER BY earned_at DESC
500            LIMIT $2
501            "#,
502        )
503        .bind(user_id)
504        .bind(limit)
505        .fetch_all(&self.pool)
506        .await
507        .map_err(|e| format!("Failed to find recent user achievements: {}", e))?;
508
509        rows.iter().map(Self::row_to_user_achievement).collect()
510    }
511}