Skip to main content

koprogo_api/application/use_cases/
syndic_response_use_cases.rs

1//! Use cases for SyndicResponse + SLA escalation (Story 3.7 — FR32 INV-23).
2//!
3//! Three operations exposed to handlers:
4//!
5//! `SyndicResponseUseCases::respond` — a syndic posts a response to a ticket.
6//! The caller (handler) MUST already have enforced the syndic / superadmin
7//! role check. The use case:
8//!
9//! - checks the target ticket exists (else `AppError::NotFound`);
10//! - mints a `SyndicResponse` (entity-level validation);
11//! - persists it (append-only — repo MUST NOT `UPDATE`);
12//! - if the response is posted BEFORE the ticket's `sla_due_at`, marks the
13//!   ticket as escalated (idempotent) to pre-empt the future cron
14//!   escalation. This is the SLA-satisfied path: the SLA window is
15//!   "consumed" by a timely response.
16//!
17//! `SyndicResponseUseCases::list_for_ticket` — read-only listing for the
18//! ticket detail view.
19//!
20//! `SyndicResponseUseCases::escalate_overdue` — cron entry point. Returns
21//! the list of ticket ids that were just escalated by this pass. Idempotent
22//! across calls.
23
24use crate::application::error::AppError;
25use crate::application::ports::{SyndicResponseRepository, TicketRepository};
26use crate::domain::entities::SyndicResponse;
27use chrono::{DateTime, Utc};
28use std::sync::Arc;
29use uuid::Uuid;
30
31pub struct SyndicResponseUseCases<R, T>
32where
33    R: SyndicResponseRepository,
34    T: TicketRepository,
35{
36    repo: Arc<R>,
37    ticket_repo: Arc<T>,
38}
39
40impl<R, T> SyndicResponseUseCases<R, T>
41where
42    R: SyndicResponseRepository,
43    T: TicketRepository,
44{
45    pub fn new(repo: Arc<R>, ticket_repo: Arc<T>) -> Self {
46        Self { repo, ticket_repo }
47    }
48
49    /// Syndic responds to a ticket.
50    ///
51    /// Invariants:
52    /// - Ticket MUST exist → `AppError::NotFound`.
53    /// - `SyndicResponse::new` validates body + action_proposed.
54    ///
55    /// Side effect — SLA pre-emption: if the response is posted strictly
56    /// before the ticket's `sla_due_at`, we set `sla_escalated_at = now()`
57    /// idempotently so the SLA cron job will not double-escalate later.
58    /// The `sla_escalated_at` column is here a "SLA consumed" marker, not
59    /// strictly an "escalation happened" marker — its semantics is
60    /// "the open SLA window is closed for this ticket".
61    pub async fn respond(
62        &self,
63        ticket_id: Uuid,
64        syndic_user_id: Uuid,
65        body: String,
66        action_proposed: Option<String>,
67    ) -> Result<SyndicResponse, AppError> {
68        // Confirm ticket exists. We bridge the legacy `Result<_, String>`
69        // surface of TicketRepository here (cluster #555 follow-up will
70        // migrate the trait).
71        let ticket = self
72            .ticket_repo
73            .find_by_id(ticket_id)
74            .await
75            .map_err(AppError::from)?
76            .ok_or_else(|| AppError::NotFound(format!("ticket {}", ticket_id)))?;
77
78        let response = SyndicResponse::new(ticket_id, syndic_user_id, body, action_proposed)?;
79        self.repo.save(&response).await?;
80
81        // SLA pre-emption — only when a deadline exists AND it's still
82        // open AND it has not yet been consumed (idempotency).
83        let now = Utc::now();
84        if let Some(due) = ticket.sla_due_at {
85            if ticket.sla_escalated_at.is_none() && now < due {
86                // Best-effort: an error here MUST NOT undo the response,
87                // which is already persisted. Surface the error so the
88                // caller can decide; in practice the repo only errors on
89                // infra failures which the handler maps to 500.
90                self.repo.mark_ticket_escalated(ticket_id, now).await?;
91            }
92        }
93
94        Ok(response)
95    }
96
97    pub async fn list_for_ticket(&self, ticket_id: Uuid) -> Result<Vec<SyndicResponse>, AppError> {
98        self.repo.list_for_ticket(ticket_id).await
99    }
100
101    /// Cron entry point — one pass of SLA escalation.
102    ///
103    /// Returns the list of ticket ids that were just escalated (i.e. went
104    /// from `sla_escalated_at IS NULL` to `Some(now)`). Subsequent calls
105    /// at the same `now` are no-ops (idempotency).
106    ///
107    /// NOTE: no role guard — the caller is the scheduler, not a user.
108    pub async fn escalate_overdue(&self, now: DateTime<Utc>) -> Result<Vec<Uuid>, AppError> {
109        let overdue = self.repo.find_overdue_tickets(now).await?;
110        let mut escalated = Vec::with_capacity(overdue.len());
111        for id in overdue {
112            self.repo.mark_ticket_escalated(id, now).await?;
113            escalated.push(id);
114        }
115        Ok(escalated)
116    }
117}
118
119// ============================================================================
120// Tests — taxonomie 4 catégories (CRITICAL.md #3, Story 3.7)
121// ============================================================================
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::application::ports::TicketRepository;
127    use crate::domain::entities::{
128        Ticket, TicketCategory, TicketKind, TicketPriority, TicketSeverity, TicketStatus,
129    };
130    use async_trait::async_trait;
131    use chrono::Duration;
132    use std::collections::HashMap;
133    use std::sync::Mutex;
134
135    // ── Mock SyndicResponseRepository ────────────────────────────────────
136
137    #[derive(Default)]
138    struct InMemorySyndicResponseRepo {
139        rows: Mutex<Vec<SyndicResponse>>,
140        /// `ticket_id → sla_escalated_at` (proxy for the column on `tickets`,
141        /// kept here because the mock ticket repo is independent).
142        escalated: Mutex<HashMap<Uuid, DateTime<Utc>>>,
143        /// Tickets whose SLA is "due" (computed externally by the test).
144        /// Pretend a separate index keyed on `(sla_due_at <= now)` exists.
145        due_tickets: Mutex<HashMap<Uuid, DateTime<Utc>>>,
146    }
147
148    #[async_trait]
149    impl SyndicResponseRepository for InMemorySyndicResponseRepo {
150        async fn save(&self, response: &SyndicResponse) -> Result<(), AppError> {
151            self.rows.lock().unwrap().push(response.clone());
152            Ok(())
153        }
154
155        async fn list_for_ticket(&self, ticket_id: Uuid) -> Result<Vec<SyndicResponse>, AppError> {
156            let mut out: Vec<SyndicResponse> = self
157                .rows
158                .lock()
159                .unwrap()
160                .iter()
161                .filter(|r| r.ticket_id == ticket_id)
162                .cloned()
163                .collect();
164            out.sort_by_key(|r| r.created_at);
165            Ok(out)
166        }
167
168        async fn find_overdue_tickets(&self, now: DateTime<Utc>) -> Result<Vec<Uuid>, AppError> {
169            let due = self.due_tickets.lock().unwrap();
170            let escalated = self.escalated.lock().unwrap();
171            Ok(due
172                .iter()
173                .filter(|(id, deadline)| **deadline <= now && !escalated.contains_key(id))
174                .map(|(id, _)| *id)
175                .collect())
176        }
177
178        async fn mark_ticket_escalated(
179            &self,
180            ticket_id: Uuid,
181            escalated_at: DateTime<Utc>,
182        ) -> Result<(), AppError> {
183            // Idempotent: first one wins.
184            self.escalated
185                .lock()
186                .unwrap()
187                .entry(ticket_id)
188                .or_insert(escalated_at);
189            Ok(())
190        }
191    }
192
193    // ── Mock TicketRepository (minimal — only what we need) ──────────────
194
195    struct InMemoryTicketRepo {
196        tickets: Mutex<HashMap<Uuid, Ticket>>,
197    }
198
199    impl InMemoryTicketRepo {
200        fn new() -> Self {
201            Self {
202                tickets: Mutex::new(HashMap::new()),
203            }
204        }
205
206        fn insert(&self, t: Ticket) {
207            self.tickets.lock().unwrap().insert(t.id, t);
208        }
209    }
210
211    #[async_trait]
212    impl TicketRepository for InMemoryTicketRepo {
213        async fn create(&self, ticket: &Ticket) -> Result<Ticket, String> {
214            self.tickets
215                .lock()
216                .unwrap()
217                .insert(ticket.id, ticket.clone());
218            Ok(ticket.clone())
219        }
220        async fn find_by_id(&self, id: Uuid) -> Result<Option<Ticket>, String> {
221            Ok(self.tickets.lock().unwrap().get(&id).cloned())
222        }
223        async fn find_by_building(&self, _: Uuid) -> Result<Vec<Ticket>, String> {
224            Ok(vec![])
225        }
226        async fn find_by_organization(&self, _: Uuid) -> Result<Vec<Ticket>, String> {
227            Ok(vec![])
228        }
229        async fn find_by_created_by(&self, _: Uuid) -> Result<Vec<Ticket>, String> {
230            Ok(vec![])
231        }
232        async fn find_by_assigned_to(&self, _: Uuid) -> Result<Vec<Ticket>, String> {
233            Ok(vec![])
234        }
235        async fn find_by_status(&self, _: Uuid, _: TicketStatus) -> Result<Vec<Ticket>, String> {
236            Ok(vec![])
237        }
238        async fn update(&self, ticket: &Ticket) -> Result<Ticket, String> {
239            self.tickets
240                .lock()
241                .unwrap()
242                .insert(ticket.id, ticket.clone());
243            Ok(ticket.clone())
244        }
245        async fn delete(&self, id: Uuid) -> Result<bool, String> {
246            Ok(self.tickets.lock().unwrap().remove(&id).is_some())
247        }
248        async fn count_by_building(&self, _: Uuid) -> Result<i64, String> {
249            Ok(0)
250        }
251        async fn count_by_status(&self, _: Uuid, _: TicketStatus) -> Result<i64, String> {
252            Ok(0)
253        }
254        async fn count_by_organization(&self, _: Uuid) -> Result<i64, String> {
255            Ok(0)
256        }
257        async fn count_by_organization_and_status(
258            &self,
259            _: Uuid,
260            _: TicketStatus,
261        ) -> Result<i64, String> {
262            Ok(0)
263        }
264    }
265
266    // ── Helpers ──────────────────────────────────────────────────────────
267
268    fn make_use_cases() -> (
269        Arc<InMemorySyndicResponseRepo>,
270        Arc<InMemoryTicketRepo>,
271        SyndicResponseUseCases<InMemorySyndicResponseRepo, InMemoryTicketRepo>,
272    ) {
273        let resp_repo = Arc::new(InMemorySyndicResponseRepo::default());
274        let ticket_repo = Arc::new(InMemoryTicketRepo::new());
275        let uc = SyndicResponseUseCases::new(resp_repo.clone(), ticket_repo.clone());
276        (resp_repo, ticket_repo, uc)
277    }
278
279    fn make_complaint_ticket(severity: TicketSeverity) -> Ticket {
280        Ticket::new_with_kind(
281            Uuid::new_v4(),
282            Uuid::new_v4(),
283            None,
284            Uuid::new_v4(),
285            "Plainte tapage".to_string(),
286            "Description suffisamment longue".to_string(),
287            TicketCategory::Other,
288            TicketPriority::High,
289            TicketKind::Complaint,
290            Some(severity),
291            None,
292            Vec::new(),
293            Vec::new(),
294        )
295        .expect("complaint must be valid in fixture")
296    }
297
298    fn fixture_body() -> String {
299        "Bonjour, devis demandé chez le prestataire.".to_string()
300    }
301
302    // ---- @happy ---------------------------------------------------------
303
304    #[tokio::test]
305    async fn happy_respond_before_sla_due_marks_ticket_as_escalated() {
306        let (resp_repo, ticket_repo, uc) = make_use_cases();
307        let ticket = make_complaint_ticket(TicketSeverity::Critical);
308        let ticket_id = ticket.id;
309        ticket_repo.insert(ticket);
310
311        let response = uc
312            .respond(ticket_id, Uuid::new_v4(), fixture_body(), None)
313            .await
314            .expect("respond should succeed");
315
316        assert_eq!(response.ticket_id, ticket_id);
317        // Response persisted.
318        assert_eq!(resp_repo.rows.lock().unwrap().len(), 1);
319        // SLA pre-emption: ticket marked as "SLA consumed".
320        assert!(
321            resp_repo.escalated.lock().unwrap().contains_key(&ticket_id),
322            "respond inside SLA window must pre-empt future cron escalation"
323        );
324    }
325
326    #[tokio::test]
327    async fn happy_list_for_ticket_returns_responses_oldest_first() {
328        let (_resp_repo, ticket_repo, uc) = make_use_cases();
329        let ticket = make_complaint_ticket(TicketSeverity::Normal);
330        let ticket_id = ticket.id;
331        ticket_repo.insert(ticket);
332
333        uc.respond(
334            ticket_id,
335            Uuid::new_v4(),
336            "Premier message du syndic.".to_string(),
337            None,
338        )
339        .await
340        .unwrap();
341        // Second response — needs a different syndic to be realistic but
342        // not strictly required by the entity (multiple replies allowed).
343        uc.respond(
344            ticket_id,
345            Uuid::new_v4(),
346            "Deuxième mise à jour du syndic.".to_string(),
347            Some("schedule_inspection".to_string()),
348        )
349        .await
350        .unwrap();
351
352        let listed = uc.list_for_ticket(ticket_id).await.unwrap();
353        assert_eq!(listed.len(), 2);
354        assert!(listed[0].created_at <= listed[1].created_at);
355    }
356
357    // ---- @edge ----------------------------------------------------------
358
359    #[tokio::test]
360    async fn edge_respond_after_sla_due_does_not_pre_empt_escalation() {
361        let (resp_repo, ticket_repo, uc) = make_use_cases();
362        let mut ticket = make_complaint_ticket(TicketSeverity::Critical);
363        // Force the deadline to the past.
364        ticket.sla_due_at = Some(Utc::now() - Duration::seconds(10));
365        let ticket_id = ticket.id;
366        ticket_repo.insert(ticket);
367
368        let _ = uc
369            .respond(ticket_id, Uuid::new_v4(), fixture_body(), None)
370            .await
371            .expect("respond past deadline still records the reply");
372
373        // No SLA pre-emption (window already closed).
374        assert!(
375            !resp_repo.escalated.lock().unwrap().contains_key(&ticket_id),
376            "late response must NOT touch sla_escalated_at"
377        );
378    }
379
380    #[tokio::test]
381    async fn edge_respond_on_ticket_without_sla_due_succeeds_and_no_escalation() {
382        let (resp_repo, ticket_repo, uc) = make_use_cases();
383        // Build a Request ticket without severity → sla_due_at = None.
384        let t = Ticket::new(
385            Uuid::new_v4(),
386            Uuid::new_v4(),
387            None,
388            Uuid::new_v4(),
389            "Title".to_string(),
390            "Description".to_string(),
391            TicketCategory::Other,
392            TicketPriority::Low,
393        )
394        .unwrap();
395        let ticket_id = t.id;
396        ticket_repo.insert(t);
397
398        uc.respond(ticket_id, Uuid::new_v4(), fixture_body(), None)
399            .await
400            .unwrap();
401
402        assert!(!resp_repo.escalated.lock().unwrap().contains_key(&ticket_id));
403    }
404
405    // ---- @security ------------------------------------------------------
406
407    #[tokio::test]
408    async fn security_escalate_overdue_is_idempotent_on_already_escalated_tickets() {
409        let (resp_repo, _ticket_repo, uc) = make_use_cases();
410
411        let t1 = Uuid::new_v4();
412        let t2 = Uuid::new_v4();
413        let now = Utc::now();
414
415        // Two due tickets, neither escalated yet.
416        resp_repo
417            .due_tickets
418            .lock()
419            .unwrap()
420            .insert(t1, now - Duration::minutes(5));
421        resp_repo
422            .due_tickets
423            .lock()
424            .unwrap()
425            .insert(t2, now - Duration::hours(1));
426
427        let first_pass = uc.escalate_overdue(now).await.unwrap();
428        assert_eq!(first_pass.len(), 2, "first pass should escalate both");
429
430        // Second pass at the same `now` — both already escalated.
431        let second_pass = uc.escalate_overdue(now).await.unwrap();
432        assert!(
433            second_pass.is_empty(),
434            "second pass must be a no-op (idempotency)"
435        );
436    }
437
438    // ---- @negative ------------------------------------------------------
439
440    #[tokio::test]
441    async fn negative_respond_on_unknown_ticket_returns_not_found() {
442        let (_resp_repo, _ticket_repo, uc) = make_use_cases();
443        let err = uc
444            .respond(Uuid::new_v4(), Uuid::new_v4(), fixture_body(), None)
445            .await
446            .unwrap_err();
447        assert!(matches!(err, AppError::NotFound(_)));
448    }
449
450    #[tokio::test]
451    async fn negative_respond_with_short_body_is_rejected_at_entity_level() {
452        let (_resp_repo, ticket_repo, uc) = make_use_cases();
453        let ticket = make_complaint_ticket(TicketSeverity::Normal);
454        let ticket_id = ticket.id;
455        ticket_repo.insert(ticket);
456
457        let err = uc
458            .respond(ticket_id, Uuid::new_v4(), "ko".to_string(), None)
459            .await
460            .unwrap_err();
461        assert!(matches!(err, AppError::Validation(_)));
462    }
463}