Skip to main content

koprogo_api/infrastructure/database/repositories/
module_registry_impl.rs

1//! Adaptateur PostgreSQL du registre de modules — Story 5.1 (#585).
2//!
3//! **Requêtes vérifiées à l'exécution, pas à la compilation.** Le reste du
4//! dépôt emploie surtout les macros `sqlx::query!`, qui exigent le cache
5//! `.sqlx`. Ce cache est suivi par git et `sqlx prepare` le vide avant de le
6//! refaire : le régénérer pour trois requêtes ferait porter à cette story le
7//! risque de perdre les entrées de tout le monde. Les requêtes ici sont
8//! simples (une table, deux colonnes) et leur typage est couvert par les
9//! tests d'intégration testcontainers, qui exécutent le SQL pour de vrai.
10//! Précédent dans le dépôt : `charge_distribution_repository_impl.rs`.
11//!
12//! INV-27 — rien n'est jamais supprimé : `archived_at` est posée puis
13//! retirée. Réactiver retrouve la ligne, donc les données du module.
14
15use async_trait::async_trait;
16use sqlx::PgPool;
17use uuid::Uuid;
18
19use crate::application::error::AppError;
20use crate::application::ports::ModuleRegistry;
21use crate::domain::entities::Module;
22
23pub struct PostgresModuleRegistry {
24    pool: PgPool,
25}
26
27impl PostgresModuleRegistry {
28    pub fn new(pool: PgPool) -> Self {
29        Self { pool }
30    }
31}
32
33fn erreur_sql(e: sqlx::Error) -> AppError {
34    AppError::Database(e.to_string())
35}
36
37#[async_trait]
38impl ModuleRegistry for PostgresModuleRegistry {
39    async fn list_enabled(&self, acp_id: Uuid) -> Result<Vec<Module>, AppError> {
40        let noms: Vec<String> = sqlx::query_scalar(
41            "SELECT module FROM acp_enabled_modules \
42             WHERE acp_id = $1 AND archived_at IS NULL \
43             ORDER BY module",
44        )
45        .bind(acp_id)
46        .fetch_all(&self.pool)
47        .await
48        .map_err(erreur_sql)?;
49
50        // Un nom en base que l'enum ne connaît pas signifie que la contrainte
51        // SQL et l'enum Rust ont divergé. On refuse plutôt que de filtrer en
52        // silence : filtrer rendrait une liste incomplète qui se lirait comme
53        // « ce module est éteint ». `garde_enum_contre_contrainte` empêche
54        // normalement d'en arriver là ; ceci est la ceinture.
55        noms.into_iter()
56            .map(|nom| {
57                Module::depuis_nom(&nom).ok_or_else(|| {
58                    AppError::Database(format!(
59                        "Module « {nom} » présent en base mais inconnu de l'enum Rust : \
60                         la contrainte SQL et le domaine ont divergé"
61                    ))
62                })
63            })
64            .collect()
65    }
66
67    async fn enable(&self, acp_id: Uuid, module: Module) -> Result<(), AppError> {
68        // Idempotent : le conflit sur (acp_id, module) rallume la ligne
69        // existante au lieu d'en créer une seconde. C'est ce qui fait que le
70        // cycle activer/désactiver/réactiver laisse les données intactes.
71        sqlx::query(
72            "INSERT INTO acp_enabled_modules (acp_id, module) VALUES ($1, $2) \
73             ON CONFLICT (acp_id, module) \
74             DO UPDATE SET archived_at = NULL, enabled_at = NOW()",
75        )
76        .bind(acp_id)
77        .bind(module.as_str())
78        .execute(&self.pool)
79        .await
80        .map_err(erreur_sql)?;
81        Ok(())
82    }
83
84    async fn disable(&self, acp_id: Uuid, module: Module) -> Result<(), AppError> {
85        // Pas de DELETE. Éteindre un module doit rester réversible (INV-27).
86        // `archived_at IS NULL` dans le WHERE rend l'appel idempotent sans
87        // écraser la date d'archivage d'une extinction précédente.
88        sqlx::query(
89            "UPDATE acp_enabled_modules SET archived_at = NOW() \
90             WHERE acp_id = $1 AND module = $2 AND archived_at IS NULL",
91        )
92        .bind(acp_id)
93        .bind(module.as_str())
94        .execute(&self.pool)
95        .await
96        .map_err(erreur_sql)?;
97        Ok(())
98    }
99
100    async fn is_enabled(&self, acp_id: Uuid, module: Module) -> Result<bool, AppError> {
101        let actif: Option<bool> = sqlx::query_scalar(
102            "SELECT TRUE FROM acp_enabled_modules \
103             WHERE acp_id = $1 AND module = $2 AND archived_at IS NULL",
104        )
105        .bind(acp_id)
106        .bind(module.as_str())
107        .fetch_optional(&self.pool)
108        .await
109        .map_err(erreur_sql)?;
110        Ok(actif.unwrap_or(false))
111    }
112}