Skip to main content

koprogo_api/infrastructure/web/handlers/
iot_grid_handlers.rs

1use crate::application::use_cases::boinc_use_cases::SubmitOptimisationTaskDto;
2use crate::infrastructure::web::classification_erreurs;
3use crate::infrastructure::web::middleware::scope_guard::verify_owner_org_access;
4use crate::infrastructure::web::middleware::AuthenticatedUser;
5use crate::infrastructure::web::AppState;
6use actix_web::{delete, get, post, web, HttpRequest, HttpResponse, ResponseError, Result};
7use serde::Deserialize;
8use uuid::Uuid;
9
10/// L'appelant peut-il agir au nom de cette organisation ?
11///
12/// Les routes de calcul distribué reçoivent leur `organization_id` dans le
13/// CORPS de la requête, et non en chemin : aucun garde de `scope_guard` ne
14/// s'applique, puisqu'ils partent tous d'un identifiant de ressource.
15fn user_ne_peut_pas(auth: &AuthenticatedUser, organisation: uuid::Uuid) -> bool {
16    auth.verify_org_access(organisation).is_err()
17}
18
19// ─────────────────────────────────────────────────────────────────────────────
20// MQTT Control Endpoints
21// ─────────────────────────────────────────────────────────────────────────────
22
23/// Démarre le listener MQTT Home Assistant.
24///
25/// POST /api/v1/iot/mqtt/start
26/// Requiert rôle: syndic ou superadmin
27#[post("/iot/mqtt/start")]
28pub async fn start_mqtt_listener(
29    state: web::Data<AppState>,
30    auth: AuthenticatedUser,
31) -> Result<HttpResponse> {
32    // Le commentaire de cette route annonce « Requiert rôle: syndic ou
33    // superadmin » depuis toujours. Rien ne le vérifiait : la règle n'existait
34    // qu'en prose, et l'identité était prise puis ignorée — `_auth` (#772).
35    //
36    // Démarrer ou arrêter la passerelle MQTT coupe la collecte de mesures pour
37    // TOUTES les copropriétés à la fois. Ce n'est pas une action de
38    // copropriétaire.
39    if !auth.is_superadmin() && auth.role != "syndic" {
40        return Ok(HttpResponse::Forbidden().json(serde_json::json!({
41            "error": "Réservé au syndic et à l'administration de la plateforme"
42        })));
43    }
44
45    match state.mqtt_energy_adapter.start_listening().await {
46        Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({
47            "status": "started",
48            "message": "MQTT listener started successfully"
49        }))),
50        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
51            "error": e.to_string()
52        }))),
53    }
54}
55
56/// Arrête le listener MQTT Home Assistant.
57///
58/// POST /api/v1/iot/mqtt/stop
59#[post("/iot/mqtt/stop")]
60pub async fn stop_mqtt_listener(
61    state: web::Data<AppState>,
62    auth: AuthenticatedUser,
63) -> Result<HttpResponse> {
64    // Le commentaire de cette route annonce « Requiert rôle: syndic ou
65    // superadmin » depuis toujours. Rien ne le vérifiait : la règle n'existait
66    // qu'en prose, et l'identité était prise puis ignorée — `_auth` (#772).
67    //
68    // Démarrer ou arrêter la passerelle MQTT coupe la collecte de mesures pour
69    // TOUTES les copropriétés à la fois. Ce n'est pas une action de
70    // copropriétaire.
71    if !auth.is_superadmin() && auth.role != "syndic" {
72        return Ok(HttpResponse::Forbidden().json(serde_json::json!({
73            "error": "Réservé au syndic et à l'administration de la plateforme"
74        })));
75    }
76
77    match state.mqtt_energy_adapter.stop_listening().await {
78        Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({
79            "status": "stopped",
80            "message": "MQTT listener stopped"
81        }))),
82        Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
83            "error": e.to_string()
84        }))),
85    }
86}
87
88/// Statut du listener MQTT.
89///
90/// GET /api/v1/iot/mqtt/status
91#[get("/iot/mqtt/status")]
92pub async fn mqtt_status(
93    state: web::Data<AppState>,
94    auth: AuthenticatedUser,
95) -> Result<HttpResponse> {
96    // Démarrer, arrêter ou interroger la passerelle MQTT agit sur
97    // l'infrastructure de collecte, pas sur les données d'une copropriété :
98    // c'est une opération de plateforme. L'identité était prise puis ignorée
99    // — `_auth` (#772).
100    if !auth.is_superadmin() {
101        return Ok(HttpResponse::Forbidden().json(serde_json::json!({
102            "error": "Réservé à l'administration de la plateforme"
103        })));
104    }
105
106    let running = state.mqtt_energy_adapter.is_running().await;
107    Ok(HttpResponse::Ok().json(serde_json::json!({
108        "running": running,
109        "topic": std::env::var("MQTT_TOPIC").unwrap_or_else(|_| "koprogo/+/energy/#".to_string())
110    })))
111}
112
113// ─────────────────────────────────────────────────────────────────────────────
114// BOINC Grid Consent Endpoints (GDPR)
115// ─────────────────────────────────────────────────────────────────────────────
116
117#[derive(Deserialize)]
118pub struct ConsentRequest {
119    pub owner_id: Uuid,
120    pub organization_id: Uuid,
121    /// true = accorder, false = révoquer
122    pub granted: bool,
123}
124
125/// Accorder ou révoquer le consentement BOINC (GDPR Art. 7).
126///
127/// POST /api/v1/iot/grid/consent
128///
129/// Body: { "owner_id": "uuid", "organization_id": "uuid", "granted": true|false }
130#[post("/iot/grid/consent")]
131pub async fn update_grid_consent(
132    state: web::Data<AppState>,
133    body: web::Json<ConsentRequest>,
134    req: HttpRequest,
135    auth: AuthenticatedUser,
136) -> Result<HttpResponse> {
137    // Cloisonnement : l'organisation visée est celle du CORPS de la requête,
138    // et rien ne vérifiait qu'elle soit celle de l'appelant. On pouvait donc
139    // enregistrer un consentement, ou soumettre un calcul, au nom d'une autre
140    // organisation (#772).
141    if user_ne_peut_pas(&auth, body.organization_id) {
142        return Ok(
143            actix_web::HttpResponse::Forbidden().json(serde_json::json!({
144                "error": "Cette organisation n'est pas la vôtre"
145            })),
146        );
147    }
148
149    let ip = req
150        .connection_info()
151        .realip_remote_addr()
152        .map(|s| s.chars().take(45).collect::<String>());
153
154    if body.granted {
155        match state
156            .boinc_use_cases
157            .grant_consent(body.owner_id, body.organization_id, ip.as_deref())
158            .await
159        {
160            Ok(consent) => Ok(HttpResponse::Ok().json(consent)),
161            Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
162                "error": e
163            }))),
164        }
165    } else {
166        match state.boinc_use_cases.revoke_consent(body.owner_id).await {
167            Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({
168                "status": "consent_revoked",
169                "owner_id": body.owner_id
170            }))),
171            Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
172                "error": e
173            }))),
174        }
175    }
176}
177
178/// Récupère le consentement BOINC courant d'un propriétaire.
179///
180/// GET /api/v1/iot/grid/consent/{owner_id}
181#[get("/iot/grid/consent/{owner_id}")]
182pub async fn get_grid_consent(
183    state: web::Data<AppState>,
184    path: web::Path<Uuid>,
185    auth: AuthenticatedUser,
186) -> Result<HttpResponse> {
187    // Cloisonnement : le consentement au calcul distribué est une donnée
188    // personnelle, rattachée à un copropriétaire NOMMÉ. La lire hors de son
189    // organisation, c'est apprendre d'un tiers ce qu'il a accepté ou refusé.
190    //
191    // L'identité était prise puis ignorée — `_auth` (#772).
192    if let Err(err) = verify_owner_org_access(&auth, *path, &state.owner_use_cases).await {
193        return Ok(err.error_response());
194    }
195
196    match state.boinc_use_cases.get_consent(*path).await {
197        Ok(Some(consent)) => Ok(HttpResponse::Ok().json(consent)),
198        Ok(None) => Ok(HttpResponse::Ok().json(serde_json::json!({
199            "owner_id": *path,
200            "granted": false,
201            "message": "No consent record found"
202        }))),
203        Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
204            "error": e
205        }))),
206    }
207}
208
209// ─────────────────────────────────────────────────────────────────────────────
210// BOINC Grid Task Endpoints
211// ─────────────────────────────────────────────────────────────────────────────
212
213/// Soumet une tâche d'optimisation énergétique groupée à BOINC.
214///
215/// POST /api/v1/iot/grid/tasks
216///
217/// Body: { "building_id": "uuid", "owner_id": "uuid", "organization_id": "uuid", "simulation_months": 12 }
218/// Requiert consentement BOINC préalable (GDPR).
219#[post("/iot/grid/tasks")]
220pub async fn submit_grid_task(
221    state: web::Data<AppState>,
222    body: web::Json<SubmitOptimisationTaskDto>,
223    auth: AuthenticatedUser,
224) -> Result<HttpResponse> {
225    // Cloisonnement : l'organisation visée est celle du CORPS de la requête,
226    // et rien ne vérifiait qu'elle soit celle de l'appelant. On pouvait donc
227    // enregistrer un consentement, ou soumettre un calcul, au nom d'une autre
228    // organisation (#772).
229    if user_ne_peut_pas(&auth, body.organization_id) {
230        return Ok(
231            actix_web::HttpResponse::Forbidden().json(serde_json::json!({
232                "error": "Cette organisation n'est pas la vôtre"
233            })),
234        );
235    }
236
237    match state
238        .boinc_use_cases
239        .submit_optimisation_task(body.into_inner())
240        .await
241    {
242        Ok(resp) => Ok(HttpResponse::Created().json(resp)),
243        Err(e) if e.contains("not consented") => {
244            Ok(HttpResponse::Forbidden().json(serde_json::json!({
245                "error": e,
246                "hint": "Grant BOINC consent first via POST /iot/grid/consent"
247            })))
248        }
249        Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
250            "error": e
251        }))),
252    }
253}
254
255/// Récupère le statut d'une tâche BOINC.
256///
257/// GET /api/v1/iot/grid/tasks/{task_id}
258#[get("/iot/grid/tasks/{task_id}")]
259pub async fn get_task_status(
260    state: web::Data<AppState>,
261    path: web::Path<String>,
262    auth: AuthenticatedUser,
263) -> Result<HttpResponse> {
264    // Cette route agit sur une tâche de calcul distribué identifiée par un
265    // jeton EXTERNE (BOINC), sans périmètre de copropriété : il n'y a pas de
266    // chaîne à remonter. Le contrôle qui vaut est donc celui du rôle — c'est
267    // une opération d'infrastructure, comme la passerelle MQTT (#772).
268    if !auth.is_superadmin() && auth.role != "syndic" {
269        return Ok(
270            actix_web::HttpResponse::Forbidden().json(serde_json::json!({
271                "error": "Réservé au syndic et à l'administration de la plateforme"
272            })),
273        );
274    }
275
276    match state.boinc_use_cases.poll_task(&path).await {
277        Ok(status) => Ok(HttpResponse::Ok().json(status)),
278        Err(e) if classification_erreurs::est_introuvable(&e) => {
279            Ok(HttpResponse::NotFound().json(serde_json::json!({
280                "error": e
281            })))
282        }
283        Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
284            "error": e
285        }))),
286    }
287}
288
289/// Annule une tâche BOINC en cours.
290///
291/// DELETE /api/v1/iot/grid/tasks/{task_id}
292#[delete("/iot/grid/tasks/{task_id}")]
293pub async fn cancel_grid_task(
294    state: web::Data<AppState>,
295    path: web::Path<String>,
296    auth: AuthenticatedUser,
297) -> Result<HttpResponse> {
298    // Cette route agit sur une tâche de calcul distribué identifiée par un
299    // jeton EXTERNE (BOINC), sans périmètre de copropriété : il n'y a pas de
300    // chaîne à remonter. Le contrôle qui vaut est donc celui du rôle — c'est
301    // une opération d'infrastructure, comme la passerelle MQTT (#772).
302    if !auth.is_superadmin() && auth.role != "syndic" {
303        return Ok(
304            actix_web::HttpResponse::Forbidden().json(serde_json::json!({
305                "error": "Réservé au syndic et à l'administration de la plateforme"
306            })),
307        );
308    }
309
310    match state.boinc_use_cases.cancel_task(&path).await {
311        Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({
312            "status": "cancelled",
313            "task_id": *path
314        }))),
315        Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
316            "error": e
317        }))),
318    }
319}