koprogo_api/infrastructure/web/handlers/
building_handlers.rs

1use crate::application::dto::{CreateBuildingDto, PageRequest, PageResponse, UpdateBuildingDto};
2use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
3use crate::infrastructure::web::{AppState, AuthenticatedUser};
4use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
5use chrono::{DateTime, Datelike, Utc};
6use serde::Deserialize;
7use uuid::Uuid;
8use validator::Validate;
9
10#[post("/buildings")]
11pub async fn create_building(
12    state: web::Data<AppState>,
13    user: AuthenticatedUser, // JWT-extracted user info (SECURE!)
14    mut dto: web::Json<CreateBuildingDto>,
15) -> impl Responder {
16    // Only SuperAdmin can create buildings (structural data)
17    if user.role != "superadmin" {
18        return HttpResponse::Forbidden().json(serde_json::json!({
19            "error": "Only SuperAdmin can create buildings (structural data cannot be modified after creation)"
20        }));
21    }
22
23    // SuperAdmin can create buildings for any organization
24    // Regular users can only create for their own organization
25    let organization_id: Uuid;
26
27    if user.role == "superadmin" {
28        // SuperAdmin: organization_id must be provided in DTO
29        if dto.organization_id.is_empty() {
30            return HttpResponse::BadRequest().json(serde_json::json!({
31                "error": "SuperAdmin must specify organization_id"
32            }));
33        }
34        // Parse the organization_id from DTO
35        organization_id = match Uuid::parse_str(&dto.organization_id) {
36            Ok(id) => id,
37            Err(_) => {
38                return HttpResponse::BadRequest().json(serde_json::json!({
39                    "error": "Invalid organization_id format"
40                }));
41            }
42        };
43    } else {
44        // Regular user: override organization_id from JWT token
45        // This prevents users from creating buildings in other organizations
46        organization_id = match user.require_organization() {
47            Ok(org_id) => org_id,
48            Err(e) => {
49                return HttpResponse::Unauthorized().json(serde_json::json!({
50                    "error": e.to_string()
51                }))
52            }
53        };
54        dto.organization_id = organization_id.to_string();
55    }
56
57    if let Err(errors) = dto.validate() {
58        return HttpResponse::BadRequest().json(serde_json::json!({
59            "error": "Validation failed",
60            "details": errors.to_string()
61        }));
62    }
63
64    match state
65        .building_use_cases
66        .create_building(dto.into_inner())
67        .await
68    {
69        Ok(building) => {
70            // Audit log: successful building creation
71            AuditLogEntry::new(
72                AuditEventType::BuildingCreated,
73                Some(user.user_id),
74                Some(organization_id),
75            )
76            .with_resource("Building", Uuid::parse_str(&building.id).unwrap())
77            .log();
78
79            HttpResponse::Created().json(building)
80        }
81        Err(err) => {
82            // Audit log: failed building creation
83            AuditLogEntry::new(
84                AuditEventType::BuildingCreated,
85                Some(user.user_id),
86                Some(organization_id),
87            )
88            .with_error(err.clone())
89            .log();
90
91            HttpResponse::BadRequest().json(serde_json::json!({
92                "error": err
93            }))
94        }
95    }
96}
97
98#[get("/buildings")]
99pub async fn list_buildings(
100    state: web::Data<AppState>,
101    user: AuthenticatedUser,
102    page_request: web::Query<PageRequest>,
103) -> impl Responder {
104    // Extract organization_id from authenticated user (secure!)
105    let organization_id = user.organization_id;
106
107    match state
108        .building_use_cases
109        .list_buildings_paginated(&page_request, organization_id)
110        .await
111    {
112        Ok((buildings, total)) => {
113            let response =
114                PageResponse::new(buildings, page_request.page, page_request.per_page, total);
115            HttpResponse::Ok().json(response)
116        }
117        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
118            "error": err
119        })),
120    }
121}
122
123#[get("/buildings/{id}")]
124pub async fn get_building(state: web::Data<AppState>, id: web::Path<Uuid>) -> impl Responder {
125    match state.building_use_cases.get_building(*id).await {
126        Ok(Some(building)) => HttpResponse::Ok().json(building),
127        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
128            "error": "Building not found"
129        })),
130        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
131            "error": err
132        })),
133    }
134}
135
136#[put("/buildings/{id}")]
137pub async fn update_building(
138    state: web::Data<AppState>,
139    user: AuthenticatedUser,
140    id: web::Path<Uuid>,
141    dto: web::Json<UpdateBuildingDto>,
142) -> impl Responder {
143    // Only SuperAdmin can update buildings (structural data)
144    if user.role != "superadmin" {
145        return HttpResponse::Forbidden().json(serde_json::json!({
146            "error": "Only SuperAdmin can update buildings (structural data)"
147        }));
148    }
149
150    if let Err(errors) = dto.validate() {
151        return HttpResponse::BadRequest().json(serde_json::json!({
152            "error": "Validation failed",
153            "details": errors.to_string()
154        }));
155    }
156
157    // Only SuperAdmin can change organization_id
158    if dto.organization_id.is_some() && user.role != "superadmin" {
159        return HttpResponse::Forbidden().json(serde_json::json!({
160            "error": "Only SuperAdmins can change building organization"
161        }));
162    }
163
164    // For non-SuperAdmins, verify they own the building
165    if user.role != "superadmin" {
166        match state.building_use_cases.get_building(*id).await {
167            Ok(Some(building)) => {
168                let building_org_id = match Uuid::parse_str(&building.organization_id) {
169                    Ok(id) => id,
170                    Err(_) => {
171                        return HttpResponse::InternalServerError().json(serde_json::json!({
172                            "error": "Invalid building organization_id"
173                        }));
174                    }
175                };
176
177                let user_org_id = match user.require_organization() {
178                    Ok(id) => id,
179                    Err(e) => {
180                        return HttpResponse::Unauthorized().json(serde_json::json!({
181                            "error": e.to_string()
182                        }));
183                    }
184                };
185
186                if building_org_id != user_org_id {
187                    return HttpResponse::Forbidden().json(serde_json::json!({
188                        "error": "You can only update buildings in your own organization"
189                    }));
190                }
191            }
192            Ok(None) => {
193                return HttpResponse::NotFound().json(serde_json::json!({
194                    "error": "Building not found"
195                }));
196            }
197            Err(err) => {
198                return HttpResponse::InternalServerError().json(serde_json::json!({
199                    "error": err
200                }));
201            }
202        }
203    }
204
205    match state
206        .building_use_cases
207        .update_building(*id, dto.into_inner())
208        .await
209    {
210        Ok(building) => {
211            // Audit log: successful building update
212            AuditLogEntry::new(
213                AuditEventType::BuildingUpdated,
214                Some(user.user_id),
215                user.organization_id,
216            )
217            .with_resource("Building", *id)
218            .log();
219
220            HttpResponse::Ok().json(building)
221        }
222        Err(err) => {
223            // Audit log: failed building update
224            AuditLogEntry::new(
225                AuditEventType::BuildingUpdated,
226                Some(user.user_id),
227                user.organization_id,
228            )
229            .with_resource("Building", *id)
230            .with_error(err.clone())
231            .log();
232
233            HttpResponse::BadRequest().json(serde_json::json!({
234                "error": err
235            }))
236        }
237    }
238}
239
240#[delete("/buildings/{id}")]
241pub async fn delete_building(
242    state: web::Data<AppState>,
243    user: AuthenticatedUser,
244    id: web::Path<Uuid>,
245) -> impl Responder {
246    // Only SuperAdmin can delete buildings
247    if user.role != "superadmin" {
248        return HttpResponse::Forbidden().json(serde_json::json!({
249            "error": "Only SuperAdmin can delete buildings"
250        }));
251    }
252
253    match state.building_use_cases.delete_building(*id).await {
254        Ok(true) => {
255            // Audit log: successful building deletion
256            AuditLogEntry::new(
257                AuditEventType::BuildingDeleted,
258                Some(user.user_id),
259                user.organization_id,
260            )
261            .with_resource("Building", *id)
262            .log();
263
264            HttpResponse::NoContent().finish()
265        }
266        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
267            "error": "Building not found"
268        })),
269        Err(err) => {
270            // Audit log: failed building deletion
271            AuditLogEntry::new(
272                AuditEventType::BuildingDeleted,
273                Some(user.user_id),
274                user.organization_id,
275            )
276            .with_resource("Building", *id)
277            .with_error(err.clone())
278            .log();
279
280            HttpResponse::InternalServerError().json(serde_json::json!({
281                "error": err
282            }))
283        }
284    }
285}
286
287/// Export Annual Financial Report to PDF
288///
289/// GET /buildings/{building_id}/export-annual-report-pdf?year={2025}&reserve_fund={10000.00}&total_income={50000.00}
290///
291/// Generates a "Rapport Financier Annuel" PDF for a building's annual financial summary.
292#[derive(Debug, Deserialize)]
293pub struct ExportAnnualReportQuery {
294    pub year: i32,
295    #[serde(default)]
296    pub reserve_fund: Option<f64>, // Optional reserve fund balance
297    #[serde(default)]
298    pub total_income: Option<f64>, // Optional total income (calculated if not provided)
299}
300
301#[get("/buildings/{id}/export-annual-report-pdf")]
302pub async fn export_annual_report_pdf(
303    state: web::Data<AppState>,
304    user: AuthenticatedUser,
305    id: web::Path<Uuid>,
306    query: web::Query<ExportAnnualReportQuery>,
307) -> impl Responder {
308    use crate::domain::entities::{Building, Expense};
309    use crate::domain::services::{AnnualReportExporter, BudgetItem};
310
311    let organization_id = match user.require_organization() {
312        Ok(org_id) => org_id,
313        Err(e) => {
314            return HttpResponse::Unauthorized().json(serde_json::json!({
315                "error": e.to_string()
316            }))
317        }
318    };
319
320    let building_id = *id;
321    let year = query.year;
322
323    // 1. Get building
324    let building_dto = match state.building_use_cases.get_building(building_id).await {
325        Ok(Some(dto)) => dto,
326        Ok(None) => {
327            return HttpResponse::NotFound().json(serde_json::json!({
328                "error": "Building not found"
329            }))
330        }
331        Err(err) => {
332            return HttpResponse::InternalServerError().json(serde_json::json!({
333                "error": err
334            }))
335        }
336    };
337
338    // 2. Get all expenses for this building
339    let expenses_dto = match state
340        .expense_use_cases
341        .list_expenses_by_building(building_id)
342        .await
343    {
344        Ok(expenses) => expenses,
345        Err(err) => {
346            return HttpResponse::InternalServerError().json(serde_json::json!({
347                "error": format!("Failed to get expenses: {}", err)
348            }))
349        }
350    };
351
352    // Filter expenses by year (using expense_date from DTO)
353    let year_expenses: Vec<_> = expenses_dto
354        .into_iter()
355        .filter(|e| {
356            // Parse expense_date string to get year
357            DateTime::parse_from_rfc3339(&e.expense_date)
358                .map(|dt| dt.year() == year)
359                .unwrap_or(false)
360        })
361        .collect();
362
363    // Calculate total income if not provided (sum of all paid expenses)
364    use crate::domain::entities::PaymentStatus;
365    let total_income = query.total_income.unwrap_or_else(|| {
366        year_expenses
367            .iter()
368            .filter(|e| e.payment_status == PaymentStatus::Paid)
369            .map(|e| e.amount)
370            .sum()
371    });
372
373    // Reserve fund (default to 0.0 if not provided)
374    let reserve_fund = query.reserve_fund.unwrap_or(0.0);
375
376    // Convert DTOs to domain entities
377    let building_org_id = Uuid::parse_str(&building_dto.organization_id).unwrap_or(organization_id);
378
379    let building_created_at = DateTime::parse_from_rfc3339(&building_dto.created_at)
380        .map(|dt| dt.with_timezone(&Utc))
381        .unwrap_or_else(|_| Utc::now());
382
383    let building_updated_at = DateTime::parse_from_rfc3339(&building_dto.updated_at)
384        .map(|dt| dt.with_timezone(&Utc))
385        .unwrap_or_else(|_| Utc::now());
386
387    let building_entity = Building {
388        id: Uuid::parse_str(&building_dto.id).unwrap_or(building_id),
389        name: building_dto.name.clone(),
390        address: building_dto.address,
391        city: building_dto.city,
392        postal_code: building_dto.postal_code,
393        country: building_dto.country,
394        total_units: building_dto.total_units,
395        total_tantiemes: building_dto.total_tantiemes,
396        construction_year: building_dto.construction_year,
397        syndic_name: None,
398        syndic_email: None,
399        syndic_phone: None,
400        syndic_address: None,
401        syndic_office_hours: None,
402        syndic_emergency_contact: None,
403        slug: None,
404        organization_id: building_org_id,
405        created_at: building_created_at,
406        updated_at: building_updated_at,
407    };
408
409    // Convert expenses to domain entities
410    let expense_entities: Vec<Expense> = year_expenses
411        .iter()
412        .filter_map(|e| {
413            // Parse DTO fields
414            let exp_id = Uuid::parse_str(&e.id).ok()?;
415            let bldg_id = Uuid::parse_str(&e.building_id).ok()?;
416            let exp_date = DateTime::parse_from_rfc3339(&e.expense_date)
417                .ok()?
418                .with_timezone(&Utc);
419
420            Some(Expense {
421                id: exp_id,
422                organization_id,
423                building_id: bldg_id,
424                category: e.category.clone(),
425                description: e.description.clone(),
426                amount: e.amount,
427                amount_excl_vat: None,
428                vat_rate: None,
429                vat_amount: None,
430                amount_incl_vat: None,
431                expense_date: exp_date,
432                invoice_date: None,
433                due_date: None,
434                paid_date: None,
435                approval_status: e.approval_status.clone(),
436                submitted_at: None,
437                approved_by: None,
438                approved_at: None,
439                rejection_reason: None,
440                payment_status: e.payment_status.clone(),
441                supplier: e.supplier.clone(),
442                invoice_number: e.invoice_number.clone(),
443                account_code: e.account_code.clone(),
444                created_at: Utc::now(), // Simplified
445                updated_at: Utc::now(), // Simplified
446            })
447        })
448        .collect();
449
450    // Budget items (empty for now, to be implemented with budget system)
451    let budget_items: Vec<BudgetItem> = Vec::new();
452
453    // 3. Generate PDF
454    match AnnualReportExporter::export_to_pdf(
455        &building_entity,
456        year,
457        &expense_entities,
458        &budget_items,
459        total_income,
460        reserve_fund,
461    ) {
462        Ok(pdf_bytes) => {
463            // Audit log
464            AuditLogEntry::new(
465                AuditEventType::ReportGenerated,
466                Some(user.user_id),
467                Some(organization_id),
468            )
469            .with_resource("Building", building_id)
470            .with_metadata(serde_json::json!({
471                "report_type": "annual_report_pdf",
472                "building_name": building_entity.name,
473                "year": year,
474                "total_income": total_income,
475                "reserve_fund": reserve_fund
476            }))
477            .log();
478
479            HttpResponse::Ok()
480                .content_type("application/pdf")
481                .insert_header((
482                    "Content-Disposition",
483                    format!(
484                        "attachment; filename=\"Rapport_Annuel_{}_{}.pdf\"",
485                        building_entity.name.replace(' ', "_"),
486                        year
487                    ),
488                ))
489                .body(pdf_bytes)
490        }
491        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
492            "error": format!("Failed to generate PDF: {}", err)
493        })),
494    }
495}