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::{AppState, AuthenticatedUser};
8use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
9use uuid::Uuid;
10
11/// POST /meetings/{id}/validate-quorum — Art. 3.87 §5 CC
12/// Validates quorum (>50% millièmes présents + procurations).
13/// If quorum not reached, automatically schedules a 2nd convocation 15 days later.
14#[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, // JWT-extracted user info (SECURE!)
65    mut request: web::Json<CreateMeetingRequest>,
66) -> impl Responder {
67    // Override the organization_id from request with the one from JWT token
68    // This prevents users from creating meetings in other organizations
69    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            // Audit log: successful meeting creation
86            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            // Audit log: failed meeting creation
98            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            // Multi-tenant isolation: verify meeting's building belongs to user's organization
122            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    // Multi-tenant isolation: verify building belongs to user's organization
175    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            // Audit log: successful meeting completion
257            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            // Audit log: failed meeting completion
269            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    // 1. Get meeting
419    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    // 2. Get building
434    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    // 3. Get resolutions for this meeting
453    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    // 4. Get votes for each resolution and collect attendees
467    let mut attendees_map = std::collections::HashMap::new();
468    let mut resolutions_with_votes = Vec::new();
469
470    for resolution_dto in resolutions {
471        // Get votes for this resolution
472        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        // Collect attendees from votes
486        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                // Get owner info
491                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        // Convert DTOs to domain entities for PDF generation
521        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    // Convert DTOs to domain entities
565    use crate::domain::entities::{Building, Meeting};
566
567    // Parse building organization_id from string
568    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    // Parse building dates
578    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    // 5. Generate PDF
641    match MeetingMinutesExporter::export_to_pdf(
642        &building_entity,
643        &meeting_entity,
644        &attendees,
645        &resolutions_with_votes,
646    ) {
647        Ok(pdf_bytes) => {
648            // Audit log
649            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}