koprogo_api/infrastructure/web/handlers/
iot_handlers.rs

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