Skip to main content

koprogo_api/infrastructure/web/handlers/
acp_module_handlers.rs

1//! Handlers Actix du registre de modules — Story 5.1 (#585), ADR-0015.
2//!
3//! Endpoints :
4//! - GET `/acps/{id}/modules`                   : modules actifs (portée ACP)
5//! - PUT `/acps/{id}/modules/{module}/enable`   : allumer (admin + portée)
6//! - PUT `/acps/{id}/modules/{module}/disable`  : éteindre (admin + portée)
7//!
8//! Les chemins ne sont pas choisis ici : ils sont **imposés par la moitié
9//! frontend déjà livrée** (`modules.ts`, `OnboardingWizard.svelte`), écrite
10//! en attendant cette story. Les changer casserait un client en production.
11//!
12//! Le mapping `AuthenticatedUser → AcpCaller` est **réutilisé** depuis
13//! `acp_handlers` plutôt que recopié : deux mappings de rôles qui dérivent
14//! l'un de l'autre est une faille, pas une duplication anodine.
15
16use actix_web::{get, put, web, HttpResponse, Responder, ResponseError};
17use uuid::Uuid;
18
19use crate::application::dto::EnabledModulesResponseDto;
20use crate::application::use_cases::module_registry_use_cases::ModuleRegistryUseCases;
21use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
22use crate::infrastructure::web::handlers::acp_handlers::caller_from_user;
23use crate::infrastructure::web::{AppState, AuthenticatedUser};
24
25#[utoipa::path(
26    get,
27    path = "/acps/{id}/modules",
28    tag = "Acps",
29    summary = "Modules actifs d'une ACP (portée ACP)",
30    params(("id" = Uuid, Path, description = "ACP UUID")),
31    responses(
32        (status = 200, description = "Modules actifs", body = crate::application::dto::EnabledModulesResponseDto),
33        (status = 403, description = "Hors portée"),
34    ),
35    security(("bearer_auth" = []))
36)]
37#[get("/acps/{id}/modules")]
38pub async fn list_acp_modules(
39    state: web::Data<AppState>,
40    user: AuthenticatedUser,
41    id: web::Path<Uuid>,
42) -> impl Responder {
43    let acp_id = *id;
44    let caller = caller_from_user(&user);
45    match state
46        .module_registry_use_cases
47        .list_enabled(&caller, acp_id)
48        .await
49    {
50        Ok(modules) => HttpResponse::Ok().json(EnabledModulesResponseDto {
51            acp_id: acp_id.to_string(),
52            modules,
53        }),
54        Err(err) => err.error_response(),
55    }
56}
57
58#[utoipa::path(
59    put,
60    path = "/acps/{id}/modules/{module}/enable",
61    tag = "Acps",
62    summary = "Allumer un module pour une ACP (admin + portée)",
63    params(
64        ("id" = Uuid, Path, description = "ACP UUID"),
65        ("module" = String, Path, description = "Nom du module (ADR-0015)"),
66    ),
67    responses(
68        (status = 204, description = "Module actif"),
69        (status = 403, description = "Hors portée ou droits insuffisants"),
70        (status = 422, description = "Nom de module inconnu"),
71    ),
72    security(("bearer_auth" = []))
73)]
74#[put("/acps/{id}/modules/{module}/enable")]
75pub async fn enable_acp_module(
76    state: web::Data<AppState>,
77    user: AuthenticatedUser,
78    chemin: web::Path<(Uuid, String)>,
79) -> impl Responder {
80    let (acp_id, nom) = chemin.into_inner();
81    let module = match ModuleRegistryUseCases::reconnaitre(&nom) {
82        Ok(m) => m,
83        Err(err) => return err.error_response(),
84    };
85    let caller = caller_from_user(&user);
86    let issue = state
87        .module_registry_use_cases
88        .enable(&caller, acp_id, module)
89        .await;
90    journaliser(AuditEventType::AcpModuleEnabled, &user, acp_id, &issue);
91    match issue {
92        // 204 et non 200 : le client n'a pas besoin du corps, et le rendre
93        // obligerait à relire l'état pour l'afficher.
94        Ok(()) => HttpResponse::NoContent().finish(),
95        Err(err) => err.error_response(),
96    }
97}
98
99#[utoipa::path(
100    put,
101    path = "/acps/{id}/modules/{module}/disable",
102    tag = "Acps",
103    summary = "Éteindre un module pour une ACP (admin + portée)",
104    params(
105        ("id" = Uuid, Path, description = "ACP UUID"),
106        ("module" = String, Path, description = "Nom du module (ADR-0015)"),
107    ),
108    responses(
109        (status = 204, description = "Module éteint"),
110        (status = 403, description = "Hors portée, droits insuffisants, ou module toujours actif"),
111        (status = 422, description = "Nom de module inconnu"),
112    ),
113    security(("bearer_auth" = []))
114)]
115#[put("/acps/{id}/modules/{module}/disable")]
116pub async fn disable_acp_module(
117    state: web::Data<AppState>,
118    user: AuthenticatedUser,
119    chemin: web::Path<(Uuid, String)>,
120) -> impl Responder {
121    let (acp_id, nom) = chemin.into_inner();
122    let module = match ModuleRegistryUseCases::reconnaitre(&nom) {
123        Ok(m) => m,
124        Err(err) => return err.error_response(),
125    };
126    let caller = caller_from_user(&user);
127    let issue = state
128        .module_registry_use_cases
129        .disable(&caller, acp_id, module)
130        .await;
131    journaliser(AuditEventType::AcpModuleDisabled, &user, acp_id, &issue);
132    match issue {
133        Ok(()) => HttpResponse::NoContent().finish(),
134        Err(err) => err.error_response(),
135    }
136}
137
138/// Consigne la tentative, réussie **ou non**.
139///
140/// Éteindre un module fait disparaître des écrans pour tous les utilisateurs
141/// de l'ACP : la tentative refusée est aussi intéressante que celle qui
142/// aboutit (pattern des handlers ACP, traçabilité INV-24).
143fn journaliser(
144    evenement: AuditEventType,
145    user: &AuthenticatedUser,
146    acp_id: Uuid,
147    issue: &Result<(), crate::application::error::AppError>,
148) {
149    let entree = AuditLogEntry::new(evenement, Some(user.user_id), user.organization_id)
150        .with_resource("Acp", acp_id);
151    match issue {
152        Ok(()) => entree.log(),
153        Err(err) => entree.with_error(err.to_string()).log(),
154    }
155}