Skip to main content

koprogo_api/infrastructure/web/handlers/
meeting_handlers.rs

1use crate::application::dto::{
2    AddAgendaItemRequest, AttachMinutesRequest, CompleteMeetingRequest, CreateMeetingRequest,
3    PageRequest, PageResponse, RescheduleMeetingRequest, UpdateMeetingRequest,
4    ValidateQuorumRequest,
5};
6use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
7use crate::infrastructure::web::classification_erreurs;
8use crate::infrastructure::web::middleware::scope_guard::verify_acp_org_access;
9use crate::infrastructure::web::middleware::scope_guard::verify_building_org_access;
10use crate::infrastructure::web::{AppState, AuthenticatedUser};
11use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
12use uuid::Uuid;
13
14/// Cloisonne une assemblée AVANT de la muter (#864).
15///
16/// ── Ce que ces quatre routes laissaient passer ────────────────────────────
17///
18/// `cancel_meeting`, `complete_meeting`, `reschedule_meeting` et
19/// `validate_meeting_quorum` prenaient `AuthenticatedUser` et ne s'en
20/// servaient que pour journaliser après coup. `get_meeting`, dans ce même
21/// fichier, remonte pourtant la chaîne `meeting → building → acp →
22/// organization` depuis le hotfix #603 : le chemin existait, il n'était pas
23/// emprunté par les écritures.
24///
25/// ── Pourquoi une assemblée n'est pas un enregistrement comme un autre ─────
26///
27/// Annuler l'AG d'une autre copropriété, la reporter, ou en valider le
28/// quorum touche à l'Art. 3.87 : la tenue de l'assemblée, ses délais, et la
29/// validité de ce qui s'y vote. Une AG annulée par un tiers est une AG qui
30/// n'a pas eu lieu, et les décisions qu'elle aurait prises n'existent pas.
31///
32/// Rend `Some(réponse)` quand l'appel doit être refusé, `None` sinon.
33async fn verify_meeting_org_access(
34    state: &web::Data<AppState>,
35    user: &AuthenticatedUser,
36    id: Uuid,
37) -> Option<HttpResponse> {
38    let meeting = match state.meeting_use_cases.get_meeting(id).await {
39        Ok(Some(m)) => m,
40        Ok(None) => {
41            return Some(HttpResponse::NotFound().json(serde_json::json!({
42                "error": "Meeting not found"
43            })))
44        }
45        Err(err) => {
46            return Some(HttpResponse::InternalServerError().json(serde_json::json!({
47                "error": err.to_string()
48            })))
49        }
50    };
51
52    // Même chaîne que `get_meeting` : l'assemblée ne porte pas d'organisation,
53    // elle porte un immeuble, qui porte une ACP, qui porte l'organisation.
54    let building = match state
55        .building_use_cases
56        .get_building(meeting.building_id)
57        .await
58    {
59        Ok(Some(b)) => b,
60        // Pas d'immeuble résoluble : on REFUSE, on ne laisse pas passer.
61        //
62        // `get_meeting` fait l'inverse — son `if let Ok(Some(building))`
63        // laisse filer la lecture quand l'immeuble est introuvable. Sur une
64        // lecture c'est une fuite ; sur une écriture ce serait un blanc-seing,
65        // et l'asymétrie est volontaire.
66        _ => {
67            return Some(HttpResponse::Forbidden().json(serde_json::json!({
68                "error": "Impossible de rattacher cette assemblée à une ACP"
69            })))
70        }
71    };
72
73    let acp_id = match Uuid::parse_str(&building.acp_id) {
74        Ok(id) => id,
75        Err(_) => {
76            return Some(HttpResponse::InternalServerError().json(serde_json::json!({
77                "error": "Invalid building.acp_id format"
78            })))
79        }
80    };
81
82    match verify_acp_org_access(user, acp_id, &state.acp_use_cases).await {
83        Ok(()) => None,
84        Err(err) => Some(err.error_response()),
85    }
86}
87
88/// POST /meetings/{id}/validate-quorum — Art. 3.87 §5 CC
89/// Validates quorum (>50% millièmes présents + procurations).
90/// If quorum not reached, automatically schedules a 2nd convocation 15 days later.
91#[post("/meetings/{id}/validate-quorum")]
92pub async fn validate_meeting_quorum(
93    state: web::Data<AppState>,
94    user: AuthenticatedUser,
95    id: web::Path<Uuid>,
96    request: web::Json<ValidateQuorumRequest>,
97) -> impl Responder {
98    let meeting_id = id.into_inner();
99    let req = request.into_inner();
100
101    // Cloisonnement AVANT la mutation (#864).
102    if let Some(refus) = verify_meeting_org_access(&state, &user, meeting_id).await {
103        return refus;
104    }
105
106    match state
107        .meeting_use_cases
108        .validate_quorum(meeting_id, req.present_quotas, req.total_quotas)
109        .await
110    {
111        Ok((quorum_reached, meeting)) => {
112            AuditLogEntry::new(
113                AuditEventType::MeetingQuorumValidated,
114                Some(user.user_id),
115                user.organization_id,
116            )
117            .with_resource("Meeting", meeting_id)
118            .with_metadata(serde_json::json!({
119                "quorum_reached": quorum_reached,
120                "present_quotas": req.present_quotas,
121                "total_quotas": req.total_quotas,
122                "quorum_percentage": meeting.quorum_percentage,
123                "auto_second_convocation": !quorum_reached,
124            }))
125            .log();
126
127            HttpResponse::Ok().json(serde_json::json!({
128                "quorum_reached": quorum_reached,
129                "meeting": meeting,
130                "message": if quorum_reached {
131                    "Quorum atteint (Art. 3.87 §5 CC). Les votes peuvent commencer."
132                } else {
133                    "Quorum non atteint. Une 2ème convocation a été planifiée automatiquement (Art. 3.87 §5 CC)."
134                }
135            }))
136        }
137        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
138            "error": err
139        })),
140    }
141}
142
143#[post("/meetings")]
144pub async fn create_meeting(
145    state: web::Data<AppState>,
146    user: AuthenticatedUser, // JWT-extracted user info (SECURE!)
147    mut request: web::Json<CreateMeetingRequest>,
148) -> impl Responder {
149    // Override the organization_id from request with the one from JWT token
150    // This prevents users from creating meetings in other organizations
151    let organization_id = match user.require_organization() {
152        Ok(org_id) => org_id,
153        Err(e) => {
154            return HttpResponse::Unauthorized().json(serde_json::json!({
155                "error": e.to_string()
156            }))
157        }
158    };
159    request.organization_id = organization_id;
160
161    // Isolation multi-tenant à l'ÉCRITURE (ADR-0045). L'affectation de
162    // `organization_id` depuis le jeton protège l'ESTAMPILLE, pas le
163    // RATTACHEMENT : elle empêche d'écrire au nom d'autrui, pas d'écrire dans
164    // le dossier d'autrui. L'immeuble visé doit relever d'une ACP confiée à ce
165    // syndic.
166    if let Err(err) = verify_building_org_access(
167        &user,
168        request.building_id,
169        &state.building_use_cases,
170        &state.acp_use_cases,
171    )
172    .await
173    {
174        return err.error_response();
175    }
176
177    match state
178        .meeting_use_cases
179        .create_meeting(request.into_inner())
180        .await
181    {
182        Ok(meeting) => {
183            // Audit log: successful meeting creation
184            AuditLogEntry::new(
185                AuditEventType::MeetingCreated,
186                Some(user.user_id),
187                Some(organization_id),
188            )
189            .with_resource("Meeting", meeting.id)
190            .log();
191
192            HttpResponse::Created().json(meeting)
193        }
194        Err(err) => {
195            // Audit log: failed meeting creation
196            AuditLogEntry::new(
197                AuditEventType::MeetingCreated,
198                Some(user.user_id),
199                Some(organization_id),
200            )
201            .with_error(err.clone())
202            .log();
203
204            HttpResponse::BadRequest().json(serde_json::json!({
205                "error": err
206            }))
207        }
208    }
209}
210
211#[get("/meetings/{id}")]
212pub async fn get_meeting(
213    state: web::Data<AppState>,
214    user: AuthenticatedUser,
215    id: web::Path<Uuid>,
216) -> impl Responder {
217    match state.meeting_use_cases.get_meeting(*id).await {
218        Ok(Some(meeting)) => {
219            // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
220            if let Ok(Some(building)) = state
221                .building_use_cases
222                .get_building(meeting.building_id)
223                .await
224            {
225                let acp_id = match Uuid::parse_str(&building.acp_id) {
226                    Ok(id) => id,
227                    Err(_) => {
228                        return HttpResponse::InternalServerError().json(serde_json::json!({
229                            "error": "Invalid building.acp_id format"
230                        }));
231                    }
232                };
233                if let Err(err) = verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await {
234                    return err.error_response();
235                }
236            }
237            HttpResponse::Ok().json(meeting)
238        }
239        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
240            "error": "Meeting not found"
241        })),
242        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
243            "error": err
244        })),
245    }
246}
247
248#[get("/meetings")]
249pub async fn list_meetings(
250    state: web::Data<AppState>,
251    user: AuthenticatedUser,
252    page_request: web::Query<PageRequest>,
253) -> impl Responder {
254    let organization_id = user.organization_id;
255
256    match state
257        .meeting_use_cases
258        .list_meetings_paginated(&page_request, organization_id)
259        .await
260    {
261        Ok((meetings, total)) => {
262            let response =
263                PageResponse::new(meetings, page_request.page, page_request.per_page, total);
264            HttpResponse::Ok().json(response)
265        }
266        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
267            "error": err
268        })),
269    }
270}
271
272#[get("/buildings/{building_id}/meetings")]
273pub async fn list_meetings_by_building(
274    state: web::Data<AppState>,
275    user: AuthenticatedUser,
276    building_id: web::Path<Uuid>,
277) -> impl Responder {
278    // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
279    match state.building_use_cases.get_building(*building_id).await {
280        Ok(Some(building)) => {
281            let acp_id = match Uuid::parse_str(&building.acp_id) {
282                Ok(id) => id,
283                Err(_) => {
284                    return HttpResponse::InternalServerError().json(serde_json::json!({
285                        "error": "Invalid building.acp_id format"
286                    }));
287                }
288            };
289            if let Err(err) = verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await {
290                return err.error_response();
291            }
292        }
293        Ok(None) => {
294            return HttpResponse::NotFound().json(serde_json::json!({
295                "error": "Building not found"
296            }));
297        }
298        Err(err) => {
299            return HttpResponse::InternalServerError().json(serde_json::json!({
300                "error": err
301            }));
302        }
303    }
304
305    match state
306        .meeting_use_cases
307        .list_meetings_by_building(*building_id)
308        .await
309    {
310        Ok(meetings) => HttpResponse::Ok().json(meetings),
311        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
312            "error": err
313        })),
314    }
315}
316
317#[put("/meetings/{id}")]
318pub async fn update_meeting(
319    state: web::Data<AppState>,
320    user: AuthenticatedUser,
321    id: web::Path<Uuid>,
322    request: web::Json<UpdateMeetingRequest>,
323) -> impl Responder {
324    // Aucune identité n'était exigée ici : ce gestionnaire ne prenait ni
325    // `AuthenticatedUser`, ni jeton lu à la main. N'importe qui pouvait donc
326    // modifier n'importe quelle assemblée générale sur simple connaissance de son identifiant.
327    //
328    // Le cliquet d'identité de #772 ne pouvait pas le voir : il compte les
329    // routes qui PRENNENT `AuthenticatedUser` sans s'en servir. Une route qui
330    // ne le prend pas du tout lui échappait entièrement. Cf. #845.
331    if let Err(err) =
332        crate::infrastructure::web::middleware::scope_guard::verify_meeting_org_access(
333            &user,
334            *id,
335            &state.meeting_use_cases,
336            &state.building_use_cases,
337            &state.acp_use_cases,
338        )
339        .await
340    {
341        return err.error_response();
342    }
343
344    match state
345        .meeting_use_cases
346        .update_meeting(*id, request.into_inner())
347        .await
348    {
349        Ok(meeting) => HttpResponse::Ok().json(meeting),
350        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
351            "error": err
352        })),
353    }
354}
355
356#[post("/meetings/{id}/agenda")]
357pub async fn add_agenda_item(
358    state: web::Data<AppState>,
359    id: web::Path<Uuid>,
360    request: web::Json<AddAgendaItemRequest>,
361    user: AuthenticatedUser,
362) -> impl Responder {
363    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772).
364    if let Err(err) =
365        crate::infrastructure::web::middleware::scope_guard::verify_meeting_org_access(
366            &user,
367            *id,
368            &state.meeting_use_cases,
369            &state.building_use_cases,
370            &state.acp_use_cases,
371        )
372        .await
373    {
374        return err.error_response();
375    }
376
377    match state
378        .meeting_use_cases
379        .add_agenda_item(*id, request.into_inner())
380        .await
381    {
382        Ok(meeting) => HttpResponse::Ok().json(meeting),
383        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
384            "error": err
385        })),
386    }
387}
388
389/// GET /meetings/{id}/completion-checklist — Track H Story H3
390/// Retourne la checklist d'invariants Art. 3.87 §3-5 CC + liste des invariants
391/// manquants, consommé côté FE par `<MissingInvariantsList>`.
392#[get("/meetings/{id}/completion-checklist")]
393pub async fn get_meeting_completion_checklist(
394    state: web::Data<AppState>,
395    user: AuthenticatedUser,
396    id: web::Path<Uuid>,
397) -> impl Responder {
398    // Cloisonnement : la liste des conditions de clôture d'une AG dit où en est
399    // la copropriété — convocations envoyées, résolutions votées, PV rédigé.
400    // L'identité était prise puis ignorée — `_user` (#772).
401    if let Err(err) =
402        crate::infrastructure::web::middleware::scope_guard::verify_meeting_org_access(
403            &user,
404            *id,
405            &state.meeting_use_cases,
406            &state.building_use_cases,
407            &state.acp_use_cases,
408        )
409        .await
410    {
411        return err.error_response();
412    }
413
414    let meeting_id = id.into_inner();
415    match state
416        .meeting_use_cases
417        .build_completion_checklist(meeting_id)
418        .await
419    {
420        Ok((checklist, missing_json)) => HttpResponse::Ok().json(serde_json::json!({
421            "meeting_id": meeting_id,
422            "convocations_sent": checklist.convocations_sent,
423            "open_resolutions": checklist.open_resolutions,
424            "attendance_recorded": checklist.attendance_recorded,
425            "attended_quotas": checklist.attended_quotas.to_string(),
426            "total_quotas": checklist.total_quotas.to_string(),
427            "minutes_draft_exists": checklist.minutes_draft_exists,
428            "missing": missing_json,
429        })),
430        Err(err)
431            if classification_erreurs::est_introuvable(&err) || err.contains("not configured") =>
432        {
433            HttpResponse::NotFound().json(serde_json::json!({ "error": err }))
434        }
435        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({ "error": err })),
436    }
437}
438
439#[post("/meetings/{id}/complete")]
440pub async fn complete_meeting(
441    state: web::Data<AppState>,
442    user: AuthenticatedUser,
443    id: web::Path<Uuid>,
444    request: web::Json<CompleteMeetingRequest>,
445) -> impl Responder {
446    // Cloisonnement AVANT la mutation (#864).
447    if let Some(refus) = verify_meeting_org_access(&state, &user, *id).await {
448        return refus;
449    }
450
451    match state
452        .meeting_use_cases
453        .complete_meeting(*id, request.into_inner())
454        .await
455    {
456        Ok(meeting) => {
457            // Audit log: successful meeting completion
458            AuditLogEntry::new(
459                AuditEventType::MeetingCompleted,
460                Some(user.user_id),
461                user.organization_id,
462            )
463            .with_resource("Meeting", *id)
464            .log();
465
466            HttpResponse::Ok().json(meeting)
467        }
468        Err(err) => {
469            // Audit log: failed meeting completion
470            AuditLogEntry::new(
471                AuditEventType::MeetingCompleted,
472                Some(user.user_id),
473                user.organization_id,
474            )
475            .with_resource("Meeting", *id)
476            .with_error(err.clone())
477            .log();
478
479            HttpResponse::BadRequest().json(serde_json::json!({
480                "error": err
481            }))
482        }
483    }
484}
485
486#[post("/meetings/{id}/cancel")]
487pub async fn cancel_meeting(
488    state: web::Data<AppState>,
489    user: AuthenticatedUser,
490    id: web::Path<Uuid>,
491) -> impl Responder {
492    // Cloisonnement AVANT la mutation (#864).
493    if let Some(refus) = verify_meeting_org_access(&state, &user, *id).await {
494        return refus;
495    }
496
497    match state.meeting_use_cases.cancel_meeting(*id).await {
498        Ok(meeting) => {
499            // `MeetingCancelled`, et non `MeetingCompleted` : une assemblée
500            // annulée n'a pas eu lieu. Cf. #780.
501            AuditLogEntry::new(
502                AuditEventType::MeetingCancelled,
503                Some(user.user_id),
504                user.organization_id,
505            )
506            .with_resource("Meeting", *id)
507            .log();
508
509            HttpResponse::Ok().json(meeting)
510        }
511        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
512            "error": err
513        })),
514    }
515}
516
517#[post("/meetings/{id}/reschedule")]
518pub async fn reschedule_meeting(
519    state: web::Data<AppState>,
520    user: AuthenticatedUser,
521    id: web::Path<Uuid>,
522    request: web::Json<RescheduleMeetingRequest>,
523) -> impl Responder {
524    // Cloisonnement AVANT la mutation (#864).
525    if let Some(refus) = verify_meeting_org_access(&state, &user, *id).await {
526        return refus;
527    }
528
529    match state
530        .meeting_use_cases
531        .reschedule_meeting(*id, request.scheduled_date)
532        .await
533    {
534        Ok(meeting) => {
535            // `MeetingRescheduled`, et non `MeetingCompleted` : reporter une
536            // assemblée n'est pas la clôturer. Cf. #780.
537            AuditLogEntry::new(
538                AuditEventType::MeetingRescheduled,
539                Some(user.user_id),
540                user.organization_id,
541            )
542            .with_resource("Meeting", *id)
543            .log();
544
545            HttpResponse::Ok().json(meeting)
546        }
547        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
548            "error": err
549        })),
550    }
551}
552
553#[delete("/meetings/{id}")]
554pub async fn delete_meeting(
555    state: web::Data<AppState>,
556    user: AuthenticatedUser,
557    id: web::Path<Uuid>,
558) -> impl Responder {
559    // Aucune identité n'était exigée ici : ce gestionnaire ne prenait ni
560    // `AuthenticatedUser`, ni jeton lu à la main. N'importe qui pouvait donc
561    // supprimer n'importe quelle assemblée générale sur simple connaissance de son identifiant.
562    //
563    // Le cliquet d'identité de #772 ne pouvait pas le voir : il compte les
564    // routes qui PRENNENT `AuthenticatedUser` sans s'en servir. Une route qui
565    // ne le prend pas du tout lui échappait entièrement. Cf. #845.
566    if let Err(err) =
567        crate::infrastructure::web::middleware::scope_guard::verify_meeting_org_access(
568            &user,
569            *id,
570            &state.meeting_use_cases,
571            &state.building_use_cases,
572            &state.acp_use_cases,
573        )
574        .await
575    {
576        return err.error_response();
577    }
578
579    match state.meeting_use_cases.delete_meeting(*id).await {
580        Ok(true) => HttpResponse::NoContent().finish(),
581        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
582            "error": "Meeting not found"
583        })),
584        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
585            "error": err
586        })),
587    }
588}
589
590#[post("/meetings/{id}/attach-minutes")]
591pub async fn attach_minutes(
592    state: web::Data<AppState>,
593    user: AuthenticatedUser,
594    id: web::Path<Uuid>,
595    request: web::Json<AttachMinutesRequest>,
596) -> impl Responder {
597    let organization_id = match user.require_organization() {
598        Ok(org_id) => org_id,
599        Err(e) => {
600            return HttpResponse::Unauthorized().json(serde_json::json!({
601                "error": e.to_string()
602            }))
603        }
604    };
605
606    match state
607        .meeting_use_cases
608        .attach_minutes(*id, request.document_id)
609        .await
610    {
611        Ok(meeting) => {
612            AuditLogEntry::new(
613                AuditEventType::MeetingMinutesSent,
614                Some(user.user_id),
615                Some(organization_id),
616            )
617            .with_resource("Meeting", *id)
618            .log();
619
620            HttpResponse::Ok().json(meeting)
621        }
622        Err(err) => {
623            AuditLogEntry::new(
624                AuditEventType::MeetingMinutesSent,
625                Some(user.user_id),
626                Some(organization_id),
627            )
628            .with_error(err.clone())
629            .log();
630
631            HttpResponse::BadRequest().json(serde_json::json!({
632                "error": err
633            }))
634        }
635    }
636}
637
638#[get("/meetings/{id}/export-minutes-pdf")]
639pub async fn export_meeting_minutes_pdf(
640    state: web::Data<AppState>,
641    user: AuthenticatedUser,
642    id: web::Path<Uuid>,
643) -> impl Responder {
644    use crate::domain::services::{AttendeeInfo, MeetingMinutesExporter, ResolutionWithVotes};
645
646    let organization_id = match user.require_organization() {
647        Ok(org_id) => org_id,
648        Err(e) => {
649            return HttpResponse::Unauthorized().json(serde_json::json!({
650                "error": e.to_string()
651            }))
652        }
653    };
654
655    let meeting_id = *id;
656
657    // 1. Get meeting
658    let meeting = match state.meeting_use_cases.get_meeting(meeting_id).await {
659        Ok(Some(meeting_dto)) => meeting_dto,
660        Ok(None) => {
661            return HttpResponse::NotFound().json(serde_json::json!({
662                "error": "Meeting not found"
663            }))
664        }
665        Err(err) => {
666            return HttpResponse::InternalServerError().json(serde_json::json!({
667                "error": err
668            }))
669        }
670    };
671
672    // 2. Get building
673    let building = match state
674        .building_use_cases
675        .get_building(meeting.building_id)
676        .await
677    {
678        Ok(Some(building_dto)) => building_dto,
679        Ok(None) => {
680            return HttpResponse::NotFound().json(serde_json::json!({
681                "error": "Building not found"
682            }))
683        }
684        Err(err) => {
685            return HttpResponse::InternalServerError().json(serde_json::json!({
686                "error": err
687            }))
688        }
689    };
690
691    // 3. Get resolutions for this meeting
692    let resolutions = match state
693        .resolution_use_cases
694        .get_meeting_resolutions(meeting_id)
695        .await
696    {
697        Ok(resolutions) => resolutions,
698        Err(err) => {
699            return HttpResponse::InternalServerError().json(serde_json::json!({
700                "error": format!("Failed to get resolutions: {}", err)
701            }))
702        }
703    };
704
705    // 4. Get votes for each resolution and collect attendees
706    let mut attendees_map = std::collections::HashMap::new();
707    let mut resolutions_with_votes = Vec::new();
708
709    for resolution_dto in resolutions {
710        // Get votes for this resolution
711        let votes_dto = match state
712            .resolution_use_cases
713            .get_resolution_votes(resolution_dto.id)
714            .await
715        {
716            Ok(votes) => votes,
717            Err(err) => {
718                return HttpResponse::InternalServerError().json(serde_json::json!({
719                    "error": format!("Failed to get votes: {}", err)
720                }))
721            }
722        };
723
724        // Collect attendees from votes
725        for vote_dto in &votes_dto {
726            if let std::collections::hash_map::Entry::Vacant(e) =
727                attendees_map.entry(vote_dto.owner_id)
728            {
729                // Get owner info
730                if let Ok(Some(owner_dto)) =
731                    state.owner_use_cases.get_owner(vote_dto.owner_id).await
732                {
733                    let proxy_for_name = if let Some(proxy_id) = vote_dto.proxy_owner_id {
734                        state
735                            .owner_use_cases
736                            .get_owner(proxy_id)
737                            .await
738                            .ok()
739                            .flatten()
740                            .map(|o| format!("{} {}", o.first_name, o.last_name))
741                    } else {
742                        None
743                    };
744
745                    let full_name = format!("{} {}", owner_dto.first_name, owner_dto.last_name);
746
747                    e.insert(AttendeeInfo {
748                        owner_id: vote_dto.owner_id,
749                        name: full_name,
750                        email: owner_dto.email.clone(),
751                        voting_power: vote_dto.voting_power,
752                        is_proxy: vote_dto.proxy_owner_id.is_some(),
753                        proxy_for: proxy_for_name,
754                    });
755                }
756            }
757        }
758
759        // Convert DTOs to domain entities for PDF generation
760        use crate::domain::entities::{Resolution, Vote};
761
762        let resolution_entity = Resolution {
763            prestataire_de_la_mission: None,
764            id: resolution_dto.id,
765            meeting_id: resolution_dto.meeting_id,
766            title: resolution_dto.title,
767            description: resolution_dto.description,
768            resolution_type: resolution_dto.resolution_type,
769            majority_required: resolution_dto.majority_required,
770            vote_count_pour: resolution_dto.vote_count_pour,
771            vote_count_contre: resolution_dto.vote_count_contre,
772            vote_count_abstention: resolution_dto.vote_count_abstention,
773            total_voting_power_pour: resolution_dto.total_voting_power_pour,
774            total_voting_power_contre: resolution_dto.total_voting_power_contre,
775            total_voting_power_abstention: resolution_dto.total_voting_power_abstention,
776            status: resolution_dto.status,
777            voted_at: resolution_dto.voted_at,
778            created_at: resolution_dto.created_at,
779            agenda_item_index: None,
780            kind: crate::domain::entities::ResolutionKind::Standard,
781        };
782
783        let votes: Vec<Vote> = votes_dto
784            .iter()
785            .map(|v| Vote {
786                id: v.id,
787                resolution_id: v.resolution_id,
788                owner_id: v.owner_id,
789                unit_id: v.unit_id,
790                vote_choice: v.vote_choice.clone(),
791                voting_power: v.voting_power,
792                proxy_owner_id: v.proxy_owner_id,
793                voted_at: v.voted_at,
794                auth_method: v.auth_method,
795            })
796            .collect();
797
798        resolutions_with_votes.push(ResolutionWithVotes {
799            resolution: resolution_entity,
800            votes,
801        });
802    }
803
804    let attendees: Vec<AttendeeInfo> = attendees_map.into_values().collect();
805
806    // Convert DTOs to domain entities
807    use crate::domain::entities::{Building, Meeting};
808
809    // Story 1.2 — building.acp_id (was organization_id).
810    let building_acp_id = match Uuid::parse_str(&building.acp_id) {
811        Ok(id) => id,
812        Err(err) => {
813            return HttpResponse::InternalServerError().json(serde_json::json!({
814                "error": format!("Invalid acp_id: {}", err)
815            }))
816        }
817    };
818
819    // Parse building dates
820    use chrono::DateTime;
821    let building_created_at = match DateTime::parse_from_rfc3339(&building.created_at) {
822        Ok(dt) => dt.with_timezone(&chrono::Utc),
823        Err(_) => chrono::Utc::now(),
824    };
825    let building_updated_at = match DateTime::parse_from_rfc3339(&building.updated_at) {
826        Ok(dt) => dt.with_timezone(&chrono::Utc),
827        Err(_) => chrono::Utc::now(),
828    };
829
830    let building_entity = Building {
831        id: match Uuid::parse_str(&building.id) {
832            Ok(id) => id,
833            Err(err) => {
834                return HttpResponse::InternalServerError().json(serde_json::json!({
835                    "error": format!("Invalid building id: {}", err)
836                }))
837            }
838        },
839        name: building.name,
840        address: building.address,
841        city: building.city,
842        postal_code: building.postal_code,
843        country: building.country,
844        total_units: building.total_units,
845        total_tantiemes: building.total_tantiemes,
846        construction_year: building.construction_year,
847        syndic_name: None,
848        syndic_email: None,
849        syndic_phone: None,
850        syndic_address: None,
851        syndic_office_hours: None,
852        syndic_emergency_contact: None,
853        slug: None,
854        acp_id: building_acp_id,
855        created_at: building_created_at,
856        updated_at: building_updated_at,
857    };
858
859    let meeting_entity = Meeting {
860        id: meeting.id,
861        // L'assemblée relève de l'ACP de son immeuble (Art. 3.87, ADR-0045).
862        acp_id: building_acp_id,
863        organization_id,
864        building_id: meeting.building_id,
865        meeting_type: meeting.meeting_type,
866        title: meeting.title,
867        description: meeting.description,
868        scheduled_date: meeting.scheduled_date,
869        location: meeting.location,
870        status: meeting.status,
871        agenda: meeting.agenda,
872        attendees_count: meeting.attendees_count,
873        quorum_validated: meeting.quorum_validated,
874        quorum_percentage: meeting.quorum_percentage,
875        total_quotas: meeting.total_quotas,
876        present_quotas: meeting.present_quotas,
877        created_at: meeting.created_at,
878        updated_at: meeting.updated_at,
879        is_second_convocation: false,
880        minutes_document_id: None,
881        minutes_sent_at: None,
882        // Le PV n'a pas besoin de la modalité de tenue de l'AG : cette
883        // reconstruction ne sert qu'à générer le document, pas à refléter
884        // l'entité persistée (cf. `PostgresMeetingRepository` pour la source
885        // de vérité de `mode`/`videoconf_url`).
886        mode: crate::domain::entities::MeetingMode::InPerson,
887        videoconf_url: None,
888    };
889
890    // 5. Generate PDF
891    match MeetingMinutesExporter::export_to_pdf(
892        &building_entity,
893        &meeting_entity,
894        &attendees,
895        &resolutions_with_votes,
896    ) {
897        Ok(pdf_bytes) => {
898            // Audit log
899            AuditLogEntry::new(
900                AuditEventType::ReportGenerated,
901                Some(user.user_id),
902                Some(organization_id),
903            )
904            .with_resource("Meeting", meeting_id)
905            .with_metadata(serde_json::json!({
906                "report_type": "meeting_minutes_pdf",
907                "building_name": building_entity.name,
908                "meeting_date": meeting_entity.scheduled_date.to_rfc3339()
909            }))
910            .log();
911
912            HttpResponse::Ok()
913                .content_type("application/pdf")
914                .insert_header((
915                    "Content-Disposition",
916                    format!(
917                        "attachment; filename=\"PV_{}_{}_{}.pdf\"",
918                        building_entity.name.replace(' ', "_"),
919                        meeting_entity.scheduled_date.format("%Y%m%d"),
920                        meeting_entity.id
921                    ),
922                ))
923                .body(pdf_bytes)
924        }
925        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
926            "error": format!("Failed to generate PDF: {}", err)
927        })),
928    }
929}