Skip to main content

koprogo_api/infrastructure/web/handlers/
skill_handlers.rs

1use crate::application::dto::{CreateSkillDto, UpdateSkillDto};
2use crate::domain::entities::{ExpertiseLevel, SkillCategory};
3use crate::infrastructure::web::app_state::AppState;
4use crate::infrastructure::web::classification_erreurs;
5use crate::infrastructure::web::middleware::AuthenticatedUser;
6use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
7use uuid::Uuid;
8
9/// Create a new skill
10///
11/// POST /skills
12#[post("/skills")]
13pub async fn create_skill(
14    data: web::Data<AppState>,
15    auth: AuthenticatedUser,
16    request: web::Json<CreateSkillDto>,
17) -> impl Responder {
18    let org_id = match auth.require_organization() {
19        Ok(id) => id,
20        Err(e) => {
21            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
22        }
23    };
24    match data
25        .skill_use_cases
26        .create_skill(auth.user_id, org_id, request.into_inner())
27        .await
28    {
29        Ok(skill) => HttpResponse::Created().json(skill),
30        Err(e) => {
31            // Issue #781 — ce refus est un 403 (règle métier : offrir une
32            // compétence engage une personne, pas la copropriété), jamais un
33            // 400 (qui suggérerait une saisie invalide côté client). Le
34            // `kind` laisse le frontend router vers un message traduit dans
35            // les quatre locales sans dépendre du libellé français.
36            if classification_erreurs::est_refus_owner_requis(&e) {
37                HttpResponse::Forbidden().json(serde_json::json!({
38                    "error": e,
39                    "kind": "owner_profile_required",
40                }))
41            } else if classification_erreurs::est_interdit(&e) {
42                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
43            } else if classification_erreurs::est_introuvable(&e) {
44                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
45            } else {
46                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
47            }
48        }
49    }
50}
51
52/// Get skill by ID with owner name enrichment
53///
54/// GET /skills/:id
55#[get("/skills/{id}")]
56pub async fn get_skill(
57    data: web::Data<AppState>,
58    user: AuthenticatedUser,
59    id: web::Path<Uuid>,
60) -> impl Responder {
61    let identifiant = id.into_inner();
62
63    // Cette route ne prenait AUCUNE identité : n'importe qui pouvait la lire
64    // sur simple connaissance de l'identifiant. Le cliquet de #772 ne la
65    // voyait pas — il ne compte que les routes PRENANT une identité sans
66    // s'en servir. Cf. #845.
67    if let Err(err) = crate::infrastructure::web::middleware::scope_guard::verify_skill_org_access(
68        &user,
69        identifiant,
70        &data.skill_use_cases,
71        &data.building_use_cases,
72        &data.acp_use_cases,
73    )
74    .await
75    {
76        return err.error_response();
77    }
78
79    match data.skill_use_cases.get_skill(identifiant).await {
80        Ok(skill) => HttpResponse::Ok().json(skill),
81        Err(e) => {
82            if classification_erreurs::est_introuvable(&e) {
83                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
84            } else {
85                HttpResponse::InternalServerError().json(serde_json::json!({"error": e}))
86            }
87        }
88    }
89}
90
91/// List all skills for a building
92///
93/// GET /buildings/:building_id/skills
94#[get("/buildings/{building_id}/skills")]
95pub async fn list_building_skills(
96    data: web::Data<AppState>,
97    building_id: web::Path<Uuid>,
98    user: AuthenticatedUser,
99) -> impl Responder {
100    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
101    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
102    // un identifiant, sans demander d'identite.
103    if let Err(err) =
104        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
105            &user,
106            *building_id,
107            &data.building_use_cases,
108            &data.acp_use_cases,
109        )
110        .await
111    {
112        return err.error_response();
113    }
114
115    match data
116        .skill_use_cases
117        .list_building_skills(building_id.into_inner())
118        .await
119    {
120        Ok(skills) => HttpResponse::Ok().json(skills),
121        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
122    }
123}
124
125/// List available skills for a building (marketplace view)
126///
127/// GET /buildings/:building_id/skills/available
128#[get("/buildings/{building_id}/skills/available")]
129pub async fn list_available_skills(
130    data: web::Data<AppState>,
131    building_id: web::Path<Uuid>,
132    user: AuthenticatedUser,
133) -> impl Responder {
134    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
135    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
136    // un identifiant, sans demander d'identite.
137    if let Err(err) =
138        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
139            &user,
140            *building_id,
141            &data.building_use_cases,
142            &data.acp_use_cases,
143        )
144        .await
145    {
146        return err.error_response();
147    }
148
149    match data
150        .skill_use_cases
151        .list_available_skills(building_id.into_inner())
152        .await
153    {
154        Ok(skills) => HttpResponse::Ok().json(skills),
155        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
156    }
157}
158
159/// List free/volunteer skills for a building
160///
161/// GET /buildings/:building_id/skills/free
162#[get("/buildings/{building_id}/skills/free")]
163pub async fn list_free_skills(
164    data: web::Data<AppState>,
165    building_id: web::Path<Uuid>,
166    user: AuthenticatedUser,
167) -> impl Responder {
168    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
169    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
170    // un identifiant, sans demander d'identite.
171    if let Err(err) =
172        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
173            &user,
174            *building_id,
175            &data.building_use_cases,
176            &data.acp_use_cases,
177        )
178        .await
179    {
180        return err.error_response();
181    }
182
183    match data
184        .skill_use_cases
185        .list_free_skills(building_id.into_inner())
186        .await
187    {
188        Ok(skills) => HttpResponse::Ok().json(skills),
189        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
190    }
191}
192
193/// List professional skills for a building
194///
195/// GET /buildings/:building_id/skills/professional
196#[get("/buildings/{building_id}/skills/professional")]
197pub async fn list_professional_skills(
198    data: web::Data<AppState>,
199    building_id: web::Path<Uuid>,
200    user: AuthenticatedUser,
201) -> impl Responder {
202    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
203    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
204    // un identifiant, sans demander d'identite.
205    if let Err(err) =
206        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
207            &user,
208            *building_id,
209            &data.building_use_cases,
210            &data.acp_use_cases,
211        )
212        .await
213    {
214        return err.error_response();
215    }
216
217    match data
218        .skill_use_cases
219        .list_professional_skills(building_id.into_inner())
220        .await
221    {
222        Ok(skills) => HttpResponse::Ok().json(skills),
223        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
224    }
225}
226
227/// List skills by category (HomeRepair, Languages, Technology, etc.)
228///
229/// GET /buildings/:building_id/skills/category/:category
230#[get("/buildings/{building_id}/skills/category/{category}")]
231pub async fn list_skills_by_category(
232    data: web::Data<AppState>,
233    path: web::Path<(Uuid, String)>,
234    user: AuthenticatedUser,
235) -> impl Responder {
236    let (building_id, category_str) = path.into_inner();
237    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
238    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
239    // un identifiant, sans demander d'identite.
240    if let Err(err) =
241        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
242            &user,
243            building_id,
244            &data.building_use_cases,
245            &data.acp_use_cases,
246        )
247        .await
248    {
249        return err.error_response();
250    }
251
252    // Parse skill category
253    let category = match serde_json::from_str::<SkillCategory>(&format!("\"{}\"", category_str)) {
254        Ok(c) => c,
255        Err(_) => {
256            return HttpResponse::BadRequest().json(serde_json::json!({
257                "error": format!("Invalid skill category: {}. Valid categories: HomeRepair, Languages, Technology, Education, Arts, Sports, Cooking, Gardening, Health, Legal, Financial, PetCare, Other", category_str)
258            }))
259        }
260    };
261
262    match data
263        .skill_use_cases
264        .list_skills_by_category(building_id, category)
265        .await
266    {
267        Ok(skills) => HttpResponse::Ok().json(skills),
268        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
269    }
270}
271
272/// List skills by expertise level (Beginner, Intermediate, Advanced, Expert)
273///
274/// GET /buildings/:building_id/skills/expertise/:level
275#[get("/buildings/{building_id}/skills/expertise/{level}")]
276pub async fn list_skills_by_expertise(
277    data: web::Data<AppState>,
278    path: web::Path<(Uuid, String)>,
279    user: AuthenticatedUser,
280) -> impl Responder {
281    let (building_id, level_str) = path.into_inner();
282    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
283    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
284    // un identifiant, sans demander d'identite.
285    if let Err(err) =
286        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
287            &user,
288            building_id,
289            &data.building_use_cases,
290            &data.acp_use_cases,
291        )
292        .await
293    {
294        return err.error_response();
295    }
296
297    // Parse expertise level
298    let level = match serde_json::from_str::<ExpertiseLevel>(&format!("\"{}\"", level_str)) {
299        Ok(l) => l,
300        Err(_) => {
301            return HttpResponse::BadRequest().json(serde_json::json!({
302                "error": format!("Invalid expertise level: {}. Valid levels: Beginner, Intermediate, Advanced, Expert", level_str)
303            }))
304        }
305    };
306
307    match data
308        .skill_use_cases
309        .list_skills_by_expertise(building_id, level)
310        .await
311    {
312        Ok(skills) => HttpResponse::Ok().json(skills),
313        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
314    }
315}
316
317/// List all skills created by an owner
318///
319/// GET /owners/:owner_id/skills
320#[get("/owners/{owner_id}/skills")]
321pub async fn list_owner_skills(
322    data: web::Data<AppState>,
323    owner_id: web::Path<Uuid>,
324    user: AuthenticatedUser,
325) -> impl Responder {
326    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
327    // servait la situation financiere NOMINATIVE d'une personne a quiconque
328    // connaissait son identifiant.
329    if let Err(err) = crate::infrastructure::web::middleware::scope_guard::verify_owner_org_access(
330        &user,
331        *owner_id,
332        &data.owner_use_cases,
333    )
334    .await
335    {
336        return err.error_response();
337    }
338
339    match data
340        .skill_use_cases
341        .list_owner_skills(owner_id.into_inner())
342        .await
343    {
344        Ok(skills) => HttpResponse::Ok().json(skills),
345        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
346    }
347}
348
349/// Update a skill
350///
351/// PUT /skills/:id
352#[put("/skills/{id}")]
353pub async fn update_skill(
354    data: web::Data<AppState>,
355    auth: AuthenticatedUser,
356    id: web::Path<Uuid>,
357    request: web::Json<UpdateSkillDto>,
358) -> impl Responder {
359    let org_id = match auth.require_organization() {
360        Ok(id) => id,
361        Err(e) => {
362            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
363        }
364    };
365    match data
366        .skill_use_cases
367        .update_skill(id.into_inner(), auth.user_id, org_id, request.into_inner())
368        .await
369    {
370        Ok(skill) => HttpResponse::Ok().json(skill),
371        Err(e) => {
372            if classification_erreurs::est_interdit(&e) {
373                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
374            } else if classification_erreurs::est_introuvable(&e) {
375                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
376            } else {
377                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
378            }
379        }
380    }
381}
382
383/// Mark skill as available for help
384///
385/// POST /skills/:id/mark-available
386#[post("/skills/{id}/mark-available")]
387pub async fn mark_skill_available(
388    data: web::Data<AppState>,
389    auth: AuthenticatedUser,
390    id: web::Path<Uuid>,
391) -> impl Responder {
392    let org_id = match auth.require_organization() {
393        Ok(id) => id,
394        Err(e) => {
395            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
396        }
397    };
398    match data
399        .skill_use_cases
400        .mark_skill_available(id.into_inner(), auth.user_id, org_id)
401        .await
402    {
403        Ok(skill) => HttpResponse::Ok().json(skill),
404        Err(e) => {
405            if classification_erreurs::est_interdit(&e) {
406                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
407            } else if classification_erreurs::est_introuvable(&e) {
408                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
409            } else {
410                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
411            }
412        }
413    }
414}
415
416/// Mark skill as unavailable for help
417///
418/// POST /skills/:id/mark-unavailable
419#[post("/skills/{id}/mark-unavailable")]
420pub async fn mark_skill_unavailable(
421    data: web::Data<AppState>,
422    auth: AuthenticatedUser,
423    id: web::Path<Uuid>,
424) -> impl Responder {
425    let org_id = match auth.require_organization() {
426        Ok(id) => id,
427        Err(e) => {
428            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
429        }
430    };
431    match data
432        .skill_use_cases
433        .mark_skill_unavailable(id.into_inner(), auth.user_id, org_id)
434        .await
435    {
436        Ok(skill) => HttpResponse::Ok().json(skill),
437        Err(e) => {
438            if classification_erreurs::est_interdit(&e) {
439                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
440            } else if classification_erreurs::est_introuvable(&e) {
441                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
442            } else {
443                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
444            }
445        }
446    }
447}
448
449/// Delete a skill
450///
451/// DELETE /skills/:id
452#[delete("/skills/{id}")]
453pub async fn delete_skill(
454    data: web::Data<AppState>,
455    auth: AuthenticatedUser,
456    id: web::Path<Uuid>,
457) -> impl Responder {
458    let org_id = match auth.require_organization() {
459        Ok(id) => id,
460        Err(e) => {
461            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
462        }
463    };
464    match data
465        .skill_use_cases
466        .delete_skill(id.into_inner(), auth.user_id, org_id)
467        .await
468    {
469        Ok(_) => HttpResponse::NoContent().finish(),
470        Err(e) => {
471            if classification_erreurs::est_interdit(&e) {
472                HttpResponse::Forbidden().json(serde_json::json!({"error": e}))
473            } else if classification_erreurs::est_introuvable(&e) {
474                HttpResponse::NotFound().json(serde_json::json!({"error": e}))
475            } else {
476                HttpResponse::BadRequest().json(serde_json::json!({"error": e}))
477            }
478        }
479    }
480}
481
482/// Get skill statistics for a building
483///
484/// GET /buildings/:building_id/skills/statistics
485#[get("/buildings/{building_id}/skills/statistics")]
486pub async fn get_skill_statistics(
487    data: web::Data<AppState>,
488    building_id: web::Path<Uuid>,
489    user: AuthenticatedUser,
490) -> impl Responder {
491    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
492    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
493    // un identifiant, sans demander d'identite.
494    if let Err(err) =
495        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
496            &user,
497            *building_id,
498            &data.building_use_cases,
499            &data.acp_use_cases,
500        )
501        .await
502    {
503        return err.error_response();
504    }
505
506    match data
507        .skill_use_cases
508        .get_skill_statistics(building_id.into_inner())
509        .await
510    {
511        Ok(stats) => HttpResponse::Ok().json(stats),
512        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
513    }
514}