Skip to main content

koprogo_api/infrastructure/web/handlers/
gamification_handlers.rs

1use crate::application::dto::{
2    CreateAchievementDto, CreateChallengeDto, UpdateAchievementDto, UpdateChallengeDto,
3};
4use crate::domain::entities::{AchievementCategory, ChallengeStatus};
5use crate::infrastructure::web::app_state::AppState;
6use crate::infrastructure::web::classification_erreurs;
7use crate::infrastructure::web::middleware::scope_guard::{
8    verify_building_org_access, verify_challenge_org_access,
9};
10use crate::infrastructure::web::middleware::AuthenticatedUser;
11use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
12use serde::Deserialize;
13use uuid::Uuid;
14
15// ============================================================================
16// ACHIEVEMENT HANDLERS
17// ============================================================================
18
19/// Create a new achievement (admin only)
20///
21/// POST /achievements
22///
23/// # Request Body
24/// - organization_id: UUID
25/// - category: AchievementCategory
26/// - tier: AchievementTier
27/// - name: String (3-100 chars)
28/// - description: String (10-500 chars)
29/// - icon: String (emoji or URL)
30/// - points_value: i32 (0-1000)
31/// - requirements: String (JSON criteria)
32/// - is_secret: bool
33/// - is_repeatable: bool
34/// - display_order: i32
35///
36/// # Responses
37/// - 201 Created: Achievement created successfully
38/// - 400 Bad Request: Validation error
39#[post("/achievements")]
40pub async fn create_achievement(
41    data: web::Data<AppState>,
42    auth: AuthenticatedUser,
43    request: web::Json<CreateAchievementDto>,
44) -> impl Responder {
45    if !auth.is_superadmin() && auth.role != "syndic" {
46        return HttpResponse::Forbidden().json(serde_json::json!({
47            "error": "Only superadmin or syndic can create achievements"
48        }));
49    }
50    match data
51        .achievement_use_cases
52        .create_achievement(request.into_inner())
53        .await
54    {
55        Ok(achievement) => HttpResponse::Created().json(achievement),
56        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
57    }
58}
59
60/// Get achievement by ID
61///
62/// GET /achievements/:id
63///
64/// # Responses
65/// - 200 OK: Achievement details
66/// - 404 Not Found: Achievement not found
67#[get("/achievements/{id}")]
68pub async fn get_achievement(
69    data: web::Data<AppState>,
70    auth: AuthenticatedUser,
71    id: web::Path<Uuid>,
72) -> impl Responder {
73    match data
74        .achievement_use_cases
75        .get_achievement(id.into_inner())
76        .await
77    {
78        Ok(achievement) => {
79            // Cloisonnement (#882) : l'identité était prise et jetée (`_auth`).
80            // `Achievement.organization_id` est obligatoire, et `list_achievements`
81            // juste au-dessus le vérifie déjà — la lecture par id ne le faisait
82            // pas, alors qu'un badge `is_secret` d'une autre organisation n'a
83            // pas à être lisible.
84            if let Err(e) = auth.verify_org_access(achievement.organization_id) {
85                return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
86            }
87            HttpResponse::Ok().json(achievement)
88        }
89        Err(e) => HttpResponse::NotFound().json(serde_json::json!({"error": e})),
90    }
91}
92
93/// List all achievements for an organization
94///
95/// GET /organizations/:organization_id/achievements
96///
97/// # Responses
98/// - 200 OK: List of achievements
99#[get("/organizations/{organization_id}/achievements")]
100pub async fn list_achievements(
101    data: web::Data<AppState>,
102    auth: AuthenticatedUser,
103    organization_id: web::Path<Uuid>,
104) -> impl Responder {
105    if let Err(e) = auth.verify_org_access(*organization_id) {
106        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
107    }
108    match data
109        .achievement_use_cases
110        .list_achievements(organization_id.into_inner())
111        .await
112    {
113        Ok(achievements) => HttpResponse::Ok().json(achievements),
114        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
115    }
116}
117
118/// List achievements by category
119///
120/// GET /organizations/:organization_id/achievements/category/:category
121///
122/// # Responses
123/// - 200 OK: List of achievements in category
124#[get("/organizations/{organization_id}/achievements/category/{category}")]
125pub async fn list_achievements_by_category(
126    data: web::Data<AppState>,
127    auth: AuthenticatedUser,
128    path: web::Path<(Uuid, String)>,
129) -> impl Responder {
130    let (organization_id, category_str) = path.into_inner();
131    if let Err(e) = auth.verify_org_access(organization_id) {
132        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
133    }
134
135    // Parse category
136    let category: AchievementCategory = match serde_json::from_str(&format!("\"{}\"", category_str))
137    {
138        Ok(cat) => cat,
139        Err(_) => {
140            return HttpResponse::BadRequest()
141                .json(serde_json::json!({"error": "Invalid category"}))
142        }
143    };
144
145    match data
146        .achievement_use_cases
147        .list_achievements_by_category(organization_id, category)
148        .await
149    {
150        Ok(achievements) => HttpResponse::Ok().json(achievements),
151        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
152    }
153}
154
155/// List visible achievements for current user
156///
157/// GET /organizations/:organization_id/achievements/visible
158///
159/// # Responses
160/// - 200 OK: List of visible achievements (non-secret or already earned)
161#[get("/organizations/{organization_id}/achievements/visible")]
162pub async fn list_visible_achievements(
163    data: web::Data<AppState>,
164    auth: AuthenticatedUser,
165    organization_id: web::Path<Uuid>,
166) -> impl Responder {
167    if let Err(e) = auth.verify_org_access(*organization_id) {
168        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
169    }
170    match data
171        .achievement_use_cases
172        .list_visible_achievements(organization_id.into_inner(), auth.user_id)
173        .await
174    {
175        Ok(achievements) => HttpResponse::Ok().json(achievements),
176        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
177    }
178}
179
180/// Update achievement (admin only)
181///
182/// PUT /achievements/:id
183///
184/// # Responses
185/// - 200 OK: Achievement updated successfully
186/// - 400 Bad Request: Validation error
187/// - 404 Not Found: Achievement not found
188#[put("/achievements/{id}")]
189pub async fn update_achievement(
190    data: web::Data<AppState>,
191    auth: AuthenticatedUser,
192    id: web::Path<Uuid>,
193    request: web::Json<UpdateAchievementDto>,
194) -> impl Responder {
195    if !auth.is_superadmin() && auth.role != "syndic" {
196        return HttpResponse::Forbidden().json(serde_json::json!({
197            "error": "Only superadmin or syndic can update achievements"
198        }));
199    }
200    match data
201        .achievement_use_cases
202        .update_achievement(id.into_inner(), request.into_inner())
203        .await
204    {
205        Ok(achievement) => HttpResponse::Ok().json(achievement),
206        Err(e) if classification_erreurs::est_introuvable(&e) => {
207            HttpResponse::NotFound().json(serde_json::json!({"error": e}))
208        }
209        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
210    }
211}
212
213/// Delete achievement (admin only)
214///
215/// DELETE /achievements/:id
216///
217/// # Responses
218/// - 204 No Content: Achievement deleted successfully
219/// - 404 Not Found: Achievement not found
220#[delete("/achievements/{id}")]
221pub async fn delete_achievement(
222    data: web::Data<AppState>,
223    auth: AuthenticatedUser,
224    id: web::Path<Uuid>,
225) -> impl Responder {
226    if !auth.is_superadmin() && auth.role != "syndic" {
227        return HttpResponse::Forbidden().json(serde_json::json!({
228            "error": "Only superadmin or syndic can delete achievements"
229        }));
230    }
231    match data
232        .achievement_use_cases
233        .delete_achievement(id.into_inner())
234        .await
235    {
236        Ok(_) => HttpResponse::NoContent().finish(),
237        Err(e) => HttpResponse::NotFound().json(serde_json::json!({"error": e})),
238    }
239}
240
241// ============================================================================
242// USER ACHIEVEMENT HANDLERS
243// ============================================================================
244
245#[derive(Debug, Deserialize)]
246pub struct AwardAchievementRequest {
247    pub achievement_id: Uuid,
248    pub progress_data: Option<String>,
249}
250
251/// Award achievement to user
252///
253/// POST /users/achievements
254///
255/// # Request Body
256/// - achievement_id: UUID
257/// - progress_data: `Option<String>` (JSON)
258///
259/// # Responses
260/// - 201 Created: Achievement awarded successfully
261/// - 400 Bad Request: Already earned (non-repeatable) or validation error
262/// - 404 Not Found: Achievement not found
263#[post("/users/achievements")]
264pub async fn award_achievement(
265    data: web::Data<AppState>,
266    auth: AuthenticatedUser,
267    request: web::Json<AwardAchievementRequest>,
268) -> impl Responder {
269    let req = request.into_inner();
270    match data
271        .achievement_use_cases
272        .award_achievement(auth.user_id, req.achievement_id, req.progress_data)
273        .await
274    {
275        Ok(user_achievement) => HttpResponse::Created().json(user_achievement),
276        Err(e) if classification_erreurs::est_introuvable(&e) => {
277            HttpResponse::NotFound().json(serde_json::json!({"error": e}))
278        }
279        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
280    }
281}
282
283/// Get all achievements earned by current user
284///
285/// GET /users/achievements
286///
287/// # Responses
288/// - 200 OK: List of user achievements with enriched achievement data
289#[get("/users/achievements")]
290pub async fn get_user_achievements(
291    data: web::Data<AppState>,
292    auth: AuthenticatedUser,
293) -> impl Responder {
294    match data
295        .achievement_use_cases
296        .get_user_achievements(auth.user_id)
297        .await
298    {
299        Ok(achievements) => HttpResponse::Ok().json(achievements),
300        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
301    }
302}
303
304/// Get recent achievements for current user
305///
306/// GET /users/achievements/recent?limit=5
307///
308/// # Query Parameters
309/// - limit: i64 (default: 5)
310///
311/// # Responses
312/// - 200 OK: List of recent achievements
313#[get("/users/achievements/recent")]
314pub async fn get_recent_achievements(
315    data: web::Data<AppState>,
316    auth: AuthenticatedUser,
317    query: web::Query<serde_json::Value>,
318) -> impl Responder {
319    let limit = query.get("limit").and_then(|v| v.as_i64()).unwrap_or(5);
320
321    match data
322        .achievement_use_cases
323        .get_recent_achievements(auth.user_id, limit)
324        .await
325    {
326        Ok(achievements) => HttpResponse::Ok().json(achievements),
327        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
328    }
329}
330
331// ============================================================================
332// CHALLENGE HANDLERS
333// ============================================================================
334
335/// Create a new challenge (admin only)
336///
337/// POST /challenges
338///
339/// # Request Body
340/// - organization_id: UUID
341/// - building_id: `Option<UUID>` (null = organization-wide)
342/// - challenge_type: ChallengeType (Individual, Team, Building)
343/// - title: String (3-100 chars)
344/// - description: String (10-1000 chars)
345/// - icon: String
346/// - start_date: `DateTime<Utc>`
347/// - end_date: `DateTime<Utc>`
348/// - target_metric: String (e.g., "bookings_created")
349/// - target_value: i32
350/// - reward_points: i32 (0-10000)
351///
352/// # Responses
353/// - 201 Created: Challenge created successfully (Draft status)
354/// - 400 Bad Request: Validation error
355#[post("/challenges")]
356pub async fn create_challenge(
357    data: web::Data<AppState>,
358    auth: AuthenticatedUser,
359    request: web::Json<CreateChallengeDto>,
360) -> impl Responder {
361    if !auth.is_superadmin() && auth.role != "syndic" {
362        return HttpResponse::Forbidden().json(serde_json::json!({
363            "error": "Only superadmin or syndic can create challenges"
364        }));
365    }
366    match data
367        .challenge_use_cases
368        .create_challenge(request.into_inner())
369        .await
370    {
371        Ok(challenge) => HttpResponse::Created().json(challenge),
372        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
373    }
374}
375
376/// Get challenge by ID
377///
378/// GET /challenges/:id
379///
380/// # Responses
381/// - 200 OK: Challenge details
382/// - 404 Not Found: Challenge not found
383#[get("/challenges/{id}")]
384pub async fn get_challenge(
385    data: web::Data<AppState>,
386    auth: AuthenticatedUser,
387    id: web::Path<Uuid>,
388) -> impl Responder {
389    // Cloisonnement (#882) : l'identité était prise et jetée (`_auth`), alors
390    // que `verify_challenge_org_access` est déjà appelé trois fois plus bas
391    // dans ce même fichier pour la même entité.
392    if let Err(err) = verify_challenge_org_access(&auth, *id, &data.challenge_use_cases).await {
393        return err.error_response();
394    }
395
396    match data
397        .challenge_use_cases
398        .get_challenge(id.into_inner())
399        .await
400    {
401        Ok(challenge) => HttpResponse::Ok().json(challenge),
402        Err(e) => HttpResponse::NotFound().json(serde_json::json!({"error": e})),
403    }
404}
405
406/// List all challenges for an organization
407///
408/// GET /organizations/:organization_id/challenges
409///
410/// # Responses
411/// - 200 OK: List of challenges
412#[get("/organizations/{organization_id}/challenges")]
413pub async fn list_challenges(
414    data: web::Data<AppState>,
415    auth: AuthenticatedUser,
416    organization_id: web::Path<Uuid>,
417) -> impl Responder {
418    if let Err(e) = auth.verify_org_access(*organization_id) {
419        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
420    }
421    match data
422        .challenge_use_cases
423        .list_challenges(organization_id.into_inner())
424        .await
425    {
426        Ok(challenges) => HttpResponse::Ok().json(challenges),
427        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
428    }
429}
430
431/// List challenges by status
432///
433/// GET /organizations/:organization_id/challenges/status/:status
434///
435/// # Responses
436/// - 200 OK: List of challenges with specified status
437#[get("/organizations/{organization_id}/challenges/status/{status}")]
438pub async fn list_challenges_by_status(
439    data: web::Data<AppState>,
440    auth: AuthenticatedUser,
441    path: web::Path<(Uuid, String)>,
442) -> impl Responder {
443    let (organization_id, status_str) = path.into_inner();
444    if let Err(e) = auth.verify_org_access(organization_id) {
445        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
446    }
447
448    // Parse status
449    let status: ChallengeStatus = match serde_json::from_str(&format!("\"{}\"", status_str)) {
450        Ok(s) => s,
451        Err(_) => {
452            return HttpResponse::BadRequest().json(serde_json::json!({"error": "Invalid status"}))
453        }
454    };
455
456    match data
457        .challenge_use_cases
458        .list_challenges_by_status(organization_id, status)
459        .await
460    {
461        Ok(challenges) => HttpResponse::Ok().json(challenges),
462        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
463    }
464}
465
466/// List challenges for a building
467///
468/// GET /buildings/:building_id/challenges
469///
470/// # Responses
471/// - 200 OK: List of building challenges
472#[get("/buildings/{building_id}/challenges")]
473pub async fn list_building_challenges(
474    data: web::Data<AppState>,
475    auth: AuthenticatedUser,
476    building_id: web::Path<Uuid>,
477) -> impl Responder {
478    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
479    // utilisateur a le droit de voir (#772).
480    if let Err(err) = verify_building_org_access(
481        &auth,
482        *building_id,
483        &data.building_use_cases,
484        &data.acp_use_cases,
485    )
486    .await
487    {
488        return err.error_response();
489    }
490
491    match data
492        .challenge_use_cases
493        .list_building_challenges(building_id.into_inner())
494        .await
495    {
496        Ok(challenges) => HttpResponse::Ok().json(challenges),
497        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
498    }
499}
500
501/// List active challenges (Active status + date range)
502///
503/// GET /organizations/:organization_id/challenges/active
504///
505/// # Responses
506/// - 200 OK: List of currently active challenges
507#[get("/organizations/{organization_id}/challenges/active")]
508pub async fn list_active_challenges(
509    data: web::Data<AppState>,
510    auth: AuthenticatedUser,
511    organization_id: web::Path<Uuid>,
512) -> impl Responder {
513    if let Err(e) = auth.verify_org_access(*organization_id) {
514        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
515    }
516    match data
517        .challenge_use_cases
518        .list_active_challenges(organization_id.into_inner())
519        .await
520    {
521        Ok(challenges) => HttpResponse::Ok().json(challenges),
522        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
523    }
524}
525
526/// Update challenge (Draft only, admin only)
527///
528/// PUT /challenges/:id
529///
530/// # Responses
531/// - 200 OK: Challenge updated successfully
532/// - 400 Bad Request: Validation error or not Draft status
533/// - 404 Not Found: Challenge not found
534#[put("/challenges/{id}")]
535pub async fn update_challenge(
536    data: web::Data<AppState>,
537    auth: AuthenticatedUser,
538    id: web::Path<Uuid>,
539    request: web::Json<UpdateChallengeDto>,
540) -> impl Responder {
541    if !auth.is_superadmin() && auth.role != "syndic" {
542        return HttpResponse::Forbidden().json(serde_json::json!({
543            "error": "Only superadmin or syndic can update challenges"
544        }));
545    }
546    match data
547        .challenge_use_cases
548        .update_challenge(id.into_inner(), request.into_inner())
549        .await
550    {
551        Ok(challenge) => HttpResponse::Ok().json(challenge),
552        Err(e) if classification_erreurs::est_introuvable(&e) => {
553            HttpResponse::NotFound().json(serde_json::json!({"error": e}))
554        }
555        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
556    }
557}
558
559/// Activate challenge (Draft → Active, admin only)
560///
561/// PUT /challenges/:id/activate
562///
563/// # Responses
564/// - 200 OK: Challenge activated successfully
565/// - 400 Bad Request: Invalid state transition
566/// - 404 Not Found: Challenge not found
567#[put("/challenges/{id}/activate")]
568pub async fn activate_challenge(
569    data: web::Data<AppState>,
570    auth: AuthenticatedUser,
571    id: web::Path<Uuid>,
572) -> impl Responder {
573    if !auth.is_superadmin() && auth.role != "syndic" {
574        return HttpResponse::Forbidden().json(serde_json::json!({
575            "error": "Only superadmin or syndic can activate challenges"
576        }));
577    }
578    match data
579        .challenge_use_cases
580        .activate_challenge(id.into_inner())
581        .await
582    {
583        Ok(challenge) => HttpResponse::Ok().json(challenge),
584        Err(e) if classification_erreurs::est_introuvable(&e) => {
585            HttpResponse::NotFound().json(serde_json::json!({"error": e}))
586        }
587        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
588    }
589}
590
591/// Complete challenge (Active → Completed, admin only)
592///
593/// PUT /challenges/:id/complete
594///
595/// # Responses
596/// - 200 OK: Challenge completed successfully
597/// - 400 Bad Request: Invalid state transition
598/// - 404 Not Found: Challenge not found
599#[put("/challenges/{id}/complete")]
600pub async fn complete_challenge(
601    data: web::Data<AppState>,
602    auth: AuthenticatedUser,
603    id: web::Path<Uuid>,
604) -> impl Responder {
605    if !auth.is_superadmin() && auth.role != "syndic" {
606        return HttpResponse::Forbidden().json(serde_json::json!({
607            "error": "Only superadmin or syndic can complete challenges"
608        }));
609    }
610    match data
611        .challenge_use_cases
612        .complete_challenge(id.into_inner())
613        .await
614    {
615        Ok(challenge) => HttpResponse::Ok().json(challenge),
616        Err(e) if classification_erreurs::est_introuvable(&e) => {
617            HttpResponse::NotFound().json(serde_json::json!({"error": e}))
618        }
619        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
620    }
621}
622
623/// Cancel challenge (Draft/Active → Cancelled, admin only)
624///
625/// PUT /challenges/:id/cancel
626///
627/// # Responses
628/// - 200 OK: Challenge cancelled successfully
629/// - 400 Bad Request: Invalid state transition
630/// - 404 Not Found: Challenge not found
631#[put("/challenges/{id}/cancel")]
632pub async fn cancel_challenge(
633    data: web::Data<AppState>,
634    auth: AuthenticatedUser,
635    id: web::Path<Uuid>,
636) -> impl Responder {
637    if !auth.is_superadmin() && auth.role != "syndic" {
638        return HttpResponse::Forbidden().json(serde_json::json!({
639            "error": "Only superadmin or syndic can cancel challenges"
640        }));
641    }
642    match data
643        .challenge_use_cases
644        .cancel_challenge(id.into_inner())
645        .await
646    {
647        Ok(challenge) => HttpResponse::Ok().json(challenge),
648        Err(e) if classification_erreurs::est_introuvable(&e) => {
649            HttpResponse::NotFound().json(serde_json::json!({"error": e}))
650        }
651        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
652    }
653}
654
655/// Delete challenge (admin only)
656///
657/// DELETE /challenges/:id
658///
659/// # Responses
660/// - 204 No Content: Challenge deleted successfully
661/// - 404 Not Found: Challenge not found
662#[delete("/challenges/{id}")]
663pub async fn delete_challenge(
664    data: web::Data<AppState>,
665    auth: AuthenticatedUser,
666    id: web::Path<Uuid>,
667) -> impl Responder {
668    if !auth.is_superadmin() && auth.role != "syndic" {
669        return HttpResponse::Forbidden().json(serde_json::json!({
670            "error": "Only superadmin or syndic can delete challenges"
671        }));
672    }
673    match data
674        .challenge_use_cases
675        .delete_challenge(id.into_inner())
676        .await
677    {
678        Ok(_) => HttpResponse::NoContent().finish(),
679        Err(e) => HttpResponse::NotFound().json(serde_json::json!({"error": e})),
680    }
681}
682
683// ============================================================================
684// CHALLENGE PROGRESS HANDLERS
685// ============================================================================
686
687/// Get user progress for a challenge
688///
689/// GET /challenges/:challenge_id/progress
690///
691/// # Responses
692/// - 200 OK: User progress details
693/// - 404 Not Found: Progress or challenge not found
694#[get("/challenges/{challenge_id}/progress")]
695pub async fn get_challenge_progress(
696    data: web::Data<AppState>,
697    auth: AuthenticatedUser,
698    challenge_id: web::Path<Uuid>,
699) -> impl Responder {
700    // Cloisonnement : ce défi doit relever d'une organisation que cet
701    // utilisateur a le droit de voir. Le périmètre est l'ORGANISATION —
702    // `Challenge.building_id` est optionnel, « None = organization-wide »
703    // (#772).
704    if let Err(err) =
705        verify_challenge_org_access(&auth, *challenge_id, &data.challenge_use_cases).await
706    {
707        return err.error_response();
708    }
709
710    match data
711        .challenge_use_cases
712        .get_challenge_progress(auth.user_id, challenge_id.into_inner())
713        .await
714    {
715        Ok(progress) => HttpResponse::Ok().json(progress),
716        Err(e) => HttpResponse::NotFound().json(serde_json::json!({"error": e})),
717    }
718}
719
720/// List all progress for a challenge
721///
722/// GET /challenges/:challenge_id/all-progress
723///
724/// # Responses
725/// - 200 OK: List of all user progress for challenge
726#[get("/challenges/{challenge_id}/all-progress")]
727pub async fn list_challenge_progress(
728    data: web::Data<AppState>,
729    auth: AuthenticatedUser,
730    challenge_id: web::Path<Uuid>,
731) -> impl Responder {
732    // Cloisonnement : ce défi doit relever d'une organisation que cet
733    // utilisateur a le droit de voir. Le périmètre est l'ORGANISATION —
734    // `Challenge.building_id` est optionnel, « None = organization-wide »
735    // (#772).
736    if let Err(err) =
737        verify_challenge_org_access(&auth, *challenge_id, &data.challenge_use_cases).await
738    {
739        return err.error_response();
740    }
741
742    match data
743        .challenge_use_cases
744        .list_challenge_progress(challenge_id.into_inner())
745        .await
746    {
747        Ok(progress_list) => HttpResponse::Ok().json(progress_list),
748        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
749    }
750}
751
752/// List active challenges for current user with progress
753///
754/// GET /users/challenges/active
755///
756/// # Responses
757/// - 200 OK: List of active challenges with user progress
758#[get("/users/challenges/active")]
759pub async fn list_user_active_challenges(
760    data: web::Data<AppState>,
761    auth: AuthenticatedUser,
762) -> impl Responder {
763    match data
764        .challenge_use_cases
765        .list_user_active_progress(auth.user_id)
766        .await
767    {
768        Ok(progress_list) => HttpResponse::Ok().json(progress_list),
769        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
770    }
771}
772
773#[derive(Debug, Deserialize)]
774pub struct IncrementProgressRequest {
775    pub increment: i32,
776}
777
778/// Increment user progress for a challenge
779///
780/// POST /challenges/:challenge_id/progress/increment
781///
782/// # Request Body
783/// - increment: i32 (value to add to current progress)
784///
785/// # Responses
786/// - 200 OK: Progress incremented successfully (auto-completes if target reached)
787/// - 400 Bad Request: Validation error
788/// - 404 Not Found: Challenge not found
789#[post("/challenges/{challenge_id}/progress/increment")]
790pub async fn increment_progress(
791    data: web::Data<AppState>,
792    auth: AuthenticatedUser,
793    challenge_id: web::Path<Uuid>,
794    request: web::Json<IncrementProgressRequest>,
795) -> impl Responder {
796    // Cloisonnement : ce défi doit relever d'une organisation que cet
797    // utilisateur a le droit de voir. Le périmètre est l'ORGANISATION —
798    // `Challenge.building_id` est optionnel, « None = organization-wide »
799    // (#772).
800    if let Err(err) =
801        verify_challenge_org_access(&auth, *challenge_id, &data.challenge_use_cases).await
802    {
803        return err.error_response();
804    }
805
806    match data
807        .challenge_use_cases
808        .increment_progress(auth.user_id, challenge_id.into_inner(), request.increment)
809        .await
810    {
811        Ok(progress) => HttpResponse::Ok().json(progress),
812        Err(e) if classification_erreurs::est_introuvable(&e) => {
813            HttpResponse::NotFound().json(serde_json::json!({"error": e}))
814        }
815        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
816    }
817}
818
819// ============================================================================
820// GAMIFICATION STATS HANDLERS
821// ============================================================================
822
823/// Get comprehensive gamification stats for current user
824///
825/// GET /organizations/:organization_id/gamification/stats
826///
827/// # Responses
828/// - 200 OK: User gamification statistics
829#[get("/organizations/{organization_id}/gamification/stats")]
830pub async fn get_gamification_user_stats(
831    data: web::Data<AppState>,
832    auth: AuthenticatedUser,
833    organization_id: web::Path<Uuid>,
834) -> impl Responder {
835    if let Err(e) = auth.verify_org_access(*organization_id) {
836        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
837    }
838    match data
839        .gamification_stats_use_cases
840        .get_user_stats(auth.user_id, organization_id.into_inner())
841        .await
842    {
843        Ok(stats) => HttpResponse::Ok().json(stats),
844        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
845    }
846}
847
848/// Get leaderboard for organization or building
849///
850/// GET /organizations/:organization_id/gamification/leaderboard?building_id=`<uuid>`&limit=10
851///
852/// # Query Parameters
853/// - building_id: `Option<UUID>` (filter by building)
854/// - limit: `i64` (default: 10)
855///
856/// # Responses
857/// - 200 OK: Leaderboard with top users
858#[get("/organizations/{organization_id}/gamification/leaderboard")]
859pub async fn get_gamification_leaderboard(
860    data: web::Data<AppState>,
861    auth: AuthenticatedUser,
862    organization_id: web::Path<Uuid>,
863    query: web::Query<serde_json::Value>,
864) -> impl Responder {
865    if let Err(e) = auth.verify_org_access(*organization_id) {
866        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
867    }
868    let building_id = query
869        .get("building_id")
870        .and_then(|v| v.as_str())
871        .and_then(|s| Uuid::parse_str(s).ok());
872
873    let limit = query.get("limit").and_then(|v| v.as_i64()).unwrap_or(10);
874
875    match data
876        .gamification_stats_use_cases
877        .get_leaderboard(organization_id.into_inner(), building_id, limit)
878        .await
879    {
880        Ok(leaderboard) => HttpResponse::Ok().json(leaderboard),
881        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
882    }
883}