Skip to main content

koprogo_api/domain/copropriete/
acp_enabled_module.rs

1//! Registre des modules activables par ACP — Story 5.1 (#585), ADR-0015.
2//!
3//! Une ACP n'allume que les capacités dont elle a besoin. Ce fichier porte
4//! le vocabulaire (`Module`) et l'état (`AcpEnabledModule`) ; les règles
5//! d'activation vivent dans `module_registry_use_cases`.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11/// Les capacités que KoproGo sait allumer ou éteindre par copropriété.
12///
13/// Les variantes doivent rester alignées, dans les deux sens, sur la
14/// contrainte `acp_enabled_modules_module_check`
15/// (`migrations/20260917000000_create_acp_enabled_modules.sql`). La garde
16/// `garde_enum_contre_contrainte` casse si l'une des deux listes devance
17/// l'autre — c'est elle qui rend cet alignement vérifié plutôt que promis.
18///
19/// Le frontend tient la même liste dans `frontend/src/lib/api/modules.ts`
20/// (`MODULE_NAMES`), dette documentée en tête de ce fichier-là, à résorber
21/// en important les types générés dès que ce handler est au schéma OpenAPI.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema)]
23#[serde(rename_all = "snake_case")]
24pub enum Module {
25    /// Identité, comptes, rôles. **Toujours actif** : sans lui, plus personne
26    /// ne peut se connecter pour rallumer quoi que ce soit.
27    Identity,
28    Community,
29    Ticketing,
30    Accounting,
31    Governance,
32    Maintenance,
33    Portfolio,
34}
35
36impl Module {
37    /// Toutes les variantes, dans l'ordre de la contrainte SQL.
38    pub const ALL: [Module; 7] = [
39        Module::Identity,
40        Module::Community,
41        Module::Ticketing,
42        Module::Accounting,
43        Module::Governance,
44        Module::Maintenance,
45        Module::Portfolio,
46    ];
47
48    /// Le nom porté en base et sur le fil HTTP.
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            Module::Identity => "identity",
52            Module::Community => "community",
53            Module::Ticketing => "ticketing",
54            Module::Accounting => "accounting",
55            Module::Governance => "governance",
56            Module::Maintenance => "maintenance",
57            Module::Portfolio => "portfolio",
58        }
59    }
60
61    /// Un module « toujours actif » ne peut pas être éteint (403).
62    ///
63    /// Ce n'est pas une politique configurable : c'est une propriété de la
64    /// capacité. Éteindre `identity` rendrait l'ACP inaccessible et donc
65    /// irrécupérable par ses propres administrateurs.
66    pub fn est_toujours_actif(&self) -> bool {
67        matches!(self, Module::Identity)
68    }
69
70    /// Reconnaît un nom de module. `None` = nom inconnu, que l'appelant
71    /// traduit en 422 (Story 5.1 @negative : `foobar`).
72    ///
73    /// Volontairement **strict sur la casse** : le nom vient d'un chemin
74    /// d'URL, et accepter `Accounting` comme `accounting` ferait diverger la
75    /// clé d'unicité en base du nom reçu.
76    pub fn depuis_nom(nom: &str) -> Option<Module> {
77        Module::ALL.into_iter().find(|m| m.as_str() == nom)
78    }
79}
80
81impl std::fmt::Display for Module {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.write_str(self.as_str())
84    }
85}
86
87/// L'état d'un module pour une ACP donnée.
88///
89/// `archived_at` est la seule lecture de l'état : `None` = actif. Le couple
90/// (acp_id, module) est unique — réactiver réutilise la ligne et remet
91/// `archived_at` à `None`, ce qui laisse les données du module intactes
92/// (INV-27). C'est cette réversibilité qui rend la désactivation acceptable.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct AcpEnabledModule {
95    pub id: Uuid,
96    pub acp_id: Uuid,
97    pub module: Module,
98    pub enabled_at: DateTime<Utc>,
99    pub archived_at: Option<DateTime<Utc>>,
100}
101
102impl AcpEnabledModule {
103    pub fn est_actif(&self) -> bool {
104        self.archived_at.is_none()
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn negative_un_nom_inconnu_nest_pas_un_module() {
114        assert_eq!(Module::depuis_nom("foobar"), None);
115        assert_eq!(Module::depuis_nom(""), None);
116    }
117
118    #[test]
119    fn edge_la_casse_nest_pas_rattrapee() {
120        // Le nom vient d'un chemin d'URL. L'accepter en majuscules ferait
121        // diverger la clé d'unicité en base du nom reçu.
122        assert_eq!(Module::depuis_nom("Accounting"), None);
123        assert_eq!(Module::depuis_nom("accounting"), Some(Module::Accounting));
124    }
125
126    #[test]
127    fn happy_chaque_variante_fait_laller_retour_par_son_nom() {
128        for module in Module::ALL {
129            assert_eq!(Module::depuis_nom(module.as_str()), Some(module));
130        }
131    }
132
133    #[test]
134    fn happy_identity_est_le_seul_module_toujours_actif() {
135        let toujours_actifs: Vec<&str> = Module::ALL
136            .into_iter()
137            .filter(|m| m.est_toujours_actif())
138            .map(|m| m.as_str())
139            .collect();
140        assert_eq!(toujours_actifs, vec!["identity"]);
141    }
142
143    #[test]
144    fn happy_archived_at_est_la_seule_lecture_de_letat() {
145        let mut m = AcpEnabledModule {
146            id: Uuid::new_v4(),
147            acp_id: Uuid::new_v4(),
148            module: Module::Community,
149            enabled_at: Utc::now(),
150            archived_at: None,
151        };
152        assert!(m.est_actif());
153        m.archived_at = Some(Utc::now());
154        assert!(!m.est_actif());
155    }
156}