Skip to main content

koprogo_api/domain/copropriete/
syndic_response.rs

1//! SyndicResponse — append-only structured reply to a ticket (Story 3.7 —
2//! FR32 INV-23).
3//!
4//! Each row represents a syndic's reply (text + optional declared action).
5//! The relation is **append-only**: edits and deletions are blocked both at
6//! the SQL trigger level (cf. `20260605050000_create_syndic_responses.sql`)
7//! and via the `AppError::ResponseImmutable` typed error returned by any
8//! upstream layer that tried to mutate a persisted response.
9//!
10//! # SLA model
11//!
12//! The `sla_window_for_severity` function returns the maximum acceptable
13//! delay between ticket creation and the first syndic response, depending
14//! on the ticket's [`TicketSeverity`]. The SLA escalation cron job
15//! ([`crate::infrastructure::jobs::sla_escalation_job`]) compares
16//! `Ticket.sla_due_at` to `now()` to flag overdue tickets.
17//!
18//! | Severity  | Window     | Rationale                              |
19//! |-----------|------------|----------------------------------------|
20//! | Critical  | 24 hours   | Imminent risk (eau, gaz, sécurité)     |
21//! | High      | 72 hours   | Strong impact (ascenseur, chauffage)   |
22//! | Normal    | 5 days     | Standard request                       |
23//! | Low       | 10 days    | Cosmetic / non-urgent                  |
24
25use crate::application::error::AppError;
26use crate::domain::entities::TicketSeverity;
27use chrono::{DateTime, Duration, Utc};
28use serde::{Deserialize, Serialize};
29use uuid::Uuid;
30
31/// Minimum body length (chars).
32pub const MIN_RESPONSE_BODY_LEN: usize = 10;
33
34/// Maximum body length (chars).
35pub const MAX_RESPONSE_BODY_LEN: usize = 5000;
36
37/// Whitelisted values for `action_proposed`. Keeping this hard-coded server-
38/// side means a hostile client cannot inject free-form action strings into
39/// audit-grade rows.
40pub const ALLOWED_ACTIONS: &[&str] = &[
41    "schedule_inspection",
42    "request_quote",
43    "closed_no_action",
44    "escalated_board",
45    "other",
46];
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
49pub struct SyndicResponse {
50    pub id: Uuid,
51    pub ticket_id: Uuid,
52    pub syndic_user_id: Uuid,
53    pub body: String,
54    pub action_proposed: Option<String>,
55    pub created_at: DateTime<Utc>,
56}
57
58impl SyndicResponse {
59    /// Constructs a new SyndicResponse. Invariants enforced here:
60    ///
61    /// - `body.len()` ∈ `[MIN_RESPONSE_BODY_LEN, MAX_RESPONSE_BODY_LEN]`
62    ///   (after trim);
63    /// - `action_proposed` (if `Some`) MUST be in [`ALLOWED_ACTIONS`].
64    ///
65    /// The constructor is the ONLY supported way to mint a response — no
66    /// public setters; the entity is built once and persisted as-is.
67    pub fn new(
68        ticket_id: Uuid,
69        syndic_user_id: Uuid,
70        body: String,
71        action_proposed: Option<String>,
72    ) -> Result<Self, AppError> {
73        let trimmed_body = body.trim().to_string();
74        if trimmed_body.len() < MIN_RESPONSE_BODY_LEN {
75            return Err(AppError::Validation(format!(
76                "SyndicResponse body must be at least {} chars",
77                MIN_RESPONSE_BODY_LEN
78            )));
79        }
80        if trimmed_body.len() > MAX_RESPONSE_BODY_LEN {
81            return Err(AppError::Validation(format!(
82                "SyndicResponse body must be at most {} chars",
83                MAX_RESPONSE_BODY_LEN
84            )));
85        }
86        if let Some(ref action) = action_proposed {
87            let normalised = action.trim().to_lowercase();
88            if !ALLOWED_ACTIONS.contains(&normalised.as_str()) {
89                return Err(AppError::Validation(format!(
90                    "Invalid action_proposed: {} (allowed: {:?})",
91                    action, ALLOWED_ACTIONS
92                )));
93            }
94        }
95        if ticket_id.is_nil() || syndic_user_id.is_nil() {
96            return Err(AppError::Validation(
97                "SyndicResponse references must not be nil UUIDs".to_string(),
98            ));
99        }
100
101        Ok(Self {
102            id: Uuid::new_v4(),
103            ticket_id,
104            syndic_user_id,
105            body: trimmed_body,
106            action_proposed: action_proposed.map(|a| a.trim().to_lowercase()),
107            created_at: Utc::now(),
108        })
109    }
110}
111
112/// SLA policy : maximum acceptable delay between ticket creation and the
113/// first syndic response for a given severity tier.
114///
115/// Used both by the use-case at create time (to compute
116/// `Ticket.sla_due_at`) and by the SLA escalation cron job (to detect
117/// overdue tickets).
118pub fn sla_window_for_severity(severity: TicketSeverity) -> Duration {
119    match severity {
120        TicketSeverity::Critical => Duration::hours(24),
121        TicketSeverity::High => Duration::hours(72),
122        TicketSeverity::Normal => Duration::days(5),
123        TicketSeverity::Low => Duration::days(10),
124    }
125}
126
127// ============================================================================
128// Tests — taxonomie 4 catégories obligatoire (CRITICAL.md #3, Story 3.7)
129// ============================================================================
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn fixture_ids() -> (Uuid, Uuid) {
136        (Uuid::new_v4(), Uuid::new_v4())
137    }
138
139    fn fixture_body() -> String {
140        "Bonjour, j'ai bien noté votre plainte, devis demandé.".to_string()
141    }
142
143    // ---- @happy -------------------------------------------------------------
144
145    #[test]
146    fn happy_create_response_with_minimal_body_and_no_action() {
147        let (ticket_id, syndic) = fixture_ids();
148        // 10 chars exactly (minimum bound — see also @edge).
149        let body = "0123456789".to_string();
150        let r = SyndicResponse::new(ticket_id, syndic, body.clone(), None)
151            .expect("10-char body without action must succeed");
152        assert_eq!(r.ticket_id, ticket_id);
153        assert_eq!(r.syndic_user_id, syndic);
154        assert_eq!(r.body, body);
155        assert!(r.action_proposed.is_none());
156    }
157
158    #[test]
159    fn happy_create_response_with_allowed_action() {
160        let (ticket_id, syndic) = fixture_ids();
161        let r = SyndicResponse::new(
162            ticket_id,
163            syndic,
164            fixture_body(),
165            Some("schedule_inspection".to_string()),
166        )
167        .expect("allowed action must succeed");
168        assert_eq!(r.action_proposed.as_deref(), Some("schedule_inspection"));
169    }
170
171    #[test]
172    fn happy_sla_window_critical_is_24_hours() {
173        assert_eq!(
174            sla_window_for_severity(TicketSeverity::Critical),
175            Duration::hours(24)
176        );
177    }
178
179    #[test]
180    fn happy_sla_window_low_is_ten_days() {
181        assert_eq!(
182            sla_window_for_severity(TicketSeverity::Low),
183            Duration::days(10)
184        );
185    }
186
187    #[test]
188    fn happy_sla_window_high_is_72_hours_and_normal_is_5_days() {
189        assert_eq!(
190            sla_window_for_severity(TicketSeverity::High),
191            Duration::hours(72)
192        );
193        assert_eq!(
194            sla_window_for_severity(TicketSeverity::Normal),
195            Duration::days(5)
196        );
197    }
198
199    #[test]
200    fn happy_sla_windows_strictly_decrease_with_severity() {
201        // Triage signal: a more severe ticket MUST get a stricter SLA.
202        let critical = sla_window_for_severity(TicketSeverity::Critical);
203        let high = sla_window_for_severity(TicketSeverity::High);
204        let normal = sla_window_for_severity(TicketSeverity::Normal);
205        let low = sla_window_for_severity(TicketSeverity::Low);
206        assert!(critical < high);
207        assert!(high < normal);
208        assert!(normal < low);
209    }
210
211    // ---- @edge --------------------------------------------------------------
212
213    #[test]
214    fn edge_body_exactly_min_len_is_accepted() {
215        let (ticket_id, syndic) = fixture_ids();
216        let body = "X".repeat(MIN_RESPONSE_BODY_LEN);
217        assert!(SyndicResponse::new(ticket_id, syndic, body, None).is_ok());
218    }
219
220    #[test]
221    fn edge_body_exactly_max_len_is_accepted() {
222        let (ticket_id, syndic) = fixture_ids();
223        let body = "X".repeat(MAX_RESPONSE_BODY_LEN);
224        assert!(SyndicResponse::new(ticket_id, syndic, body, None).is_ok());
225    }
226
227    #[test]
228    fn edge_body_one_under_min_is_rejected() {
229        let (ticket_id, syndic) = fixture_ids();
230        let body = "X".repeat(MIN_RESPONSE_BODY_LEN - 1);
231        let err = SyndicResponse::new(ticket_id, syndic, body, None).unwrap_err();
232        assert!(matches!(err, AppError::Validation(_)));
233    }
234
235    #[test]
236    fn edge_body_one_over_max_is_rejected() {
237        let (ticket_id, syndic) = fixture_ids();
238        let body = "X".repeat(MAX_RESPONSE_BODY_LEN + 1);
239        let err = SyndicResponse::new(ticket_id, syndic, body, None).unwrap_err();
240        assert!(matches!(err, AppError::Validation(_)));
241    }
242
243    #[test]
244    fn edge_action_normalised_to_lowercase() {
245        let (ticket_id, syndic) = fixture_ids();
246        let r = SyndicResponse::new(
247            ticket_id,
248            syndic,
249            fixture_body(),
250            Some("  Request_Quote  ".to_string()),
251        )
252        .expect("whitespace + uppercase should be normalised");
253        assert_eq!(r.action_proposed.as_deref(), Some("request_quote"));
254    }
255
256    // ---- @security ----------------------------------------------------------
257
258    #[test]
259    fn security_unknown_action_is_rejected_no_smuggling() {
260        let (ticket_id, syndic) = fixture_ids();
261        let err = SyndicResponse::new(
262            ticket_id,
263            syndic,
264            fixture_body(),
265            Some("rm_minus_rf".to_string()),
266        )
267        .unwrap_err();
268        assert!(matches!(err, AppError::Validation(_)));
269    }
270
271    #[test]
272    fn security_nil_ticket_id_is_rejected() {
273        let err =
274            SyndicResponse::new(Uuid::nil(), Uuid::new_v4(), fixture_body(), None).unwrap_err();
275        assert!(matches!(err, AppError::Validation(_)));
276    }
277
278    #[test]
279    fn security_nil_syndic_id_is_rejected() {
280        let err =
281            SyndicResponse::new(Uuid::new_v4(), Uuid::nil(), fixture_body(), None).unwrap_err();
282        assert!(matches!(err, AppError::Validation(_)));
283    }
284
285    // ---- @negative ----------------------------------------------------------
286
287    #[test]
288    fn negative_empty_body_is_rejected() {
289        let (ticket_id, syndic) = fixture_ids();
290        let err = SyndicResponse::new(ticket_id, syndic, String::new(), None).unwrap_err();
291        assert!(matches!(err, AppError::Validation(_)));
292    }
293
294    #[test]
295    fn negative_whitespace_only_body_is_rejected() {
296        let (ticket_id, syndic) = fixture_ids();
297        let err = SyndicResponse::new(ticket_id, syndic, "        ".to_string(), None).unwrap_err();
298        assert!(matches!(err, AppError::Validation(_)));
299    }
300
301    #[test]
302    fn negative_short_body_five_chars_is_rejected() {
303        let (ticket_id, syndic) = fixture_ids();
304        let err = SyndicResponse::new(ticket_id, syndic, "short".to_string(), None).unwrap_err();
305        assert!(matches!(err, AppError::Validation(_)));
306    }
307}