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
14async 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 let building = match state
55 .building_use_cases
56 .get_building(meeting.building_id)
57 .await
58 {
59 Ok(Some(b)) => b,
60 _ => {
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")]
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 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, mut request: web::Json<CreateMeetingRequest>,
148) -> impl Responder {
149 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 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 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 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 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 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 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 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")]
393pub async fn get_meeting_completion_checklist(
394 state: web::Data<AppState>,
395 user: AuthenticatedUser,
396 id: web::Path<Uuid>,
397) -> impl Responder {
398 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 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 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 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 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 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 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 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 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 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 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 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 let mut attendees_map = std::collections::HashMap::new();
707 let mut resolutions_with_votes = Vec::new();
708
709 for resolution_dto in resolutions {
710 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 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 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 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 use crate::domain::entities::{Building, Meeting};
808
809 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 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 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 mode: crate::domain::entities::MeetingMode::InPerson,
887 videoconf_url: None,
888 };
889
890 match MeetingMinutesExporter::export_to_pdf(
892 &building_entity,
893 &meeting_entity,
894 &attendees,
895 &resolutions_with_votes,
896 ) {
897 Ok(pdf_bytes) => {
898 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}