Skip to main content

koprogo_api/infrastructure/web/handlers/
convocation_handlers.rs

1use crate::application::dto::{
2    CreateConvocationRequest, ScheduleConvocationRequest, ScheduleSecondConvocationRequest,
3    SendConvocationRequest, SetProxyRequest, UpdateAttendanceRequest,
4};
5use crate::application::error::AppError;
6use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
7use crate::infrastructure::web::middleware::scope_guard::verify_building_org_access;
8use crate::infrastructure::web::{AppState, AuthenticatedUser};
9use actix_web::ResponseError;
10use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
11use uuid::Uuid;
12
13// ==================== Convocation CRUD Endpoints ====================
14
15/// Envoyer une convocation fait courir le délai légal de l'Art. 3.87 §3 CC
16/// pour tous les copropriétaires : c'est un acte du syndic, jamais celui d'un
17/// copropriétaire membre de la même organisation. Relevé en écrivant le
18/// parcours multi-rôle complet du cycle d'AG (#780).
19fn require_syndic_or_superadmin(user: &AuthenticatedUser) -> Result<(), AppError> {
20    match user.role.as_str() {
21        "syndic" | "superadmin" => Ok(()),
22        _ => Err(AppError::Forbidden(
23            "Seul le syndic peut envoyer une convocation".to_string(),
24        )),
25    }
26}
27
28#[post("/convocations")]
29pub async fn create_convocation(
30    state: web::Data<AppState>,
31    user: AuthenticatedUser,
32    request: web::Json<CreateConvocationRequest>,
33) -> impl Responder {
34    let organization_id = match user.require_organization() {
35        Ok(org_id) => org_id,
36        Err(e) => {
37            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
38        }
39    };
40
41    // Isolation multi-tenant à l'ÉCRITURE (ADR-0045) : la convocation est un
42    // acte de l'ACP, dont elle supporte les frais (Art. 3.87 § 3). L'immeuble
43    // visé doit relever d'une ACP confiée à ce syndic.
44    if let Err(err) = verify_building_org_access(
45        &user,
46        request.building_id,
47        &state.building_use_cases,
48        &state.acp_use_cases,
49    )
50    .await
51    {
52        return err.error_response();
53    }
54
55    let created_by = user.user_id;
56
57    match state
58        .convocation_use_cases
59        .create_convocation(organization_id, request.into_inner(), created_by)
60        .await
61    {
62        Ok(convocation) => {
63            AuditLogEntry::new(
64                AuditEventType::ConvocationCreated,
65                Some(user.user_id),
66                Some(organization_id),
67            )
68            .with_resource("Convocation", convocation.id)
69            .log();
70
71            HttpResponse::Created().json(convocation)
72        }
73        Err(err) => {
74            AuditLogEntry::new(
75                AuditEventType::ConvocationCreated,
76                Some(user.user_id),
77                Some(organization_id),
78            )
79            .with_error(err.clone())
80            .log();
81
82            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
83        }
84    }
85}
86
87#[get("/convocations/{id}")]
88pub async fn get_convocation(
89    state: web::Data<AppState>,
90    user: AuthenticatedUser,
91    id: web::Path<Uuid>,
92) -> impl Responder {
93    match state.convocation_use_cases.get_convocation(*id).await {
94        Ok(convocation) => {
95            // Verify organization access
96            if let Err(err) = user.verify_org_access(convocation.organization_id) {
97                return HttpResponse::Forbidden().json(serde_json::json!({"error": err}));
98            }
99            HttpResponse::Ok().json(convocation)
100        }
101        Err(err) => HttpResponse::NotFound().json(serde_json::json!({"error": err})),
102    }
103}
104
105#[get("/meetings/{meeting_id}/convocation")]
106pub async fn get_convocation_by_meeting(
107    state: web::Data<AppState>,
108    meeting_id: web::Path<Uuid>,
109    user: AuthenticatedUser,
110) -> impl Responder {
111    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772). C'est
112    // par ce genre de route qu'un cabinet a lu les bulletins NOMINATIFS d'une
113    // autre copropriete (RN-2).
114    if let Err(err) =
115        crate::infrastructure::web::middleware::scope_guard::verify_meeting_org_access(
116            &user,
117            *meeting_id,
118            &state.meeting_use_cases,
119            &state.building_use_cases,
120            &state.acp_use_cases,
121        )
122        .await
123    {
124        return err.error_response();
125    }
126
127    match state
128        .convocation_use_cases
129        .get_convocation_by_meeting(*meeting_id)
130        .await
131    {
132        Ok(Some(convocation)) => HttpResponse::Ok().json(convocation),
133        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
134            "error": "Convocation not found for this meeting"
135        })),
136        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
137    }
138}
139
140#[get("/buildings/{building_id}/convocations")]
141pub async fn list_building_convocations(
142    state: web::Data<AppState>,
143    building_id: web::Path<Uuid>,
144    user: AuthenticatedUser,
145) -> impl Responder {
146    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
147    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
148    // un identifiant, sans demander d'identite.
149    if let Err(err) =
150        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
151            &user,
152            *building_id,
153            &state.building_use_cases,
154            &state.acp_use_cases,
155        )
156        .await
157    {
158        return err.error_response();
159    }
160
161    match state
162        .convocation_use_cases
163        .list_building_convocations(*building_id)
164        .await
165    {
166        Ok(convocations) => HttpResponse::Ok().json(convocations),
167        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
168    }
169}
170
171#[get("/organizations/{organization_id}/convocations")]
172pub async fn list_organization_convocations(
173    state: web::Data<AppState>,
174    user: AuthenticatedUser,
175    organization_id: web::Path<Uuid>,
176) -> impl Responder {
177    if let Err(e) = user.verify_org_access(*organization_id) {
178        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
179    }
180    match state
181        .convocation_use_cases
182        .list_organization_convocations(*organization_id)
183        .await
184    {
185        Ok(convocations) => HttpResponse::Ok().json(convocations),
186        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
187    }
188}
189
190#[delete("/convocations/{id}")]
191pub async fn delete_convocation(
192    state: web::Data<AppState>,
193    user: AuthenticatedUser,
194    id: web::Path<Uuid>,
195) -> impl Responder {
196    let organization_id = match user.require_organization() {
197        Ok(org_id) => org_id,
198        Err(e) => {
199            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
200        }
201    };
202
203    match state.convocation_use_cases.delete_convocation(*id).await {
204        Ok(true) => {
205            AuditLogEntry::new(
206                AuditEventType::ConvocationDeleted,
207                Some(user.user_id),
208                Some(organization_id),
209            )
210            .with_resource("Convocation", *id)
211            .log();
212
213            HttpResponse::NoContent().finish()
214        }
215        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
216            "error": "Convocation not found"
217        })),
218        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
219    }
220}
221
222// ==================== Convocation Actions ====================
223
224#[put("/convocations/{id}/schedule")]
225pub async fn schedule_convocation(
226    state: web::Data<AppState>,
227    user: AuthenticatedUser,
228    id: web::Path<Uuid>,
229    request: web::Json<ScheduleConvocationRequest>,
230) -> impl Responder {
231    let organization_id = match user.require_organization() {
232        Ok(org_id) => org_id,
233        Err(e) => {
234            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
235        }
236    };
237
238    match state
239        .convocation_use_cases
240        .schedule_convocation(*id, request.into_inner())
241        .await
242    {
243        Ok(convocation) => {
244            AuditLogEntry::new(
245                AuditEventType::ConvocationScheduled,
246                Some(user.user_id),
247                Some(organization_id),
248            )
249            .with_resource("Convocation", convocation.id)
250            .log();
251
252            HttpResponse::Ok().json(convocation)
253        }
254        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
255    }
256}
257
258#[post("/convocations/{id}/send")]
259pub async fn send_convocation(
260    state: web::Data<AppState>,
261    user: AuthenticatedUser,
262    id: web::Path<Uuid>,
263    request: web::Json<SendConvocationRequest>,
264) -> impl Responder {
265    let organization_id = match user.require_organization() {
266        Ok(org_id) => org_id,
267        Err(e) => {
268            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
269        }
270    };
271
272    if let Err(err) = require_syndic_or_superadmin(&user) {
273        return err.error_response();
274    }
275
276    // PDF generation now happens in the use case layer
277    match state
278        .convocation_use_cases
279        .send_convocation(*id, request.into_inner())
280        .await
281    {
282        Ok(convocation) => {
283            AuditLogEntry::new(
284                AuditEventType::ConvocationSent,
285                Some(user.user_id),
286                Some(organization_id),
287            )
288            .with_resource("Convocation", convocation.id)
289            .with_details(format!("recipients: {}", convocation.total_recipients))
290            .log();
291
292            HttpResponse::Ok().json(convocation)
293        }
294        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
295    }
296}
297
298#[put("/convocations/{id}/cancel")]
299pub async fn cancel_convocation(
300    state: web::Data<AppState>,
301    user: AuthenticatedUser,
302    id: web::Path<Uuid>,
303) -> impl Responder {
304    let organization_id = match user.require_organization() {
305        Ok(org_id) => org_id,
306        Err(e) => {
307            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
308        }
309    };
310
311    match state.convocation_use_cases.cancel_convocation(*id).await {
312        Ok(convocation) => {
313            AuditLogEntry::new(
314                AuditEventType::ConvocationCancelled,
315                Some(user.user_id),
316                Some(organization_id),
317            )
318            .with_resource("Convocation", convocation.id)
319            .log();
320
321            HttpResponse::Ok().json(convocation)
322        }
323        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
324    }
325}
326
327// ==================== Recipient Endpoints ====================
328
329#[get("/convocations/{id}/recipients")]
330pub async fn list_convocation_recipients(
331    state: web::Data<AppState>,
332    id: web::Path<Uuid>,
333    user: AuthenticatedUser,
334) -> impl Responder {
335    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
336    // sert la liste NOMINATIVE des copropriétaires convoqués.
337    if let Err(err) =
338        crate::infrastructure::web::middleware::scope_guard::verify_convocation_org_access(
339            &user,
340            *id,
341            &state.convocation_use_cases,
342            &state.building_use_cases,
343            &state.acp_use_cases,
344        )
345        .await
346    {
347        return err.error_response();
348    }
349
350    match state
351        .convocation_use_cases
352        .list_convocation_recipients(*id)
353        .await
354    {
355        Ok(recipients) => HttpResponse::Ok().json(recipients),
356        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
357    }
358}
359
360/// Les copropriétaires qu'une convocation pour cet immeuble toucherait.
361///
362/// Sert l'écran de sélection des destinataires (#780 verrou 1, #784) : le
363/// syndic doit pouvoir voir et choisir AVANT d'envoyer, pas seulement
364/// constater après coup que « 0 destinataire » était resté un libellé.
365#[utoipa::path(
366    get,
367    path = "/buildings/{building_id}/eligible-convocation-recipients",
368    tag = "Convocations",
369    summary = "Copropriétaires éligibles à une convocation",
370    params(("building_id" = Uuid, Path, description = "UUID de l'immeuble")),
371    responses(
372        (status = 200, description = "Destinataires éligibles", body = Vec<crate::application::dto::convocation_dto::EligibleRecipientResponse>),
373        (status = 403, description = "Hors portée"),
374    ),
375    security(("bearer_auth" = []))
376)]
377#[get("/buildings/{building_id}/eligible-convocation-recipients")]
378pub async fn list_eligible_convocation_recipients(
379    state: web::Data<AppState>,
380    user: AuthenticatedUser,
381    building_id: web::Path<Uuid>,
382) -> impl Responder {
383    if let Err(err) = verify_building_org_access(
384        &user,
385        *building_id,
386        &state.building_use_cases,
387        &state.acp_use_cases,
388    )
389    .await
390    {
391        return err.error_response();
392    }
393
394    match state
395        .convocation_use_cases
396        .list_eligible_recipients(*building_id)
397        .await
398    {
399        Ok(destinataires) => HttpResponse::Ok().json(destinataires),
400        Err(err) => err.error_response(),
401    }
402}
403
404#[get("/convocations/{id}/tracking-summary")]
405pub async fn get_convocation_tracking_summary(
406    state: web::Data<AppState>,
407    id: web::Path<Uuid>,
408    user: AuthenticatedUser,
409) -> impl Responder {
410    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
411    // sert la liste NOMINATIVE des copropriétaires convoqués.
412    if let Err(err) =
413        crate::infrastructure::web::middleware::scope_guard::verify_convocation_org_access(
414            &user,
415            *id,
416            &state.convocation_use_cases,
417            &state.building_use_cases,
418            &state.acp_use_cases,
419        )
420        .await
421    {
422        return err.error_response();
423    }
424
425    match state.convocation_use_cases.get_tracking_summary(*id).await {
426        Ok(summary) => HttpResponse::Ok().json(summary),
427        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
428    }
429}
430
431#[put("/convocation-recipients/{id}/email-opened")]
432pub async fn mark_recipient_email_opened(
433    state: web::Data<AppState>,
434    id: web::Path<Uuid>,
435) -> impl Responder {
436    match state
437        .convocation_use_cases
438        .mark_recipient_email_opened(*id)
439        .await
440    {
441        Ok(recipient) => HttpResponse::Ok().json(recipient),
442        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
443    }
444}
445
446#[put("/convocation-recipients/{id}/attendance")]
447pub async fn update_recipient_attendance(
448    state: web::Data<AppState>,
449    user: AuthenticatedUser,
450    id: web::Path<Uuid>,
451    request: web::Json<UpdateAttendanceRequest>,
452) -> impl Responder {
453    let organization_id = match user.require_organization() {
454        Ok(org_id) => org_id,
455        Err(e) => {
456            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
457        }
458    };
459
460    match state
461        .convocation_use_cases
462        .update_recipient_attendance(*id, request.attendance_status.clone())
463        .await
464    {
465        Ok(recipient) => {
466            AuditLogEntry::new(
467                AuditEventType::ConvocationAttendanceUpdated,
468                Some(user.user_id),
469                Some(organization_id),
470            )
471            .with_resource("ConvocationRecipient", recipient.id)
472            .with_details(format!("status: {:?}", recipient.attendance_status))
473            .log();
474
475            HttpResponse::Ok().json(recipient)
476        }
477        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
478    }
479}
480
481#[put("/convocation-recipients/{id}/proxy")]
482pub async fn set_recipient_proxy(
483    state: web::Data<AppState>,
484    user: AuthenticatedUser,
485    id: web::Path<Uuid>,
486    request: web::Json<SetProxyRequest>,
487) -> impl Responder {
488    let organization_id = match user.require_organization() {
489        Ok(org_id) => org_id,
490        Err(e) => {
491            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
492        }
493    };
494
495    match state
496        .convocation_use_cases
497        .set_recipient_proxy(*id, request.proxy_owner_id)
498        .await
499    {
500        Ok(recipient) => {
501            AuditLogEntry::new(
502                AuditEventType::ConvocationProxySet,
503                Some(user.user_id),
504                Some(organization_id),
505            )
506            .with_resource("ConvocationRecipient", recipient.id)
507            .with_details(format!("proxy_owner_id: {:?}", recipient.proxy_owner_id))
508            .log();
509
510            HttpResponse::Ok().json(recipient)
511        }
512        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
513    }
514}
515
516#[post("/convocations/{id}/reminders")]
517pub async fn send_convocation_reminders(
518    state: web::Data<AppState>,
519    user: AuthenticatedUser,
520    id: web::Path<Uuid>,
521) -> impl Responder {
522    let organization_id = match user.require_organization() {
523        Ok(org_id) => org_id,
524        Err(e) => {
525            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
526        }
527    };
528
529    match state.convocation_use_cases.send_reminders(*id).await {
530        Ok(recipients) => {
531            AuditLogEntry::new(
532                AuditEventType::ConvocationReminderSent,
533                Some(user.user_id),
534                Some(organization_id),
535            )
536            .with_resource("Convocation", *id)
537            .with_details(format!("recipients: {}", recipients.len()))
538            .log();
539
540            HttpResponse::Ok().json(recipients)
541        }
542        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
543    }
544}
545
546// ==================== Second Convocation Endpoint (Art. 3.87 §5 CC) ====================
547
548#[post("/convocations/second")]
549pub async fn schedule_second_convocation(
550    state: web::Data<AppState>,
551    user: AuthenticatedUser,
552    request: web::Json<ScheduleSecondConvocationRequest>,
553) -> impl Responder {
554    let organization_id = match user.require_organization() {
555        Ok(org_id) => org_id,
556        Err(e) => {
557            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
558        }
559    };
560
561    let req = request.into_inner();
562
563    // Isolation multi-tenant à l'ÉCRITURE (ADR-0045) : la seconde convocation
564    // crée une assemblée ET une convocation dans le dossier de l'ACP. Sans
565    // garde, un cabinet tiers convoquerait les copropriétaires d'un autre.
566    if let Err(err) = verify_building_org_access(
567        &user,
568        req.building_id,
569        &state.building_use_cases,
570        &state.acp_use_cases,
571    )
572    .await
573    {
574        return err.error_response();
575    }
576
577    // Create a new meeting for the second convocation
578    let new_meeting_req = crate::application::dto::CreateMeetingRequest {
579        organization_id,
580        building_id: req.building_id,
581        meeting_type: crate::domain::entities::MeetingType::Ordinary,
582        title: format!("Second Convocation (Art. 3.87 §5 CC)"),
583        description: Some("Second convocation after quorum not reached".to_string()),
584        scheduled_date: req.new_meeting_date,
585        location: "Same as first meeting".to_string(),
586        is_second_convocation: true,
587    };
588
589    // Create the new meeting
590    let new_meeting = match state
591        .meeting_use_cases
592        .create_meeting(new_meeting_req)
593        .await
594    {
595        Ok(m) => m,
596        Err(err) => {
597            return HttpResponse::BadRequest()
598                .json(serde_json::json!({"error": format!("Failed to create meeting: {}", err)}))
599        }
600    };
601
602    match state
603        .convocation_use_cases
604        .schedule_second_convocation(
605            organization_id,
606            req.building_id,
607            req.first_meeting_id,
608            new_meeting.id,
609            req.new_meeting_date,
610            req.language,
611            user.user_id,
612        )
613        .await
614    {
615        Ok(convocation) => {
616            AuditLogEntry::new(
617                AuditEventType::SecondConvocationScheduled,
618                Some(user.user_id),
619                Some(organization_id),
620            )
621            .with_resource("Convocation", convocation.id)
622            .with_details(format!(
623                "first_meeting_id: {}, new_meeting_id: {}",
624                req.first_meeting_id, new_meeting.id
625            ))
626            .log();
627
628            HttpResponse::Created().json(convocation)
629        }
630        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
631    }
632}