koprogo_api/infrastructure/web/handlers/
resolution_handlers.rs

1use crate::application::dto::{
2    CastVoteRequest, ChangeVoteRequest, CloseVotingRequest, CreateResolutionRequest,
3    ResolutionResponse, VoteResponse,
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// ==================== Resolution Endpoints ====================
11
12#[utoipa::path(
13    post,
14    path = "/meetings/{meeting_id}/resolutions",
15    tag = "Resolutions",
16    summary = "Create a resolution for a meeting",
17    params(
18        ("meeting_id" = Uuid, Path, description = "Meeting UUID")
19    ),
20    request_body = CreateResolutionRequest,
21    responses(
22        (status = 201, description = "Resolution created"),
23        (status = 400, description = "Bad Request"),
24        (status = 401, description = "Unauthorized"),
25    ),
26    security(("bearer_auth" = []))
27)]
28#[post("/meetings/{meeting_id}/resolutions")]
29pub async fn create_resolution(
30    state: web::Data<AppState>,
31    user: AuthenticatedUser,
32    meeting_id: web::Path<Uuid>,
33    request: web::Json<CreateResolutionRequest>,
34) -> impl Responder {
35    let organization_id = match user.require_organization() {
36        Ok(org_id) => org_id,
37        Err(e) => {
38            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
39        }
40    };
41
42    let meeting_id = *meeting_id;
43
44    match state
45        .resolution_use_cases
46        .create_resolution(
47            meeting_id,
48            request.title.clone(),
49            request.description.clone(),
50            request.resolution_type.clone(),
51            request.majority_required.clone(),
52            request.agenda_item_index,
53        )
54        .await
55    {
56        Ok(resolution) => {
57            AuditLogEntry::new(
58                AuditEventType::ResolutionCreated,
59                Some(user.user_id),
60                Some(organization_id),
61            )
62            .with_resource("Resolution", resolution.id)
63            .log();
64
65            HttpResponse::Created().json(ResolutionResponse::from(resolution))
66        }
67        Err(err) => {
68            AuditLogEntry::new(
69                AuditEventType::ResolutionCreated,
70                Some(user.user_id),
71                Some(organization_id),
72            )
73            .with_error(err.clone())
74            .log();
75
76            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
77        }
78    }
79}
80
81#[utoipa::path(
82    get,
83    path = "/resolutions/{id}",
84    tag = "Resolutions",
85    summary = "Get resolution by ID",
86    params(
87        ("id" = Uuid, Path, description = "Resolution UUID")
88    ),
89    responses(
90        (status = 200, description = "Resolution found"),
91        (status = 404, description = "Resolution not found"),
92        (status = 500, description = "Internal Server Error"),
93    ),
94    security(("bearer_auth" = []))
95)]
96#[get("/resolutions/{id}")]
97pub async fn get_resolution(
98    state: web::Data<AppState>,
99    user: AuthenticatedUser,
100    id: web::Path<Uuid>,
101) -> impl Responder {
102    match state.resolution_use_cases.get_resolution(*id).await {
103        Ok(Some(resolution)) => {
104            // Multi-tenant isolation: verify resolution's meeting belongs to user's organization
105            // Resolution → Meeting → Building → Organization
106            if let Ok(Some(meeting)) = state
107                .meeting_use_cases
108                .get_meeting(resolution.meeting_id)
109                .await
110            {
111                if let Ok(Some(building)) = state
112                    .building_use_cases
113                    .get_building(meeting.building_id)
114                    .await
115                {
116                    if let Ok(building_org) = Uuid::parse_str(&building.organization_id) {
117                        if let Err(e) = user.verify_org_access(building_org) {
118                            return HttpResponse::Forbidden()
119                                .json(serde_json::json!({ "error": e }));
120                        }
121                    }
122                }
123            }
124            HttpResponse::Ok().json(ResolutionResponse::from(resolution))
125        }
126        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
127            "error": "Resolution not found"
128        })),
129        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
130            "error": err
131        })),
132    }
133}
134
135#[utoipa::path(
136    get,
137    path = "/meetings/{meeting_id}/resolutions",
138    tag = "Resolutions",
139    summary = "List all resolutions for a meeting",
140    params(
141        ("meeting_id" = Uuid, Path, description = "Meeting UUID")
142    ),
143    responses(
144        (status = 200, description = "List of resolutions"),
145        (status = 500, description = "Internal Server Error"),
146    ),
147    security(("bearer_auth" = []))
148)]
149#[get("/meetings/{meeting_id}/resolutions")]
150pub async fn list_meeting_resolutions(
151    state: web::Data<AppState>,
152    meeting_id: web::Path<Uuid>,
153) -> impl Responder {
154    match state
155        .resolution_use_cases
156        .get_meeting_resolutions(*meeting_id)
157        .await
158    {
159        Ok(resolutions) => {
160            let responses: Vec<ResolutionResponse> = resolutions
161                .into_iter()
162                .map(ResolutionResponse::from)
163                .collect();
164            HttpResponse::Ok().json(responses)
165        }
166        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
167            "error": err
168        })),
169    }
170}
171
172#[utoipa::path(
173    delete,
174    path = "/resolutions/{id}",
175    tag = "Resolutions",
176    summary = "Delete a resolution",
177    params(
178        ("id" = Uuid, Path, description = "Resolution UUID")
179    ),
180    responses(
181        (status = 204, description = "Resolution deleted"),
182        (status = 400, description = "Bad Request"),
183        (status = 401, description = "Unauthorized"),
184        (status = 404, description = "Resolution not found"),
185    ),
186    security(("bearer_auth" = []))
187)]
188#[delete("/resolutions/{id}")]
189pub async fn delete_resolution(
190    state: web::Data<AppState>,
191    user: AuthenticatedUser,
192    id: web::Path<Uuid>,
193) -> impl Responder {
194    let organization_id = match user.require_organization() {
195        Ok(org_id) => org_id,
196        Err(e) => {
197            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
198        }
199    };
200
201    match state.resolution_use_cases.delete_resolution(*id).await {
202        Ok(true) => {
203            AuditLogEntry::new(
204                AuditEventType::ResolutionDeleted,
205                Some(user.user_id),
206                Some(organization_id),
207            )
208            .with_resource("Resolution", *id)
209            .log();
210
211            HttpResponse::NoContent().finish()
212        }
213        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
214            "error": "Resolution not found"
215        })),
216        Err(err) => {
217            AuditLogEntry::new(
218                AuditEventType::ResolutionDeleted,
219                Some(user.user_id),
220                Some(organization_id),
221            )
222            .with_error(err.clone())
223            .log();
224
225            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
226        }
227    }
228}
229
230// ==================== Vote Endpoints ====================
231
232#[utoipa::path(
233    post,
234    path = "/resolutions/{resolution_id}/vote",
235    tag = "Resolutions",
236    summary = "Cast a vote on a resolution",
237    params(
238        ("resolution_id" = Uuid, Path, description = "Resolution UUID")
239    ),
240    request_body = CastVoteRequest,
241    responses(
242        (status = 201, description = "Vote cast"),
243        (status = 400, description = "Bad Request"),
244        (status = 401, description = "Unauthorized"),
245    ),
246    security(("bearer_auth" = []))
247)]
248#[post("/resolutions/{resolution_id}/vote")]
249pub async fn cast_vote(
250    state: web::Data<AppState>,
251    user: AuthenticatedUser,
252    resolution_id: web::Path<Uuid>,
253    request: web::Json<CastVoteRequest>,
254) -> impl Responder {
255    let organization_id = match user.require_organization() {
256        Ok(org_id) => org_id,
257        Err(e) => {
258            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
259        }
260    };
261
262    match state
263        .resolution_use_cases
264        .cast_vote(
265            *resolution_id,
266            request.owner_id,
267            request.unit_id,
268            request.vote_choice.clone(),
269            request.voting_power,
270            request.proxy_owner_id,
271        )
272        .await
273    {
274        Ok(vote) => {
275            AuditLogEntry::new(
276                AuditEventType::VoteCast,
277                Some(user.user_id),
278                Some(organization_id),
279            )
280            .with_resource("Vote", vote.id)
281            .with_metadata(serde_json::json!({
282                "resolution_id": *resolution_id,
283                "vote_choice": format!("{:?}", vote.vote_choice)
284            }))
285            .log();
286
287            HttpResponse::Created().json(VoteResponse::from(vote))
288        }
289        Err(err) => {
290            AuditLogEntry::new(
291                AuditEventType::VoteCast,
292                Some(user.user_id),
293                Some(organization_id),
294            )
295            .with_error(err.clone())
296            .log();
297
298            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
299        }
300    }
301}
302
303#[utoipa::path(
304    get,
305    path = "/resolutions/{resolution_id}/votes",
306    tag = "Resolutions",
307    summary = "List all votes for a resolution",
308    params(
309        ("resolution_id" = Uuid, Path, description = "Resolution UUID")
310    ),
311    responses(
312        (status = 200, description = "List of votes"),
313        (status = 500, description = "Internal Server Error"),
314    ),
315    security(("bearer_auth" = []))
316)]
317#[get("/resolutions/{resolution_id}/votes")]
318pub async fn list_resolution_votes(
319    state: web::Data<AppState>,
320    resolution_id: web::Path<Uuid>,
321) -> impl Responder {
322    match state
323        .resolution_use_cases
324        .get_resolution_votes(*resolution_id)
325        .await
326    {
327        Ok(votes) => {
328            let responses: Vec<VoteResponse> = votes.into_iter().map(VoteResponse::from).collect();
329            HttpResponse::Ok().json(responses)
330        }
331        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
332            "error": err
333        })),
334    }
335}
336
337#[utoipa::path(
338    put,
339    path = "/votes/{vote_id}",
340    tag = "Resolutions",
341    summary = "Change an existing vote",
342    params(
343        ("vote_id" = Uuid, Path, description = "Vote UUID")
344    ),
345    request_body = ChangeVoteRequest,
346    responses(
347        (status = 200, description = "Vote changed"),
348        (status = 400, description = "Bad Request"),
349        (status = 401, description = "Unauthorized"),
350    ),
351    security(("bearer_auth" = []))
352)]
353#[put("/votes/{vote_id}")]
354pub async fn change_vote(
355    state: web::Data<AppState>,
356    user: AuthenticatedUser,
357    vote_id: web::Path<Uuid>,
358    request: web::Json<ChangeVoteRequest>,
359) -> impl Responder {
360    let organization_id = match user.require_organization() {
361        Ok(org_id) => org_id,
362        Err(e) => {
363            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
364        }
365    };
366
367    match state
368        .resolution_use_cases
369        .change_vote(*vote_id, request.vote_choice.clone())
370        .await
371    {
372        Ok(vote) => {
373            AuditLogEntry::new(
374                AuditEventType::VoteChanged,
375                Some(user.user_id),
376                Some(organization_id),
377            )
378            .with_resource("Vote", vote.id)
379            .with_metadata(serde_json::json!({
380                "new_choice": format!("{:?}", vote.vote_choice)
381            }))
382            .log();
383
384            HttpResponse::Ok().json(VoteResponse::from(vote))
385        }
386        Err(err) => {
387            AuditLogEntry::new(
388                AuditEventType::VoteChanged,
389                Some(user.user_id),
390                Some(organization_id),
391            )
392            .with_error(err.clone())
393            .log();
394
395            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
396        }
397    }
398}
399
400#[utoipa::path(
401    put,
402    path = "/resolutions/{resolution_id}/close",
403    tag = "Resolutions",
404    summary = "Close voting on a resolution and calculate result",
405    params(
406        ("resolution_id" = Uuid, Path, description = "Resolution UUID")
407    ),
408    request_body = CloseVotingRequest,
409    responses(
410        (status = 200, description = "Voting closed and result calculated"),
411        (status = 400, description = "Bad Request"),
412        (status = 401, description = "Unauthorized"),
413    ),
414    security(("bearer_auth" = []))
415)]
416#[put("/resolutions/{resolution_id}/close")]
417pub async fn close_voting(
418    state: web::Data<AppState>,
419    user: AuthenticatedUser,
420    resolution_id: web::Path<Uuid>,
421    request: web::Json<CloseVotingRequest>,
422) -> impl Responder {
423    let organization_id = match user.require_organization() {
424        Ok(org_id) => org_id,
425        Err(e) => {
426            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
427        }
428    };
429
430    match state
431        .resolution_use_cases
432        .close_voting(*resolution_id, request.total_voting_power)
433        .await
434    {
435        Ok(resolution) => {
436            AuditLogEntry::new(
437                AuditEventType::VotingClosed,
438                Some(user.user_id),
439                Some(organization_id),
440            )
441            .with_resource("Resolution", resolution.id)
442            .with_metadata(serde_json::json!({
443                "final_status": format!("{:?}", resolution.status)
444            }))
445            .log();
446
447            HttpResponse::Ok().json(ResolutionResponse::from(resolution))
448        }
449        Err(err) => {
450            AuditLogEntry::new(
451                AuditEventType::VotingClosed,
452                Some(user.user_id),
453                Some(organization_id),
454            )
455            .with_error(err.clone())
456            .log();
457
458            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
459        }
460    }
461}
462
463#[utoipa::path(
464    get,
465    path = "/meetings/{meeting_id}/vote-summary",
466    tag = "Resolutions",
467    summary = "Get vote summary for a meeting",
468    params(
469        ("meeting_id" = Uuid, Path, description = "Meeting UUID")
470    ),
471    responses(
472        (status = 200, description = "Vote summary for all meeting resolutions"),
473        (status = 500, description = "Internal Server Error"),
474    ),
475    security(("bearer_auth" = []))
476)]
477#[get("/meetings/{meeting_id}/vote-summary")]
478pub async fn get_meeting_vote_summary(
479    state: web::Data<AppState>,
480    meeting_id: web::Path<Uuid>,
481) -> impl Responder {
482    match state
483        .resolution_use_cases
484        .get_meeting_vote_summary(*meeting_id)
485        .await
486    {
487        Ok(resolutions) => {
488            let responses: Vec<ResolutionResponse> = resolutions
489                .into_iter()
490                .map(ResolutionResponse::from)
491                .collect();
492            HttpResponse::Ok().json(responses)
493        }
494        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
495            "error": err
496        })),
497    }
498}