koprogo_api/infrastructure/web/handlers/
iot_handlers.rs1use 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
11pub 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
53pub 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
97pub async fn query_iot_readings(
101 auth: AuthenticatedUser,
102 query: web::Query<QueryIoTReadingsDto>,
103 state: web::Data<AppState>,
104) -> Result<HttpResponse> {
105 let _ = auth; 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
114pub 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; 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
162pub 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; 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
218pub 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; 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
274pub 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; 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
321pub 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
359pub async fn get_linky_device(
363 auth: AuthenticatedUser,
364 path: web::Path<Uuid>,
365 state: web::Data<AppState>,
366) -> Result<HttpResponse> {
367 let _ = auth; 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
378pub 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
403pub 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
438pub 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
475pub async fn find_devices_needing_sync(
479 auth: AuthenticatedUser,
480 state: web::Data<AppState>,
481) -> Result<HttpResponse> {
482 let _ = auth; 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
492pub async fn find_devices_with_expired_tokens(
496 auth: AuthenticatedUser,
497 state: web::Data<AppState>,
498) -> Result<HttpResponse> {
499 let _ = auth; 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
513pub fn configure_iot_routes(cfg: &mut web::ServiceConfig) {
515 cfg.service(
516 web::scope("/iot")
517 .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 .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}