koprogo_api/infrastructure/web/handlers/
meeting_handlers.rs

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