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#[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, dto: web::Json<CreateBuildingDto>,
40) -> impl Responder {
41 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 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 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 let organization_id: Option<Uuid> = user.organization_id;
94 let _ = acp_id; 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 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 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 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 match state
213 .building_use_cases
214 .get_building_with_metrics(*id)
215 .await
216 {
217 Ok(Some(building)) => {
218 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 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 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 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 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 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 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 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 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#[derive(Debug, Deserialize, utoipa::IntoParams)]
398pub struct ExportAnnualReportQuery {
399 pub year: i32,
400 #[serde(default)]
401 pub reserve_fund: Option<rust_decimal::Decimal>, #[serde(default)]
403 pub total_income: Option<rust_decimal::Decimal>, }
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 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 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 let year_expenses: Vec<_> = expenses_dto
476 .into_iter()
477 .filter(|e| {
478 DateTime::parse_from_rfc3339(&e.expense_date)
480 .map(|dt| dt.year() == year)
481 .unwrap_or(false)
482 })
483 .collect();
484
485 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 let reserve_fund = query.reserve_fund.unwrap_or(rust_decimal::Decimal::ZERO);
497
498 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 let expense_entities: Vec<Expense> = year_expenses
535 .iter()
536 .filter_map(|e| {
537 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(), updated_at: Utc::now(), })
573 })
574 .collect();
575
576 let budget_items: Vec<BudgetItem> = Vec::new();
578
579 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 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}