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, mut request: web::Json<CreateMeetingRequest>,
15) -> impl Responder {
16 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 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 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 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 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 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 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 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 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 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 let mut attendees_map = std::collections::HashMap::new();
417 let mut resolutions_with_votes = Vec::new();
418
419 for resolution_dto in resolutions {
420 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 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 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 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 use crate::domain::entities::{Building, Meeting};
515
516 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 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 match MeetingMinutesExporter::export_to_pdf(
591 &building_entity,
592 &meeting_entity,
593 &attendees,
594 &resolutions_with_votes,
595 ) {
596 Ok(pdf_bytes) => {
597 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}