koprogo_api/infrastructure/web/handlers/
iot_handlers.rs

1use crate::application::dto::{
2    ConfigureLinkyDeviceDto, CreateIoTReadingDto, QueryIoTReadingsDto, SyncLinkyDataDto,
3};
4use crate::application::use_cases::{IoTUseCases, LinkyUseCases};
5use crate::domain::entities::{DeviceType, MetricType};
6use crate::infrastructure::web::middleware::AuthenticatedUser;
7use actix_web::{error::ErrorBadRequest, web, HttpResponse, Result};
8use chrono::{DateTime, Utc};
9use std::sync::Arc;
10use uuid::Uuid;
11
12// ============================================================================
13// IoT Readings Handlers
14// ============================================================================
15
16/// Create a single IoT reading
17///
18/// POST /api/v1/iot/readings
19///
20/// Request body:
21/// ```json
22/// {
23///   "building_id": "uuid",
24///   "device_type": "Linky",
25///   "metric_type": "ElectricityConsumption",
26///   "value": 15.5,
27///   "unit": "kWh",
28///   "timestamp": "2024-01-01T00:00:00Z",
29///   "source": "Enedis",
30///   "metadata": {"prm": "12345678901234"}
31/// }
32/// ```
33pub async fn create_iot_reading(
34    auth: AuthenticatedUser,
35    dto: web::Json<CreateIoTReadingDto>,
36    iot_use_cases: web::Data<Arc<IoTUseCases>>,
37) -> Result<HttpResponse> {
38    let organization_id = auth
39        .organization_id
40        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
41
42    match iot_use_cases
43        .create_reading(dto.into_inner(), auth.user_id, organization_id)
44        .await
45    {
46        Ok(reading) => Ok(HttpResponse::Created().json(reading)),
47        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
48            "error": e
49        }))),
50    }
51}
52
53/// Create multiple IoT readings in bulk
54///
55/// POST /api/v1/iot/readings/bulk
56///
57/// Request body:
58/// ```json
59/// [
60///   {
61///     "building_id": "uuid",
62///     "device_type": "Linky",
63///     "metric_type": "ElectricityConsumption",
64///     "value": 15.5,
65///     "unit": "kWh",
66///     "timestamp": "2024-01-01T00:00:00Z",
67///     "source": "Enedis",
68///     "metadata": null
69///   },
70///   ...
71/// ]
72/// ```
73pub async fn create_iot_readings_bulk(
74    auth: AuthenticatedUser,
75    dtos: web::Json<Vec<CreateIoTReadingDto>>,
76    iot_use_cases: web::Data<Arc<IoTUseCases>>,
77) -> Result<HttpResponse> {
78    let organization_id = auth
79        .organization_id
80        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
81
82    match iot_use_cases
83        .create_readings_bulk(dtos.into_inner(), auth.user_id, organization_id)
84        .await
85    {
86        Ok(count) => Ok(HttpResponse::Created().json(serde_json::json!({
87            "count": count,
88            "message": format!("{} IoT readings created", count)
89        }))),
90        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
91            "error": e
92        }))),
93    }
94}
95
96/// Query IoT readings with filters
97///
98/// 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
99pub async fn query_iot_readings(
100    auth: AuthenticatedUser,
101    query: web::Query<QueryIoTReadingsDto>,
102    iot_use_cases: web::Data<Arc<IoTUseCases>>,
103) -> Result<HttpResponse> {
104    let _ = auth; // Authentication required
105    match iot_use_cases.query_readings(query.into_inner()).await {
106        Ok(readings) => Ok(HttpResponse::Ok().json(readings)),
107        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
108            "error": e
109        }))),
110    }
111}
112
113/// Get consumption statistics for a building
114///
115/// GET /api/v1/iot/buildings/{building_id}/consumption/stats?metric_type=ElectricityConsumption&start_date=2024-01-01&end_date=2024-01-31
116pub async fn get_consumption_stats(
117    auth: AuthenticatedUser,
118    path: web::Path<Uuid>,
119    query: web::Query<serde_json::Value>,
120    iot_use_cases: web::Data<Arc<IoTUseCases>>,
121) -> Result<HttpResponse> {
122    let _ = auth; // Authentication required
123    let building_id = path.into_inner();
124
125    let metric_type_str = query
126        .get("metric_type")
127        .and_then(|v| v.as_str())
128        .ok_or_else(|| ErrorBadRequest("metric_type query param required"))?;
129    let metric_type: MetricType = metric_type_str
130        .parse()
131        .map_err(|_| ErrorBadRequest("Invalid metric_type"))?;
132
133    let start_date_str = query
134        .get("start_date")
135        .and_then(|v| v.as_str())
136        .ok_or_else(|| ErrorBadRequest("start_date query param required"))?;
137    let start_date: DateTime<Utc> = start_date_str
138        .parse()
139        .map_err(|_| ErrorBadRequest("Invalid start_date format (use ISO 8601)"))?;
140
141    let end_date_str = query
142        .get("end_date")
143        .and_then(|v| v.as_str())
144        .ok_or_else(|| ErrorBadRequest("end_date query param required"))?;
145    let end_date: DateTime<Utc> = end_date_str
146        .parse()
147        .map_err(|_| ErrorBadRequest("Invalid end_date format (use ISO 8601)"))?;
148
149    match iot_use_cases
150        .get_consumption_stats(building_id, metric_type, start_date, end_date)
151        .await
152    {
153        Ok(stats) => Ok(HttpResponse::Ok().json(stats)),
154        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
155            "error": e
156        }))),
157    }
158}
159
160/// Get daily aggregates for a building
161///
162/// 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
163pub async fn get_daily_aggregates(
164    auth: AuthenticatedUser,
165    path: web::Path<Uuid>,
166    query: web::Query<serde_json::Value>,
167    iot_use_cases: web::Data<Arc<IoTUseCases>>,
168) -> Result<HttpResponse> {
169    let _ = auth; // Authentication required
170    let building_id = path.into_inner();
171
172    let device_type_str = query
173        .get("device_type")
174        .and_then(|v| v.as_str())
175        .ok_or_else(|| ErrorBadRequest("device_type query param required"))?;
176    let device_type: DeviceType = device_type_str
177        .parse()
178        .map_err(|_| ErrorBadRequest("Invalid device_type"))?;
179
180    let metric_type_str = query
181        .get("metric_type")
182        .and_then(|v| v.as_str())
183        .ok_or_else(|| ErrorBadRequest("metric_type query param required"))?;
184    let metric_type: MetricType = metric_type_str
185        .parse()
186        .map_err(|_| ErrorBadRequest("Invalid metric_type"))?;
187
188    let start_date_str = query
189        .get("start_date")
190        .and_then(|v| v.as_str())
191        .ok_or_else(|| ErrorBadRequest("start_date query param required"))?;
192    let start_date: DateTime<Utc> = start_date_str
193        .parse()
194        .map_err(|_| ErrorBadRequest("Invalid start_date format (use ISO 8601)"))?;
195
196    let end_date_str = query
197        .get("end_date")
198        .and_then(|v| v.as_str())
199        .ok_or_else(|| ErrorBadRequest("end_date query param required"))?;
200    let end_date: DateTime<Utc> = end_date_str
201        .parse()
202        .map_err(|_| ErrorBadRequest("Invalid end_date format (use ISO 8601)"))?;
203
204    match iot_use_cases
205        .get_daily_aggregates(building_id, device_type, metric_type, start_date, end_date)
206        .await
207    {
208        Ok(aggregates) => Ok(HttpResponse::Ok().json(aggregates)),
209        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
210            "error": e
211        }))),
212    }
213}
214
215/// Get monthly aggregates for a building
216///
217/// 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
218pub async fn get_monthly_aggregates(
219    auth: AuthenticatedUser,
220    path: web::Path<Uuid>,
221    query: web::Query<serde_json::Value>,
222    iot_use_cases: web::Data<Arc<IoTUseCases>>,
223) -> Result<HttpResponse> {
224    let _ = auth; // Authentication required
225    let building_id = path.into_inner();
226
227    let device_type_str = query
228        .get("device_type")
229        .and_then(|v| v.as_str())
230        .ok_or_else(|| ErrorBadRequest("device_type query param required"))?;
231    let device_type: DeviceType = device_type_str
232        .parse()
233        .map_err(|_| ErrorBadRequest("Invalid device_type"))?;
234
235    let metric_type_str = query
236        .get("metric_type")
237        .and_then(|v| v.as_str())
238        .ok_or_else(|| ErrorBadRequest("metric_type query param required"))?;
239    let metric_type: MetricType = metric_type_str
240        .parse()
241        .map_err(|_| ErrorBadRequest("Invalid metric_type"))?;
242
243    let start_date_str = query
244        .get("start_date")
245        .and_then(|v| v.as_str())
246        .ok_or_else(|| ErrorBadRequest("start_date query param required"))?;
247    let start_date: DateTime<Utc> = start_date_str
248        .parse()
249        .map_err(|_| ErrorBadRequest("Invalid start_date format (use ISO 8601)"))?;
250
251    let end_date_str = query
252        .get("end_date")
253        .and_then(|v| v.as_str())
254        .ok_or_else(|| ErrorBadRequest("end_date query param required"))?;
255    let end_date: DateTime<Utc> = end_date_str
256        .parse()
257        .map_err(|_| ErrorBadRequest("Invalid end_date format (use ISO 8601)"))?;
258
259    match iot_use_cases
260        .get_monthly_aggregates(building_id, device_type, metric_type, start_date, end_date)
261        .await
262    {
263        Ok(aggregates) => Ok(HttpResponse::Ok().json(aggregates)),
264        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
265            "error": e
266        }))),
267    }
268}
269
270/// Detect consumption anomalies for a building
271///
272/// GET /api/v1/iot/buildings/{building_id}/consumption/anomalies?metric_type=ElectricityConsumption&threshold_percentage=30&lookback_days=30
273pub async fn detect_anomalies(
274    auth: AuthenticatedUser,
275    path: web::Path<Uuid>,
276    query: web::Query<serde_json::Value>,
277    iot_use_cases: web::Data<Arc<IoTUseCases>>,
278) -> Result<HttpResponse> {
279    let _ = auth; // Authentication required
280    let building_id = path.into_inner();
281
282    let metric_type_str = query
283        .get("metric_type")
284        .and_then(|v| v.as_str())
285        .ok_or_else(|| ErrorBadRequest("metric_type query param required"))?;
286    let metric_type: MetricType = metric_type_str
287        .parse()
288        .map_err(|_| ErrorBadRequest("Invalid metric_type"))?;
289
290    let threshold_percentage = query
291        .get("threshold_percentage")
292        .and_then(|v| v.as_f64())
293        .unwrap_or(30.0);
294
295    let lookback_days = query
296        .get("lookback_days")
297        .and_then(|v| v.as_i64())
298        .unwrap_or(30);
299
300    match iot_use_cases
301        .detect_anomalies(
302            building_id,
303            metric_type,
304            threshold_percentage,
305            lookback_days,
306        )
307        .await
308    {
309        Ok(anomalies) => Ok(HttpResponse::Ok().json(anomalies)),
310        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
311            "error": e
312        }))),
313    }
314}
315
316// ============================================================================
317// Linky Device Handlers
318// ============================================================================
319
320/// Configure a Linky device for a building
321///
322/// POST /api/v1/iot/linky/devices
323///
324/// Request body:
325/// ```json
326/// {
327///   "building_id": "uuid",
328///   "prm": "12345678901234",
329///   "provider": "Enedis",
330///   "authorization_code": "abc123..."
331/// }
332/// ```
333pub async fn configure_linky_device(
334    auth: AuthenticatedUser,
335    dto: web::Json<ConfigureLinkyDeviceDto>,
336    linky_use_cases: web::Data<Arc<LinkyUseCases>>,
337) -> Result<HttpResponse> {
338    let organization_id = auth
339        .organization_id
340        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
341
342    match linky_use_cases
343        .configure_linky_device(dto.into_inner(), auth.user_id, organization_id)
344        .await
345    {
346        Ok(device) => Ok(HttpResponse::Created().json(device)),
347        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
348            "error": e
349        }))),
350    }
351}
352
353/// Get Linky device for a building
354///
355/// GET /api/v1/iot/linky/buildings/{building_id}/device
356pub async fn get_linky_device(
357    auth: AuthenticatedUser,
358    path: web::Path<Uuid>,
359    linky_use_cases: web::Data<Arc<LinkyUseCases>>,
360) -> Result<HttpResponse> {
361    let _ = auth; // Authentication required
362    let building_id = path.into_inner();
363
364    match linky_use_cases.get_linky_device(building_id).await {
365        Ok(device) => Ok(HttpResponse::Ok().json(device)),
366        Err(e) => Ok(HttpResponse::NotFound().json(serde_json::json!({
367            "error": e
368        }))),
369    }
370}
371
372/// Delete Linky device for a building
373///
374/// DELETE /api/v1/iot/linky/buildings/{building_id}/device
375pub async fn delete_linky_device(
376    auth: AuthenticatedUser,
377    path: web::Path<Uuid>,
378    linky_use_cases: web::Data<Arc<LinkyUseCases>>,
379) -> Result<HttpResponse> {
380    let building_id = path.into_inner();
381    let organization_id = auth
382        .organization_id
383        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
384
385    match linky_use_cases
386        .delete_linky_device(building_id, auth.user_id, organization_id)
387        .await
388    {
389        Ok(_) => Ok(HttpResponse::NoContent().finish()),
390        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
391            "error": e
392        }))),
393    }
394}
395
396/// Sync Linky data for a building
397///
398/// POST /api/v1/iot/linky/buildings/{building_id}/sync
399///
400/// Request body:
401/// ```json
402/// {
403///   "building_id": "uuid",
404///   "start_date": "2024-01-01T00:00:00Z",
405///   "end_date": "2024-01-31T23:59:59Z"
406/// }
407/// ```
408pub async fn sync_linky_data(
409    auth: AuthenticatedUser,
410    path: web::Path<Uuid>,
411    dto: web::Json<SyncLinkyDataDto>,
412    linky_use_cases: web::Data<Arc<LinkyUseCases>>,
413) -> Result<HttpResponse> {
414    let _building_id = path.into_inner();
415    let organization_id = auth
416        .organization_id
417        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
418
419    match linky_use_cases
420        .sync_linky_data(dto.into_inner(), auth.user_id, organization_id)
421        .await
422    {
423        Ok(sync_result) => Ok(HttpResponse::Ok().json(sync_result)),
424        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
425            "error": e
426        }))),
427    }
428}
429
430/// Toggle sync for a Linky device
431///
432/// PUT /api/v1/iot/linky/buildings/{building_id}/sync/toggle
433///
434/// Request body:
435/// ```json
436/// {
437///   "enabled": true
438/// }
439/// ```
440pub async fn toggle_linky_sync(
441    auth: AuthenticatedUser,
442    path: web::Path<Uuid>,
443    body: web::Json<serde_json::Value>,
444    linky_use_cases: web::Data<Arc<LinkyUseCases>>,
445) -> Result<HttpResponse> {
446    let building_id = path.into_inner();
447    let enabled = body
448        .get("enabled")
449        .and_then(|v| v.as_bool())
450        .ok_or_else(|| ErrorBadRequest("enabled field required (boolean)"))?;
451    let organization_id = auth
452        .organization_id
453        .ok_or_else(|| ErrorBadRequest("Organization ID is required"))?;
454
455    match linky_use_cases
456        .toggle_sync(building_id, enabled, auth.user_id, organization_id)
457        .await
458    {
459        Ok(device) => Ok(HttpResponse::Ok().json(device)),
460        Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({
461            "error": e
462        }))),
463    }
464}
465
466/// Find Linky devices needing sync
467///
468/// GET /api/v1/iot/linky/devices/needing-sync
469pub async fn find_devices_needing_sync(
470    auth: AuthenticatedUser,
471    linky_use_cases: web::Data<Arc<LinkyUseCases>>,
472) -> Result<HttpResponse> {
473    let _ = auth; // Authentication required
474
475    match linky_use_cases.find_devices_needing_sync().await {
476        Ok(devices) => Ok(HttpResponse::Ok().json(devices)),
477        Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
478            "error": e
479        }))),
480    }
481}
482
483/// Find Linky devices with expired tokens
484///
485/// GET /api/v1/iot/linky/devices/expired-tokens
486pub async fn find_devices_with_expired_tokens(
487    auth: AuthenticatedUser,
488    linky_use_cases: web::Data<Arc<LinkyUseCases>>,
489) -> Result<HttpResponse> {
490    let _ = auth; // Authentication required
491
492    match linky_use_cases.find_devices_with_expired_tokens().await {
493        Ok(devices) => Ok(HttpResponse::Ok().json(devices)),
494        Err(e) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({
495            "error": e
496        }))),
497    }
498}
499
500/// Configure IoT routes
501pub fn configure_iot_routes(cfg: &mut web::ServiceConfig) {
502    cfg.service(
503        web::scope("/iot")
504            // IoT Readings
505            .route("/readings", web::post().to(create_iot_reading))
506            .route("/readings/bulk", web::post().to(create_iot_readings_bulk))
507            .route("/readings", web::get().to(query_iot_readings))
508            .route(
509                "/buildings/{building_id}/consumption/stats",
510                web::get().to(get_consumption_stats),
511            )
512            .route(
513                "/buildings/{building_id}/consumption/daily",
514                web::get().to(get_daily_aggregates),
515            )
516            .route(
517                "/buildings/{building_id}/consumption/monthly",
518                web::get().to(get_monthly_aggregates),
519            )
520            .route(
521                "/buildings/{building_id}/consumption/anomalies",
522                web::get().to(detect_anomalies),
523            )
524            // Linky Devices
525            .route("/linky/devices", web::post().to(configure_linky_device))
526            .route(
527                "/linky/buildings/{building_id}/device",
528                web::get().to(get_linky_device),
529            )
530            .route(
531                "/linky/buildings/{building_id}/device",
532                web::delete().to(delete_linky_device),
533            )
534            .route(
535                "/linky/buildings/{building_id}/sync",
536                web::post().to(sync_linky_data),
537            )
538            .route(
539                "/linky/buildings/{building_id}/sync/toggle",
540                web::put().to(toggle_linky_sync),
541            )
542            .route(
543                "/linky/devices/needing-sync",
544                web::get().to(find_devices_needing_sync),
545            )
546            .route(
547                "/linky/devices/expired-tokens",
548                web::get().to(find_devices_with_expired_tokens),
549            ),
550    );
551}