koprogo_api/infrastructure/web/handlers/
legal_handlers.rs

1use actix_web::{get, web, HttpResponse, Responder};
2use serde_json::{json, Value};
3
4/// Static legal index embedded in binary (Issue #277)
5const LEGAL_INDEX: &str = include_str!("../../legal_index.json");
6
7/// GET /api/v1/legal/rules
8/// List all legal rules with optional filtering by role or category
9///
10/// Query parameters:
11/// - `role` (optional): Filter by role (syndic, coproprietaire, commissaire, conseil-copropriete, etc.)
12/// - `category` (optional): Filter by category (assemblee-generale, travaux, coproprietaire, finance, syndic-mandat)
13///
14/// Returns:
15/// * `200 OK` - Array of legal rules matching filter criteria
16/// * `500 Internal Server Error` - JSON parsing error (should not happen with static embed)
17///
18/// # Example
19/// ```
20/// GET /api/v1/legal/rules?role=syndic&category=travaux
21///
22/// Response 200 OK:
23/// [
24///   {
25///     "code": "T01",
26///     "category": "travaux",
27///     "roles": ["syndic"],
28///     "article": "Art. 3.89 §5 2° CC",
29///     "title": "Travaux urgents — syndic seul",
30///     "content": "Le syndic peut exécuter seul les actes conservatoires...",
31///     "keywords": ["urgents", "conservatoires", ...]
32///   }
33/// ]
34/// ```
35#[utoipa::path(
36    get,
37    path = "/api/v1/legal/rules",
38    tag = "Legal Reference",
39    params(
40        ("role" = Option<String>, Query, description = "Filter by role (e.g., syndic, coproprietaire)"),
41        ("category" = Option<String>, Query, description = "Filter by category (e.g., assemblee-generale, travaux)")
42    ),
43    responses(
44        (status = 200, description = "Array of legal rules", body = Vec<serde_json::Value>),
45        (status = 500, description = "Server error")
46    )
47)]
48#[get("/legal/rules")]
49pub async fn list_legal_rules(
50    query: web::Query<std::collections::HashMap<String, String>>,
51) -> impl Responder {
52    match serde_json::from_str::<Value>(LEGAL_INDEX) {
53        Ok(index) => {
54            if let Some(rules) = index.get("rules").and_then(|r| r.as_array()) {
55                let role_filter = query.get("role").map(|r| r.as_str());
56                let category_filter = query.get("category").map(|c| c.as_str());
57
58                let filtered: Vec<&Value> = rules
59                    .iter()
60                    .filter(|rule| {
61                        // Filter by role if provided
62                        if let Some(role) = role_filter {
63                            if let Some(roles) = rule.get("roles").and_then(|r| r.as_array()) {
64                                let matches = roles
65                                    .iter()
66                                    .any(|r| r.as_str().map(|s| s == role).unwrap_or(false));
67                                if !matches {
68                                    return false;
69                                }
70                            } else {
71                                return false;
72                            }
73                        }
74
75                        // Filter by category if provided
76                        if let Some(category) = category_filter {
77                            if let Some(cat) = rule.get("category").and_then(|c| c.as_str()) {
78                                if cat != category {
79                                    return false;
80                                }
81                            } else {
82                                return false;
83                            }
84                        }
85
86                        true
87                    })
88                    .collect();
89
90                HttpResponse::Ok().json(filtered)
91            } else {
92                HttpResponse::InternalServerError().json(json!({
93                    "error": "Malformed legal index: missing 'rules' array"
94                }))
95            }
96        }
97        Err(e) => HttpResponse::InternalServerError().json(json!({
98            "error": format!("Failed to parse legal index: {}", e)
99        })),
100    }
101}
102
103/// GET /api/v1/legal/rules/:code
104/// Get a specific legal rule by its code
105///
106/// Path parameters:
107/// - `code`: Rule code (e.g., AG01, T03, F01)
108///
109/// Returns:
110/// * `200 OK` - The requested legal rule
111/// * `404 Not Found` - Rule not found
112/// * `500 Internal Server Error` - JSON parsing error
113///
114/// # Example
115/// ```
116/// GET /api/v1/legal/rules/AG01
117///
118/// Response 200 OK:
119/// {
120///   "code": "AG01",
121///   "category": "assemblee-generale",
122///   "roles": ["syndic", "coproprietaire", "commissaire", "conseil-copropriete"],
123///   "article": "Art. 3.87 §3 CC",
124///   "title": "Convocation AG ordinaire — délai minimum",
125///   "content": "Le syndic convoque l'assemblée générale ordinaire au moins 15 jours avant...",
126///   "keywords": ["convocation", "délai", "15 jours", "ordinaire", "ordre du jour"]
127/// }
128/// ```
129#[utoipa::path(
130    get,
131    path = "/api/v1/legal/rules/{code}",
132    tag = "Legal Reference",
133    params(
134        ("code" = String, Path, description = "Legal rule code (e.g., AG01, T03)")
135    ),
136    responses(
137        (status = 200, description = "Legal rule details", body = serde_json::Value),
138        (status = 404, description = "Rule not found"),
139        (status = 500, description = "Server error")
140    )
141)]
142#[get("/legal/rules/{code}")]
143pub async fn get_legal_rule(code: web::Path<String>) -> impl Responder {
144    let code = code.into_inner();
145
146    match serde_json::from_str::<Value>(LEGAL_INDEX) {
147        Ok(index) => {
148            if let Some(rules) = index.get("rules").and_then(|r| r.as_array()) {
149                if let Some(rule) = rules.iter().find(|r| {
150                    r.get("code")
151                        .and_then(|c| c.as_str())
152                        .map(|c| c == code)
153                        .unwrap_or(false)
154                }) {
155                    HttpResponse::Ok().json(rule)
156                } else {
157                    HttpResponse::NotFound().json(json!({
158                        "error": format!("Legal rule not found: {}", code)
159                    }))
160                }
161            } else {
162                HttpResponse::InternalServerError().json(json!({
163                    "error": "Malformed legal index: missing 'rules' array"
164                }))
165            }
166        }
167        Err(e) => HttpResponse::InternalServerError().json(json!({
168            "error": format!("Failed to parse legal index: {}", e)
169        })),
170    }
171}
172
173/// GET /api/v1/legal/ag-sequence
174/// Get the mandatory sequence of general assembly agenda items
175///
176/// Returns the full sequence of AG agenda items with their order, mandatory status,
177/// required majority, and legal notes.
178///
179/// Returns:
180/// * `200 OK` - Array of AG sequence steps
181/// * `500 Internal Server Error` - JSON parsing error
182///
183/// # Example
184/// ```
185/// GET /api/v1/legal/ag-sequence
186///
187/// Response 200 OK:
188/// [
189///   {
190///     "step": 1,
191///     "point_odj": "Ouverture et constitution du bureau",
192///     "mandatory": true,
193///     "majority": null,
194///     "notes": "Élection du président (copropriétaire), désignation du secrétaire..."
195///   },
196///   {
197///     "step": 2,
198///     "point_odj": "Vérification du quorum et émargement",
199///     "mandatory": true,
200///     "majority": null,
201///     "notes": "Signature feuille de présence, calcul quorum (>50% quotes-parts)..."
202///   },
203///   ...
204/// ]
205/// ```
206#[utoipa::path(
207    get,
208    path = "/api/v1/legal/ag-sequence",
209    tag = "Legal Reference",
210    responses(
211        (status = 200, description = "AG sequence steps", body = Vec<serde_json::Value>),
212        (status = 500, description = "Server error")
213    )
214)]
215#[get("/legal/ag-sequence")]
216pub async fn get_ag_sequence() -> impl Responder {
217    match serde_json::from_str::<Value>(LEGAL_INDEX) {
218        Ok(index) => {
219            if let Some(sequence) = index.get("ag_sequence") {
220                HttpResponse::Ok().json(sequence)
221            } else {
222                HttpResponse::InternalServerError().json(json!({
223                    "error": "Malformed legal index: missing 'ag_sequence'"
224                }))
225            }
226        }
227        Err(e) => HttpResponse::InternalServerError().json(json!({
228            "error": format!("Failed to parse legal index: {}", e)
229        })),
230    }
231}
232
233/// GET /api/v1/legal/majority-for/:decision_type
234/// Get majority information for a specific decision type
235///
236/// Path parameters:
237/// - `decision_type`: Type of decision (ordinary, qualified_two_thirds, qualified_four_fifths, unanimity, proxy_limit)
238///
239/// Returns:
240/// * `200 OK` - Majority rules and examples
241/// * `404 Not Found` - Decision type not found
242/// * `500 Internal Server Error` - JSON parsing error
243///
244/// # Example
245/// ```
246/// GET /api/v1/legal/majority-for/qualified_two_thirds
247///
248/// Response 200 OK:
249/// {
250///   "decision_type": "qualified_two_thirds",
251///   "label": "Majorité qualifiée (2/3)",
252///   "threshold_description": "Au moins 2/3 des voix",
253///   "article": "Art. 3.88 §1 1° CC",
254///   "examples": ["Travaux non-conservatoires", "Modification statuts", "Mise en concurrence"],
255///   "percentage": 66.67
256/// }
257/// ```
258#[utoipa::path(
259    get,
260    path = "/api/v1/legal/majority-for/{decision_type}",
261    tag = "Legal Reference",
262    params(
263        ("decision_type" = String, Path, description = "Decision type (ordinary, qualified_two_thirds, qualified_four_fifths, unanimity, proxy_limit)")
264    ),
265    responses(
266        (status = 200, description = "Majority rules for decision type", body = serde_json::Value),
267        (status = 404, description = "Decision type not found"),
268        (status = 500, description = "Server error")
269    )
270)]
271#[get("/legal/majority-for/{decision_type}")]
272pub async fn get_majority_for(decision_type: web::Path<String>) -> impl Responder {
273    let decision_type = decision_type.into_inner();
274
275    match serde_json::from_str::<Value>(LEGAL_INDEX) {
276        Ok(index) => {
277            if let Some(majorities) = index.get("majority_types").and_then(|m| m.as_array()) {
278                if let Some(majority) = majorities.iter().find(|m| {
279                    m.get("decision_type")
280                        .and_then(|dt| dt.as_str())
281                        .map(|dt| dt == decision_type)
282                        .unwrap_or(false)
283                }) {
284                    HttpResponse::Ok().json(majority)
285                } else {
286                    HttpResponse::NotFound().json(json!({
287                        "error": format!("Decision type not found: {}", decision_type),
288                        "valid_types": ["ordinary", "qualified_two_thirds", "qualified_four_fifths", "unanimity", "proxy_limit"]
289                    }))
290                }
291            } else {
292                HttpResponse::InternalServerError().json(json!({
293                    "error": "Malformed legal index: missing 'majority_types'"
294                }))
295            }
296        }
297        Err(e) => HttpResponse::InternalServerError().json(json!({
298            "error": format!("Failed to parse legal index: {}", e)
299        })),
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_legal_index_is_valid_json() {
309        // Verify the embedded JSON is valid
310        let result = serde_json::from_str::<Value>(LEGAL_INDEX);
311        assert!(result.is_ok(), "Legal index JSON is malformed");
312
313        let index = result.unwrap();
314        assert!(
315            index.get("legal_rules").is_some(),
316            "Missing 'legal_rules' field"
317        );
318        assert!(
319            index.get("jurisdiction").is_some(),
320            "Missing 'jurisdiction' field"
321        );
322        assert!(index.get("metadata").is_some(), "Missing 'metadata' field");
323        assert!(index.get("version").is_some(), "Missing 'version' field");
324    }
325
326    #[test]
327    fn test_legal_rules_have_required_fields() {
328        let index: Value = serde_json::from_str(LEGAL_INDEX).unwrap();
329        let rules = index.get("legal_rules").unwrap().as_array().unwrap();
330
331        assert!(!rules.is_empty(), "legal_rules should not be empty");
332
333        for rule in rules {
334            assert!(rule.get("id").is_some(), "Rule missing 'id' field");
335            assert!(rule.get("title").is_some(), "Rule missing 'title' field");
336            assert!(
337                rule.get("reference").is_some(),
338                "Rule missing 'reference' field"
339            );
340            assert!(
341                rule.get("summary").is_some(),
342                "Rule missing 'summary' field"
343            );
344            assert!(
345                rule.get("key_points").is_some(),
346                "Rule missing 'key_points' field"
347            );
348        }
349    }
350
351    #[test]
352    fn test_majority_rules_exist() {
353        let index: Value = serde_json::from_str(LEGAL_INDEX).unwrap();
354        let rules = index.get("legal_rules").unwrap().as_array().unwrap();
355
356        // Verify majority-related rules exist in legal_rules
357        let majority_ids = vec!["art_3_88_1", "art_3_88_2", "art_3_88_3"];
358        for id in majority_ids {
359            let found = rules.iter().any(|r| {
360                r.get("id")
361                    .and_then(|i| i.as_str())
362                    .map(|i| i == id)
363                    .unwrap_or(false)
364            });
365            assert!(found, "Expected majority rule id {} not found", id);
366        }
367    }
368
369    #[test]
370    fn test_ag_related_rules_exist() {
371        let index: Value = serde_json::from_str(LEGAL_INDEX).unwrap();
372        let rules = index.get("legal_rules").unwrap().as_array().unwrap();
373
374        // Verify AG-related rules exist in legal_rules
375        let ag_ids = vec![
376            "art_3_87_3",
377            "art_3_87_5",
378            "bc15_ag_session",
379            "bc17_age_concertation",
380        ];
381        for id in ag_ids {
382            let found = rules.iter().any(|r| {
383                r.get("id")
384                    .and_then(|i| i.as_str())
385                    .map(|i| i == id)
386                    .unwrap_or(false)
387            });
388            assert!(found, "Expected AG rule id {} not found", id);
389        }
390    }
391
392    #[test]
393    fn test_sample_rules_exist() {
394        let index: Value = serde_json::from_str(LEGAL_INDEX).unwrap();
395        let rules = index.get("legal_rules").unwrap().as_array().unwrap();
396
397        let ids = vec![
398            "art_3_84",
399            "art_3_87_3",
400            "ar_12_07_2012",
401            "gdpr_art_15",
402            "quotas_distribution",
403        ];
404        for id in ids {
405            let found = rules.iter().any(|r| {
406                r.get("id")
407                    .and_then(|i| i.as_str())
408                    .map(|i| i == id)
409                    .unwrap_or(false)
410            });
411            assert!(found, "Expected rule id {} not found", id);
412        }
413    }
414}