Skip to main content

koprogo_api/infrastructure/web/handlers/
unit_owner_handlers.rs

1use crate::application::dto::{
2    AddOwnerToUnitDto, DesignateVotingRepresentativeDto, TransferOwnershipDto,
3    UnitOwnerResponseDto, UpdateOwnershipDto, VotingRepresentativeResponseDto,
4};
5use crate::domain::entities::UnitOwner;
6use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
7use crate::infrastructure::web::middleware::scope_guard::{
8    verify_owner_org_access, verify_unit_org_access,
9};
10use crate::infrastructure::web::{AppState, AuthenticatedUser};
11use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
12use chrono::{DateTime, Utc};
13use uuid::Uuid;
14use validator::Validate;
15
16/// Helper function to check if user role can modify unit ownership
17/// Only SuperAdmin and Syndic can modify unit ownership (who owns what)
18fn verify_unit_ownership_permission(user: &AuthenticatedUser) -> Option<HttpResponse> {
19    if user.role == "owner" || user.role == "accountant" {
20        Some(HttpResponse::Forbidden().json(serde_json::json!({
21            "error": "Only SuperAdmin and Syndic can modify unit ownership"
22        })))
23    } else {
24        None
25    }
26}
27
28/// Add an owner to a unit
29#[post("/units/{unit_id}/owners")]
30pub async fn add_owner_to_unit(
31    state: web::Data<AppState>,
32    user: AuthenticatedUser,
33    unit_id: web::Path<String>,
34    dto: web::Json<AddOwnerToUnitDto>,
35) -> impl Responder {
36    if let Some(response) = verify_unit_ownership_permission(&user) {
37        return response;
38    }
39
40    // Validate DTO
41    if let Err(errors) = dto.validate() {
42        return HttpResponse::BadRequest().json(serde_json::json!({
43            "error": "Validation failed",
44            "details": errors.to_string()
45        }));
46    }
47
48    // Parse UUIDs
49    let unit_id = match Uuid::parse_str(&unit_id) {
50        Ok(id) => id,
51        Err(_) => {
52            return HttpResponse::BadRequest().json(serde_json::json!({
53                "error": "Invalid unit_id format"
54            }))
55        }
56    };
57
58    let owner_id = match Uuid::parse_str(&dto.owner_id) {
59        Ok(id) => id,
60        Err(_) => {
61            return HttpResponse::BadRequest().json(serde_json::json!({
62                "error": "Invalid owner_id format"
63            }))
64        }
65    };
66
67    // Call use case
68    match state
69        .unit_owner_use_cases
70        .add_owner_to_unit(
71            unit_id,
72            owner_id,
73            dto.ownership_percentage,
74            dto.is_primary_contact,
75        )
76        .await
77    {
78        Ok(unit_owner) => {
79            // Audit log
80            if let Some(org_id) = user.organization_id {
81                AuditLogEntry::new(
82                    AuditEventType::UnitOwnerCreated,
83                    Some(user.user_id),
84                    Some(org_id),
85                )
86                .with_resource("UnitOwner", unit_owner.id)
87                .log();
88            }
89
90            HttpResponse::Created().json(to_response_dto(&unit_owner))
91        }
92        Err(err) => {
93            if let Some(org_id) = user.organization_id {
94                AuditLogEntry::new(
95                    AuditEventType::UnitOwnerCreated,
96                    Some(user.user_id),
97                    Some(org_id),
98                )
99                .with_error(err.clone())
100                .log();
101            }
102
103            HttpResponse::BadRequest().json(serde_json::json!({
104                "error": err
105            }))
106        }
107    }
108}
109
110/// Remove an owner from a unit
111#[delete("/units/{unit_id}/owners/{owner_id}")]
112pub async fn remove_owner_from_unit(
113    state: web::Data<AppState>,
114    user: AuthenticatedUser,
115    path: web::Path<(String, String)>,
116) -> impl Responder {
117    if let Some(response) = verify_unit_ownership_permission(&user) {
118        return response;
119    }
120
121    let (unit_id_str, owner_id_str) = path.into_inner();
122
123    // Parse UUIDs
124    let unit_id = match Uuid::parse_str(&unit_id_str) {
125        Ok(id) => id,
126        Err(_) => {
127            return HttpResponse::BadRequest().json(serde_json::json!({
128                "error": "Invalid unit_id format"
129            }))
130        }
131    };
132
133    let owner_id = match Uuid::parse_str(&owner_id_str) {
134        Ok(id) => id,
135        Err(_) => {
136            return HttpResponse::BadRequest().json(serde_json::json!({
137                "error": "Invalid owner_id format"
138            }))
139        }
140    };
141
142    // Cloisonnement AVANT la suppression (#864).
143    //
144    // `verify_unit_ownership_permission` plus haut vérifie le RÔLE : seuls un
145    // syndic ou un superadministrateur peuvent toucher aux titularités. Il ne
146    // vérifie pas l'ORGANISATION — un syndic du cabinet A pouvait donc détacher
147    // un copropriétaire d'un lot du cabinet B.
148    //
149    // Deux contrôles distincts, qu'il est facile de confondre : « ce rôle a le
150    // droit de faire ce geste » ne dit rien de « sur cet objet-ci ». Les deux
151    // routes voisines de ce fichier appellent déjà cette garde.
152    if let Err(err) = verify_unit_org_access(
153        &user,
154        unit_id,
155        &state.unit_use_cases,
156        &state.building_use_cases,
157        &state.acp_use_cases,
158    )
159    .await
160    {
161        return err.error_response();
162    }
163
164    // Call use case
165    match state
166        .unit_owner_use_cases
167        .remove_owner_from_unit(unit_id, owner_id)
168        .await
169    {
170        Ok(unit_owner) => {
171            // Audit log
172            if let Some(org_id) = user.organization_id {
173                AuditLogEntry::new(
174                    AuditEventType::UnitOwnerDeleted,
175                    Some(user.user_id),
176                    Some(org_id),
177                )
178                .with_resource("UnitOwner", unit_owner.id)
179                .log();
180            }
181
182            HttpResponse::Ok().json(to_response_dto(&unit_owner))
183        }
184        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
185            "error": err
186        })),
187    }
188}
189
190/// Update a unit-owner relationship (ownership percentage or primary contact)
191#[put("/unit-owners/{id}")]
192pub async fn update_unit_owner(
193    state: web::Data<AppState>,
194    user: AuthenticatedUser,
195    id: web::Path<String>,
196    dto: web::Json<UpdateOwnershipDto>,
197) -> impl Responder {
198    if let Some(response) = verify_unit_ownership_permission(&user) {
199        return response;
200    }
201
202    // Validate DTO
203    if let Err(errors) = dto.validate() {
204        return HttpResponse::BadRequest().json(serde_json::json!({
205            "error": "Validation failed",
206            "details": errors.to_string()
207        }));
208    }
209
210    // Parse UUID
211    let unit_owner_id = match Uuid::parse_str(&id) {
212        Ok(id) => id,
213        Err(_) => {
214            return HttpResponse::BadRequest().json(serde_json::json!({
215                "error": "Invalid unit_owner_id format"
216            }))
217        }
218    };
219
220    // Update ownership percentage if provided
221    let result = if let Some(percentage) = dto.ownership_percentage {
222        state
223            .unit_owner_use_cases
224            .update_ownership_percentage(unit_owner_id, percentage)
225            .await
226    } else if let Some(is_primary) = dto.is_primary_contact {
227        if is_primary {
228            state
229                .unit_owner_use_cases
230                .set_primary_contact(unit_owner_id)
231                .await
232        } else {
233            // If unsetting primary, just get the current one and update it
234            match state
235                .unit_owner_use_cases
236                .get_unit_owner(unit_owner_id)
237                .await
238            {
239                Ok(Some(mut unit_owner)) => {
240                    unit_owner.set_primary_contact(false);
241                    // We'd need an update method in the repository, but for now return error
242                    return HttpResponse::BadRequest().json(serde_json::json!({
243                        "error": "Cannot unset primary contact directly. Set another owner as primary instead."
244                    }));
245                }
246                Ok(None) => {
247                    return HttpResponse::NotFound().json(serde_json::json!({
248                        "error": "Unit-owner relationship not found"
249                    }))
250                }
251                Err(err) => {
252                    return HttpResponse::InternalServerError().json(serde_json::json!({
253                        "error": err
254                    }))
255                }
256            }
257        }
258    } else {
259        return HttpResponse::BadRequest().json(serde_json::json!({
260            "error": "Must provide either ownership_percentage or is_primary_contact"
261        }));
262    };
263
264    match result {
265        Ok(unit_owner) => {
266            // Audit log
267            if let Some(org_id) = user.organization_id {
268                AuditLogEntry::new(
269                    AuditEventType::UnitOwnerUpdated,
270                    Some(user.user_id),
271                    Some(org_id),
272                )
273                .with_resource("UnitOwner", unit_owner.id)
274                .log();
275            }
276
277            HttpResponse::Ok().json(to_response_dto(&unit_owner))
278        }
279        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
280            "error": err
281        })),
282    }
283}
284
285/// Get all current owners of a unit
286#[get("/units/{unit_id}/owners")]
287pub async fn get_unit_owners(
288    state: web::Data<AppState>,
289    user: AuthenticatedUser,
290    unit_id: web::Path<String>,
291) -> impl Responder {
292    // Parse UUID
293    let unit_id = match Uuid::parse_str(&unit_id) {
294        Ok(id) => id,
295        Err(_) => {
296            return HttpResponse::BadRequest().json(serde_json::json!({
297                "error": "Invalid unit_id format"
298            }))
299        }
300    };
301
302    // Cloisonnement : le lot visé doit relever d'une ACP que cet utilisateur a
303    // le droit de voir. Ces routes disent QUI DÉTIENT QUOI, nominativement —
304    // la donnée la plus sensible du produit après les montants.
305    //
306    // L'identité était prise puis ignorée — `_user` (#772).
307    if let Err(err) = verify_unit_org_access(
308        &user,
309        unit_id,
310        &state.unit_use_cases,
311        &state.building_use_cases,
312        &state.acp_use_cases,
313    )
314    .await
315    {
316        return err.error_response();
317    }
318
319    // Call use case
320    match state.unit_owner_use_cases.get_unit_owners(unit_id).await {
321        Ok(unit_owners) => {
322            let dtos: Vec<UnitOwnerResponseDto> = unit_owners.iter().map(to_response_dto).collect();
323            HttpResponse::Ok().json(dtos)
324        }
325        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
326            "error": err
327        })),
328    }
329}
330
331/// Get all current units owned by an owner
332#[get("/owners/{owner_id}/units")]
333pub async fn get_owner_units(
334    state: web::Data<AppState>,
335    user: AuthenticatedUser,
336    owner_id: web::Path<String>,
337) -> impl Responder {
338    // Parse UUID
339    let owner_id = match Uuid::parse_str(&owner_id) {
340        Ok(id) => id,
341        Err(_) => {
342            return HttpResponse::BadRequest().json(serde_json::json!({
343                "error": "Invalid owner_id format"
344            }))
345        }
346    };
347
348    // Cloisonnement : le copropriétaire visé doit relever d'une organisation
349    // que cet utilisateur a le droit de voir. Lister ses lots hors de son ACP,
350    // c'est apprendre le patrimoine d'un tiers (#772).
351    if let Err(err) = verify_owner_org_access(&user, owner_id, &state.owner_use_cases).await {
352        return err.error_response();
353    }
354
355    // Call use case
356    match state.unit_owner_use_cases.get_owner_units(owner_id).await {
357        Ok(unit_owners) => {
358            let dtos: Vec<UnitOwnerResponseDto> = unit_owners.iter().map(to_response_dto).collect();
359            HttpResponse::Ok().json(dtos)
360        }
361        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
362            "error": err
363        })),
364    }
365}
366
367/// Get ownership history for a unit
368#[get("/units/{unit_id}/owners/history")]
369pub async fn get_unit_ownership_history(
370    state: web::Data<AppState>,
371    user: AuthenticatedUser,
372    unit_id: web::Path<String>,
373) -> impl Responder {
374    // Parse UUID
375    let unit_id = match Uuid::parse_str(&unit_id) {
376        Ok(id) => id,
377        Err(_) => {
378            return HttpResponse::BadRequest().json(serde_json::json!({
379                "error": "Invalid unit_id format"
380            }))
381        }
382    };
383
384    // Cloisonnement : le lot visé doit relever d'une ACP que cet utilisateur a
385    // le droit de voir. Ces routes disent QUI DÉTIENT QUOI, nominativement —
386    // la donnée la plus sensible du produit après les montants.
387    //
388    // L'identité était prise puis ignorée — `_user` (#772).
389    if let Err(err) = verify_unit_org_access(
390        &user,
391        unit_id,
392        &state.unit_use_cases,
393        &state.building_use_cases,
394        &state.acp_use_cases,
395    )
396    .await
397    {
398        return err.error_response();
399    }
400
401    // Call use case
402    match state
403        .unit_owner_use_cases
404        .get_unit_ownership_history(unit_id)
405        .await
406    {
407        Ok(unit_owners) => {
408            let dtos: Vec<UnitOwnerResponseDto> = unit_owners.iter().map(to_response_dto).collect();
409            HttpResponse::Ok().json(dtos)
410        }
411        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
412            "error": err
413        })),
414    }
415}
416
417/// Get ownership history for an owner
418#[get("/owners/{owner_id}/units/history")]
419pub async fn get_owner_ownership_history(
420    state: web::Data<AppState>,
421    user: AuthenticatedUser,
422    owner_id: web::Path<String>,
423) -> impl Responder {
424    // Parse UUID
425    let owner_id = match Uuid::parse_str(&owner_id) {
426        Ok(id) => id,
427        Err(_) => {
428            return HttpResponse::BadRequest().json(serde_json::json!({
429                "error": "Invalid owner_id format"
430            }))
431        }
432    };
433
434    // Cloisonnement : le copropriétaire visé doit relever d'une organisation
435    // que cet utilisateur a le droit de voir. Lister ses lots hors de son ACP,
436    // c'est apprendre le patrimoine d'un tiers (#772).
437    if let Err(err) = verify_owner_org_access(&user, owner_id, &state.owner_use_cases).await {
438        return err.error_response();
439    }
440
441    // Call use case
442    match state
443        .unit_owner_use_cases
444        .get_owner_ownership_history(owner_id)
445        .await
446    {
447        Ok(unit_owners) => {
448            let dtos: Vec<UnitOwnerResponseDto> = unit_owners.iter().map(to_response_dto).collect();
449            HttpResponse::Ok().json(dtos)
450        }
451        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
452            "error": err
453        })),
454    }
455}
456
457/// Transfer ownership from one owner to another
458#[post("/units/{unit_id}/owners/transfer")]
459pub async fn transfer_ownership(
460    state: web::Data<AppState>,
461    user: AuthenticatedUser,
462    unit_id: web::Path<String>,
463    dto: web::Json<TransferOwnershipDto>,
464) -> impl Responder {
465    if let Some(response) = verify_unit_ownership_permission(&user) {
466        return response;
467    }
468
469    // Validate DTO
470    if let Err(errors) = dto.validate() {
471        return HttpResponse::BadRequest().json(serde_json::json!({
472            "error": "Validation failed",
473            "details": errors.to_string()
474        }));
475    }
476
477    // Parse UUIDs
478    let unit_id = match Uuid::parse_str(&unit_id) {
479        Ok(id) => id,
480        Err(_) => {
481            return HttpResponse::BadRequest().json(serde_json::json!({
482                "error": "Invalid unit_id format"
483            }))
484        }
485    };
486
487    let from_owner_id = match Uuid::parse_str(&dto.from_owner_id) {
488        Ok(id) => id,
489        Err(_) => {
490            return HttpResponse::BadRequest().json(serde_json::json!({
491                "error": "Invalid from_owner_id format"
492            }))
493        }
494    };
495
496    let to_owner_id = match Uuid::parse_str(&dto.to_owner_id) {
497        Ok(id) => id,
498        Err(_) => {
499            return HttpResponse::BadRequest().json(serde_json::json!({
500                "error": "Invalid to_owner_id format"
501            }))
502        }
503    };
504
505    // Call use case
506    match state
507        .unit_owner_use_cases
508        .transfer_ownership(from_owner_id, to_owner_id, unit_id)
509        .await
510    {
511        Ok((ended, created)) => {
512            // Audit log
513            if let Some(org_id) = user.organization_id {
514                AuditLogEntry::new(
515                    AuditEventType::UnitOwnerUpdated,
516                    Some(user.user_id),
517                    Some(org_id),
518                )
519                .with_resource("UnitOwner", ended.id)
520                .with_metadata(serde_json::json!({
521                    "transferred_to": created.id.to_string(),
522                    "new_unit_owner_id": created.id.to_string()
523                }))
524                .log();
525            }
526
527            HttpResponse::Ok().json(serde_json::json!({
528                "ended_relationship": to_response_dto(&ended),
529                "new_relationship": to_response_dto(&created)
530            }))
531        }
532        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
533            "error": err
534        })),
535    }
536}
537
538/// Get the total ownership percentage for a unit
539#[get("/units/{unit_id}/owners/total-percentage")]
540pub async fn get_total_ownership_percentage(
541    state: web::Data<AppState>,
542    user: AuthenticatedUser,
543    unit_id: web::Path<String>,
544) -> impl Responder {
545    // Parse UUID
546    let unit_id = match Uuid::parse_str(&unit_id) {
547        Ok(id) => id,
548        Err(_) => {
549            return HttpResponse::BadRequest().json(serde_json::json!({
550                "error": "Invalid unit_id format"
551            }))
552        }
553    };
554
555    // Cloisonnement : le lot visé doit relever d'une ACP que cet utilisateur a
556    // le droit de voir. Ces routes disent QUI DÉTIENT QUOI, nominativement —
557    // la donnée la plus sensible du produit après les montants.
558    //
559    // L'identité était prise puis ignorée — `_user` (#772).
560    if let Err(err) = verify_unit_org_access(
561        &user,
562        unit_id,
563        &state.unit_use_cases,
564        &state.building_use_cases,
565        &state.acp_use_cases,
566    )
567    .await
568    {
569        return err.error_response();
570    }
571
572    // Call use case
573    match state
574        .unit_owner_use_cases
575        .get_total_ownership_percentage(unit_id)
576        .await
577    {
578        Ok(total) => HttpResponse::Ok().json(serde_json::json!({
579            "unit_id": unit_id.to_string(),
580            "total_ownership_percentage": total,
581            "percentage_display": format!("{:.2}%", total * rust_decimal_macros::dec!(100))
582        })),
583        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
584            "error": err
585        })),
586    }
587}
588
589/// Désigne le représentant de vote d'un lot (#848, Art. 3.87 §1 CC).
590///
591/// Un lot à plusieurs titulaires actifs (couple, succession — le cas le plus
592/// ordinaire d'une copropriété belge) a son vote SUSPENDU jusqu'à cette
593/// désignation. Refuse un second représentant tant qu'un premier est en place
594/// (409, `assert_single_voting_representative`).
595#[utoipa::path(
596    post,
597    path = "/units/{unit_id}/voting-representative",
598    tag = "UnitOwners",
599    summary = "Désigner le représentant de vote d'un lot (Art. 3.87 §1er)",
600    params(("unit_id" = String, Path, description = "UUID du lot")),
601    request_body = DesignateVotingRepresentativeDto,
602    responses(
603        (status = 200, description = "Représentant désigné", body = crate::application::dto::unit_owner_dto::VotingRepresentativeResponseDto),
604        (status = 400, description = "Identifiant invalide"),
605        (status = 403, description = "Hors mandat sur ce lot"),
606    ),
607    security(("bearer_auth" = []))
608)]
609#[post("/units/{unit_id}/voting-representative")]
610pub async fn designate_voting_representative(
611    state: web::Data<AppState>,
612    user: AuthenticatedUser,
613    unit_id: web::Path<String>,
614    dto: web::Json<DesignateVotingRepresentativeDto>,
615) -> impl Responder {
616    if let Some(response) = verify_unit_ownership_permission(&user) {
617        return response;
618    }
619
620    if let Err(errors) = dto.validate() {
621        return HttpResponse::BadRequest().json(serde_json::json!({
622            "error": "Validation failed",
623            "details": errors.to_string()
624        }));
625    }
626
627    let unit_id = match Uuid::parse_str(&unit_id) {
628        Ok(id) => id,
629        Err(_) => {
630            return HttpResponse::BadRequest().json(serde_json::json!({
631                "error": "Invalid unit_id format"
632            }))
633        }
634    };
635
636    let owner_id = match Uuid::parse_str(&dto.owner_id) {
637        Ok(id) => id,
638        Err(_) => {
639            return HttpResponse::BadRequest().json(serde_json::json!({
640                "error": "Invalid owner_id format"
641            }))
642        }
643    };
644
645    // Cloisonnement : désigner un représentant de vote engage le droit de
646    // vote d'autrui (story #848, critère @security) — un syndic hors mandat
647    // sur ce lot ne peut pas le faire, comme pour les autres routes de ce
648    // fichier qui touchent aux titularités (#772, #864).
649    if let Err(err) = verify_unit_org_access(
650        &user,
651        unit_id,
652        &state.unit_use_cases,
653        &state.building_use_cases,
654        &state.acp_use_cases,
655    )
656    .await
657    {
658        return err.error_response();
659    }
660
661    match state
662        .unit_owner_use_cases
663        .designate_voting_representative(unit_id, owner_id)
664        .await
665    {
666        Ok(target) => {
667            if let Some(org_id) = user.organization_id {
668                AuditLogEntry::new(
669                    AuditEventType::UnitOwnerUpdated,
670                    Some(user.user_id),
671                    Some(org_id),
672                )
673                .with_resource("UnitOwner", target.id)
674                .with_metadata(serde_json::json!({
675                    "voting_representative_designated": true,
676                    "unit_id": unit_id.to_string(),
677                    "owner_id": owner_id.to_string()
678                }))
679                .log();
680            }
681
682            HttpResponse::Ok().json(VotingRepresentativeResponseDto {
683                unit_id: unit_id.to_string(),
684                owner_id: owner_id.to_string(),
685                unit_owner_id: target.id.to_string(),
686                is_voting_representative: true,
687            })
688        }
689        Err(err) => err.error_response(),
690    }
691}
692
693// Helper function to convert entity to response DTO
694fn to_response_dto(unit_owner: &UnitOwner) -> UnitOwnerResponseDto {
695    UnitOwnerResponseDto {
696        id: unit_owner.id.to_string(),
697        unit_id: unit_owner.unit_id.to_string(),
698        owner_id: unit_owner.owner_id.to_string(),
699        ownership_percentage: unit_owner.ownership_percentage,
700        start_date: unit_owner.start_date,
701        end_date: unit_owner.end_date,
702        is_primary_contact: unit_owner.is_primary_contact,
703        is_active: unit_owner.is_active(),
704        created_at: unit_owner.created_at,
705        updated_at: unit_owner.updated_at,
706    }
707}
708
709/// Export Ownership Contract to PDF
710///
711/// GET /unit-owners/{relationship_id}/export-contract-pdf
712///
713/// Generates a "Contrat de Copropriété" PDF for a unit ownership relationship.
714#[get("/unit-owners/{id}/export-contract-pdf")]
715pub async fn export_ownership_contract_pdf(
716    state: web::Data<AppState>,
717    user: AuthenticatedUser,
718    id: web::Path<Uuid>,
719) -> impl Responder {
720    use crate::domain::entities::{Building, Owner, Unit};
721    use crate::domain::services::OwnershipContractExporter;
722
723    let organization_id = match user.require_organization() {
724        Ok(org_id) => org_id,
725        Err(e) => {
726            return HttpResponse::Unauthorized().json(serde_json::json!({
727                "error": e.to_string()
728            }))
729        }
730    };
731
732    let relationship_id = *id;
733
734    // 1. Get the UnitOwner relationship
735    let unit_owner = match state
736        .unit_owner_use_cases
737        .get_unit_owner(relationship_id)
738        .await
739    {
740        Ok(Some(uo)) => uo,
741        Ok(None) => {
742            return HttpResponse::NotFound().json(serde_json::json!({
743                "error": "Unit ownership relationship not found"
744            }))
745        }
746        Err(err) => {
747            return HttpResponse::InternalServerError().json(serde_json::json!({
748                "error": format!("Failed to get unit ownership: {}", err)
749            }))
750        }
751    };
752
753    // 2. Get unit
754    let unit_dto = match state.unit_use_cases.get_unit(unit_owner.unit_id).await {
755        Ok(Some(dto)) => dto,
756        Ok(None) => {
757            return HttpResponse::NotFound().json(serde_json::json!({
758                "error": "Unit not found"
759            }))
760        }
761        Err(err) => {
762            return HttpResponse::InternalServerError().json(serde_json::json!({
763                "error": err
764            }))
765        }
766    };
767
768    // 3. Get owner
769    let owner_dto = match state.owner_use_cases.get_owner(unit_owner.owner_id).await {
770        Ok(Some(dto)) => dto,
771        Ok(None) => {
772            return HttpResponse::NotFound().json(serde_json::json!({
773                "error": "Owner not found"
774            }))
775        }
776        Err(err) => {
777            return HttpResponse::InternalServerError().json(serde_json::json!({
778                "error": err
779            }))
780        }
781    };
782
783    // 4. Get building
784    let building_uuid = match Uuid::parse_str(&unit_dto.building_id) {
785        Ok(uuid) => uuid,
786        Err(_) => {
787            return HttpResponse::BadRequest().json(serde_json::json!({
788                "error": "Invalid building ID format"
789            }))
790        }
791    };
792    let building_dto = match state.building_use_cases.get_building(building_uuid).await {
793        Ok(Some(dto)) => dto,
794        Ok(None) => {
795            return HttpResponse::NotFound().json(serde_json::json!({
796                "error": "Building not found"
797            }))
798        }
799        Err(err) => {
800            return HttpResponse::InternalServerError().json(serde_json::json!({
801                "error": err
802            }))
803        }
804    };
805
806    // Story 1.2 — Building.acp_id (FK acps.id, was organization_id).
807    let building_acp_id = Uuid::parse_str(&building_dto.acp_id).unwrap_or_else(|_| Uuid::new_v4());
808
809    let building_created_at = DateTime::parse_from_rfc3339(&building_dto.created_at)
810        .map(|dt| dt.with_timezone(&Utc))
811        .unwrap_or_else(|_| Utc::now());
812
813    let building_updated_at = DateTime::parse_from_rfc3339(&building_dto.updated_at)
814        .map(|dt| dt.with_timezone(&Utc))
815        .unwrap_or_else(|_| Utc::now());
816
817    let building_entity = Building {
818        id: Uuid::parse_str(&building_dto.id).unwrap_or(building_uuid),
819        name: building_dto.name.clone(),
820        address: building_dto.address,
821        city: building_dto.city,
822        postal_code: building_dto.postal_code,
823        country: building_dto.country,
824        total_units: building_dto.total_units,
825        total_tantiemes: building_dto.total_tantiemes,
826        construction_year: building_dto.construction_year,
827        syndic_name: None,
828        syndic_email: None,
829        syndic_phone: None,
830        syndic_address: None,
831        syndic_office_hours: None,
832        syndic_emergency_contact: None,
833        slug: None,
834        acp_id: building_acp_id,
835        created_at: building_created_at,
836        updated_at: building_updated_at,
837    };
838
839    let unit_entity = Unit {
840        id: Uuid::parse_str(&unit_dto.id).unwrap_or(unit_owner.unit_id),
841        acp_id: building_acp_id,
842        building_id: building_uuid,
843        unit_number: unit_dto.unit_number,
844        floor: unit_dto.floor,
845        unit_type: unit_dto.unit_type,
846        surface_area: unit_dto.surface_area,
847        quota: unit_dto.quota,
848        owner_id: unit_dto.owner_id.and_then(|s| Uuid::parse_str(&s).ok()),
849        created_at: Utc::now(), // DTOs don't have timestamps, use current time
850        updated_at: Utc::now(),
851    };
852
853    let owner_entity = Owner {
854        id: Uuid::parse_str(&owner_dto.id).unwrap_or(unit_owner.owner_id),
855        organization_id: Uuid::parse_str(&owner_dto.organization_id).unwrap_or(organization_id),
856        first_name: owner_dto.first_name.clone(),
857        last_name: owner_dto.last_name.clone(),
858        email: owner_dto.email,
859        phone: owner_dto.phone,
860        address: owner_dto.address,
861        city: owner_dto.city,
862        postal_code: owner_dto.postal_code,
863        country: owner_dto.country,
864        user_id: owner_dto.user_id.and_then(|s| Uuid::parse_str(&s).ok()),
865        created_at: Utc::now(), // DTOs don't have timestamps, use current time
866        updated_at: Utc::now(),
867    };
868
869    // 5. Generate PDF
870    match OwnershipContractExporter::export_to_pdf(
871        &building_entity,
872        &unit_entity,
873        &owner_entity,
874        unit_owner.ownership_percentage,
875        unit_owner.start_date,
876    ) {
877        Ok(pdf_bytes) => {
878            // Audit log
879            AuditLogEntry::new(
880                AuditEventType::ReportGenerated,
881                Some(user.user_id),
882                Some(organization_id),
883            )
884            .with_resource("UnitOwner", relationship_id)
885            .with_metadata(serde_json::json!({
886                "report_type": "ownership_contract_pdf",
887                "building_name": building_entity.name,
888                "unit_number": unit_entity.unit_number,
889                "owner_name": format!("{} {}", owner_entity.first_name, owner_entity.last_name)
890            }))
891            .log();
892
893            HttpResponse::Ok()
894                .content_type("application/pdf")
895                .insert_header((
896                    "Content-Disposition",
897                    format!(
898                        "attachment; filename=\"Contrat_Copropriete_{}_{}_{}.pdf\"",
899                        building_entity.name.replace(' ', "_"),
900                        unit_entity.unit_number.replace(' ', "_"),
901                        owner_entity.last_name.replace(' ', "_")
902                    ),
903                ))
904                .body(pdf_bytes)
905        }
906        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
907            "error": format!("Failed to generate PDF: {}", err)
908        })),
909    }
910}