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