koprogo_api/infrastructure/web/handlers/
board_member_handlers.rs

1use 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/// Élire un nouveau membre du conseil de copropriété
8#[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    // SuperAdmin can elect members for any organization, others need to belong to an organization
15    let organization_id = if user.role == "superadmin" {
16        // For superadmin, get organization_id from the building
17        None // Will be determined by the use case
18    } 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            // Audit log: successful board member election
36            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            // Audit log: failed board member election
50            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/// Récupérer un membre du conseil par ID
66#[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/// Lister tous les membres actifs du conseil pour un immeuble
93#[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    // SuperAdmin can access all buildings, others need to belong to an organization
100    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/// Lister tous les membres du conseil (actifs et historique) pour un immeuble
121#[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    // SuperAdmin can access all buildings, others need to belong to an organization
128    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/// Renouveler le mandat d'un membre du conseil
149#[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            // Audit log: successful mandate renewal
172            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            // Audit log: failed mandate renewal
186            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/// Retirer un membre du conseil (fin de mandat anticipée)
202#[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            // Audit log: successful board member removal
220            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            // Audit log: failed board member removal
235            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/// Obtenir des statistiques sur le conseil d'un immeuble
251#[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/// Obtenir le tableau de bord d'un membre du conseil
279/// Accessible uniquement aux membres du conseil et superadmins
280#[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    // SuperAdmin can access dashboard for any building, others need to belong to an organization
287    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    // Get building_id from query parameters
301    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    // Get owner_id from user->owner link
318    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            // User is not linked to an owner
328            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    // Authorization: Verify user is an active board member for this building (unless superadmin)
340    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                // User is an active board member, proceed
349            }
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 - Get all active board mandates for the authenticated user
376#[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    // Get the owner ID for this user
391    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            // User is not linked to an owner - return empty list
402            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    // Get all active board member mandates for this owner
414    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}