Skip to main content

koprogo_api/infrastructure/database/repositories/
acp_repository_impl.rs

1//! `PostgresAcpRepository` — adapter sqlx pour le port `AcpRepository`.
2//!
3//! Story 1.1. Utilise `sqlx::query` runtime (pas `query_as!` macro) pour
4//! cohérence avec le pattern existant `PostgresBuildingRepository` (évite
5//! la dépendance compile-time à la DB en CI ; cf. mémoire
6//! `use-docker-compose-for-tooling`).
7
8use crate::application::error::AppError;
9use crate::application::ports::{AcpRepository, ListScope};
10use crate::domain::entities::{Acp, AcpLegalStatus, AcpMetrics};
11use crate::infrastructure::database::pool::DbPool;
12use async_trait::async_trait;
13use rust_decimal::Decimal;
14use sqlx::Row;
15use uuid::Uuid;
16
17pub struct PostgresAcpRepository {
18    pool: DbPool,
19}
20
21impl PostgresAcpRepository {
22    pub fn new(pool: DbPool) -> Self {
23        Self { pool }
24    }
25
26    fn row_to_acp(row: &sqlx::postgres::PgRow) -> Acp {
27        let legal_status_str: String = row.get("legal_status");
28        Acp {
29            // Le ROI n'est pas encore persisté : la fenêtre statutaire de l'AG
30            // ordinaire (Art. 3.85 § 3, 3°) reste à saisir. Voir #747.
31            fenetre_ag_ordinaire: None,
32            // Pas encore persistée non plus : elle fait courir le délai de
33            // cinq ans du fonds de réserve (Art. 3.86 § 3 al. 4). Voir #738.
34            reception_provisoire_parties_communes: None,
35            // Ni les deux conditions de l'Art. 3.86 § 1er : sans elles, la
36            // personnalité juridique se lit `Inexistante`. Voir #740.
37            premiere_cession_de_lot: None,
38            transcription_statuts: None,
39            id: row.get("id"),
40            organization_id: row.get("organization_id"),
41            name: row.get("name"),
42            slug: row.get("slug"),
43            legal_status: AcpLegalStatus::from_db_str(&legal_status_str),
44            total_tantiemes: row.get("total_tantiemes"),
45            bce_number: row.get("bce_number"),
46            address_street: row.get("address_street"),
47            address_postal_code: row.get("address_postal_code"),
48            address_city: row.get("address_city"),
49            // Story H13 — fonds réserve/roulement. `try_get` + défaut : robuste
50            // aux SELECT qui n'incluent pas (encore) ces colonnes (ex. branches
51            // list() All/Organization), pas de panic.
52            reserve_fund_balance: row.try_get("reserve_fund_balance").unwrap_or(Decimal::ZERO),
53            working_capital_balance: row
54                .try_get("working_capital_balance")
55                .unwrap_or(Decimal::ZERO),
56            reserve_fund_waived: row.try_get("reserve_fund_waived").unwrap_or(false),
57            created_at: row.get("created_at"),
58            updated_at: row.get("updated_at"),
59        }
60    }
61}
62
63#[async_trait]
64impl AcpRepository for PostgresAcpRepository {
65    async fn create(&self, acp: &Acp) -> Result<Acp, AppError> {
66        sqlx::query(
67            r#"
68            INSERT INTO acps (
69                id, organization_id, name, slug, legal_status, bce_number,
70                address_street, address_postal_code, address_city,
71                total_tantiemes, created_at, updated_at,
72                reserve_fund_balance, working_capital_balance, reserve_fund_waived
73            )
74            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
75            "#,
76        )
77        .bind(acp.id)
78        .bind(acp.organization_id)
79        .bind(&acp.name)
80        .bind(&acp.slug)
81        .bind(acp.legal_status.as_db_str())
82        .bind(&acp.bce_number)
83        .bind(&acp.address_street)
84        .bind(&acp.address_postal_code)
85        .bind(&acp.address_city)
86        .bind(acp.total_tantiemes)
87        .bind(acp.created_at)
88        .bind(acp.updated_at)
89        .bind(acp.reserve_fund_balance)
90        .bind(acp.working_capital_balance)
91        .bind(acp.reserve_fund_waived)
92        .execute(&self.pool)
93        .await
94        .map_err(|e| {
95            // Conflits unicité (slug ou id en doublon) → Conflict typé.
96            if let Some(db_err) = e.as_database_error() {
97                if db_err.is_unique_violation() {
98                    return AppError::Conflict(format!("ACP unique violation: {}", db_err));
99                }
100            }
101            AppError::Database(e.to_string())
102        })?;
103
104        Ok(acp.clone())
105    }
106
107    async fn find_by_id(&self, id: Uuid) -> Result<Option<Acp>, AppError> {
108        let row = sqlx::query(
109            r#"
110            SELECT id, organization_id, name, slug, legal_status, bce_number,
111                   address_street, address_postal_code, address_city,
112                   total_tantiemes, created_at, updated_at,
113                   reserve_fund_balance, working_capital_balance, reserve_fund_waived
114            FROM acps
115            WHERE id = $1
116            "#,
117        )
118        .bind(id)
119        .fetch_optional(&self.pool)
120        .await
121        .map_err(|e| AppError::Database(e.to_string()))?;
122
123        Ok(row.as_ref().map(Self::row_to_acp))
124    }
125
126    async fn find_by_id_with_metrics(
127        &self,
128        id: Uuid,
129    ) -> Result<Option<(Acp, AcpMetrics)>, AppError> {
130        // Sous-requêtes indépendantes : évite le fan-out du JOIN
131        // (buildings × units multiplierait `total_units`). Chaque agrégat est
132        // calculé séparément sur le périmètre de l'ACP. `SUM(quota::NUMERIC)`
133        // reste Decimal exact (ADR-0007/0008). Story H6 — ADR-0010.
134        let row = sqlx::query(
135            r#"
136            SELECT
137                a.id, a.organization_id, a.name, a.slug, a.legal_status, a.bce_number,
138                a.address_street, a.address_postal_code, a.address_city,
139                a.total_tantiemes, a.created_at, a.updated_at,
140                a.reserve_fund_balance, a.working_capital_balance, a.reserve_fund_waived,
141                (SELECT COALESCE(COUNT(u.id), 0)::INT
142                   FROM buildings b JOIN units u ON u.building_id = b.id
143                   WHERE b.acp_id = a.id)                                   AS units_count,
144                (SELECT COALESCE(SUM(u.quota::NUMERIC), 0::NUMERIC)
145                   FROM buildings b JOIN units u ON u.building_id = b.id
146                   WHERE b.acp_id = a.id)                                   AS quota_sum,
147                (SELECT COALESCE(SUM(b.total_units), 0)::INT
148                   FROM buildings b WHERE b.acp_id = a.id)                  AS declared_units_total,
149                (SELECT COALESCE(COUNT(*), 0)::INT
150                   FROM buildings b WHERE b.acp_id = a.id)                  AS buildings_count
151            FROM acps a
152            WHERE a.id = $1
153            "#,
154        )
155        .bind(id)
156        .fetch_optional(&self.pool)
157        .await
158        .map_err(|e| AppError::Database(e.to_string()))?;
159
160        Ok(row.map(|row| {
161            let acp = Self::row_to_acp(&row);
162            let metrics = AcpMetrics {
163                units_count: row.try_get("units_count").unwrap_or(0),
164                declared_units_total: row.try_get("declared_units_total").unwrap_or(0),
165                quota_sum: row.try_get("quota_sum").unwrap_or(Decimal::ZERO),
166                buildings_count: row.try_get("buildings_count").unwrap_or(0),
167            };
168            (acp, metrics)
169        }))
170    }
171
172    async fn list_with_metrics(
173        &self,
174        scope: ListScope,
175    ) -> Result<Vec<(Acp, AcpMetrics)>, AppError> {
176        // Les quatre sous-requêtes sont MOT POUR MOT celles de
177        // `find_by_id_with_metrics`. Les réécrire autrement ferait diverger la
178        // fiche d'une ACP et sa ligne dans la liste, et personne ne saurait
179        // lequel des deux nombres croire.
180        //
181        // Sous-requêtes indépendantes, pas de JOIN : `buildings × units`
182        // multiplierait `total_units` par le nombre de lots.
183        const METRIQUES: &str = r#"
184                (SELECT COALESCE(COUNT(u.id), 0)::INT
185                   FROM buildings b JOIN units u ON u.building_id = b.id
186                   WHERE b.acp_id = a.id)                                   AS units_count,
187                (SELECT COALESCE(SUM(u.quota::NUMERIC), 0::NUMERIC)
188                   FROM buildings b JOIN units u ON u.building_id = b.id
189                   WHERE b.acp_id = a.id)                                   AS quota_sum,
190                (SELECT COALESCE(SUM(b.total_units), 0)::INT
191                   FROM buildings b WHERE b.acp_id = a.id)                  AS declared_units_total,
192                (SELECT COALESCE(COUNT(*), 0)::INT
193                   FROM buildings b WHERE b.acp_id = a.id)                  AS buildings_count"#;
194
195        const COLONNES: &str = r#"
196                a.id, a.organization_id, a.name, a.slug, a.legal_status, a.bce_number,
197                a.address_street, a.address_postal_code, a.address_city,
198                a.total_tantiemes, a.created_at, a.updated_at,
199                a.reserve_fund_balance, a.working_capital_balance, a.reserve_fund_waived"#;
200
201        let rows = match scope {
202            ListScope::All => {
203                let sql =
204                    format!("SELECT {COLONNES},{METRIQUES} FROM acps a ORDER BY a.created_at DESC");
205                sqlx::query(&sql)
206                    .fetch_all(&self.pool)
207                    .await
208                    .map_err(|e| AppError::Database(e.to_string()))?
209            }
210            ListScope::Organization(org_id) => {
211                let sql = format!(
212                    "SELECT {COLONNES},{METRIQUES} FROM acps a \
213                     WHERE a.organization_id = $1 ORDER BY a.created_at DESC"
214                );
215                sqlx::query(&sql)
216                    .bind(org_id)
217                    .fetch_all(&self.pool)
218                    .await
219                    .map_err(|e| AppError::Database(e.to_string()))?
220            }
221            ListScope::Owner(user_id) => {
222                let sql = format!(
223                    "SELECT {COLONNES},{METRIQUES} FROM acps a \
224                     INNER JOIN user_role_assignments ura \
225                        ON ura.scope = 'acp' AND ura.scope_id = a.id \
226                     WHERE ura.user_id = $1 ORDER BY a.created_at DESC"
227                );
228                sqlx::query(&sql)
229                    .bind(user_id)
230                    .fetch_all(&self.pool)
231                    .await
232                    .map_err(|e| AppError::Database(e.to_string()))?
233            }
234        };
235
236        Ok(rows
237            .into_iter()
238            .map(|row| {
239                let acp = Self::row_to_acp(&row);
240                let metrics = AcpMetrics {
241                    units_count: row.try_get("units_count").unwrap_or(0),
242                    declared_units_total: row.try_get("declared_units_total").unwrap_or(0),
243                    quota_sum: row.try_get("quota_sum").unwrap_or(Decimal::ZERO),
244                    buildings_count: row.try_get("buildings_count").unwrap_or(0),
245                };
246                (acp, metrics)
247            })
248            .collect())
249    }
250
251    async fn list(&self, scope: ListScope) -> Result<Vec<Acp>, AppError> {
252        let rows = match scope {
253            ListScope::All => sqlx::query(
254                r#"
255                SELECT id, organization_id, name, slug, legal_status, bce_number,
256                       address_street, address_postal_code, address_city,
257                       total_tantiemes, created_at, updated_at
258                FROM acps
259                ORDER BY created_at DESC
260                "#,
261            )
262            .fetch_all(&self.pool)
263            .await
264            .map_err(|e| AppError::Database(e.to_string()))?,
265
266            ListScope::Organization(org_id) => sqlx::query(
267                r#"
268                SELECT id, organization_id, name, slug, legal_status, bce_number,
269                       address_street, address_postal_code, address_city,
270                       total_tantiemes, created_at, updated_at
271                FROM acps
272                WHERE organization_id = $1
273                ORDER BY created_at DESC
274                "#,
275            )
276            .bind(org_id)
277            .fetch_all(&self.pool)
278            .await
279            .map_err(|e| AppError::Database(e.to_string()))?,
280
281            ListScope::Owner(user_id) => {
282                // Story 1.1 — scope Owner direct via user_role_assignments
283                // sur scope='acp'. Le filtrage transitif (via building/unit)
284                // arrivera en Story 1.3 (`list_buildings` refacto rôle).
285                sqlx::query(
286                    r#"
287                    SELECT a.id, a.organization_id, a.name, a.slug, a.legal_status,
288                           a.bce_number, a.address_street, a.address_postal_code,
289                           a.address_city, a.total_tantiemes, a.created_at, a.updated_at
290                    FROM acps a
291                    INNER JOIN user_role_assignments ura
292                        ON ura.scope = 'acp'
293                       AND ura.scope_id = a.id
294                    WHERE ura.user_id = $1
295                    ORDER BY a.created_at DESC
296                    "#,
297                )
298                .bind(user_id)
299                .fetch_all(&self.pool)
300                .await
301                // L'absence de colonnes scope/scope_id sur
302                // user_role_assignments (avant migration future Story 3.5)
303                // ne doit PAS faire crasher la lecture : on retourne une
304                // liste vide en logguant — propre, pas de panic.
305                .unwrap_or_else(|e| {
306                    log::warn!(
307                        "Owner-scope ACP listing fell back to empty (likely \
308                         missing user_role_assignments scope/scope_id columns \
309                         pending Story 3.5): {}",
310                        e
311                    );
312                    Vec::new()
313                })
314            }
315        };
316
317        Ok(rows.iter().map(Self::row_to_acp).collect())
318    }
319
320    async fn update(&self, acp: &Acp) -> Result<Acp, AppError> {
321        let result = sqlx::query(
322            r#"
323            UPDATE acps
324            SET organization_id = $2,
325                name = $3,
326                slug = $4,
327                legal_status = $5,
328                bce_number = $6,
329                address_street = $7,
330                address_postal_code = $8,
331                address_city = $9,
332                total_tantiemes = $10,
333                updated_at = $11,
334                reserve_fund_balance = $12,
335                working_capital_balance = $13,
336                reserve_fund_waived = $14
337            WHERE id = $1
338            "#,
339        )
340        .bind(acp.id)
341        .bind(acp.organization_id)
342        .bind(&acp.name)
343        .bind(&acp.slug)
344        .bind(acp.legal_status.as_db_str())
345        .bind(&acp.bce_number)
346        .bind(&acp.address_street)
347        .bind(&acp.address_postal_code)
348        .bind(&acp.address_city)
349        .bind(acp.total_tantiemes)
350        .bind(acp.updated_at)
351        .bind(acp.reserve_fund_balance)
352        .bind(acp.working_capital_balance)
353        .bind(acp.reserve_fund_waived)
354        .execute(&self.pool)
355        .await
356        .map_err(|e| {
357            if let Some(db_err) = e.as_database_error() {
358                if db_err.is_unique_violation() {
359                    return AppError::Conflict(format!("ACP unique violation: {}", db_err));
360                }
361            }
362            AppError::Database(e.to_string())
363        })?;
364
365        if result.rows_affected() == 0 {
366            return Err(AppError::NotFound(format!("ACP {} not found", acp.id)));
367        }
368        Ok(acp.clone())
369    }
370
371    async fn archive(&self, id: Uuid) -> Result<(), AppError> {
372        let result = sqlx::query("DELETE FROM acps WHERE id = $1")
373            .bind(id)
374            .execute(&self.pool)
375            .await
376            .map_err(|e| AppError::Database(e.to_string()))?;
377
378        if result.rows_affected() == 0 {
379            return Err(AppError::NotFound(format!("ACP {} not found", id)));
380        }
381        Ok(())
382    }
383
384    /// Nombre d'immeubles rattachés à cette ACP.
385    ///
386    /// Le corps précédent renvoyait `Ok(0)` en dur, avec ce commentaire :
387    /// « Story 1.1 : la colonne `buildings.acp_id` n'existe pas encore
388    /// (Story 1.2) ». Le commentaire était périmé — la colonne existe depuis
389    /// la migration `20260601020000`, et elle est même `NOT NULL` depuis
390    /// `20260601040000`. Le garde-fou était donc en façade, et personne ne
391    /// l'appelait.
392    ///
393    /// Conséquence : `archive()` faisait un `DELETE FROM acps` nu contre une
394    /// clé étrangère en `NO ACTION`, ce qui remontait une violation SQL en
395    /// `AppError::Database` — soit un **500** à l'utilisateur pour ce qui est
396    /// une règle métier parfaitement légitime.
397    async fn count_buildings(&self, id: Uuid) -> Result<i64, AppError> {
398        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM buildings WHERE acp_id = $1")
399            .bind(id)
400            .fetch_one(&self.pool)
401            .await
402            .map_err(|e| AppError::Database(e.to_string()))?;
403        Ok(count)
404    }
405}