Skip to main content

koprogo_api/infrastructure/web/handlers/
iot_handlers.rs

1//! Relevés IoT et compteurs Linky.
2//!
3//! # Le périmètre de ces routes (#864)
4//!
5//! Huit routes de ce module portaient `let _ = auth; // Authentication
6//! required`. La ligne disait vrai et ne protégeait rien : l'extracteur refuse
7//! bien un appel anonyme, mais **l'immeuble est fourni par l'appelant**. Tout
8//! utilisateur authentifié lisait donc les relevés de n'importe quel immeuble
9//! du produit en connaissant son UUID.
10//!
11//! Une courbe de consommation électrique n'est pas une donnée neutre : elle dit
12//! quand le logement est occupé, et quand il ne l'est pas.
13//!
14//! Deux régimes s'appliquent désormais, selon ce que la route parcourt :
15//!
16//! - **route portant un `building_id`** → `verify_building_org_access`, qui
17//!   rattache l'immeuble à l'organisation de l'appelant ;
18//! - **balayage sans immeuble** (`needing-sync`, `expired-tokens`) → réservé au
19//!   superadministrateur, puisqu'il traverse toutes les organisations.
20//!
21//! Les routes d'écriture, elles, passaient déjà `organization_id` au cas
22//! d'usage : elles n'ont pas changé.
23
24use crate::application::dto::{
25    ConfigureLinkyDeviceDto, CreateIoTReadingDto, QueryIoTReadingsDto, SyncLinkyDataDto,
26};
27use crate::domain::entities::{DeviceType, MetricType};
28use crate::infrastructure::web::middleware::scope_guard::verify_building_org_access;
29use crate::infrastructure::web::middleware::AuthenticatedUser;
30use crate::infrastructure::web::AppState;
31use actix_web::{error::ErrorBadRequest, web, HttpResponse, ResponseError, Result};
32use chrono::{DateTime, Utc};
33use uuid::Uuid;
34
35// ============================================================================
36// IoT Readings Handlers
37// ============================================================================
38
39/// Create a single IoT reading
40///
41/// POST /api/v1/iot/readings
42///
43/// Request body:
44/// ```json
45/// {
46///   "building_id": "uuid",
47///   "device_type": "Linky",
48///   "metric_type": "ElectricityConsumption",
49///   "value": 15.5,
50///   "unit": "kWh",
51///   "timestamp": "2024-01-01T00:00:00Z",
52///   "source": "Enedis",
53///   "metadata": {"prm": "12345678901234"}
54/// }
55/// ```
56pub async fn create_iot_reading(
57    auth: AuthenticatedUser,
58    dto: web::Json<CreateIoTReadingDto>,
59    state: web::Data<AppState>,
60) -> Result<HttpResponse> {
61    let organization_id = auth
62        .organization_id
63        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
64
65    match state
66        .iot_use_cases
67        .create_reading(dto.into_inner(), auth.user_id, organization_id)
68        .await
69    {
70        Ok(reading) => Ok(HttpResponse::Created().json(reading)),
71        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
72            "error": e
73        }))),
74    }
75}
76
77/// Create multiple IoT readings in bulk
78///
79/// POST /api/v1/iot/readings/bulk
80///
81/// Request body:
82/// ```json
83/// [
84///   {
85///     "building_id": "uuid",
86///     "device_type": "Linky",
87///     "metric_type": "ElectricityConsumption",
88///     "value": 15.5,
89///     "unit": "kWh",
90///     "timestamp": "2024-01-01T00:00:00Z",
91///     "source": "Enedis",
92///     "metadata": null
93///   },
94///   ...
95/// ]
96/// ```
97pub async fn create_iot_readings_bulk(
98    auth: AuthenticatedUser,
99    dtos: web::Json<Vec<CreateIoTReadingDto>>,
100    state: web::Data<AppState>,
101) -> Result<HttpResponse> {
102    let organization_id = auth
103        .organization_id
104        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
105
106    match state
107        .iot_use_cases
108        .create_readings_bulk(dtos.into_inner(), auth.user_id, organization_id)
109        .await
110    {
111        Ok(count) => Ok(HttpResponse::Created().json(serde_json::json!({
112            "count": count,
113            "message": format!("{} IoT readings created", count)
114        }))),
115        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
116            "error": e
117        }))),
118    }
119}
120
121/// Query IoT readings with filters
122///
123/// GET /api/v1/iot/readings?building_id=uuid&device_type=Linky&metric_type=ElectricityConsumption&start_date=2024-01-01&end_date=2024-01-31&limit=100
124pub async fn query_iot_readings(
125    auth: AuthenticatedUser,
126    query: web::Query<QueryIoTReadingsDto>,
127    state: web::Data<AppState>,
128) -> Result<HttpResponse> {
129    // #864 — voir la note « Le périmètre de ces routes » en tête de module.
130    let requete = query.into_inner();
131    if let Err(err) = verify_building_org_access(
132        &auth,
133        requete.building_id,
134        &state.building_use_cases,
135        &state.acp_use_cases,
136    )
137    .await
138    {
139        return Ok(err.error_response());
140    }
141
142    match state.iot_use_cases.query_readings(requete).await {
143        Ok(readings) => Ok(HttpResponse::Ok().json(readings)),
144        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
145            "error": e
146        }))),
147    }
148}
149
150/// Get consumption statistics for a building
151///
152/// GET /api/v1/iot/buildings/{building_id}/consumption/stats?metric_type=ElectricityConsumption&start_date=2024-01-01&end_date=2024-01-31
153pub async fn get_consumption_stats(
154    auth: AuthenticatedUser,
155    path: web::Path<Uuid>,
156    query: web::Query<serde_json::Value>,
157    state: web::Data<AppState>,
158) -> Result<HttpResponse> {
159    // #864 — voir la note « Le périmètre de ces routes » en tête de module.
160    let building_id = path.into_inner();
161    if let Err(err) = verify_building_org_access(
162        &auth,
163        building_id,
164        &state.building_use_cases,
165        &state.acp_use_cases,
166    )
167    .await
168    {
169        return Ok(err.error_response());
170    }
171
172    let metric_type_str = query
173        .get("metric_type")
174        .and_then(|v| v.as_str())
175        .ok_or_else(|| ErrorBadRequest("metric_type query param required"))?;
176    let metric_type: MetricType = metric_type_str
177        .parse()
178        .map_err(|_| ErrorBadRequest("Invalid metric_type"))?;
179
180    let start_date_str = query
181        .get("start_date")
182        .and_then(|v| v.as_str())
183        .ok_or_else(|| ErrorBadRequest("start_date query param required"))?;
184    let start_date: DateTime<Utc> = start_date_str
185        .parse()
186        .map_err(|_| ErrorBadRequest("Invalid start_date format (use ISO 8601)"))?;
187
188    let end_date_str = query
189        .get("end_date")
190        .and_then(|v| v.as_str())
191        .ok_or_else(|| ErrorBadRequest("end_date query param required"))?;
192    let end_date: DateTime<Utc> = end_date_str
193        .parse()
194        .map_err(|_| ErrorBadRequest("Invalid end_date format (use ISO 8601)"))?;
195
196    match state
197        .iot_use_cases
198        .get_consumption_stats(building_id, metric_type, start_date, end_date)
199        .await
200    {
201        Ok(stats) => Ok(HttpResponse::Ok().json(stats)),
202        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
203            "error": e
204        }))),
205    }
206}
207
208/// Get daily aggregates for a building
209///
210/// GET /api/v1/iot/buildings/{building_id}/consumption/daily?device_type=Linky&metric_type=ElectricityConsumption&start_date=2024-01-01&end_date=2024-01-31
211pub async fn get_daily_aggregates(
212    auth: AuthenticatedUser,
213    path: web::Path<Uuid>,
214    query: web::Query<serde_json::Value>,
215    state: web::Data<AppState>,
216) -> Result<HttpResponse> {
217    // #864 — voir la note « Le périmètre de ces routes » en tête de module.
218    let building_id = path.into_inner();
219    if let Err(err) = verify_building_org_access(
220        &auth,
221        building_id,
222        &state.building_use_cases,
223        &state.acp_use_cases,
224    )
225    .await
226    {
227        return Ok(err.error_response());
228    }
229
230    let device_type_str = query
231        .get("device_type")
232        .and_then(|v| v.as_str())
233        .ok_or_else(|| ErrorBadRequest("device_type query param required"))?;
234    let device_type: DeviceType = device_type_str
235        .parse()
236        .map_err(|_| ErrorBadRequest("Invalid device_type"))?;
237
238    let metric_type_str = query
239        .get("metric_type")
240        .and_then(|v| v.as_str())
241        .ok_or_else(|| ErrorBadRequest("metric_type query param required"))?;
242    let metric_type: MetricType = metric_type_str
243        .parse()
244        .map_err(|_| ErrorBadRequest("Invalid metric_type"))?;
245
246    let start_date_str = query
247        .get("start_date")
248        .and_then(|v| v.as_str())
249        .ok_or_else(|| ErrorBadRequest("start_date query param required"))?;
250    let start_date: DateTime<Utc> = start_date_str
251        .parse()
252        .map_err(|_| ErrorBadRequest("Invalid start_date format (use ISO 8601)"))?;
253
254    let end_date_str = query
255        .get("end_date")
256        .and_then(|v| v.as_str())
257        .ok_or_else(|| ErrorBadRequest("end_date query param required"))?;
258    let end_date: DateTime<Utc> = end_date_str
259        .parse()
260        .map_err(|_| ErrorBadRequest("Invalid end_date format (use ISO 8601)"))?;
261
262    match state
263        .iot_use_cases
264        .get_daily_aggregates(building_id, device_type, metric_type, start_date, end_date)
265        .await
266    {
267        Ok(aggregates) => Ok(HttpResponse::Ok().json(aggregates)),
268        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
269            "error": e
270        }))),
271    }
272}
273
274/// Get monthly aggregates for a building
275///
276/// GET /api/v1/iot/buildings/{building_id}/consumption/monthly?device_type=Linky&metric_type=ElectricityConsumption&start_date=2024-01-01&end_date=2024-12-31
277pub async fn get_monthly_aggregates(
278    auth: AuthenticatedUser,
279    path: web::Path<Uuid>,
280    query: web::Query<serde_json::Value>,
281    state: web::Data<AppState>,
282) -> Result<HttpResponse> {
283    // #864 — voir la note « Le périmètre de ces routes » en tête de module.
284    let building_id = path.into_inner();
285    if let Err(err) = verify_building_org_access(
286        &auth,
287        building_id,
288        &state.building_use_cases,
289        &state.acp_use_cases,
290    )
291    .await
292    {
293        return Ok(err.error_response());
294    }
295
296    let device_type_str = query
297        .get("device_type")
298        .and_then(|v| v.as_str())
299        .ok_or_else(|| ErrorBadRequest("device_type query param required"))?;
300    let device_type: DeviceType = device_type_str
301        .parse()
302        .map_err(|_| ErrorBadRequest("Invalid device_type"))?;
303
304    let metric_type_str = query
305        .get("metric_type")
306        .and_then(|v| v.as_str())
307        .ok_or_else(|| ErrorBadRequest("metric_type query param required"))?;
308    let metric_type: MetricType = metric_type_str
309        .parse()
310        .map_err(|_| ErrorBadRequest("Invalid metric_type"))?;
311
312    let start_date_str = query
313        .get("start_date")
314        .and_then(|v| v.as_str())
315        .ok_or_else(|| ErrorBadRequest("start_date query param required"))?;
316    let start_date: DateTime<Utc> = start_date_str
317        .parse()
318        .map_err(|_| ErrorBadRequest("Invalid start_date format (use ISO 8601)"))?;
319
320    let end_date_str = query
321        .get("end_date")
322        .and_then(|v| v.as_str())
323        .ok_or_else(|| ErrorBadRequest("end_date query param required"))?;
324    let end_date: DateTime<Utc> = end_date_str
325        .parse()
326        .map_err(|_| ErrorBadRequest("Invalid end_date format (use ISO 8601)"))?;
327
328    match state
329        .iot_use_cases
330        .get_monthly_aggregates(building_id, device_type, metric_type, start_date, end_date)
331        .await
332    {
333        Ok(aggregates) => Ok(HttpResponse::Ok().json(aggregates)),
334        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
335            "error": e
336        }))),
337    }
338}
339
340/// Detect consumption anomalies for a building
341///
342/// GET /api/v1/iot/buildings/{building_id}/consumption/anomalies?metric_type=ElectricityConsumption&threshold_percentage=30&lookback_days=30
343pub async fn detect_anomalies(
344    auth: AuthenticatedUser,
345    path: web::Path<Uuid>,
346    query: web::Query<serde_json::Value>,
347    state: web::Data<AppState>,
348) -> Result<HttpResponse> {
349    // #864 — voir la note « Le périmètre de ces routes » en tête de module.
350    let building_id = path.into_inner();
351    if let Err(err) = verify_building_org_access(
352        &auth,
353        building_id,
354        &state.building_use_cases,
355        &state.acp_use_cases,
356    )
357    .await
358    {
359        return Ok(err.error_response());
360    }
361
362    let metric_type_str = query
363        .get("metric_type")
364        .and_then(|v| v.as_str())
365        .ok_or_else(|| ErrorBadRequest("metric_type query param required"))?;
366    let metric_type: MetricType = metric_type_str
367        .parse()
368        .map_err(|_| ErrorBadRequest("Invalid metric_type"))?;
369
370    let threshold_percentage = query
371        .get("threshold_percentage")
372        .and_then(|v| v.as_f64())
373        .unwrap_or(30.0);
374
375    let lookback_days = query
376        .get("lookback_days")
377        .and_then(|v| v.as_i64())
378        .unwrap_or(30);
379
380    match state
381        .iot_use_cases
382        .detect_anomalies(
383            building_id,
384            metric_type,
385            threshold_percentage,
386            lookback_days,
387        )
388        .await
389    {
390        Ok(anomalies) => Ok(HttpResponse::Ok().json(anomalies)),
391        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
392            "error": e
393        }))),
394    }
395}
396
397// ============================================================================
398// Linky Device Handlers
399// ============================================================================
400
401/// Configure a Linky device for a building
402///
403/// POST /api/v1/iot/linky/devices
404///
405/// Request body:
406/// ```json
407/// {
408///   "building_id": "uuid",
409///   "prm": "12345678901234",
410///   "provider": "Enedis",
411///   "authorization_code": "abc123..."
412/// }
413/// ```
414pub async fn configure_linky_device(
415    auth: AuthenticatedUser,
416    dto: web::Json<ConfigureLinkyDeviceDto>,
417    state: web::Data<AppState>,
418) -> Result<HttpResponse> {
419    let organization_id = auth
420        .organization_id
421        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
422
423    match state
424        .linky_use_cases
425        .configure_linky_device(dto.into_inner(), auth.user_id, organization_id)
426        .await
427    {
428        Ok(device) => Ok(HttpResponse::Created().json(device)),
429        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
430            "error": e
431        }))),
432    }
433}
434
435/// Get Linky device for a building
436///
437/// GET /api/v1/iot/linky/buildings/{building_id}/device
438pub async fn get_linky_device(
439    auth: AuthenticatedUser,
440    path: web::Path<Uuid>,
441    state: web::Data<AppState>,
442) -> Result<HttpResponse> {
443    // #864 — voir la note « Le périmètre de ces routes » en tête de module.
444    let building_id = path.into_inner();
445    if let Err(err) = verify_building_org_access(
446        &auth,
447        building_id,
448        &state.building_use_cases,
449        &state.acp_use_cases,
450    )
451    .await
452    {
453        return Ok(err.error_response());
454    }
455
456    match state.linky_use_cases.get_linky_device(building_id).await {
457        Ok(device) => Ok(HttpResponse::Ok().json(device)),
458        Err(e) => Ok(HttpResponse::NotFound().json(serde_json::json!({
459            "error": e
460        }))),
461    }
462}
463
464/// Delete Linky device for a building
465///
466/// DELETE /api/v1/iot/linky/buildings/{building_id}/device
467pub async fn delete_linky_device(
468    auth: AuthenticatedUser,
469    path: web::Path<Uuid>,
470    state: web::Data<AppState>,
471) -> Result<HttpResponse> {
472    let building_id = path.into_inner();
473    let organization_id = auth
474        .organization_id
475        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
476
477    match state
478        .linky_use_cases
479        .delete_linky_device(building_id, auth.user_id, organization_id)
480        .await
481    {
482        Ok(_) => Ok(HttpResponse::NoContent().finish()),
483        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
484            "error": e
485        }))),
486    }
487}
488
489/// Sync Linky data for a building
490///
491/// POST /api/v1/iot/linky/buildings/{building_id}/sync
492///
493/// Request body:
494/// ```json
495/// {
496///   "building_id": "uuid",
497///   "start_date": "2024-01-01T00:00:00Z",
498///   "end_date": "2024-01-31T23:59:59Z"
499/// }
500/// ```
501pub async fn sync_linky_data(
502    auth: AuthenticatedUser,
503    path: web::Path<Uuid>,
504    dto: web::Json<SyncLinkyDataDto>,
505    state: web::Data<AppState>,
506) -> Result<HttpResponse> {
507    let _building_id = path.into_inner();
508    let organization_id = auth
509        .organization_id
510        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
511
512    match state
513        .linky_use_cases
514        .sync_linky_data(dto.into_inner(), auth.user_id, organization_id)
515        .await
516    {
517        Ok(sync_result) => Ok(HttpResponse::Ok().json(sync_result)),
518        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
519            "error": e
520        }))),
521    }
522}
523
524/// Toggle sync for a Linky device
525///
526/// PUT /api/v1/iot/linky/buildings/{building_id}/sync/toggle
527///
528/// Request body:
529/// ```json
530/// {
531///   "enabled": true
532/// }
533/// ```
534pub async fn toggle_linky_sync(
535    auth: AuthenticatedUser,
536    path: web::Path<Uuid>,
537    body: web::Json<serde_json::Value>,
538    state: web::Data<AppState>,
539) -> Result<HttpResponse> {
540    let building_id = path.into_inner();
541    let enabled = body
542        .get("enabled")
543        .and_then(|v| v.as_bool())
544        .ok_or_else(|| ErrorBadRequest("enabled field required (boolean)"))?;
545    let organization_id = auth
546        .organization_id
547        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
548
549    match state
550        .linky_use_cases
551        .toggle_sync(building_id, enabled, auth.user_id, organization_id)
552        .await
553    {
554        Ok(device) => Ok(HttpResponse::Ok().json(device)),
555        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
556            "error": e
557        }))),
558    }
559}
560
561/// Find Linky devices needing sync
562///
563/// GET /api/v1/iot/linky/devices/needing-sync
564pub async fn find_devices_needing_sync(
565    auth: AuthenticatedUser,
566    state: web::Data<AppState>,
567) -> Result<HttpResponse> {
568    // #864 — balayage inter-organisations : superadministrateur seulement.
569    if !auth.is_superadmin() {
570        return Ok(HttpResponse::Forbidden().json(serde_json::json!({
571            "error": "Accès refusé : ce relevé porte sur toutes les organisations."
572        })));
573    }
574
575    match state.linky_use_cases.find_devices_needing_sync().await {
576        Ok(devices) => Ok(HttpResponse::Ok().json(devices)),
577        Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
578            "error": e
579        }))),
580    }
581}
582
583/// Find Linky devices with expired tokens
584///
585/// GET /api/v1/iot/linky/devices/expired-tokens
586pub async fn find_devices_with_expired_tokens(
587    auth: AuthenticatedUser,
588    state: web::Data<AppState>,
589) -> Result<HttpResponse> {
590    // #864 — balayage inter-organisations : superadministrateur seulement.
591    if !auth.is_superadmin() {
592        return Ok(HttpResponse::Forbidden().json(serde_json::json!({
593            "error": "Accès refusé : ce relevé porte sur toutes les organisations."
594        })));
595    }
596
597    match state
598        .linky_use_cases
599        .find_devices_with_expired_tokens()
600        .await
601    {
602        Ok(devices) => Ok(HttpResponse::Ok().json(devices)),
603        Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
604            "error": e
605        }))),
606    }
607}
608
609/// Configure IoT routes
610pub fn configure_iot_routes(cfg: &mut web::ServiceConfig) {
611    cfg.service(
612        web::scope("/iot")
613            // IoT Readings
614            .route("/readings", web::post().to(create_iot_reading))
615            .route("/readings/bulk", web::post().to(create_iot_readings_bulk))
616            .route("/readings", web::get().to(query_iot_readings))
617            .route(
618                "/buildings/{building_id}/consumption/stats",
619                web::get().to(get_consumption_stats),
620            )
621            .route(
622                "/buildings/{building_id}/consumption/daily",
623                web::get().to(get_daily_aggregates),
624            )
625            .route(
626                "/buildings/{building_id}/consumption/monthly",
627                web::get().to(get_monthly_aggregates),
628            )
629            .route(
630                "/buildings/{building_id}/consumption/anomalies",
631                web::get().to(detect_anomalies),
632            )
633            // Linky Devices
634            .route("/linky/devices", web::post().to(configure_linky_device))
635            .route(
636                "/linky/buildings/{building_id}/device",
637                web::get().to(get_linky_device),
638            )
639            .route(
640                "/linky/buildings/{building_id}/device",
641                web::delete().to(delete_linky_device),
642            )
643            .route(
644                "/linky/buildings/{building_id}/sync",
645                web::post().to(sync_linky_data),
646            )
647            .route(
648                "/linky/buildings/{building_id}/sync/toggle",
649                web::put().to(toggle_linky_sync),
650            )
651            .route(
652                "/linky/devices/needing-sync",
653                web::get().to(find_devices_needing_sync),
654            )
655            .route(
656                "/linky/devices/expired-tokens",
657                web::get().to(find_devices_with_expired_tokens),
658            ),
659    );
660}