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::{AppState, AuthenticatedUser};
8use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
9use uuid::Uuid;
10
11#[post("/meetings/{id}/validate-quorum")]
15pub async fn validate_meeting_quorum(
16 state: web::Data<AppState>,
17 user: AuthenticatedUser,
18 id: web::Path<Uuid>,
19 request: web::Json<ValidateQuorumRequest>,
20) -> impl Responder {
21 let meeting_id = id.into_inner();
22 let req = request.into_inner();
23
24 match state
25 .meeting_use_cases
26 .validate_quorum(meeting_id, req.present_quotas, req.total_quotas)
27 .await
28 {
29 Ok((quorum_reached, meeting)) => {
30 AuditLogEntry::new(
31 AuditEventType::MeetingQuorumValidated,
32 Some(user.user_id),
33 user.organization_id,
34 )
35 .with_resource("Meeting", meeting_id)
36 .with_metadata(serde_json::json!({
37 "quorum_reached": quorum_reached,
38 "present_quotas": req.present_quotas,
39 "total_quotas": req.total_quotas,
40 "quorum_percentage": meeting.quorum_percentage,
41 "auto_second_convocation": !quorum_reached,
42 }))
43 .log();
44
45 HttpResponse::Ok().json(serde_json::json!({
46 "quorum_reached": quorum_reached,
47 "meeting": meeting,
48 "message": if quorum_reached {
49 "Quorum atteint (Art. 3.87 §5 CC). Les votes peuvent commencer."
50 } else {
51 "Quorum non atteint. Une 2ème convocation a été planifiée automatiquement (Art. 3.87 §5 CC)."
52 }
53 }))
54 }
55 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
56 "error": err
57 })),
58 }
59}
60
61#[post("/meetings")]
62pub async fn create_meeting(
63 state: web::Data<AppState>,
64 user: AuthenticatedUser, mut request: web::Json<CreateMeetingRequest>,
66) -> impl Responder {
67 let organization_id = match user.require_organization() {
70 Ok(org_id) => org_id,
71 Err(e) => {
72 return HttpResponse::Unauthorized().json(serde_json::json!({
73 "error": e.to_string()
74 }))
75 }
76 };
77 request.organization_id = organization_id;
78
79 match state
80 .meeting_use_cases
81 .create_meeting(request.into_inner())
82 .await
83 {
84 Ok(meeting) => {
85 AuditLogEntry::new(
87 AuditEventType::MeetingCreated,
88 Some(user.user_id),
89 Some(organization_id),
90 )
91 .with_resource("Meeting", meeting.id)
92 .log();
93
94 HttpResponse::Created().json(meeting)
95 }
96 Err(err) => {
97 AuditLogEntry::new(
99 AuditEventType::MeetingCreated,
100 Some(user.user_id),
101 Some(organization_id),
102 )
103 .with_error(err.clone())
104 .log();
105
106 HttpResponse::BadRequest().json(serde_json::json!({
107 "error": err
108 }))
109 }
110 }
111}
112
113#[get("/meetings/{id}")]
114pub async fn get_meeting(
115 state: web::Data<AppState>,
116 user: AuthenticatedUser,
117 id: web::Path<Uuid>,
118) -> impl Responder {
119 match state.meeting_use_cases.get_meeting(*id).await {
120 Ok(Some(meeting)) => {
121 if let Ok(Some(building)) = state
123 .building_use_cases
124 .get_building(meeting.building_id)
125 .await
126 {
127 if let Ok(building_org) = Uuid::parse_str(&building.organization_id) {
128 if let Err(e) = user.verify_org_access(building_org) {
129 return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
130 }
131 }
132 }
133 HttpResponse::Ok().json(meeting)
134 }
135 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
136 "error": "Meeting not found"
137 })),
138 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
139 "error": err
140 })),
141 }
142}
143
144#[get("/meetings")]
145pub async fn list_meetings(
146 state: web::Data<AppState>,
147 user: AuthenticatedUser,
148 page_request: web::Query<PageRequest>,
149) -> impl Responder {
150 let organization_id = user.organization_id;
151
152 match state
153 .meeting_use_cases
154 .list_meetings_paginated(&page_request, organization_id)
155 .await
156 {
157 Ok((meetings, total)) => {
158 let response =
159 PageResponse::new(meetings, page_request.page, page_request.per_page, total);
160 HttpResponse::Ok().json(response)
161 }
162 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
163 "error": err
164 })),
165 }
166}
167
168#[get("/buildings/{building_id}/meetings")]
169pub async fn list_meetings_by_building(
170 state: web::Data<AppState>,
171 user: AuthenticatedUser,
172 building_id: web::Path<Uuid>,
173) -> impl Responder {
174 match state.building_use_cases.get_building(*building_id).await {
176 Ok(Some(building)) => {
177 if let Ok(building_org) = Uuid::parse_str(&building.organization_id) {
178 if let Err(e) = user.verify_org_access(building_org) {
179 return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
180 }
181 }
182 }
183 Ok(None) => {
184 return HttpResponse::NotFound().json(serde_json::json!({
185 "error": "Building not found"
186 }));
187 }
188 Err(err) => {
189 return HttpResponse::InternalServerError().json(serde_json::json!({
190 "error": err
191 }));
192 }
193 }
194
195 match state
196 .meeting_use_cases
197 .list_meetings_by_building(*building_id)
198 .await
199 {
200 Ok(meetings) => HttpResponse::Ok().json(meetings),
201 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
202 "error": err
203 })),
204 }
205}
206
207#[put("/meetings/{id}")]
208pub async fn update_meeting(
209 state: web::Data<AppState>,
210 id: web::Path<Uuid>,
211 request: web::Json<UpdateMeetingRequest>,
212) -> impl Responder {
213 match state
214 .meeting_use_cases
215 .update_meeting(*id, request.into_inner())
216 .await
217 {
218 Ok(meeting) => HttpResponse::Ok().json(meeting),
219 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
220 "error": err
221 })),
222 }
223}
224
225#[post("/meetings/{id}/agenda")]
226pub async fn add_agenda_item(
227 state: web::Data<AppState>,
228 id: web::Path<Uuid>,
229 request: web::Json<AddAgendaItemRequest>,
230) -> impl Responder {
231 match state
232 .meeting_use_cases
233 .add_agenda_item(*id, request.into_inner())
234 .await
235 {
236 Ok(meeting) => HttpResponse::Ok().json(meeting),
237 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
238 "error": err
239 })),
240 }
241}
242
243#[post("/meetings/{id}/complete")]
244pub async fn complete_meeting(
245 state: web::Data<AppState>,
246 user: AuthenticatedUser,
247 id: web::Path<Uuid>,
248 request: web::Json<CompleteMeetingRequest>,
249) -> impl Responder {
250 match state
251 .meeting_use_cases
252 .complete_meeting(*id, request.into_inner())
253 .await
254 {
255 Ok(meeting) => {
256 AuditLogEntry::new(
258 AuditEventType::MeetingCompleted,
259 Some(user.user_id),
260 user.organization_id,
261 )
262 .with_resource("Meeting", *id)
263 .log();
264
265 HttpResponse::Ok().json(meeting)
266 }
267 Err(err) => {
268 AuditLogEntry::new(
270 AuditEventType::MeetingCompleted,
271 Some(user.user_id),
272 user.organization_id,
273 )
274 .with_resource("Meeting", *id)
275 .with_error(err.clone())
276 .log();
277
278 HttpResponse::BadRequest().json(serde_json::json!({
279 "error": err
280 }))
281 }
282 }
283}
284
285#[post("/meetings/{id}/cancel")]
286pub async fn cancel_meeting(
287 state: web::Data<AppState>,
288 user: AuthenticatedUser,
289 id: web::Path<Uuid>,
290) -> impl Responder {
291 match state.meeting_use_cases.cancel_meeting(*id).await {
292 Ok(meeting) => {
293 AuditLogEntry::new(
294 AuditEventType::MeetingCompleted,
295 Some(user.user_id),
296 user.organization_id,
297 )
298 .with_resource("Meeting", *id)
299 .log();
300
301 HttpResponse::Ok().json(meeting)
302 }
303 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
304 "error": err
305 })),
306 }
307}
308
309#[post("/meetings/{id}/reschedule")]
310pub async fn reschedule_meeting(
311 state: web::Data<AppState>,
312 user: AuthenticatedUser,
313 id: web::Path<Uuid>,
314 request: web::Json<RescheduleMeetingRequest>,
315) -> impl Responder {
316 match state
317 .meeting_use_cases
318 .reschedule_meeting(*id, request.scheduled_date)
319 .await
320 {
321 Ok(meeting) => {
322 AuditLogEntry::new(
323 AuditEventType::MeetingCompleted,
324 Some(user.user_id),
325 user.organization_id,
326 )
327 .with_resource("Meeting", *id)
328 .log();
329
330 HttpResponse::Ok().json(meeting)
331 }
332 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
333 "error": err
334 })),
335 }
336}
337
338#[delete("/meetings/{id}")]
339pub async fn delete_meeting(state: web::Data<AppState>, id: web::Path<Uuid>) -> impl Responder {
340 match state.meeting_use_cases.delete_meeting(*id).await {
341 Ok(true) => HttpResponse::NoContent().finish(),
342 Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
343 "error": "Meeting not found"
344 })),
345 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
346 "error": err
347 })),
348 }
349}
350
351#[post("/meetings/{id}/attach-minutes")]
352pub async fn attach_minutes(
353 state: web::Data<AppState>,
354 user: AuthenticatedUser,
355 id: web::Path<Uuid>,
356 request: web::Json<AttachMinutesRequest>,
357) -> impl Responder {
358 let organization_id = match user.require_organization() {
359 Ok(org_id) => org_id,
360 Err(e) => {
361 return HttpResponse::Unauthorized().json(serde_json::json!({
362 "error": e.to_string()
363 }))
364 }
365 };
366
367 match state
368 .meeting_use_cases
369 .attach_minutes(*id, request.document_id)
370 .await
371 {
372 Ok(meeting) => {
373 AuditLogEntry::new(
374 AuditEventType::MeetingMinutesSent,
375 Some(user.user_id),
376 Some(organization_id),
377 )
378 .with_resource("Meeting", *id)
379 .log();
380
381 HttpResponse::Ok().json(meeting)
382 }
383 Err(err) => {
384 AuditLogEntry::new(
385 AuditEventType::MeetingMinutesSent,
386 Some(user.user_id),
387 Some(organization_id),
388 )
389 .with_error(err.clone())
390 .log();
391
392 HttpResponse::BadRequest().json(serde_json::json!({
393 "error": err
394 }))
395 }
396 }
397}
398
399#[get("/meetings/{id}/export-minutes-pdf")]
400pub async fn export_meeting_minutes_pdf(
401 state: web::Data<AppState>,
402 user: AuthenticatedUser,
403 id: web::Path<Uuid>,
404) -> impl Responder {
405 use crate::domain::services::{AttendeeInfo, MeetingMinutesExporter, ResolutionWithVotes};
406
407 let organization_id = match user.require_organization() {
408 Ok(org_id) => org_id,
409 Err(e) => {
410 return HttpResponse::Unauthorized().json(serde_json::json!({
411 "error": e.to_string()
412 }))
413 }
414 };
415
416 let meeting_id = *id;
417
418 let meeting = match state.meeting_use_cases.get_meeting(meeting_id).await {
420 Ok(Some(meeting_dto)) => meeting_dto,
421 Ok(None) => {
422 return HttpResponse::NotFound().json(serde_json::json!({
423 "error": "Meeting not found"
424 }))
425 }
426 Err(err) => {
427 return HttpResponse::InternalServerError().json(serde_json::json!({
428 "error": err
429 }))
430 }
431 };
432
433 let building = match state
435 .building_use_cases
436 .get_building(meeting.building_id)
437 .await
438 {
439 Ok(Some(building_dto)) => building_dto,
440 Ok(None) => {
441 return HttpResponse::NotFound().json(serde_json::json!({
442 "error": "Building not found"
443 }))
444 }
445 Err(err) => {
446 return HttpResponse::InternalServerError().json(serde_json::json!({
447 "error": err
448 }))
449 }
450 };
451
452 let resolutions = match state
454 .resolution_use_cases
455 .get_meeting_resolutions(meeting_id)
456 .await
457 {
458 Ok(resolutions) => resolutions,
459 Err(err) => {
460 return HttpResponse::InternalServerError().json(serde_json::json!({
461 "error": format!("Failed to get resolutions: {}", err)
462 }))
463 }
464 };
465
466 let mut attendees_map = std::collections::HashMap::new();
468 let mut resolutions_with_votes = Vec::new();
469
470 for resolution_dto in resolutions {
471 let votes_dto = match state
473 .resolution_use_cases
474 .get_resolution_votes(resolution_dto.id)
475 .await
476 {
477 Ok(votes) => votes,
478 Err(err) => {
479 return HttpResponse::InternalServerError().json(serde_json::json!({
480 "error": format!("Failed to get votes: {}", err)
481 }))
482 }
483 };
484
485 for vote_dto in &votes_dto {
487 if let std::collections::hash_map::Entry::Vacant(e) =
488 attendees_map.entry(vote_dto.owner_id)
489 {
490 if let Ok(Some(owner_dto)) =
492 state.owner_use_cases.get_owner(vote_dto.owner_id).await
493 {
494 let proxy_for_name = if let Some(proxy_id) = vote_dto.proxy_owner_id {
495 state
496 .owner_use_cases
497 .get_owner(proxy_id)
498 .await
499 .ok()
500 .flatten()
501 .map(|o| format!("{} {}", o.first_name, o.last_name))
502 } else {
503 None
504 };
505
506 let full_name = format!("{} {}", owner_dto.first_name, owner_dto.last_name);
507
508 e.insert(AttendeeInfo {
509 owner_id: vote_dto.owner_id,
510 name: full_name,
511 email: owner_dto.email.clone(),
512 voting_power: vote_dto.voting_power,
513 is_proxy: vote_dto.proxy_owner_id.is_some(),
514 proxy_for: proxy_for_name,
515 });
516 }
517 }
518 }
519
520 use crate::domain::entities::{Resolution, Vote};
522
523 let resolution_entity = Resolution {
524 id: resolution_dto.id,
525 meeting_id: resolution_dto.meeting_id,
526 title: resolution_dto.title,
527 description: resolution_dto.description,
528 resolution_type: resolution_dto.resolution_type,
529 majority_required: resolution_dto.majority_required,
530 vote_count_pour: resolution_dto.vote_count_pour,
531 vote_count_contre: resolution_dto.vote_count_contre,
532 vote_count_abstention: resolution_dto.vote_count_abstention,
533 total_voting_power_pour: resolution_dto.total_voting_power_pour,
534 total_voting_power_contre: resolution_dto.total_voting_power_contre,
535 total_voting_power_abstention: resolution_dto.total_voting_power_abstention,
536 status: resolution_dto.status,
537 voted_at: resolution_dto.voted_at,
538 created_at: resolution_dto.created_at,
539 agenda_item_index: None,
540 };
541
542 let votes: Vec<Vote> = votes_dto
543 .iter()
544 .map(|v| Vote {
545 id: v.id,
546 resolution_id: v.resolution_id,
547 owner_id: v.owner_id,
548 unit_id: v.unit_id,
549 vote_choice: v.vote_choice.clone(),
550 voting_power: v.voting_power,
551 proxy_owner_id: v.proxy_owner_id,
552 voted_at: v.voted_at,
553 })
554 .collect();
555
556 resolutions_with_votes.push(ResolutionWithVotes {
557 resolution: resolution_entity,
558 votes,
559 });
560 }
561
562 let attendees: Vec<AttendeeInfo> = attendees_map.into_values().collect();
563
564 use crate::domain::entities::{Building, Meeting};
566
567 let building_org_id = match Uuid::parse_str(&building.organization_id) {
569 Ok(id) => id,
570 Err(err) => {
571 return HttpResponse::InternalServerError().json(serde_json::json!({
572 "error": format!("Invalid organization_id: {}", err)
573 }))
574 }
575 };
576
577 use chrono::DateTime;
579 let building_created_at = match DateTime::parse_from_rfc3339(&building.created_at) {
580 Ok(dt) => dt.with_timezone(&chrono::Utc),
581 Err(_) => chrono::Utc::now(),
582 };
583 let building_updated_at = match DateTime::parse_from_rfc3339(&building.updated_at) {
584 Ok(dt) => dt.with_timezone(&chrono::Utc),
585 Err(_) => chrono::Utc::now(),
586 };
587
588 let building_entity = Building {
589 id: match Uuid::parse_str(&building.id) {
590 Ok(id) => id,
591 Err(err) => {
592 return HttpResponse::InternalServerError().json(serde_json::json!({
593 "error": format!("Invalid building id: {}", err)
594 }))
595 }
596 },
597 name: building.name,
598 address: building.address,
599 city: building.city,
600 postal_code: building.postal_code,
601 country: building.country,
602 total_units: building.total_units,
603 total_tantiemes: building.total_tantiemes,
604 construction_year: building.construction_year,
605 syndic_name: None,
606 syndic_email: None,
607 syndic_phone: None,
608 syndic_address: None,
609 syndic_office_hours: None,
610 syndic_emergency_contact: None,
611 slug: None,
612 organization_id: building_org_id,
613 created_at: building_created_at,
614 updated_at: building_updated_at,
615 };
616
617 let meeting_entity = Meeting {
618 id: meeting.id,
619 organization_id,
620 building_id: meeting.building_id,
621 meeting_type: meeting.meeting_type,
622 title: meeting.title,
623 description: meeting.description,
624 scheduled_date: meeting.scheduled_date,
625 location: meeting.location,
626 status: meeting.status,
627 agenda: meeting.agenda,
628 attendees_count: meeting.attendees_count,
629 quorum_validated: meeting.quorum_validated,
630 quorum_percentage: meeting.quorum_percentage,
631 total_quotas: meeting.total_quotas,
632 present_quotas: meeting.present_quotas,
633 created_at: meeting.created_at,
634 updated_at: meeting.updated_at,
635 is_second_convocation: false,
636 minutes_document_id: None,
637 minutes_sent_at: None,
638 };
639
640 match MeetingMinutesExporter::export_to_pdf(
642 &building_entity,
643 &meeting_entity,
644 &attendees,
645 &resolutions_with_votes,
646 ) {
647 Ok(pdf_bytes) => {
648 AuditLogEntry::new(
650 AuditEventType::ReportGenerated,
651 Some(user.user_id),
652 Some(organization_id),
653 )
654 .with_resource("Meeting", meeting_id)
655 .with_metadata(serde_json::json!({
656 "report_type": "meeting_minutes_pdf",
657 "building_name": building_entity.name,
658 "meeting_date": meeting_entity.scheduled_date.to_rfc3339()
659 }))
660 .log();
661
662 HttpResponse::Ok()
663 .content_type("application/pdf")
664 .insert_header((
665 "Content-Disposition",
666 format!(
667 "attachment; filename=\"PV_{}_{}_{}.pdf\"",
668 building_entity.name.replace(' ', "_"),
669 meeting_entity.scheduled_date.format("%Y%m%d"),
670 meeting_entity.id
671 ),
672 ))
673 .body(pdf_bytes)
674 }
675 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
676 "error": format!("Failed to generate PDF: {}", err)
677 })),
678 }
679}