koprogo_api/infrastructure/web/handlers/
meeting_handlers.rs

1use crate::application::dto::{
2    AddAgendaItemRequest, CompleteMeetingRequest, CreateMeetingRequest, PageRequest, PageResponse,
3    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(state: web::Data<AppState>, id: web::Path<Uuid>) -> impl Responder {
64    match state.meeting_use_cases.get_meeting(*id).await {
65        Ok(Some(meeting)) => HttpResponse::Ok().json(meeting),
66        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
67            "error": "Meeting not found"
68        })),
69        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
70            "error": err
71        })),
72    }
73}
74
75#[get("/meetings")]
76pub async fn list_meetings(
77    state: web::Data<AppState>,
78    user: AuthenticatedUser,
79    page_request: web::Query<PageRequest>,
80) -> impl Responder {
81    let organization_id = user.organization_id;
82
83    match state
84        .meeting_use_cases
85        .list_meetings_paginated(&page_request, organization_id)
86        .await
87    {
88        Ok((meetings, total)) => {
89            let response =
90                PageResponse::new(meetings, page_request.page, page_request.per_page, total);
91            HttpResponse::Ok().json(response)
92        }
93        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
94            "error": err
95        })),
96    }
97}
98
99#[get("/buildings/{building_id}/meetings")]
100pub async fn list_meetings_by_building(
101    state: web::Data<AppState>,
102    building_id: web::Path<Uuid>,
103) -> impl Responder {
104    match state
105        .meeting_use_cases
106        .list_meetings_by_building(*building_id)
107        .await
108    {
109        Ok(meetings) => HttpResponse::Ok().json(meetings),
110        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
111            "error": err
112        })),
113    }
114}
115
116#[put("/meetings/{id}")]
117pub async fn update_meeting(
118    state: web::Data<AppState>,
119    id: web::Path<Uuid>,
120    request: web::Json<UpdateMeetingRequest>,
121) -> impl Responder {
122    match state
123        .meeting_use_cases
124        .update_meeting(*id, request.into_inner())
125        .await
126    {
127        Ok(meeting) => HttpResponse::Ok().json(meeting),
128        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
129            "error": err
130        })),
131    }
132}
133
134#[post("/meetings/{id}/agenda")]
135pub async fn add_agenda_item(
136    state: web::Data<AppState>,
137    id: web::Path<Uuid>,
138    request: web::Json<AddAgendaItemRequest>,
139) -> impl Responder {
140    match state
141        .meeting_use_cases
142        .add_agenda_item(*id, request.into_inner())
143        .await
144    {
145        Ok(meeting) => HttpResponse::Ok().json(meeting),
146        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
147            "error": err
148        })),
149    }
150}
151
152#[post("/meetings/{id}/complete")]
153pub async fn complete_meeting(
154    state: web::Data<AppState>,
155    user: AuthenticatedUser,
156    id: web::Path<Uuid>,
157    request: web::Json<CompleteMeetingRequest>,
158) -> impl Responder {
159    match state
160        .meeting_use_cases
161        .complete_meeting(*id, request.into_inner())
162        .await
163    {
164        Ok(meeting) => {
165            // Audit log: successful meeting completion
166            AuditLogEntry::new(
167                AuditEventType::MeetingCompleted,
168                Some(user.user_id),
169                user.organization_id,
170            )
171            .with_resource("Meeting", *id)
172            .log();
173
174            HttpResponse::Ok().json(meeting)
175        }
176        Err(err) => {
177            // Audit log: failed meeting completion
178            AuditLogEntry::new(
179                AuditEventType::MeetingCompleted,
180                Some(user.user_id),
181                user.organization_id,
182            )
183            .with_resource("Meeting", *id)
184            .with_error(err.clone())
185            .log();
186
187            HttpResponse::BadRequest().json(serde_json::json!({
188                "error": err
189            }))
190        }
191    }
192}
193
194#[post("/meetings/{id}/cancel")]
195pub async fn cancel_meeting(
196    state: web::Data<AppState>,
197    user: AuthenticatedUser,
198    id: web::Path<Uuid>,
199) -> impl Responder {
200    match state.meeting_use_cases.cancel_meeting(*id).await {
201        Ok(meeting) => {
202            AuditLogEntry::new(
203                AuditEventType::MeetingCompleted,
204                Some(user.user_id),
205                user.organization_id,
206            )
207            .with_resource("Meeting", *id)
208            .log();
209
210            HttpResponse::Ok().json(meeting)
211        }
212        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
213            "error": err
214        })),
215    }
216}
217
218#[post("/meetings/{id}/reschedule")]
219pub async fn reschedule_meeting(
220    state: web::Data<AppState>,
221    user: AuthenticatedUser,
222    id: web::Path<Uuid>,
223    request: web::Json<RescheduleMeetingRequest>,
224) -> impl Responder {
225    match state
226        .meeting_use_cases
227        .reschedule_meeting(*id, request.scheduled_date)
228        .await
229    {
230        Ok(meeting) => {
231            AuditLogEntry::new(
232                AuditEventType::MeetingCompleted,
233                Some(user.user_id),
234                user.organization_id,
235            )
236            .with_resource("Meeting", *id)
237            .log();
238
239            HttpResponse::Ok().json(meeting)
240        }
241        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
242            "error": err
243        })),
244    }
245}
246
247#[delete("/meetings/{id}")]
248pub async fn delete_meeting(state: web::Data<AppState>, id: web::Path<Uuid>) -> impl Responder {
249    match state.meeting_use_cases.delete_meeting(*id).await {
250        Ok(true) => HttpResponse::NoContent().finish(),
251        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
252            "error": "Meeting not found"
253        })),
254        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
255            "error": err
256        })),
257    }
258}
259
260#[get("/meetings/{id}/export-minutes-pdf")]
261pub async fn export_meeting_minutes_pdf(
262    state: web::Data<AppState>,
263    user: AuthenticatedUser,
264    id: web::Path<Uuid>,
265) -> impl Responder {
266    use crate::domain::services::{AttendeeInfo, MeetingMinutesExporter, ResolutionWithVotes};
267
268    let organization_id = match user.require_organization() {
269        Ok(org_id) => org_id,
270        Err(e) => {
271            return HttpResponse::Unauthorized().json(serde_json::json!({
272                "error": e.to_string()
273            }))
274        }
275    };
276
277    let meeting_id = *id;
278
279    // 1. Get meeting
280    let meeting = match state.meeting_use_cases.get_meeting(meeting_id).await {
281        Ok(Some(meeting_dto)) => meeting_dto,
282        Ok(None) => {
283            return HttpResponse::NotFound().json(serde_json::json!({
284                "error": "Meeting not found"
285            }))
286        }
287        Err(err) => {
288            return HttpResponse::InternalServerError().json(serde_json::json!({
289                "error": err
290            }))
291        }
292    };
293
294    // 2. Get building
295    let building = match state
296        .building_use_cases
297        .get_building(meeting.building_id)
298        .await
299    {
300        Ok(Some(building_dto)) => building_dto,
301        Ok(None) => {
302            return HttpResponse::NotFound().json(serde_json::json!({
303                "error": "Building not found"
304            }))
305        }
306        Err(err) => {
307            return HttpResponse::InternalServerError().json(serde_json::json!({
308                "error": err
309            }))
310        }
311    };
312
313    // 3. Get resolutions for this meeting
314    let resolutions = match state
315        .resolution_use_cases
316        .get_meeting_resolutions(meeting_id)
317        .await
318    {
319        Ok(resolutions) => resolutions,
320        Err(err) => {
321            return HttpResponse::InternalServerError().json(serde_json::json!({
322                "error": format!("Failed to get resolutions: {}", err)
323            }))
324        }
325    };
326
327    // 4. Get votes for each resolution and collect attendees
328    let mut attendees_map = std::collections::HashMap::new();
329    let mut resolutions_with_votes = Vec::new();
330
331    for resolution_dto in resolutions {
332        // Get votes for this resolution
333        let votes_dto = match state
334            .resolution_use_cases
335            .get_resolution_votes(resolution_dto.id)
336            .await
337        {
338            Ok(votes) => votes,
339            Err(err) => {
340                return HttpResponse::InternalServerError().json(serde_json::json!({
341                    "error": format!("Failed to get votes: {}", err)
342                }))
343            }
344        };
345
346        // Collect attendees from votes
347        for vote_dto in &votes_dto {
348            if let std::collections::hash_map::Entry::Vacant(e) =
349                attendees_map.entry(vote_dto.owner_id)
350            {
351                // Get owner info
352                if let Ok(Some(owner_dto)) =
353                    state.owner_use_cases.get_owner(vote_dto.owner_id).await
354                {
355                    let proxy_for_name = if let Some(proxy_id) = vote_dto.proxy_owner_id {
356                        state
357                            .owner_use_cases
358                            .get_owner(proxy_id)
359                            .await
360                            .ok()
361                            .flatten()
362                            .map(|o| format!("{} {}", o.first_name, o.last_name))
363                    } else {
364                        None
365                    };
366
367                    let full_name = format!("{} {}", owner_dto.first_name, owner_dto.last_name);
368
369                    e.insert(AttendeeInfo {
370                        owner_id: vote_dto.owner_id,
371                        name: full_name,
372                        email: owner_dto.email.clone(),
373                        voting_power: vote_dto.voting_power,
374                        is_proxy: vote_dto.proxy_owner_id.is_some(),
375                        proxy_for: proxy_for_name,
376                    });
377                }
378            }
379        }
380
381        // Convert DTOs to domain entities for PDF generation
382        use crate::domain::entities::{Resolution, Vote};
383
384        let resolution_entity = Resolution {
385            id: resolution_dto.id,
386            meeting_id: resolution_dto.meeting_id,
387            title: resolution_dto.title,
388            description: resolution_dto.description,
389            resolution_type: resolution_dto.resolution_type,
390            majority_required: resolution_dto.majority_required,
391            vote_count_pour: resolution_dto.vote_count_pour,
392            vote_count_contre: resolution_dto.vote_count_contre,
393            vote_count_abstention: resolution_dto.vote_count_abstention,
394            total_voting_power_pour: resolution_dto.total_voting_power_pour,
395            total_voting_power_contre: resolution_dto.total_voting_power_contre,
396            total_voting_power_abstention: resolution_dto.total_voting_power_abstention,
397            status: resolution_dto.status,
398            voted_at: resolution_dto.voted_at,
399            created_at: resolution_dto.created_at,
400        };
401
402        let votes: Vec<Vote> = votes_dto
403            .iter()
404            .map(|v| Vote {
405                id: v.id,
406                resolution_id: v.resolution_id,
407                owner_id: v.owner_id,
408                unit_id: v.unit_id,
409                vote_choice: v.vote_choice.clone(),
410                voting_power: v.voting_power,
411                proxy_owner_id: v.proxy_owner_id,
412                voted_at: v.voted_at,
413            })
414            .collect();
415
416        resolutions_with_votes.push(ResolutionWithVotes {
417            resolution: resolution_entity,
418            votes,
419        });
420    }
421
422    let attendees: Vec<AttendeeInfo> = attendees_map.into_values().collect();
423
424    // Convert DTOs to domain entities
425    use crate::domain::entities::{Building, Meeting};
426
427    // Parse building organization_id from string
428    let building_org_id = match Uuid::parse_str(&building.organization_id) {
429        Ok(id) => id,
430        Err(err) => {
431            return HttpResponse::InternalServerError().json(serde_json::json!({
432                "error": format!("Invalid organization_id: {}", err)
433            }))
434        }
435    };
436
437    // Parse building dates
438    use chrono::DateTime;
439    let building_created_at = match DateTime::parse_from_rfc3339(&building.created_at) {
440        Ok(dt) => dt.with_timezone(&chrono::Utc),
441        Err(_) => chrono::Utc::now(),
442    };
443    let building_updated_at = match DateTime::parse_from_rfc3339(&building.updated_at) {
444        Ok(dt) => dt.with_timezone(&chrono::Utc),
445        Err(_) => chrono::Utc::now(),
446    };
447
448    let building_entity = Building {
449        id: match Uuid::parse_str(&building.id) {
450            Ok(id) => id,
451            Err(err) => {
452                return HttpResponse::InternalServerError().json(serde_json::json!({
453                    "error": format!("Invalid building id: {}", err)
454                }))
455            }
456        },
457        name: building.name,
458        address: building.address,
459        city: building.city,
460        postal_code: building.postal_code,
461        country: building.country,
462        total_units: building.total_units,
463        total_tantiemes: building.total_tantiemes,
464        construction_year: building.construction_year,
465        syndic_name: None,
466        syndic_email: None,
467        syndic_phone: None,
468        syndic_address: None,
469        syndic_office_hours: None,
470        syndic_emergency_contact: None,
471        slug: None,
472        organization_id: building_org_id,
473        created_at: building_created_at,
474        updated_at: building_updated_at,
475    };
476
477    let meeting_entity = Meeting {
478        id: meeting.id,
479        organization_id,
480        building_id: meeting.building_id,
481        meeting_type: meeting.meeting_type,
482        title: meeting.title,
483        description: meeting.description,
484        scheduled_date: meeting.scheduled_date,
485        location: meeting.location,
486        status: meeting.status,
487        agenda: meeting.agenda,
488        attendees_count: meeting.attendees_count,
489        created_at: meeting.created_at,
490        updated_at: meeting.updated_at,
491    };
492
493    // 5. Generate PDF
494    match MeetingMinutesExporter::export_to_pdf(
495        &building_entity,
496        &meeting_entity,
497        &attendees,
498        &resolutions_with_votes,
499    ) {
500        Ok(pdf_bytes) => {
501            // Audit log
502            AuditLogEntry::new(
503                AuditEventType::ReportGenerated,
504                Some(user.user_id),
505                Some(organization_id),
506            )
507            .with_resource("Meeting", meeting_id)
508            .with_metadata(serde_json::json!({
509                "report_type": "meeting_minutes_pdf",
510                "building_name": building_entity.name,
511                "meeting_date": meeting_entity.scheduled_date.to_rfc3339()
512            }))
513            .log();
514
515            HttpResponse::Ok()
516                .content_type("application/pdf")
517                .insert_header((
518                    "Content-Disposition",
519                    format!(
520                        "attachment; filename=\"PV_{}_{}_{}.pdf\"",
521                        building_entity.name.replace(' ', "_"),
522                        meeting_entity.scheduled_date.format("%Y%m%d"),
523                        meeting_entity.id
524                    ),
525                ))
526                .body(pdf_bytes)
527        }
528        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
529            "error": format!("Failed to generate PDF: {}", err)
530        })),
531    }
532}