Skip to main content

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::middleware::scope_guard::verify_acp_org_access;
4use crate::infrastructure::web::{AppState, AuthenticatedUser};
5use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
6use chrono::{DateTime, Datelike, Utc};
7use serde::Deserialize;
8use uuid::Uuid;
9use validator::Validate;
10
11/// Recherche libre optionnelle (ILIKE name/city/address) sur GET /buildings —
12/// évite au frontend (BuildingSelector) de devoir fetch les 100 premiers
13/// buildings et filtrer en mémoire, ce qui ratait les buildings récents une
14/// fois >100 buildings créés globalement (constaté en E2E CI, superadmin
15/// scope = toutes les orgs).
16#[derive(Deserialize)]
17pub struct BuildingSearchQuery {
18    pub search: Option<String>,
19}
20
21#[utoipa::path(
22    post,
23    path = "/buildings",
24    tag = "Buildings",
25    summary = "Create a building",
26    request_body = CreateBuildingDto,
27    responses(
28        (status = 201, description = "Building created successfully"),
29        (status = 400, description = "Bad Request"),
30        (status = 403, description = "Forbidden - SuperAdmin only"),
31        (status = 500, description = "Internal Server Error"),
32    ),
33    security(("bearer_auth" = []))
34)]
35#[post("/buildings")]
36pub async fn create_building(
37    state: web::Data<AppState>,
38    user: AuthenticatedUser, // JWT-extracted user info (SECURE!)
39    dto: web::Json<CreateBuildingDto>,
40) -> impl Responder {
41    // Le syndic crée les immeubles de SES ACP ; le SuperAdmin, de toutes.
42    //
43    // La création était réservée au SuperAdmin, au motif que ces données sont
44    // structurelles. L'intention est juste — immeubles et lots découlent de
45    // l'acte de base, un acte notarié que personne n'invente — mais la
46    // conséquence ne l'était pas : un syndic ne pouvait pas encoder sa propre
47    // copropriété, et un cabinet client devait passer par l'éditeur pour
48    // exister. Constaté en recette le 2026-09-04.
49    //
50    // Le syndic est le mandataire de l'ACP (Art. 3.89) et détient l'acte de
51    // base : c'est lui qui le retranscrit. Ce qu'il ne doit pas pouvoir faire,
52    // c'est créer dans l'ACP d'un autre — d'où le contrôle de périmètre
53    // ci-dessous, et non un refus par rôle.
54    //
55    // La cohérence de ce qu'il déclare reste contrôlée ailleurs : le
56    // garde-fou « valider avant de calculer » refuse toute opération
57    // financière tant que les lots ne totalisent pas les tantièmes annoncés.
58    if !matches!(user.role.as_str(), "superadmin" | "syndic") {
59        return HttpResponse::Forbidden().json(serde_json::json!({
60            "error": "Seuls le syndic et le SuperAdmin créent des immeubles"
61        }));
62    }
63
64    // Story 1.2 — Building::acp_id (FK vers acps.id, ex-organization_id).
65    let acp_id: Uuid = if dto.acp_id.is_empty() {
66        return HttpResponse::BadRequest().json(serde_json::json!({
67            "error": "L'immeuble doit désigner l'ACP dont il relève (acp_id)"
68        }));
69    } else {
70        match Uuid::parse_str(&dto.acp_id) {
71            Ok(id) => id,
72            Err(_) => {
73                return HttpResponse::BadRequest().json(serde_json::json!({
74                    "error": "Invalid acp_id format"
75                }));
76            }
77        }
78    };
79    // Le contrôle de périmètre : un syndic ne crée que dans les ACP de son
80    // organisation. Le SuperAdmin passe outre, c'est son rôle.
81    if let Err(e) = crate::infrastructure::web::middleware::scope_guard::verify_acp_org_access(
82        &user,
83        acp_id,
84        &state.acp_use_cases,
85    )
86    .await
87    {
88        return e.error_response();
89    }
90
91    // The audit log still records the user's home organization for traceability ;
92    // the building's organization is now derived via its parent ACP.
93    let organization_id: Option<Uuid> = user.organization_id;
94    let _ = acp_id; // used implicitly via dto.acp_id forwarded to use_case
95
96    if let Err(errors) = dto.validate() {
97        return HttpResponse::BadRequest().json(serde_json::json!({
98            "error": "Validation failed",
99            "details": errors.to_string()
100        }));
101    }
102
103    match state
104        .building_use_cases
105        .create_building(dto.into_inner())
106        .await
107    {
108        Ok(building) => {
109            // Audit log: successful building creation
110            AuditLogEntry::new(
111                AuditEventType::BuildingCreated,
112                Some(user.user_id),
113                organization_id,
114            )
115            .with_resource("Building", Uuid::parse_str(&building.id).unwrap())
116            .log();
117
118            HttpResponse::Created().json(building)
119        }
120        Err(err) => {
121            // Audit log: failed building creation
122            AuditLogEntry::new(
123                AuditEventType::BuildingCreated,
124                Some(user.user_id),
125                organization_id,
126            )
127            .with_error(err.clone())
128            .log();
129
130            HttpResponse::BadRequest().json(serde_json::json!({
131                "error": err
132            }))
133        }
134    }
135}
136
137#[utoipa::path(
138    get,
139    path = "/buildings",
140    tag = "Buildings",
141    summary = "List buildings (paginated)",
142    params(PageRequest),
143    responses(
144        (status = 200, description = "Paginated list of buildings"),
145        (status = 500, description = "Internal Server Error"),
146    ),
147    security(("bearer_auth" = []))
148)]
149#[get("/buildings")]
150pub async fn list_buildings(
151    state: web::Data<AppState>,
152    user: AuthenticatedUser,
153    page_request: web::Query<PageRequest>,
154    search_query: web::Query<BuildingSearchQuery>,
155) -> impl Responder {
156    // Story 1.3 — role-based scope derivation (ADR-0010 §3.3) :
157    // - superadmin / admin without org : ListScope::All  -> no FK filter
158    // - admin / syndic / accountant : ListScope::Organization -> filter by org
159    // - owner : ListScope::Owner -> filter via unit_owners (BUG-WF14-2)
160    let organization_id = user.effective_org_filter();
161
162    let owner_user_id = if user.role == "owner" {
163        Some(user.user_id)
164    } else {
165        None
166    };
167
168    match state
169        .building_use_cases
170        .list_buildings_paginated_for_user(
171            &page_request,
172            organization_id,
173            owner_user_id,
174            search_query.search.clone(),
175        )
176        .await
177    {
178        Ok((buildings, total)) => {
179            let response =
180                PageResponse::new(buildings, page_request.page, page_request.per_page, total);
181            HttpResponse::Ok().json(response)
182        }
183        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
184            "error": err
185        })),
186    }
187}
188
189#[utoipa::path(
190    get,
191    path = "/buildings/{id}",
192    tag = "Buildings",
193    summary = "Get a building by ID",
194    params(
195        ("id" = Uuid, Path, description = "Building UUID")
196    ),
197    responses(
198        (status = 200, description = "Building found"),
199        (status = 404, description = "Building not found"),
200        (status = 500, description = "Internal Server Error"),
201    ),
202    security(("bearer_auth" = []))
203)]
204#[get("/buildings/{id}")]
205pub async fn get_building(
206    state: web::Data<AppState>,
207    user: AuthenticatedUser,
208    id: web::Path<Uuid>,
209) -> impl Responder {
210    // Story 1.4 — use `get_building_with_metrics` so units_count, quota_sum,
211    // is_conformant and quota_delta are exposed (#553 Bugs 1/3/4 + FR11/FR12/FR23).
212    match state
213        .building_use_cases
214        .get_building_with_metrics(*id)
215        .await
216    {
217        Ok(Some(building)) => {
218            // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
219            let acp_id = match Uuid::parse_str(&building.acp_id) {
220                Ok(id) => id,
221                Err(_) => {
222                    return HttpResponse::InternalServerError().json(serde_json::json!({
223                        "error": "Invalid building.acp_id format"
224                    }));
225                }
226            };
227            if let Err(err) = verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await {
228                return err.error_response();
229            }
230            HttpResponse::Ok().json(building)
231        }
232        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
233            "error": "Building not found"
234        })),
235        Err(err) => err.error_response(),
236    }
237}
238
239#[utoipa::path(
240    put,
241    path = "/buildings/{id}",
242    tag = "Buildings",
243    summary = "Update a building",
244    params(
245        ("id" = Uuid, Path, description = "Building UUID")
246    ),
247    request_body = UpdateBuildingDto,
248    responses(
249        (status = 200, description = "Building updated successfully"),
250        (status = 400, description = "Bad Request"),
251        (status = 403, description = "Forbidden - SuperAdmin only"),
252        (status = 404, description = "Building not found"),
253        (status = 500, description = "Internal Server Error"),
254    ),
255    security(("bearer_auth" = []))
256)]
257#[put("/buildings/{id}")]
258pub async fn update_building(
259    state: web::Data<AppState>,
260    user: AuthenticatedUser,
261    id: web::Path<Uuid>,
262    dto: web::Json<UpdateBuildingDto>,
263) -> impl Responder {
264    // Only SuperAdmin can update buildings (structural data)
265    if !user.is_superadmin() {
266        return HttpResponse::Forbidden().json(serde_json::json!({
267            "error": "Only SuperAdmin can update buildings (structural data)"
268        }));
269    }
270
271    if let Err(errors) = dto.validate() {
272        return HttpResponse::BadRequest().json(serde_json::json!({
273            "error": "Validation failed",
274            "details": errors.to_string()
275        }));
276    }
277
278    // Story 1.2 — Only SuperAdmin can re-affect the parent ACP.
279    if dto.acp_id.is_some() && !user.is_superadmin() {
280        return HttpResponse::Forbidden().json(serde_json::json!({
281            "error": "Only SuperAdmins can change building ACP"
282        }));
283    }
284
285    // Hotfix #603 — defensive duplicate guard (branch unreachable as
286    // superadmin-only guard above already returns 403, but kept in case
287    // the upstream guard is relaxed in the future).
288    if !user.is_superadmin() {
289        return HttpResponse::Forbidden().json(serde_json::json!({
290            "error": "Only SuperAdmin can update buildings (structural data)"
291        }));
292    }
293
294    match state
295        .building_use_cases
296        .update_building(*id, dto.into_inner())
297        .await
298    {
299        Ok(building) => {
300            // Audit log: successful building update
301            AuditLogEntry::new(
302                AuditEventType::BuildingUpdated,
303                Some(user.user_id),
304                user.organization_id,
305            )
306            .with_resource("Building", *id)
307            .log();
308
309            HttpResponse::Ok().json(building)
310        }
311        Err(err) => {
312            // Audit log: failed building update
313            AuditLogEntry::new(
314                AuditEventType::BuildingUpdated,
315                Some(user.user_id),
316                user.organization_id,
317            )
318            .with_resource("Building", *id)
319            .with_error(err.clone())
320            .log();
321
322            HttpResponse::BadRequest().json(serde_json::json!({
323                "error": err
324            }))
325        }
326    }
327}
328
329#[utoipa::path(
330    delete,
331    path = "/buildings/{id}",
332    tag = "Buildings",
333    summary = "Delete a building",
334    params(
335        ("id" = Uuid, Path, description = "Building UUID")
336    ),
337    responses(
338        (status = 204, description = "Building deleted successfully"),
339        (status = 403, description = "Forbidden - SuperAdmin only"),
340        (status = 404, description = "Building not found"),
341        (status = 500, description = "Internal Server Error"),
342    ),
343    security(("bearer_auth" = []))
344)]
345#[delete("/buildings/{id}")]
346pub async fn delete_building(
347    state: web::Data<AppState>,
348    user: AuthenticatedUser,
349    id: web::Path<Uuid>,
350) -> impl Responder {
351    // Only SuperAdmin can delete buildings
352    if !user.is_superadmin() {
353        return HttpResponse::Forbidden().json(serde_json::json!({
354            "error": "Only SuperAdmin can delete buildings"
355        }));
356    }
357
358    match state.building_use_cases.delete_building(*id).await {
359        Ok(true) => {
360            // Audit log: successful building deletion
361            AuditLogEntry::new(
362                AuditEventType::BuildingDeleted,
363                Some(user.user_id),
364                user.organization_id,
365            )
366            .with_resource("Building", *id)
367            .log();
368
369            HttpResponse::NoContent().finish()
370        }
371        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
372            "error": "Building not found"
373        })),
374        Err(err) => {
375            // Audit log: failed building deletion
376            AuditLogEntry::new(
377                AuditEventType::BuildingDeleted,
378                Some(user.user_id),
379                user.organization_id,
380            )
381            .with_resource("Building", *id)
382            .with_error(err.clone())
383            .log();
384
385            HttpResponse::InternalServerError().json(serde_json::json!({
386                "error": err
387            }))
388        }
389    }
390}
391
392/// Export Annual Financial Report to PDF
393///
394/// GET /buildings/{building_id}/export-annual-report-pdf?year={2025}&reserve_fund={10000.00}&total_income={50000.00}
395///
396/// Generates a "Rapport Financier Annuel" PDF for a building's annual financial summary.
397#[derive(Debug, Deserialize, utoipa::IntoParams)]
398pub struct ExportAnnualReportQuery {
399    pub year: i32,
400    #[serde(default)]
401    pub reserve_fund: Option<rust_decimal::Decimal>, // Optional reserve fund balance
402    #[serde(default)]
403    pub total_income: Option<rust_decimal::Decimal>, // Optional total income (calculated if not provided)
404}
405
406#[utoipa::path(
407    get,
408    path = "/buildings/{id}/export-annual-report-pdf",
409    tag = "Buildings",
410    summary = "Export annual financial report as PDF",
411    params(
412        ("id" = Uuid, Path, description = "Building UUID"),
413        ExportAnnualReportQuery
414    ),
415    responses(
416        (status = 200, description = "PDF generated successfully", content_type = "application/pdf"),
417        (status = 401, description = "Unauthorized"),
418        (status = 404, description = "Building not found"),
419        (status = 500, description = "Internal Server Error"),
420    ),
421    security(("bearer_auth" = []))
422)]
423#[get("/buildings/{id}/export-annual-report-pdf")]
424pub async fn export_annual_report_pdf(
425    state: web::Data<AppState>,
426    user: AuthenticatedUser,
427    id: web::Path<Uuid>,
428    query: web::Query<ExportAnnualReportQuery>,
429) -> impl Responder {
430    use crate::domain::entities::{Building, Expense};
431    use crate::domain::services::{AnnualReportExporter, BudgetItem};
432
433    let organization_id = match user.require_organization() {
434        Ok(org_id) => org_id,
435        Err(e) => {
436            return HttpResponse::Unauthorized().json(serde_json::json!({
437                "error": e.to_string()
438            }))
439        }
440    };
441
442    let building_id = *id;
443    let year = query.year;
444
445    // 1. Get building
446    let building_dto = match state.building_use_cases.get_building(building_id).await {
447        Ok(Some(dto)) => dto,
448        Ok(None) => {
449            return HttpResponse::NotFound().json(serde_json::json!({
450                "error": "Building not found"
451            }))
452        }
453        Err(err) => {
454            return HttpResponse::InternalServerError().json(serde_json::json!({
455                "error": err
456            }))
457        }
458    };
459
460    // 2. Get all expenses for this building
461    let expenses_dto = match state
462        .expense_use_cases
463        .list_expenses_by_building(building_id)
464        .await
465    {
466        Ok(expenses) => expenses,
467        Err(err) => {
468            return HttpResponse::InternalServerError().json(serde_json::json!({
469                "error": format!("Failed to get expenses: {}", err)
470            }))
471        }
472    };
473
474    // Filter expenses by year (using expense_date from DTO)
475    let year_expenses: Vec<_> = expenses_dto
476        .into_iter()
477        .filter(|e| {
478            // Parse expense_date string to get year
479            DateTime::parse_from_rfc3339(&e.expense_date)
480                .map(|dt| dt.year() == year)
481                .unwrap_or(false)
482        })
483        .collect();
484
485    // Calculate total income if not provided (sum of all paid expenses)
486    use crate::domain::entities::PaymentStatus;
487    let total_income = query.total_income.unwrap_or_else(|| {
488        year_expenses
489            .iter()
490            .filter(|e| e.payment_status == PaymentStatus::Paid)
491            .map(|e| e.amount)
492            .sum()
493    });
494
495    // Reserve fund (default to 0.0 if not provided)
496    let reserve_fund = query.reserve_fund.unwrap_or(rust_decimal::Decimal::ZERO);
497
498    // Convert DTOs to domain entities — Story 1.2 : the DTO field is now
499    // `acp_id` (the building's parent ACP). The legacy variable name
500    // `organization_id` is preserved upstream for audit/scope use only.
501    let building_acp_id = Uuid::parse_str(&building_dto.acp_id).unwrap_or_else(|_| Uuid::new_v4());
502
503    let building_created_at = DateTime::parse_from_rfc3339(&building_dto.created_at)
504        .map(|dt| dt.with_timezone(&Utc))
505        .unwrap_or_else(|_| Utc::now());
506
507    let building_updated_at = DateTime::parse_from_rfc3339(&building_dto.updated_at)
508        .map(|dt| dt.with_timezone(&Utc))
509        .unwrap_or_else(|_| Utc::now());
510
511    let building_entity = Building {
512        id: Uuid::parse_str(&building_dto.id).unwrap_or(building_id),
513        name: building_dto.name.clone(),
514        address: building_dto.address,
515        city: building_dto.city,
516        postal_code: building_dto.postal_code,
517        country: building_dto.country,
518        total_units: building_dto.total_units,
519        total_tantiemes: building_dto.total_tantiemes,
520        construction_year: building_dto.construction_year,
521        syndic_name: None,
522        syndic_email: None,
523        syndic_phone: None,
524        syndic_address: None,
525        syndic_office_hours: None,
526        syndic_emergency_contact: None,
527        slug: None,
528        acp_id: building_acp_id,
529        created_at: building_created_at,
530        updated_at: building_updated_at,
531    };
532
533    // Convert expenses to domain entities
534    let expense_entities: Vec<Expense> = year_expenses
535        .iter()
536        .filter_map(|e| {
537            // Parse DTO fields
538            let exp_id = Uuid::parse_str(&e.id).ok()?;
539            let bldg_id = Uuid::parse_str(&e.building_id).ok()?;
540            let exp_date = DateTime::parse_from_rfc3339(&e.expense_date)
541                .ok()?
542                .with_timezone(&Utc);
543
544            Some(Expense {
545                id: exp_id,
546                acp_id: Uuid::parse_str(&e.acp_id).ok()?,
547                organization_id,
548                building_id: bldg_id,
549                category: e.category.clone(),
550                description: e.description.clone(),
551                amount: e.amount,
552                amount_excl_vat: None,
553                vat_rate: None,
554                vat_amount: None,
555                amount_incl_vat: None,
556                expense_date: exp_date,
557                invoice_date: None,
558                due_date: None,
559                paid_date: None,
560                approval_status: e.approval_status.clone(),
561                submitted_at: None,
562                approved_by: None,
563                approved_at: None,
564                rejection_reason: None,
565                payment_status: e.payment_status.clone(),
566                supplier: e.supplier.clone(),
567                invoice_number: e.invoice_number.clone(),
568                account_code: e.account_code.clone(),
569                contractor_report_id: None,
570                created_at: Utc::now(), // Simplified
571                updated_at: Utc::now(), // Simplified
572            })
573        })
574        .collect();
575
576    // Budget items (empty for now, to be implemented with budget system)
577    let budget_items: Vec<BudgetItem> = Vec::new();
578
579    // 3. Generate PDF
580    match AnnualReportExporter::export_to_pdf(
581        &building_entity,
582        year,
583        &expense_entities,
584        &budget_items,
585        total_income,
586        reserve_fund,
587    ) {
588        Ok(pdf_bytes) => {
589            // Audit log
590            AuditLogEntry::new(
591                AuditEventType::ReportGenerated,
592                Some(user.user_id),
593                Some(organization_id),
594            )
595            .with_resource("Building", building_id)
596            .with_metadata(serde_json::json!({
597                "report_type": "annual_report_pdf",
598                "building_name": building_entity.name,
599                "year": year,
600                "total_income": total_income,
601                "reserve_fund": reserve_fund
602            }))
603            .log();
604
605            HttpResponse::Ok()
606                .content_type("application/pdf")
607                .insert_header((
608                    "Content-Disposition",
609                    format!(
610                        "attachment; filename=\"Rapport_Annuel_{}_{}.pdf\"",
611                        building_entity.name.replace(' ', "_"),
612                        year
613                    ),
614                ))
615                .body(pdf_bytes)
616        }
617        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
618            "error": format!("Failed to generate PDF: {}", err)
619        })),
620    }
621}