1use crate::application::dto::{
2 CastVoteRequest, ChangeVoteRequest, CloseVotingRequest, CreateResolutionRequest,
3 ResolutionResponse, VoteResponse,
4};
5use crate::application::error::AppError;
6use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
7use crate::infrastructure::web::classification_erreurs::est_interdit;
8use crate::infrastructure::web::middleware::scope_guard::verify_acp_org_access;
9use crate::infrastructure::web::{AppState, AuthenticatedUser};
10use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
11use uuid::Uuid;
12
13fn require_syndic_or_superadmin(user: &AuthenticatedUser) -> Result<(), AppError> {
18 match user.role.as_str() {
19 "syndic" | "superadmin" => Ok(()),
20 _ => Err(AppError::Forbidden(
21 "Seul le syndic peut clôturer le vote d'une résolution".to_string(),
22 )),
23 }
24}
25
26async fn verifier_mandat_sur_ag(
50 state: &web::Data<AppState>,
51 user: &AuthenticatedUser,
52 meeting_id: Uuid,
53) -> Option<HttpResponse> {
54 if user.is_superadmin() {
55 return None;
56 }
57 let introuvable = || {
58 HttpResponse::NotFound().json(serde_json::json!({
59 "error": "Meeting not found"
60 }))
61 };
62 let Ok(Some(meeting)) = state.meeting_use_cases.get_meeting(meeting_id).await else {
63 return Some(introuvable());
64 };
65 let Ok(Some(building)) = state
66 .building_use_cases
67 .get_building(meeting.building_id)
68 .await
69 else {
70 return Some(introuvable());
71 };
72 let Ok(acp_id) = Uuid::parse_str(&building.acp_id) else {
73 return Some(HttpResponse::InternalServerError().json(serde_json::json!({
74 "error": "Invalid building.acp_id format"
75 })));
76 };
77 verify_acp_org_access(user, acp_id, &state.acp_use_cases)
78 .await
79 .err()
80 .map(|err| err.error_response())
81}
82
83#[utoipa::path(
84 post,
85 path = "/meetings/{meeting_id}/resolutions",
86 tag = "Resolutions",
87 summary = "Create a resolution for a meeting",
88 params(
89 ("meeting_id" = Uuid, Path, description = "Meeting UUID")
90 ),
91 request_body = CreateResolutionRequest,
92 responses(
93 (status = 201, description = "Resolution created"),
94 (status = 400, description = "Bad Request"),
95 (status = 401, description = "Unauthorized"),
96 ),
97 security(("bearer_auth" = []))
98)]
99#[post("/meetings/{meeting_id}/resolutions")]
100pub async fn create_resolution(
101 state: web::Data<AppState>,
102 user: AuthenticatedUser,
103 meeting_id: web::Path<Uuid>,
104 request: web::Json<CreateResolutionRequest>,
105) -> impl Responder {
106 let organization_id = match user.require_organization() {
107 Ok(org_id) => org_id,
108 Err(e) => {
109 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
110 }
111 };
112
113 let meeting_id = *meeting_id;
114
115 match state
116 .resolution_use_cases
117 .create_resolution(
118 meeting_id,
119 request.title.clone(),
120 request.description.clone(),
121 request.resolution_type.clone(),
122 request.majority_required.clone(),
123 request.agenda_item_index,
124 )
125 .await
126 {
127 Ok(resolution) => {
128 AuditLogEntry::new(
129 AuditEventType::ResolutionCreated,
130 Some(user.user_id),
131 Some(organization_id),
132 )
133 .with_resource("Resolution", resolution.id)
134 .log();
135
136 HttpResponse::Created().json(ResolutionResponse::from(resolution))
137 }
138 Err(err) => {
139 AuditLogEntry::new(
140 AuditEventType::ResolutionCreated,
141 Some(user.user_id),
142 Some(organization_id),
143 )
144 .with_error(err.clone())
145 .log();
146
147 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
148 }
149 }
150}
151
152#[utoipa::path(
153 get,
154 path = "/resolutions/{id}",
155 tag = "Resolutions",
156 summary = "Get resolution by ID",
157 params(
158 ("id" = Uuid, Path, description = "Resolution UUID")
159 ),
160 responses(
161 (status = 200, description = "Resolution found"),
162 (status = 404, description = "Resolution not found"),
163 (status = 500, description = "Internal Server Error"),
164 ),
165 security(("bearer_auth" = []))
166)]
167#[get("/resolutions/{id}")]
168pub async fn get_resolution(
169 state: web::Data<AppState>,
170 user: AuthenticatedUser,
171 id: web::Path<Uuid>,
172) -> impl Responder {
173 match state.resolution_use_cases.get_resolution(*id).await {
174 Ok(Some(resolution)) => {
175 if let Ok(Some(meeting)) = state
178 .meeting_use_cases
179 .get_meeting(resolution.meeting_id)
180 .await
181 {
182 if let Ok(Some(building)) = state
183 .building_use_cases
184 .get_building(meeting.building_id)
185 .await
186 {
187 let acp_id = match Uuid::parse_str(&building.acp_id) {
188 Ok(id) => id,
189 Err(_) => {
190 return HttpResponse::InternalServerError().json(serde_json::json!({
191 "error": "Invalid building.acp_id format"
192 }));
193 }
194 };
195 if let Err(err) =
196 verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await
197 {
198 return err.error_response();
199 }
200 }
201 }
202 HttpResponse::Ok().json(ResolutionResponse::from(resolution))
203 }
204 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
205 "error": "Resolution not found"
206 })),
207 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
208 "error": err
209 })),
210 }
211}
212
213#[utoipa::path(
214 get,
215 path = "/meetings/{meeting_id}/resolutions",
216 tag = "Resolutions",
217 summary = "List all resolutions for a meeting",
218 params(
219 ("meeting_id" = Uuid, Path, description = "Meeting UUID")
220 ),
221 responses(
222 (status = 200, description = "List of resolutions"),
223 (status = 500, description = "Internal Server Error"),
224 ),
225 security(("bearer_auth" = []))
226)]
227#[get("/meetings/{meeting_id}/resolutions")]
228pub async fn list_meeting_resolutions(
229 state: web::Data<AppState>,
230 user: AuthenticatedUser,
231 meeting_id: web::Path<Uuid>,
232) -> impl Responder {
233 if let Some(refus) = verifier_mandat_sur_ag(&state, &user, *meeting_id).await {
234 return refus;
235 }
236 match state
237 .resolution_use_cases
238 .get_meeting_resolutions(*meeting_id)
239 .await
240 {
241 Ok(resolutions) => {
242 let responses: Vec<ResolutionResponse> = resolutions
243 .into_iter()
244 .map(ResolutionResponse::from)
245 .collect();
246 HttpResponse::Ok().json(responses)
247 }
248 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
249 "error": err
250 })),
251 }
252}
253
254#[utoipa::path(
255 delete,
256 path = "/resolutions/{id}",
257 tag = "Resolutions",
258 summary = "Delete a resolution",
259 params(
260 ("id" = Uuid, Path, description = "Resolution UUID")
261 ),
262 responses(
263 (status = 204, description = "Resolution deleted"),
264 (status = 400, description = "Bad Request"),
265 (status = 401, description = "Unauthorized"),
266 (status = 404, description = "Resolution not found"),
267 ),
268 security(("bearer_auth" = []))
269)]
270#[delete("/resolutions/{id}")]
271pub async fn delete_resolution(
272 state: web::Data<AppState>,
273 user: AuthenticatedUser,
274 id: web::Path<Uuid>,
275) -> impl Responder {
276 let organization_id = match user.require_organization() {
277 Ok(org_id) => org_id,
278 Err(e) => {
279 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
280 }
281 };
282
283 match state.resolution_use_cases.delete_resolution(*id).await {
284 Ok(true) => {
285 AuditLogEntry::new(
286 AuditEventType::ResolutionDeleted,
287 Some(user.user_id),
288 Some(organization_id),
289 )
290 .with_resource("Resolution", *id)
291 .log();
292
293 HttpResponse::NoContent().finish()
294 }
295 Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
296 "error": "Resolution not found"
297 })),
298 Err(err) => {
299 AuditLogEntry::new(
300 AuditEventType::ResolutionDeleted,
301 Some(user.user_id),
302 Some(organization_id),
303 )
304 .with_error(err.to_string())
305 .log();
306
307 err.error_response()
308 }
309 }
310}
311
312#[utoipa::path(
315 post,
316 path = "/resolutions/{resolution_id}/vote",
317 tag = "Resolutions",
318 summary = "Cast a vote on a resolution",
319 params(
320 ("resolution_id" = Uuid, Path, description = "Resolution UUID")
321 ),
322 request_body = CastVoteRequest,
323 responses(
324 (status = 201, description = "Vote cast"),
325 (status = 400, description = "Bad Request"),
326 (status = 401, description = "Unauthorized"),
327 ),
328 security(("bearer_auth" = []))
329)]
330#[post("/resolutions/{resolution_id}/vote")]
331pub async fn cast_vote(
332 state: web::Data<AppState>,
333 user: AuthenticatedUser,
334 resolution_id: web::Path<Uuid>,
335 request: web::Json<CastVoteRequest>,
336) -> impl Responder {
337 let organization_id = match user.require_organization() {
338 Ok(org_id) => org_id,
339 Err(e) => {
340 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
341 }
342 };
343
344 let caller_owner_id = match state
352 .owner_use_cases
353 .find_owner_by_user_id(user.user_id)
354 .await
355 {
356 Ok(Some(owner)) => match Uuid::parse_str(&owner.id) {
357 Ok(id) => id,
358 Err(e) => {
359 return HttpResponse::InternalServerError().json(serde_json::json!({
360 "error": format!("Identifiant de copropriétaire illisible : {}", e)
361 }));
362 }
363 },
364 Ok(None) => {
365 return HttpResponse::Forbidden().json(serde_json::json!({
376 "error": "Aucune fiche de copropriétaire n'est rattachée à ce compte : \
377 voter à une assemblée est réservé aux copropriétaires.",
378 "kind": "owner_not_linked"
379 }));
380 }
381 Err(e) => {
382 return HttpResponse::InternalServerError().json(serde_json::json!({
383 "error": format!("Failed to resolve owner for user: {}", e)
384 }));
385 }
386 };
387
388 match state
389 .resolution_use_cases
390 .cast_vote(
391 *resolution_id,
392 request.owner_id,
393 request.unit_id,
394 request.vote_choice.clone(),
395 request.proxy_owner_id,
400 request.auth_method,
401 caller_owner_id,
402 )
403 .await
404 {
405 Ok(vote) => {
406 AuditLogEntry::new(
407 AuditEventType::VoteCast,
408 Some(user.user_id),
409 Some(organization_id),
410 )
411 .with_resource("Vote", vote.id)
412 .with_metadata(serde_json::json!({
413 "resolution_id": *resolution_id,
414 "vote_choice": format!("{:?}", vote.vote_choice)
415 }))
416 .log();
417
418 HttpResponse::Created().json(VoteResponse::from(vote))
419 }
420 Err(err) => {
421 AuditLogEntry::new(
422 AuditEventType::VoteCast,
423 Some(user.user_id),
424 Some(organization_id),
425 )
426 .with_error(err.clone())
427 .log();
428
429 if let Some(reste) = err.strip_prefix("VOTING_RIGHT_SUSPENDED: unit ") {
447 if let Ok(unit_id) = uuid::Uuid::parse_str(reste.trim()) {
448 return crate::application::error::AppError::VotingRightSuspended { unit_id }
449 .error_response();
450 }
451 }
452
453 if err == "VOTE_AUTH_METHOD_REQUIRED" {
459 return crate::application::error::AppError::VoteAuthMethodRequired
460 .error_response();
461 }
462 if let Some(reste) = err.strip_prefix("VOTE_AUTH_INSUFFICIENT:") {
463 let mut parts = reste.splitn(2, ':');
464 if let (Some(mode), Some(auth_method)) = (parts.next(), parts.next()) {
465 return crate::application::error::AppError::VoteAuthInsufficient {
466 mode: mode.to_string(),
467 auth_method: auth_method.to_string(),
468 }
469 .error_response();
470 }
471 }
472
473 if est_interdit(&err) {
477 return HttpResponse::Forbidden().json(serde_json::json!({"error": err}));
478 }
479
480 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
481 }
482 }
483}
484
485#[utoipa::path(
486 get,
487 path = "/resolutions/{resolution_id}/votes",
488 tag = "Resolutions",
489 summary = "List all votes for a resolution",
490 params(
491 ("resolution_id" = Uuid, Path, description = "Resolution UUID")
492 ),
493 responses(
494 (status = 200, description = "List of votes"),
495 (status = 500, description = "Internal Server Error"),
496 ),
497 security(("bearer_auth" = []))
498)]
499#[get("/resolutions/{resolution_id}/votes")]
500pub async fn list_resolution_votes(
501 state: web::Data<AppState>,
502 user: AuthenticatedUser,
503 resolution_id: web::Path<Uuid>,
504) -> impl Responder {
505 let Ok(Some(resolution)) = state
507 .resolution_use_cases
508 .get_resolution(*resolution_id)
509 .await
510 else {
511 return HttpResponse::NotFound().json(serde_json::json!({
512 "error": "Resolution not found"
513 }));
514 };
515 if let Some(refus) = verifier_mandat_sur_ag(&state, &user, resolution.meeting_id).await {
516 return refus;
517 }
518 match state
519 .resolution_use_cases
520 .get_resolution_votes(*resolution_id)
521 .await
522 {
523 Ok(votes) => {
524 let responses: Vec<VoteResponse> = votes.into_iter().map(VoteResponse::from).collect();
525 HttpResponse::Ok().json(responses)
526 }
527 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
528 "error": err
529 })),
530 }
531}
532
533#[utoipa::path(
534 put,
535 path = "/votes/{vote_id}",
536 tag = "Resolutions",
537 summary = "Change an existing vote",
538 params(
539 ("vote_id" = Uuid, Path, description = "Vote UUID")
540 ),
541 request_body = ChangeVoteRequest,
542 responses(
543 (status = 200, description = "Vote changed"),
544 (status = 400, description = "Bad Request"),
545 (status = 401, description = "Unauthorized"),
546 ),
547 security(("bearer_auth" = []))
548)]
549#[put("/votes/{vote_id}")]
550pub async fn change_vote(
551 state: web::Data<AppState>,
552 user: AuthenticatedUser,
553 vote_id: web::Path<Uuid>,
554 request: web::Json<ChangeVoteRequest>,
555) -> impl Responder {
556 let organization_id = match user.require_organization() {
557 Ok(org_id) => org_id,
558 Err(e) => {
559 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
560 }
561 };
562
563 match state
564 .resolution_use_cases
565 .change_vote(*vote_id, request.vote_choice.clone())
566 .await
567 {
568 Ok(vote) => {
569 AuditLogEntry::new(
570 AuditEventType::VoteChanged,
571 Some(user.user_id),
572 Some(organization_id),
573 )
574 .with_resource("Vote", vote.id)
575 .with_metadata(serde_json::json!({
576 "new_choice": format!("{:?}", vote.vote_choice)
577 }))
578 .log();
579
580 HttpResponse::Ok().json(VoteResponse::from(vote))
581 }
582 Err(err) => {
583 AuditLogEntry::new(
584 AuditEventType::VoteChanged,
585 Some(user.user_id),
586 Some(organization_id),
587 )
588 .with_error(err.clone())
589 .log();
590
591 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
592 }
593 }
594}
595
596#[utoipa::path(
597 put,
598 path = "/resolutions/{resolution_id}/close",
599 tag = "Resolutions",
600 summary = "Close voting on a resolution and calculate result",
601 params(
602 ("resolution_id" = Uuid, Path, description = "Resolution UUID")
603 ),
604 request_body = CloseVotingRequest,
605 responses(
606 (status = 200, description = "Voting closed and result calculated"),
607 (status = 400, description = "Bad Request"),
608 (status = 401, description = "Unauthorized"),
609 ),
610 security(("bearer_auth" = []))
611)]
612#[put("/resolutions/{resolution_id}/close")]
613pub async fn close_voting(
614 state: web::Data<AppState>,
615 user: AuthenticatedUser,
616 resolution_id: web::Path<Uuid>,
617 _request: web::Json<CloseVotingRequest>,
621) -> impl Responder {
622 let organization_id = match user.require_organization() {
623 Ok(org_id) => org_id,
624 Err(e) => {
625 return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
626 }
627 };
628
629 if let Err(err) = require_syndic_or_superadmin(&user) {
636 return err.error_response();
637 }
638
639 let total_voting_power = {
652 let Ok(Some(resolution)) = state
653 .resolution_use_cases
654 .get_resolution(*resolution_id)
655 .await
656 else {
657 return HttpResponse::NotFound().json(serde_json::json!({
658 "error": "Resolution not found"
659 }));
660 };
661 if let Some(refus) = verifier_mandat_sur_ag(&state, &user, resolution.meeting_id).await {
662 return refus;
663 }
664 let Ok(Some(meeting)) = state
665 .meeting_use_cases
666 .get_meeting(resolution.meeting_id)
667 .await
668 else {
669 return HttpResponse::NotFound().json(serde_json::json!({
670 "error": "Meeting not found"
671 }));
672 };
673 let Ok(Some(building)) = state
674 .building_use_cases
675 .get_building(meeting.building_id)
676 .await
677 else {
678 return HttpResponse::NotFound().json(serde_json::json!({
679 "error": "Building not found"
680 }));
681 };
682 rust_decimal::Decimal::from(building.total_tantiemes)
683 };
684
685 match state
686 .resolution_use_cases
687 .close_voting(*resolution_id, total_voting_power)
688 .await
689 {
690 Ok(resolution) => {
691 AuditLogEntry::new(
692 AuditEventType::VotingClosed,
693 Some(user.user_id),
694 Some(organization_id),
695 )
696 .with_resource("Resolution", resolution.id)
697 .with_metadata(serde_json::json!({
698 "final_status": format!("{:?}", resolution.status)
699 }))
700 .log();
701
702 HttpResponse::Ok().json(ResolutionResponse::from(resolution))
703 }
704 Err(err) => {
705 AuditLogEntry::new(
706 AuditEventType::VotingClosed,
707 Some(user.user_id),
708 Some(organization_id),
709 )
710 .with_error(err.clone())
711 .log();
712
713 HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
714 }
715 }
716}
717
718#[utoipa::path(
719 get,
720 path = "/meetings/{meeting_id}/vote-summary",
721 tag = "Resolutions",
722 summary = "Get vote summary for a meeting",
723 params(
724 ("meeting_id" = Uuid, Path, description = "Meeting UUID")
725 ),
726 responses(
727 (status = 200, description = "Vote summary for all meeting resolutions"),
728 (status = 500, description = "Internal Server Error"),
729 ),
730 security(("bearer_auth" = []))
731)]
732#[get("/meetings/{meeting_id}/vote-summary")]
733pub async fn get_meeting_vote_summary(
734 state: web::Data<AppState>,
735 meeting_id: web::Path<Uuid>,
736 user: AuthenticatedUser,
737) -> impl Responder {
738 if let Err(err) =
742 crate::infrastructure::web::middleware::scope_guard::verify_meeting_org_access(
743 &user,
744 *meeting_id,
745 &state.meeting_use_cases,
746 &state.building_use_cases,
747 &state.acp_use_cases,
748 )
749 .await
750 {
751 return err.error_response();
752 }
753
754 match state
755 .resolution_use_cases
756 .get_meeting_vote_summary(*meeting_id)
757 .await
758 {
759 Ok(resolutions) => {
760 let responses: Vec<ResolutionResponse> = resolutions
761 .into_iter()
762 .map(ResolutionResponse::from)
763 .collect();
764 HttpResponse::Ok().json(responses)
765 }
766 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
767 "error": err
768 })),
769 }
770}