koprogo_api/infrastructure/web/handlers/
board_member_handlers.rs1use crate::application::dto::{CreateBoardMemberDto, RenewMandateDto};
2use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
3use crate::infrastructure::web::{AppState, AuthenticatedUser};
4use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
5use uuid::Uuid;
6
7#[post("/board-members")]
9pub async fn elect_board_member(
10 state: web::Data<AppState>,
11 user: AuthenticatedUser,
12 request: web::Json<CreateBoardMemberDto>,
13) -> impl Responder {
14 let organization_id = if user.role == "superadmin" {
16 None } else {
19 match user.require_organization() {
20 Ok(org_id) => Some(org_id),
21 Err(e) => {
22 return HttpResponse::Unauthorized().json(serde_json::json!({
23 "error": e.to_string()
24 }))
25 }
26 }
27 };
28
29 match state
30 .board_member_use_cases
31 .elect_board_member(request.into_inner())
32 .await
33 {
34 Ok(member) => {
35 if let Ok(member_uuid) = Uuid::parse_str(&member.id) {
37 AuditLogEntry::new(
38 AuditEventType::BoardMemberElected,
39 Some(user.user_id),
40 organization_id,
41 )
42 .with_resource("BoardMember", member_uuid)
43 .log();
44 }
45
46 HttpResponse::Created().json(member)
47 }
48 Err(err) => {
49 AuditLogEntry::new(
51 AuditEventType::BoardMemberElected,
52 Some(user.user_id),
53 organization_id,
54 )
55 .with_error(err.clone())
56 .log();
57
58 HttpResponse::BadRequest().json(serde_json::json!({
59 "error": err
60 }))
61 }
62 }
63}
64
65#[get("/board-members/{id}")]
67pub async fn get_board_member(
68 state: web::Data<AppState>,
69 user: AuthenticatedUser,
70 id: web::Path<Uuid>,
71) -> impl Responder {
72 let _organization_id = match user.require_organization() {
73 Ok(org_id) => org_id,
74 Err(e) => {
75 return HttpResponse::Unauthorized().json(serde_json::json!({
76 "error": e.to_string()
77 }))
78 }
79 };
80
81 match state.board_member_use_cases.get_board_member(*id).await {
82 Ok(Some(member)) => HttpResponse::Ok().json(member),
83 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
84 "error": "Board member not found"
85 })),
86 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
87 "error": err
88 })),
89 }
90}
91
92#[get("/buildings/{building_id}/board-members/active")]
94pub async fn list_active_board_members(
95 state: web::Data<AppState>,
96 user: AuthenticatedUser,
97 building_id: web::Path<Uuid>,
98) -> impl Responder {
99 if user.role != "superadmin" {
101 if let Err(e) = user.require_organization() {
102 return HttpResponse::Unauthorized().json(serde_json::json!({
103 "error": e.to_string()
104 }));
105 }
106 }
107
108 match state
109 .board_member_use_cases
110 .list_active_board_members(*building_id)
111 .await
112 {
113 Ok(members) => HttpResponse::Ok().json(members),
114 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
115 "error": err
116 })),
117 }
118}
119
120#[get("/buildings/{building_id}/board-members")]
122pub async fn list_all_board_members(
123 state: web::Data<AppState>,
124 user: AuthenticatedUser,
125 building_id: web::Path<Uuid>,
126) -> impl Responder {
127 if user.role != "superadmin" {
129 if let Err(e) = user.require_organization() {
130 return HttpResponse::Unauthorized().json(serde_json::json!({
131 "error": e.to_string()
132 }));
133 }
134 }
135
136 match state
137 .board_member_use_cases
138 .list_all_board_members(*building_id)
139 .await
140 {
141 Ok(members) => HttpResponse::Ok().json(members),
142 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
143 "error": err
144 })),
145 }
146}
147
148#[put("/board-members/{id}/renew")]
150pub async fn renew_mandate(
151 state: web::Data<AppState>,
152 user: AuthenticatedUser,
153 id: web::Path<Uuid>,
154 request: web::Json<RenewMandateDto>,
155) -> impl Responder {
156 let organization_id = match user.require_organization() {
157 Ok(org_id) => org_id,
158 Err(e) => {
159 return HttpResponse::Unauthorized().json(serde_json::json!({
160 "error": e.to_string()
161 }))
162 }
163 };
164
165 match state
166 .board_member_use_cases
167 .renew_mandate(*id, request.into_inner())
168 .await
169 {
170 Ok(member) => {
171 if let Ok(member_uuid) = Uuid::parse_str(&member.id) {
173 AuditLogEntry::new(
174 AuditEventType::BoardMemberMandateRenewed,
175 Some(user.user_id),
176 Some(organization_id),
177 )
178 .with_resource("BoardMember", member_uuid)
179 .log();
180 }
181
182 HttpResponse::Ok().json(member)
183 }
184 Err(err) => {
185 AuditLogEntry::new(
187 AuditEventType::BoardMemberMandateRenewed,
188 Some(user.user_id),
189 Some(organization_id),
190 )
191 .with_error(err.clone())
192 .log();
193
194 HttpResponse::BadRequest().json(serde_json::json!({
195 "error": err
196 }))
197 }
198 }
199}
200
201#[delete("/board-members/{id}")]
203pub async fn remove_board_member(
204 state: web::Data<AppState>,
205 user: AuthenticatedUser,
206 id: web::Path<Uuid>,
207) -> impl Responder {
208 let organization_id = match user.require_organization() {
209 Ok(org_id) => org_id,
210 Err(e) => {
211 return HttpResponse::Unauthorized().json(serde_json::json!({
212 "error": e.to_string()
213 }))
214 }
215 };
216
217 match state.board_member_use_cases.remove_board_member(*id).await {
218 Ok(true) => {
219 AuditLogEntry::new(
221 AuditEventType::BoardMemberRemoved,
222 Some(user.user_id),
223 Some(organization_id),
224 )
225 .with_resource("BoardMember", *id)
226 .log();
227
228 HttpResponse::NoContent().finish()
229 }
230 Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
231 "error": "Board member not found"
232 })),
233 Err(err) => {
234 AuditLogEntry::new(
236 AuditEventType::BoardMemberRemoved,
237 Some(user.user_id),
238 Some(organization_id),
239 )
240 .with_error(err.clone())
241 .log();
242
243 HttpResponse::InternalServerError().json(serde_json::json!({
244 "error": err
245 }))
246 }
247 }
248}
249
250#[get("/buildings/{building_id}/board-members/stats")]
252pub async fn get_board_stats(
253 state: web::Data<AppState>,
254 user: AuthenticatedUser,
255 building_id: web::Path<Uuid>,
256) -> impl Responder {
257 let _organization_id = match user.require_organization() {
258 Ok(org_id) => org_id,
259 Err(e) => {
260 return HttpResponse::Unauthorized().json(serde_json::json!({
261 "error": e.to_string()
262 }))
263 }
264 };
265
266 match state
267 .board_member_use_cases
268 .get_board_stats(*building_id)
269 .await
270 {
271 Ok(stats) => HttpResponse::Ok().json(stats),
272 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
273 "error": err
274 })),
275 }
276}
277
278#[get("/board-members/dashboard")]
281pub async fn get_board_dashboard(
282 state: web::Data<AppState>,
283 user: AuthenticatedUser,
284 query: web::Query<std::collections::HashMap<String, String>>,
285) -> impl Responder {
286 let _organization_id = if user.role != "superadmin" {
288 match user.require_organization() {
289 Ok(org_id) => Some(org_id),
290 Err(e) => {
291 return HttpResponse::Unauthorized().json(serde_json::json!({
292 "error": e.to_string()
293 }))
294 }
295 }
296 } else {
297 None
298 };
299
300 let building_id = match query.get("building_id") {
302 Some(id_str) => match Uuid::parse_str(id_str) {
303 Ok(id) => id,
304 Err(_) => {
305 return HttpResponse::BadRequest().json(serde_json::json!({
306 "error": "Invalid building_id format"
307 }))
308 }
309 },
310 None => {
311 return HttpResponse::BadRequest().json(serde_json::json!({
312 "error": "building_id query parameter is required"
313 }))
314 }
315 };
316
317 let owner_id_result =
319 sqlx::query_scalar::<_, Uuid>("SELECT id FROM owners WHERE user_id = $1 LIMIT 1")
320 .bind(user.user_id)
321 .fetch_optional(&state.pool)
322 .await;
323
324 let owner_id = match owner_id_result {
325 Ok(Some(oid)) => oid,
326 Ok(None) => {
327 return HttpResponse::Forbidden().json(serde_json::json!({
329 "error": "User is not linked to an owner. Board dashboard is only accessible to board members."
330 }));
331 }
332 Err(err) => {
333 return HttpResponse::InternalServerError().json(serde_json::json!({
334 "error": format!("Database error: {}", err)
335 }));
336 }
337 };
338
339 let is_superadmin = user.role == "superadmin";
341 if !is_superadmin {
342 match state
343 .board_member_use_cases
344 .has_active_board_mandate(owner_id, building_id)
345 .await
346 {
347 Ok(true) => {
348 }
350 Ok(false) => {
351 return HttpResponse::Forbidden().json(serde_json::json!({
352 "error": "Access denied. You are not an active board member for this building."
353 }));
354 }
355 Err(err) => {
356 return HttpResponse::InternalServerError().json(serde_json::json!({
357 "error": format!("Authorization check failed: {}", err)
358 }));
359 }
360 }
361 }
362
363 match state
364 .board_dashboard_use_cases
365 .get_dashboard(building_id, owner_id)
366 .await
367 {
368 Ok(dashboard) => HttpResponse::Ok().json(dashboard),
369 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
370 "error": err
371 })),
372 }
373}
374
375#[get("/board-members/my-mandates")]
377pub async fn get_my_mandates(
378 state: web::Data<AppState>,
379 user: AuthenticatedUser,
380) -> impl Responder {
381 let organization_id = match user.require_organization() {
382 Ok(org_id) => org_id,
383 Err(e) => {
384 return HttpResponse::Unauthorized().json(serde_json::json!({
385 "error": e.to_string()
386 }))
387 }
388 };
389
390 let owner_id = match sqlx::query_scalar::<_, Uuid>(
392 "SELECT id FROM owners WHERE user_id = $1 AND organization_id = $2 AND is_anonymized = false"
393 )
394 .bind(user.user_id)
395 .bind(organization_id)
396 .fetch_optional(&state.pool)
397 .await
398 {
399 Ok(Some(id)) => id,
400 Ok(None) => {
401 return HttpResponse::Ok().json(serde_json::json!({
403 "mandates": []
404 }));
405 }
406 Err(err) => {
407 return HttpResponse::InternalServerError().json(serde_json::json!({
408 "error": format!("Failed to fetch owner: {}", err)
409 }));
410 }
411 };
412
413 match sqlx::query_as::<
415 _,
416 (
417 Uuid,
418 Uuid,
419 String,
420 String,
421 chrono::DateTime<chrono::Utc>,
422 chrono::DateTime<chrono::Utc>,
423 String,
424 ),
425 >(
426 r#"
427 SELECT
428 bm.id,
429 bm.building_id,
430 bm.position::TEXT,
431 b.name as building_name,
432 bm.mandate_start,
433 bm.mandate_end,
434 b.address
435 FROM board_members bm
436 JOIN buildings b ON b.id = bm.building_id
437 WHERE bm.owner_id = $1
438 AND bm.organization_id = $2
439 AND bm.is_active = true
440 ORDER BY bm.mandate_end DESC
441 "#,
442 )
443 .bind(owner_id)
444 .bind(organization_id)
445 .fetch_all(&state.pool)
446 .await
447 {
448 Ok(rows) => {
449 let mandates: Vec<serde_json::Value> = rows
450 .into_iter()
451 .map(
452 |(
453 id,
454 building_id,
455 position,
456 building_name,
457 mandate_start,
458 mandate_end,
459 address,
460 )| {
461 let now = chrono::Utc::now();
462 let days_remaining = (mandate_end - now).num_days();
463 let expires_soon = days_remaining > 0 && days_remaining <= 90;
464
465 serde_json::json!({
466 "id": id,
467 "building_id": building_id,
468 "building_name": building_name,
469 "building_address": address,
470 "position": position,
471 "mandate_start": mandate_start.format("%Y-%m-%d").to_string(),
472 "mandate_end": mandate_end.format("%Y-%m-%d").to_string(),
473 "days_remaining": days_remaining,
474 "expires_soon": expires_soon,
475 })
476 },
477 )
478 .collect();
479
480 HttpResponse::Ok().json(serde_json::json!({
481 "mandates": mandates
482 }))
483 }
484 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
485 "error": format!("Failed to fetch mandates: {}", err)
486 })),
487 }
488}