koprogo_api/infrastructure/web/handlers/
iot_handlers.rs1use 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
35pub 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
77pub 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
121pub async fn query_iot_readings(
125 auth: AuthenticatedUser,
126 query: web::Query<QueryIoTReadingsDto>,
127 state: web::Data<AppState>,
128) -> Result<HttpResponse> {
129 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
150pub 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 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
208pub 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 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
274pub 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 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
340pub 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 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
397pub 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
435pub async fn get_linky_device(
439 auth: AuthenticatedUser,
440 path: web::Path<Uuid>,
441 state: web::Data<AppState>,
442) -> Result<HttpResponse> {
443 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
464pub 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
489pub 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
524pub 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
561pub async fn find_devices_needing_sync(
565 auth: AuthenticatedUser,
566 state: web::Data<AppState>,
567) -> Result<HttpResponse> {
568 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
583pub async fn find_devices_with_expired_tokens(
587 auth: AuthenticatedUser,
588 state: web::Data<AppState>,
589) -> Result<HttpResponse> {
590 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
609pub fn configure_iot_routes(cfg: &mut web::ServiceConfig) {
611 cfg.service(
612 web::scope("/iot")
613 .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 .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}