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}