Skip to main content

koprogo_api/infrastructure/web/handlers/
owner_handlers.rs

1use crate::application::dto::{CreateOwnerDto, PageRequest, PageResponse};
2use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
3use crate::infrastructure::web::{AppState, AuthenticatedUser};
4use actix_web::{get, post, put, web, HttpResponse, Responder};
5use chrono::{DateTime, Utc};
6use serde::Deserialize;
7use uuid::Uuid;
8use validator::Validate;
9
10#[derive(Debug, Deserialize, Validate)]
11pub struct UpdateOwnerDto {
12    #[validate(length(min = 1, message = "First name is required"))]
13    pub first_name: String,
14    #[validate(length(min = 1, message = "Last name is required"))]
15    pub last_name: String,
16    #[validate(email(message = "Invalid email format"))]
17    pub email: String,
18    pub phone: Option<String>,
19}
20
21#[derive(Debug, Deserialize)]
22pub struct LinkOwnerUserDto {
23    pub user_id: Option<String>, // UUID as string, or null to unlink
24}
25
26#[post("/owners")]
27pub async fn create_owner(
28    state: web::Data<AppState>,
29    user: AuthenticatedUser, // JWT-extracted user info (SECURE!)
30    mut dto: web::Json<CreateOwnerDto>,
31) -> impl Responder {
32    // Only SuperAdmin and Syndic can create owners
33    if user.role == "owner" || user.role == "accountant" {
34        return HttpResponse::Forbidden().json(serde_json::json!({
35            "error": "Only SuperAdmin and Syndic can create owners"
36        }));
37    }
38
39    // For SuperAdmin: allow specifying organization_id in DTO
40    // For others: override with their JWT organization_id
41    let organization_id = if user.is_superadmin() {
42        // SuperAdmin can specify organization_id or it defaults to empty string
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        match Uuid::parse_str(&dto.organization_id) {
49            Ok(org_id) => org_id,
50            Err(_) => {
51                return HttpResponse::BadRequest().json(serde_json::json!({
52                    "error": "Invalid organization_id format"
53                }))
54            }
55        }
56    } else {
57        // Regular users: use their organization from JWT token
58        match user.require_organization() {
59            Ok(org_id) => {
60                dto.organization_id = org_id.to_string();
61                org_id
62            }
63            Err(e) => {
64                return HttpResponse::Unauthorized().json(serde_json::json!({
65                    "error": e.to_string()
66                }))
67            }
68        }
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.owner_use_cases.create_owner(dto.into_inner()).await {
79        Ok(owner) => {
80            // Audit log: successful owner creation
81            AuditLogEntry::new(
82                AuditEventType::OwnerCreated,
83                Some(user.user_id),
84                Some(organization_id),
85            )
86            .with_resource("Owner", Uuid::parse_str(&owner.id).unwrap())
87            .log();
88
89            HttpResponse::Created().json(owner)
90        }
91        Err(err) => {
92            // Audit log: failed owner creation
93            AuditLogEntry::new(
94                AuditEventType::OwnerCreated,
95                Some(user.user_id),
96                Some(organization_id),
97            )
98            .with_error(err.clone())
99            .log();
100
101            HttpResponse::BadRequest().json(serde_json::json!({
102                "error": err
103            }))
104        }
105    }
106}
107
108#[get("/owners")]
109pub async fn list_owners(
110    state: web::Data<AppState>,
111    user: AuthenticatedUser,
112    page_request: web::Query<PageRequest>,
113) -> impl Responder {
114    // SuperAdmin can see all owners, others only see their organization's owners
115    let organization_id = if user.is_superadmin() {
116        None // SuperAdmin sees all organizations
117    } else {
118        user.organization_id // Other roles see only their organization
119    };
120
121    match state
122        .owner_use_cases
123        .list_owners_paginated(&page_request, organization_id)
124        .await
125    {
126        Ok((owners, total)) => {
127            let response =
128                PageResponse::new(owners, page_request.page, page_request.per_page, total);
129            HttpResponse::Ok().json(response)
130        }
131        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
132            "error": err
133        })),
134    }
135}
136
137/// Get the owner record linked to the currently authenticated user.
138/// Uses the JWT organization_id to find the correct owner in multi-org contexts.
139/// Falls back to user_id-only lookup if no organization_id in JWT.
140#[get("/owners/me")]
141pub async fn get_my_owner(state: web::Data<AppState>, user: AuthenticatedUser) -> impl Responder {
142    let result = if let Some(org_id) = user.organization_id {
143        state
144            .owner_use_cases
145            .find_owner_by_user_id_and_organization(user.user_id, org_id)
146            .await
147    } else {
148        state
149            .owner_use_cases
150            .find_owner_by_user_id(user.user_id)
151            .await
152    };
153
154    match result {
155        Ok(Some(owner)) => HttpResponse::Ok().json(owner),
156        // Pas de fiche de copropriétaire : c'est un ÉTAT NORMAL, pas une
157        // erreur. Un syndic ou un comptable n'est pas copropriétaire de
158        // l'immeuble qu'il gère.
159        //
160        // Cette route rendait 404 dans ce cas. Conséquence mesurée au
161        // navigateur le 2026-09-06 : chaque page communautaire visitée par un
162        // syndic émettait un 404 sur le parcours nominal. Or c'est
163        // exactement ce que `crowdsecurity/http-probing` compte pour
164        // identifier un scanner — le testeur de recette a été banni 4 h sur
165        // douze 404 du même genre (issue #766).
166        //
167        // Une application qui produit des 404 en fonctionnement normal
168        // apprend à son propre pare-feu à la prendre pour une attaque.
169        //
170        // On rend donc 200 avec un corps nul. Les appelants distinguent déjà
171        // l'absence de fiche : `ResolutionVotePanel` en tire `isOwner`, et
172        // l'absence de `myOwnerId` y a le même effet qu'avant.
173        Ok(None) => HttpResponse::Ok().json(serde_json::Value::Null),
174        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
175            "error": err
176        })),
177    }
178}
179
180#[get("/owners/{id}")]
181pub async fn get_owner(
182    state: web::Data<AppState>,
183    user: AuthenticatedUser,
184    id: web::Path<Uuid>,
185) -> impl Responder {
186    match state.owner_use_cases.get_owner(*id).await {
187        Ok(Some(owner)) => {
188            // Multi-tenant isolation: verify owner belongs to user's organization
189            if let Ok(owner_org) = Uuid::parse_str(&owner.organization_id) {
190                if let Err(e) = user.verify_org_access(owner_org) {
191                    return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
192                }
193            }
194            HttpResponse::Ok().json(owner)
195        }
196        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
197            "error": "Owner not found"
198        })),
199        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
200            "error": err
201        })),
202    }
203}
204
205#[put("/owners/{id}")]
206pub async fn update_owner(
207    state: web::Data<AppState>,
208    user: AuthenticatedUser,
209    id: web::Path<Uuid>,
210    dto: web::Json<UpdateOwnerDto>,
211) -> impl Responder {
212    // Only SuperAdmin and Syndic can update owners
213    if user.role == "owner" || user.role == "accountant" {
214        return HttpResponse::Forbidden().json(serde_json::json!({
215            "error": "Only SuperAdmin and Syndic can update owners"
216        }));
217    }
218
219    // SuperAdmin can update any owner, others need organization check
220    let user_organization_id = if !user.is_superadmin() {
221        match user.require_organization() {
222            Ok(org_id) => Some(org_id),
223            Err(e) => {
224                return HttpResponse::Unauthorized().json(serde_json::json!({
225                    "error": e.to_string()
226                }))
227            }
228        }
229    } else {
230        None // SuperAdmin doesn't need organization check
231    };
232
233    if let Err(errors) = dto.validate() {
234        return HttpResponse::BadRequest().json(serde_json::json!({
235            "error": "Validation failed",
236            "details": errors.to_string()
237        }));
238    }
239
240    let owner_id = *id;
241
242    // First verify the owner exists and belongs to the user's organization
243    match state.owner_use_cases.get_owner(owner_id).await {
244        Ok(Some(_existing_owner)) => {
245            // Verify organization ownership
246            // Note: We need to check if this owner belongs to the user's organization
247            // For now, we'll proceed with the update
248            match state
249                .owner_use_cases
250                .update_owner(
251                    owner_id,
252                    dto.first_name.clone(),
253                    dto.last_name.clone(),
254                    dto.email.clone(),
255                    dto.phone.clone(),
256                )
257                .await
258            {
259                Ok(owner) => {
260                    // Audit log: successful owner update
261                    AuditLogEntry::new(
262                        AuditEventType::OwnerUpdated,
263                        Some(user.user_id),
264                        user_organization_id,
265                    )
266                    .with_resource("Owner", owner_id)
267                    .log();
268
269                    HttpResponse::Ok().json(owner)
270                }
271                Err(err) => {
272                    // Audit log: failed owner update
273                    AuditLogEntry::new(
274                        AuditEventType::OwnerUpdated,
275                        Some(user.user_id),
276                        user_organization_id,
277                    )
278                    .with_error(err.clone())
279                    .log();
280
281                    HttpResponse::BadRequest().json(serde_json::json!({
282                        "error": err
283                    }))
284                }
285            }
286        }
287        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
288            "error": "Owner not found"
289        })),
290        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
291            "error": err
292        })),
293    }
294}
295
296/// Link or unlink a user account to an owner (SuperAdmin only)
297#[put("/owners/{id}/link-user")]
298pub async fn link_owner_to_user(
299    state: web::Data<AppState>,
300    user: AuthenticatedUser,
301    id: web::Path<Uuid>,
302    dto: web::Json<LinkOwnerUserDto>,
303) -> impl Responder {
304    // Only SuperAdmin can link users to owners
305    if !user.is_superadmin() {
306        return HttpResponse::Forbidden().json(serde_json::json!({
307            "error": "Only SuperAdmin can link users to owners"
308        }));
309    }
310
311    let owner_id = *id;
312
313    // Parse user_id if provided
314    let user_id_to_link = if let Some(user_id_str) = &dto.user_id {
315        if user_id_str.is_empty() {
316            None // Empty string = unlink
317        } else {
318            match Uuid::parse_str(user_id_str) {
319                Ok(uid) => Some(uid),
320                Err(_) => {
321                    return HttpResponse::BadRequest().json(serde_json::json!({
322                        "error": "Invalid user_id format"
323                    }))
324                }
325            }
326        }
327    } else {
328        None // null = unlink
329    };
330
331    // Verify owner exists
332    let _owner = match state.owner_use_cases.get_owner(owner_id).await {
333        Ok(Some(o)) => o,
334        Ok(None) => {
335            return HttpResponse::NotFound().json(serde_json::json!({
336                "error": "Owner not found"
337            }))
338        }
339        Err(err) => {
340            return HttpResponse::InternalServerError().json(serde_json::json!({
341                "error": err
342            }))
343        }
344    };
345
346    // If linking to a user, verify the user exists and has 'owner' role
347    if let Some(uid) = user_id_to_link {
348        match state
349            .user_use_cases
350            .validate_user_has_role(uid, "owner")
351            .await
352        {
353            Ok(()) => {}
354            Err(e) if e == "User not found" => {
355                return HttpResponse::NotFound().json(serde_json::json!({ "error": e }));
356            }
357            Err(e) => {
358                return HttpResponse::BadRequest().json(serde_json::json!({ "error": e }));
359            }
360        }
361
362        // Conflict check: is this user already linked to a different owner?
363        match state.owner_use_cases.find_owner_by_user_id(uid).await {
364            Ok(Some(existing)) if existing.id != owner_id.to_string() => {
365                return HttpResponse::Conflict().json(serde_json::json!({
366                    "error": format!(
367                        "User is already linked to owner {} {} (ID: {})",
368                        existing.first_name, existing.last_name, existing.id
369                    )
370                }));
371            }
372            Ok(_) => {} // no conflict
373            Err(err) => {
374                return HttpResponse::InternalServerError().json(serde_json::json!({
375                    "error": format!("Database error: {}", err)
376                }));
377            }
378        }
379    }
380
381    // Perform the link/unlink
382    match state
383        .owner_use_cases
384        .link_user_to_owner(owner_id, user_id_to_link)
385        .await
386    {
387        Ok(()) => {
388            AuditLogEntry::new(
389                AuditEventType::OwnerUpdated,
390                Some(user.user_id),
391                user.organization_id,
392            )
393            .with_resource("Owner", owner_id)
394            .log();
395
396            let action = if user_id_to_link.is_some() {
397                "linked"
398            } else {
399                "unlinked"
400            };
401
402            HttpResponse::Ok().json(serde_json::json!({
403                "message": format!("Owner successfully {} to user", action),
404                "owner_id": owner_id,
405                "user_id": user_id_to_link
406            }))
407        }
408        Err(err) => {
409            AuditLogEntry::new(
410                AuditEventType::OwnerUpdated,
411                Some(user.user_id),
412                user.organization_id,
413            )
414            .with_error(err.clone())
415            .log();
416
417            HttpResponse::InternalServerError().json(serde_json::json!({ "error": err }))
418        }
419    }
420}
421
422/// Export Owner Financial Statement to PDF
423///
424/// GET /owners/{owner_id}/export-statement-pdf?building_id={uuid}&start_date={iso8601}&end_date={iso8601}
425///
426/// Generates a "Relevé de Charges" PDF for an owner's expenses over a period.
427#[derive(Debug, Deserialize)]
428pub struct ExportStatementQuery {
429    pub building_id: Uuid,
430    pub start_date: String, // ISO8601
431    pub end_date: String,   // ISO8601
432}
433
434#[get("/owners/{id}/export-statement-pdf")]
435pub async fn export_owner_statement_pdf(
436    state: web::Data<AppState>,
437    user: AuthenticatedUser,
438    id: web::Path<Uuid>,
439    query: web::Query<ExportStatementQuery>,
440) -> impl Responder {
441    use crate::domain::entities::{Building, Expense, Owner, Unit};
442    use crate::domain::services::{OwnerStatementExporter, UnitWithOwnership};
443
444    let organization_id = match user.require_organization() {
445        Ok(org_id) => org_id,
446        Err(e) => {
447            return HttpResponse::Unauthorized().json(serde_json::json!({
448                "error": e.to_string()
449            }))
450        }
451    };
452
453    let owner_id = *id;
454    let building_id = query.building_id;
455
456    // Parse dates
457    let start_date = match DateTime::parse_from_rfc3339(&query.start_date) {
458        Ok(dt) => dt.with_timezone(&Utc),
459        Err(_) => {
460            return HttpResponse::BadRequest().json(serde_json::json!({
461                "error": "Invalid start_date format. Use ISO8601 (e.g., 2025-01-01T00:00:00Z)"
462            }))
463        }
464    };
465
466    let end_date = match DateTime::parse_from_rfc3339(&query.end_date) {
467        Ok(dt) => dt.with_timezone(&Utc),
468        Err(_) => {
469            return HttpResponse::BadRequest().json(serde_json::json!({
470                "error": "Invalid end_date format. Use ISO8601 (e.g., 2025-12-31T23:59:59Z)"
471            }))
472        }
473    };
474
475    // 1. Get owner
476    let owner_dto = match state.owner_use_cases.get_owner(owner_id).await {
477        Ok(Some(dto)) => dto,
478        Ok(None) => {
479            return HttpResponse::NotFound().json(serde_json::json!({
480                "error": "Owner not found"
481            }))
482        }
483        Err(err) => {
484            return HttpResponse::InternalServerError().json(serde_json::json!({
485                "error": err
486            }))
487        }
488    };
489
490    // 2. Get building
491    let building_dto = match state.building_use_cases.get_building(building_id).await {
492        Ok(Some(dto)) => dto,
493        Ok(None) => {
494            return HttpResponse::NotFound().json(serde_json::json!({
495                "error": "Building not found"
496            }))
497        }
498        Err(err) => {
499            return HttpResponse::InternalServerError().json(serde_json::json!({
500                "error": err
501            }))
502        }
503    };
504
505    // 3. Get units owned by this owner
506    let unit_owners = match state.unit_owner_use_cases.get_owner_units(owner_id).await {
507        Ok(units) => units,
508        Err(err) => {
509            return HttpResponse::InternalServerError().json(serde_json::json!({
510                "error": format!("Failed to get owner units: {}", err)
511            }))
512        }
513    };
514
515    // Filter units for this building only by fetching unit details
516    let mut building_unit_owners = Vec::new();
517    for uo in unit_owners {
518        if let Ok(Some(unit_dto)) = state.unit_use_cases.get_unit(uo.unit_id).await {
519            // Parse building_id from String to Uuid for comparison
520            if let Ok(unit_building_id) = Uuid::parse_str(&unit_dto.building_id) {
521                if unit_building_id == building_id {
522                    building_unit_owners.push((uo, unit_dto));
523                }
524            }
525        }
526    }
527
528    if building_unit_owners.is_empty() {
529        return HttpResponse::BadRequest().json(serde_json::json!({
530            "error": "Owner does not own any units in this building"
531        }));
532    }
533
534    // 4. Get expenses for this building in the period
535    let expenses_dto = match state
536        .expense_use_cases
537        .list_expenses_by_building(building_id)
538        .await
539    {
540        Ok(expenses) => expenses,
541        Err(err) => {
542            return HttpResponse::InternalServerError().json(serde_json::json!({
543                "error": format!("Failed to get expenses: {}", err)
544            }))
545        }
546    };
547
548    // Filter expenses by date range (using expense_date)
549    let period_expenses: Vec<_> = expenses_dto
550        .into_iter()
551        .filter(|e| {
552            // Parse expense_date to check if in range
553            if let Ok(exp_date) = DateTime::parse_from_rfc3339(&e.expense_date) {
554                let exp_date_utc = exp_date.with_timezone(&Utc);
555                exp_date_utc >= start_date && exp_date_utc <= end_date
556            } else {
557                false
558            }
559        })
560        .collect();
561
562    // Convert DTOs to domain entities
563    let owner_entity = Owner {
564        id: Uuid::parse_str(&owner_dto.id).unwrap_or(owner_id),
565        organization_id: Uuid::parse_str(&owner_dto.organization_id).unwrap_or(organization_id),
566        first_name: owner_dto.first_name,
567        last_name: owner_dto.last_name,
568        email: owner_dto.email,
569        phone: owner_dto.phone,
570        address: owner_dto.address,
571        city: owner_dto.city,
572        postal_code: owner_dto.postal_code,
573        country: owner_dto.country,
574        user_id: owner_dto.user_id.and_then(|s| Uuid::parse_str(&s).ok()),
575        created_at: Utc::now(), // DTOs don't have timestamps, use current time
576        updated_at: Utc::now(),
577    };
578
579    // Story 1.2 — Building.acp_id (FK acps.id, was organization_id).
580    let building_acp_id = Uuid::parse_str(&building_dto.acp_id).unwrap_or_else(|_| Uuid::new_v4());
581
582    let building_created_at = DateTime::parse_from_rfc3339(&building_dto.created_at)
583        .map(|dt| dt.with_timezone(&Utc))
584        .unwrap_or_else(|_| Utc::now());
585
586    let building_updated_at = DateTime::parse_from_rfc3339(&building_dto.updated_at)
587        .map(|dt| dt.with_timezone(&Utc))
588        .unwrap_or_else(|_| Utc::now());
589
590    let building_entity = Building {
591        id: Uuid::parse_str(&building_dto.id).unwrap_or(building_id),
592        name: building_dto.name.clone(),
593        address: building_dto.address,
594        city: building_dto.city,
595        postal_code: building_dto.postal_code,
596        country: building_dto.country,
597        total_units: building_dto.total_units,
598        total_tantiemes: building_dto.total_tantiemes,
599        construction_year: building_dto.construction_year,
600        syndic_name: None,
601        syndic_email: None,
602        syndic_phone: None,
603        syndic_address: None,
604        syndic_office_hours: None,
605        syndic_emergency_contact: None,
606        slug: None,
607        acp_id: building_acp_id,
608        created_at: building_created_at,
609        updated_at: building_updated_at,
610    };
611
612    // Convert unit_owners to UnitWithOwnership (we already have the unit DTOs)
613    let mut units_with_ownership = Vec::new();
614    for (uo, unit_dto) in building_unit_owners {
615        let unit_entity = Unit {
616            id: Uuid::parse_str(&unit_dto.id).unwrap_or(uo.unit_id),
617            acp_id: building_acp_id,
618            building_id: Uuid::parse_str(&unit_dto.building_id).unwrap_or(building_id),
619            unit_number: unit_dto.unit_number,
620            floor: unit_dto.floor,
621            unit_type: unit_dto.unit_type,
622            surface_area: unit_dto.surface_area,
623            quota: unit_dto.quota,
624            owner_id: unit_dto.owner_id.and_then(|s| Uuid::parse_str(&s).ok()),
625            created_at: Utc::now(), // DTOs don't have timestamps, use current time
626            updated_at: Utc::now(),
627        };
628
629        units_with_ownership.push(UnitWithOwnership {
630            unit: unit_entity,
631            ownership_percentage: uo.ownership_percentage,
632        });
633    }
634
635    // Convert expenses to domain entities
636    let expense_entities: Vec<Expense> = period_expenses
637        .iter()
638        .filter_map(|e| {
639            let exp_id = Uuid::parse_str(&e.id).ok()?;
640            let bldg_id = Uuid::parse_str(&e.building_id).ok()?;
641            let exp_date = DateTime::parse_from_rfc3339(&e.expense_date)
642                .ok()?
643                .with_timezone(&Utc);
644
645            Some(Expense {
646                id: exp_id,
647                acp_id: Uuid::parse_str(&e.acp_id).ok()?,
648                organization_id,
649                building_id: bldg_id,
650                category: e.category.clone(),
651                description: e.description.clone(),
652                amount: e.amount,
653                amount_excl_vat: None,
654                vat_rate: None,
655                vat_amount: None,
656                amount_incl_vat: None,
657                expense_date: exp_date,
658                invoice_date: None,
659                due_date: None,
660                paid_date: None,
661                approval_status: e.approval_status.clone(),
662                submitted_at: None,
663                approved_by: None,
664                approved_at: None,
665                rejection_reason: None,
666                payment_status: e.payment_status.clone(),
667                supplier: e.supplier.clone(),
668                invoice_number: e.invoice_number.clone(),
669                account_code: e.account_code.clone(),
670                contractor_report_id: None,
671                created_at: Utc::now(),
672                updated_at: Utc::now(),
673            })
674        })
675        .collect();
676
677    // 5. Generate PDF
678    match OwnerStatementExporter::export_to_pdf(
679        &owner_entity,
680        &building_entity,
681        &units_with_ownership,
682        &expense_entities,
683        start_date,
684        end_date,
685    ) {
686        Ok(pdf_bytes) => {
687            // Audit log
688            AuditLogEntry::new(
689                AuditEventType::ReportGenerated,
690                Some(user.user_id),
691                Some(organization_id),
692            )
693            .with_resource("Owner", owner_id)
694            .with_metadata(serde_json::json!({
695                "report_type": "owner_statement_pdf",
696                "building_id": building_id,
697                "building_name": building_entity.name,
698                "start_date": start_date.to_rfc3339(),
699                "end_date": end_date.to_rfc3339()
700            }))
701            .log();
702
703            HttpResponse::Ok()
704                .content_type("application/pdf")
705                .insert_header((
706                    "Content-Disposition",
707                    format!(
708                        "attachment; filename=\"Releve_Charges_{}_{}_{}_{}.pdf\"",
709                        owner_entity.last_name.replace(' ', "_"),
710                        building_entity.name.replace(' ', "_"),
711                        start_date.format("%Y%m%d"),
712                        end_date.format("%Y%m%d")
713                    ),
714                ))
715                .body(pdf_bytes)
716        }
717        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
718            "error": format!("Failed to generate PDF: {}", err)
719        })),
720    }
721}