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