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 fn row_to_achievement(row: &sqlx::postgres::PgRow) -> Result<Achievement, String> {
19 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 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 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
309pub struct PostgresUserAchievementRepository {
314 pool: DbPool,
315}
316
317impl PostgresUserAchievementRepository {
318 pub fn new(pool: DbPool) -> Self {
319 Self { pool }
320 }
321
322 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}