Skip to main content

koprogo_api/infrastructure/web/handlers/
energy_bill_upload_handlers.rs

1use actix_web::{delete, get, post, put, web, HttpResponse};
2use uuid::Uuid;
3
4use crate::application::dto::{
5    DecryptedConsumptionResponse, EnergyBillUploadResponse, UploadEnergyBillRequest,
6    VerifyUploadRequest,
7};
8use crate::domain::entities::EnergyBillUpload;
9use crate::infrastructure::web::middleware::AuthenticatedUser;
10use crate::infrastructure::web::AppState;
11
12// Helper: Get encryption key from environment
13fn get_encryption_key() -> Result<[u8; 32], String> {
14    let key_hex = std::env::var("ENERGY_ENCRYPTION_MASTER_KEY")
15        .map_err(|_| "ENERGY_ENCRYPTION_MASTER_KEY not set".to_string())?;
16
17    if key_hex.len() != 64 {
18        return Err("Invalid encryption key length (expected 64 hex chars)".to_string());
19    }
20
21    let mut key = [0u8; 32];
22    hex::decode_to_slice(&key_hex, &mut key).map_err(|e| format!("Invalid hex key: {}", e))?;
23
24    Ok(key)
25}
26
27/// POST /api/v1/energy-bills/upload
28/// Upload energy bill with GDPR consent
29#[post("/energy-bills/upload")]
30pub async fn upload_bill(
31    state: web::Data<AppState>,
32    request: web::Json<UploadEnergyBillRequest>,
33    user: AuthenticatedUser,
34) -> Result<HttpResponse, actix_web::Error> {
35    // Verify GDPR consent
36    if !request.consent.accepted {
37        return Err(actix_web::error::ErrorBadRequest("GDPR consent required"));
38    }
39
40    let encryption_key =
41        get_encryption_key().map_err(actix_web::error::ErrorInternalServerError)?;
42
43    let upload = EnergyBillUpload::new(
44        request.campaign_id,
45        request.unit_id,
46        request.building_id,
47        user.organization_id
48            .ok_or_else(|| actix_web::error::ErrorUnauthorized("Organization required"))?,
49        request.bill_period_start,
50        request.bill_period_end,
51        request.total_kwh,
52        request.energy_type.clone(),
53        request.postal_code.clone(),
54        request.file_hash.clone(),
55        request.file_path.clone(), // Will be encrypted
56        user.user_id,
57        request.consent.ip.clone(),
58        request.consent.user_agent.clone(),
59        &encryption_key,
60    )
61    .map_err(actix_web::error::ErrorBadRequest)?;
62
63    let created = state
64        .energy_bill_upload_use_cases
65        .upload_bill(upload)
66        .await
67        .map_err(actix_web::error::ErrorInternalServerError)?;
68
69    // Note: Aggregation should be triggered asynchronously via message queue in production
70
71    Ok(HttpResponse::Created().json(EnergyBillUploadResponse::from(created)))
72}
73
74/// GET /api/v1/energy-bills/my-uploads
75/// Get my energy bill uploads
76#[get("/energy-bills/my-uploads")]
77pub async fn get_my_uploads(
78    state: web::Data<AppState>,
79    user: AuthenticatedUser,
80) -> Result<HttpResponse, actix_web::Error> {
81    // Verify user has organization
82    let _organization_id = user
83        .organization_id
84        .ok_or_else(|| actix_web::error::ErrorUnauthorized("Organization required"))?;
85
86    // Get uploads by the authenticated user
87    let list = state
88        .energy_bill_upload_use_cases
89        .get_my_uploads(user.user_id)
90        .await
91        .map_err(actix_web::error::ErrorInternalServerError)?;
92
93    let response: Vec<EnergyBillUploadResponse> = list
94        .into_iter()
95        .map(EnergyBillUploadResponse::from)
96        .collect();
97
98    Ok(HttpResponse::Ok().json(response))
99}
100
101/// GET /api/v1/energy-bills/{id}
102/// Get upload by ID
103#[get("/energy-bills/{id}")]
104pub async fn get_upload(
105    state: web::Data<AppState>,
106    path: web::Path<Uuid>,
107    user: AuthenticatedUser,
108) -> Result<HttpResponse, actix_web::Error> {
109    let id = path.into_inner();
110
111    let upload = state
112        .energy_bill_upload_use_cases
113        .get_upload(id)
114        .await
115        .map_err(actix_web::error::ErrorInternalServerError)?
116        .ok_or_else(|| actix_web::error::ErrorNotFound("Upload not found"))?;
117
118    // Verify access (owner or same organization)
119    if upload.organization_id
120        != user
121            .organization_id
122            .ok_or_else(|| actix_web::error::ErrorForbidden("Organization ID required"))?
123    {
124        return Err(actix_web::error::ErrorForbidden("Access denied"));
125    }
126
127    Ok(HttpResponse::Ok().json(EnergyBillUploadResponse::from(upload)))
128}
129
130/// GET /api/v1/energy-bills/{id}/decrypt
131/// Decrypt consumption data (owner only)
132#[get("/energy-bills/{id}/decrypt")]
133pub async fn decrypt_consumption(
134    state: web::Data<AppState>,
135    path: web::Path<Uuid>,
136    user: AuthenticatedUser,
137) -> Result<HttpResponse, actix_web::Error> {
138    let upload_id = path.into_inner();
139
140    let encryption_key =
141        get_encryption_key().map_err(actix_web::error::ErrorInternalServerError)?;
142
143    // Get upload to check ownership
144    let upload = state
145        .energy_bill_upload_use_cases
146        .get_upload(upload_id)
147        .await
148        .map_err(actix_web::error::ErrorInternalServerError)?
149        .ok_or_else(|| actix_web::error::ErrorNotFound("Upload not found"))?;
150
151    // Verify organization access
152    let user_org = user
153        .organization_id
154        .ok_or_else(|| actix_web::error::ErrorUnauthorized("Organization required"))?;
155    if upload.organization_id != user_org {
156        return Err(actix_web::error::ErrorForbidden("Access denied"));
157    }
158
159    let total_kwh = state
160        .energy_bill_upload_use_cases
161        .decrypt_consumption(upload_id, upload.unit_id, &encryption_key)
162        .await
163        .map_err(actix_web::error::ErrorForbidden)?;
164
165    let response = DecryptedConsumptionResponse {
166        upload_id,
167        total_kwh,
168        energy_type: upload.energy_type,
169        bill_period_start: upload.bill_period_start,
170        bill_period_end: upload.bill_period_end,
171    };
172
173    Ok(HttpResponse::Ok().json(response))
174}
175
176/// PUT /api/v1/energy-bills/{id}/verify
177/// Verify upload (admin only)
178#[put("/energy-bills/{id}/verify")]
179pub async fn verify_upload(
180    state: web::Data<AppState>,
181    path: web::Path<Uuid>,
182    _request: web::Json<VerifyUploadRequest>,
183    user: AuthenticatedUser,
184) -> Result<HttpResponse, actix_web::Error> {
185    let upload_id = path.into_inner();
186
187    // Le contrôle qui suit existait, ÉCRIT PUIS COMMENTÉ, sous un « TODO » :
188    //
189    //     // if !user.is_admin() {
190    //     //     return Err(...ErrorForbidden("Admin access required"));
191    //     // }
192    //
193    // Vérifier une facture d'énergie valide une pièce que l'ACP paiera. Le
194    // laisser ouvert à tout utilisateur authentifié était le trou ; le laisser
195    // en commentaire donnait l'illusion qu'il ne l'était pas (#772).
196    //
197    // Deux contrôles, et non un : le RÔLE — syndic ou administration — et le
198    // PÉRIMÈTRE, cette facture devant relever de l'organisation de l'appelant.
199    if !user.is_superadmin() && user.role != "syndic" {
200        return Err(actix_web::error::ErrorForbidden(
201            "Réservé au syndic et à l'administration de la plateforme",
202        ));
203    }
204
205    let upload = state
206        .energy_bill_upload_use_cases
207        .get_upload(upload_id)
208        .await
209        .map_err(actix_web::error::ErrorInternalServerError)?
210        .ok_or_else(|| actix_web::error::ErrorNotFound("Upload not found"))?;
211
212    if user.verify_org_access(upload.organization_id).is_err() {
213        return Err(actix_web::error::ErrorForbidden(
214            "Cette facture relève d'une autre organisation",
215        ));
216    }
217
218    let updated = state
219        .energy_bill_upload_use_cases
220        .verify_upload(upload_id, user.user_id)
221        .await
222        .map_err(actix_web::error::ErrorInternalServerError)?;
223
224    Ok(HttpResponse::Ok().json(EnergyBillUploadResponse::from(updated)))
225}
226
227/// DELETE /api/v1/energy-bills/{id}
228/// Delete upload (GDPR Art. 17 - Right to erasure)
229#[delete("/energy-bills/{id}")]
230pub async fn delete_upload(
231    state: web::Data<AppState>,
232    path: web::Path<Uuid>,
233    user: AuthenticatedUser,
234) -> Result<HttpResponse, actix_web::Error> {
235    let upload_id = path.into_inner();
236
237    // Get upload to verify ownership
238    let upload = state
239        .energy_bill_upload_use_cases
240        .get_upload(upload_id)
241        .await
242        .map_err(actix_web::error::ErrorInternalServerError)?
243        .ok_or_else(|| actix_web::error::ErrorNotFound("Upload not found"))?;
244
245    // Verify organization access
246    let user_org = user
247        .organization_id
248        .ok_or_else(|| actix_web::error::ErrorUnauthorized("Organization required"))?;
249    if upload.organization_id != user_org {
250        return Err(actix_web::error::ErrorForbidden("Access denied"));
251    }
252
253    state
254        .energy_bill_upload_use_cases
255        .delete_upload(upload_id, upload.unit_id)
256        .await
257        .map_err(actix_web::error::ErrorForbidden)?;
258
259    Ok(HttpResponse::NoContent().finish())
260}
261
262/// POST /api/v1/energy-bills/{id}/withdraw-consent
263/// Withdraw GDPR consent (Art. 7.3 - Immediate deletion)
264#[post("/energy-bills/{id}/withdraw-consent")]
265pub async fn withdraw_consent(
266    state: web::Data<AppState>,
267    path: web::Path<Uuid>,
268    user: AuthenticatedUser,
269) -> Result<HttpResponse, actix_web::Error> {
270    let upload_id = path.into_inner();
271
272    // Get upload to verify ownership
273    let upload = state
274        .energy_bill_upload_use_cases
275        .get_upload(upload_id)
276        .await
277        .map_err(actix_web::error::ErrorInternalServerError)?
278        .ok_or_else(|| actix_web::error::ErrorNotFound("Upload not found"))?;
279
280    // Verify organization access
281    let user_org = user
282        .organization_id
283        .ok_or_else(|| actix_web::error::ErrorUnauthorized("Organization required"))?;
284    if upload.organization_id != user_org {
285        return Err(actix_web::error::ErrorForbidden("Access denied"));
286    }
287
288    state
289        .energy_bill_upload_use_cases
290        .withdraw_consent(upload_id, upload.unit_id)
291        .await
292        .map_err(actix_web::error::ErrorForbidden)?;
293
294    Ok(HttpResponse::Ok().json(serde_json::json!({
295        "message": "Consent withdrawn and data deleted",
296        "gdpr_article": "7.3 - Right to withdraw consent"
297    })))
298}
299
300/// GET /api/v1/energy-campaigns/{campaign_id}/uploads
301/// Get all uploads for a campaign (admin)
302#[get("/energy-campaigns/{campaign_id}/uploads")]
303pub async fn get_campaign_uploads(
304    state: web::Data<AppState>,
305    path: web::Path<Uuid>,
306    user: AuthenticatedUser,
307) -> Result<HttpResponse, actix_web::Error> {
308    let campaign_id = path.into_inner();
309
310    let list = state
311        .energy_bill_upload_use_cases
312        .get_uploads_by_campaign(campaign_id)
313        .await
314        .map_err(actix_web::error::ErrorInternalServerError)?;
315
316    // Verify organization access
317    if !list.is_empty() {
318        let user_org = user
319            .organization_id
320            .ok_or_else(|| actix_web::error::ErrorUnauthorized("Organization required"))?;
321        if list[0].organization_id != user_org {
322            return Err(actix_web::error::ErrorForbidden("Access denied"));
323        }
324    }
325
326    let response: Vec<EnergyBillUploadResponse> = list
327        .into_iter()
328        .map(EnergyBillUploadResponse::from)
329        .collect();
330
331    Ok(HttpResponse::Ok().json(response))
332}