Skip to main content

koprogo_api/infrastructure/database/repositories/
meeting_completion_checker_impl.rs

1//! Track H Story H3 — Adapter sqlx pour `MeetingCompletionCheckerPort`.
2//!
3//! Une seule query SQL agrégée construit la checklist Art. 3.87 §3-5 CC :
4//! convocations envoyées, résolutions en cours, présences enregistrées,
5//! quotas (présents+représentés + total building), minutes draft.
6//!
7//! **Performance** : 6 sous-queries indépendantes, ≤ 5ms sur Postgres bêta.
8//!
9//! **Types** : `meetings.present_quotas` et `units.quota` sont `NUMERIC` en DB
10//! depuis `20260516000000_alter_governance_to_numeric.sql` (WP-A1), et lus
11//! directement en `Decimal` — aucun `f64` sur le chemin (ADR-0008, #661).
12//! Le commentaire de compatibilité précédent, qui décrivait un boundary
13//! `DOUBLE PRECISION → Decimal` « à perte IEEE754 acceptable », ne
14//! correspondait plus à la DB depuis cette migration.
15
16use async_trait::async_trait;
17use rust_decimal::Decimal;
18use sqlx::Row;
19use uuid::Uuid;
20
21use crate::application::ports::MeetingCompletionCheckerPort;
22use crate::domain::entities::MeetingCompletionChecklist;
23use crate::infrastructure::database::pool::DbPool;
24
25pub struct PostgresMeetingCompletionChecker {
26    pool: DbPool,
27}
28
29impl PostgresMeetingCompletionChecker {
30    pub fn new(pool: DbPool) -> Self {
31        Self { pool }
32    }
33}
34
35#[async_trait]
36impl MeetingCompletionCheckerPort for PostgresMeetingCompletionChecker {
37    async fn build_checklist(
38        &self,
39        meeting_id: Uuid,
40    ) -> Result<MeetingCompletionChecklist, String> {
41        // 1 round-trip, 6 sous-queries. Le building_id du meeting est résolu
42        // inline pour `total_quotas` (SUM units.quota du building).
43        //
44        // **Schema notes** (cf. migrations 2024010100005 / 20251115120000 /
45        // 20251119000000 / 20260312000000 / 20260323000001) :
46        //  - `convocations.status = 'sent'` → convocations_sent
47        //  - `resolutions.status = 'Pending'` → open_resolution
48        //  - `meetings.present_quotas IS NOT NULL` → attendance_recorded
49        //  - `meetings.present_quotas` → attended_quotas (DOUBLE PRECISION)
50        //  - `SUM(units.quota) WHERE building_id = meetings.building_id`
51        //    → total_quotas (Decimal natif via units.quota NUMERIC)
52        //  - `meetings.minutes_document_id IS NOT NULL` → minutes_draft_exists
53        //
54        // Si le meeting n'existe pas, la sous-query "building_id" renvoie
55        // NULL — le `COALESCE` en aval rend les valeurs sûres mais on
56        // détecte explicitement le cas via une row meeting check préalable.
57        let row = sqlx::query(
58            r#"
59            WITH m AS (
60                SELECT id, building_id, present_quotas, present_owners_count, minutes_document_id
61                FROM meetings
62                WHERE id = $1
63            )
64            SELECT
65                EXISTS(SELECT 1 FROM m) AS meeting_exists,
66                EXISTS(
67                    SELECT 1 FROM convocations c
68                    JOIN m ON m.id = c.meeting_id
69                    WHERE c.status = 'sent'
70                ) AS convocations_sent,
71                (
72                    SELECT COUNT(*)
73                    FROM resolutions r
74                    JOIN m ON m.id = r.meeting_id
75                    WHERE r.status = 'Pending'
76                )::int AS open_resolutions,
77                (SELECT present_quotas IS NOT NULL FROM m) AS attendance_recorded,
78                COALESCE((SELECT present_quotas FROM m), 0) AS attended_quotas,
79                COALESCE(
80                    (SELECT SUM(u.quota)
81                     FROM units u
82                     JOIN m ON m.building_id = u.building_id),
83                    0
84                ) AS total_quotas,
85                -- Story H9 — volet têtes du quorum double (Art. 3.87 §5).
86                -- présents : saisi par le syndic (meetings.present_owners_count).
87                COALESCE((SELECT present_owners_count FROM m), 0)::int AS present_owners_count,
88                -- total : COUNT DISTINCT copropriétaires actifs du building.
89                COALESCE((
90                    SELECT COUNT(DISTINCT uo.owner_id)
91                    FROM unit_owners uo
92                    JOIN units u ON uo.unit_id = u.id
93                    JOIN m ON m.building_id = u.building_id
94                    WHERE uo.end_date IS NULL
95                ), 0)::int AS total_owners_count,
96                (SELECT minutes_document_id IS NOT NULL FROM m) AS minutes_draft_exists
97            "#,
98        )
99        .bind(meeting_id)
100        .fetch_one(&self.pool)
101        .await
102        .map_err(|e| format!("DB error building completion checklist: {}", e))?;
103
104        let meeting_exists: bool = row.try_get("meeting_exists").unwrap_or(false);
105        if !meeting_exists {
106            return Err(format!("Meeting {} not found", meeting_id));
107        }
108
109        // #661 — `meetings.present_quotas` est NUMERIC(10,4) depuis la migration
110        // `20260516000000_alter_governance_to_numeric.sql` (WP-A1). Le commentaire
111        // précédent affirmait encore DOUBLE PRECISION et faisait transiter la
112        // quote-part par un `f64` : un aller-retour NUMERIC→f64→Decimal sans
113        // objet, sur le chemin de clôture d'AG (Art. 3.87 §5 CC).
114        let attended_quotas: Decimal = row.try_get("attended_quotas").unwrap_or(Decimal::ZERO);
115
116        let total_quotas: Decimal = row.try_get("total_quotas").unwrap_or(Decimal::ZERO);
117
118        Ok(MeetingCompletionChecklist {
119            convocations_sent: row.try_get("convocations_sent").unwrap_or(false),
120            open_resolutions: row.try_get("open_resolutions").unwrap_or(0),
121            attendance_recorded: row.try_get("attendance_recorded").unwrap_or(false),
122            attended_quotas,
123            total_quotas,
124            // Story H9 — volet têtes du quorum double (Art. 3.87 §5).
125            present_owners_count: row.try_get("present_owners_count").unwrap_or(0),
126            total_owners_count: row.try_get("total_owners_count").unwrap_or(0),
127            minutes_draft_exists: row.try_get("minutes_draft_exists").unwrap_or(false),
128        })
129    }
130}