koprogo_api/infrastructure/web/handlers/
building_handlers.rs1use 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, mut dto: web::Json<CreateBuildingDto>,
15) -> impl Responder {
16 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 let organization_id: Uuid;
26
27 if user.role == "superadmin" {
28 if dto.organization_id.is_empty() {
30 return HttpResponse::BadRequest().json(serde_json::json!({
31 "error": "SuperAdmin must specify organization_id"
32 }));
33 }
34 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Deserialize)]
293pub struct ExportAnnualReportQuery {
294 pub year: i32,
295 #[serde(default)]
296 pub reserve_fund: Option<f64>, #[serde(default)]
298 pub total_income: Option<f64>, }
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 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 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 let year_expenses: Vec<_> = expenses_dto
354 .into_iter()
355 .filter(|e| {
356 DateTime::parse_from_rfc3339(&e.expense_date)
358 .map(|dt| dt.year() == year)
359 .unwrap_or(false)
360 })
361 .collect();
362
363 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 let reserve_fund = query.reserve_fund.unwrap_or(0.0);
375
376 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 let expense_entities: Vec<Expense> = year_expenses
411 .iter()
412 .filter_map(|e| {
413 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(), updated_at: Utc::now(), })
447 })
448 .collect();
449
450 let budget_items: Vec<BudgetItem> = Vec::new();
452
453 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 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}