Skip to main content

koprogo_api/infrastructure/database/repositories/
resolution_repository_impl.rs

1use crate::application::ports::ResolutionRepository;
2use crate::domain::entities::{
3    MajorityType, Resolution, ResolutionKind, ResolutionStatus, ResolutionType,
4};
5use crate::infrastructure::database::pool::DbPool;
6use async_trait::async_trait;
7use rust_decimal::Decimal;
8use sqlx::Row;
9use uuid::Uuid;
10
11pub struct PostgresResolutionRepository {
12    pool: DbPool,
13}
14
15impl PostgresResolutionRepository {
16    pub fn new(pool: DbPool) -> Self {
17        Self { pool }
18    }
19
20    /// Parse MajorityType from database string format
21    fn parse_majority_type(s: &str) -> MajorityType {
22        match s {
23            "TwoThirds" => MajorityType::TwoThirds,
24            "FourFifths" => MajorityType::FourFifths,
25            "Unanimity" => MajorityType::Unanimity,
26            // "Absolute" and any legacy "Simple" values map to Absolute
27            _ => MajorityType::Absolute,
28        }
29    }
30
31    /// Convert MajorityType to database string format
32    fn majority_type_to_string(majority: &MajorityType) -> String {
33        match majority {
34            MajorityType::Absolute => "Absolute".to_string(),
35            MajorityType::TwoThirds => "TwoThirds".to_string(),
36            MajorityType::FourFifths => "FourFifths".to_string(),
37            MajorityType::Unanimity => "Unanimity".to_string(),
38        }
39    }
40
41    /// Story 4.6 (#581) — nature métier de la résolution en base.
42    fn kind_to_string(kind: &ResolutionKind) -> &'static str {
43        match kind {
44            ResolutionKind::Standard => "standard",
45            ResolutionKind::EvaluationContractorsAuto => "evaluation_contractors_auto",
46        }
47    }
48
49    /// Toute valeur inconnue retombe sur `Standard` : une résolution ne perd
50    /// jamais sa modifiabilité par défaut à cause d'une valeur imprévue.
51    fn parse_kind(s: &str) -> ResolutionKind {
52        match s {
53            "evaluation_contractors_auto" => ResolutionKind::EvaluationContractorsAuto,
54            _ => ResolutionKind::Standard,
55        }
56    }
57}
58
59#[async_trait]
60impl ResolutionRepository for PostgresResolutionRepository {
61    async fn create(&self, resolution: &Resolution) -> Result<Resolution, String> {
62        let resolution_type_str = match resolution.resolution_type {
63            ResolutionType::Ordinary => "Ordinary",
64            ResolutionType::Extraordinary => "Extraordinary",
65        };
66
67        let status_str = match resolution.status {
68            ResolutionStatus::Pending => "Pending",
69            ResolutionStatus::Adopted => "Adopted",
70            ResolutionStatus::Rejected => "Rejected",
71        };
72
73        let majority_str = Self::majority_type_to_string(&resolution.majority_required);
74        let kind_str = Self::kind_to_string(&resolution.kind);
75
76        sqlx::query(
77            r#"
78            INSERT INTO resolutions (
79                id, meeting_id, title, description, resolution_type, majority_required,
80                vote_count_pour, vote_count_contre, vote_count_abstention,
81                total_voting_power_pour, total_voting_power_contre, total_voting_power_abstention,
82                status, created_at, voted_at, agenda_item_index, prestataire_de_la_mission, kind
83            )
84            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
85            "#,
86        )
87        .bind(resolution.id)
88        .bind(resolution.meeting_id)
89        .bind(&resolution.title)
90        .bind(&resolution.description)
91        .bind(resolution_type_str)
92        .bind(majority_str)
93        .bind(resolution.vote_count_pour)
94        .bind(resolution.vote_count_contre)
95        .bind(resolution.vote_count_abstention)
96        .bind(resolution.total_voting_power_pour)
97        .bind(resolution.total_voting_power_contre)
98        .bind(resolution.total_voting_power_abstention)
99        .bind(status_str)
100        .bind(resolution.created_at)
101        .bind(resolution.voted_at)
102        // `usize` n'a pas d'encodage Postgres : l'indice est stocke en INTEGER,
103        // borne a >= 0 par un CHECK cote base.
104        .bind(resolution.agenda_item_index.map(|i| i as i32))
105        .bind(resolution.prestataire_de_la_mission)
106        .bind(kind_str)
107        .execute(&self.pool)
108        .await
109        .map_err(|e| format!("Database error creating resolution: {}", e))?;
110
111        Ok(resolution.clone())
112    }
113
114    async fn find_by_id(&self, id: Uuid) -> Result<Option<Resolution>, String> {
115        let row = sqlx::query(
116            r#"
117            SELECT id, meeting_id, title, description, resolution_type, majority_required,
118                   vote_count_pour, vote_count_contre, vote_count_abstention,
119                   total_voting_power_pour, total_voting_power_contre, total_voting_power_abstention,
120                   status, created_at, voted_at, agenda_item_index, prestataire_de_la_mission, kind
121            FROM resolutions
122            WHERE id = $1
123            "#,
124        )
125        .bind(id)
126        .fetch_optional(&self.pool)
127        .await
128        .map_err(|e| format!("Database error finding resolution: {}", e))?;
129
130        Ok(row.map(|row| {
131            let resolution_type_str: String = row.get("resolution_type");
132            let resolution_type = match resolution_type_str.as_str() {
133                "Extraordinary" => ResolutionType::Extraordinary,
134                _ => ResolutionType::Ordinary,
135            };
136
137            let status_str: String = row.get("status");
138            let status = match status_str.as_str() {
139                "Adopted" => ResolutionStatus::Adopted,
140                "Rejected" => ResolutionStatus::Rejected,
141                _ => ResolutionStatus::Pending,
142            };
143
144            let majority_str: String = row.get("majority_required");
145            let majority_required = Self::parse_majority_type(&majority_str);
146            let kind_str: String = row.get("kind");
147
148            Resolution {
149                id: row.get("id"),
150                meeting_id: row.get("meeting_id"),
151                title: row.get("title"),
152                description: row.get("description"),
153                resolution_type,
154                majority_required,
155                vote_count_pour: row.get("vote_count_pour"),
156                vote_count_contre: row.get("vote_count_contre"),
157                vote_count_abstention: row.get("vote_count_abstention"),
158                total_voting_power_pour: row.get("total_voting_power_pour"),
159                total_voting_power_contre: row.get("total_voting_power_contre"),
160                total_voting_power_abstention: row.get("total_voting_power_abstention"),
161                status,
162                created_at: row.get("created_at"),
163                voted_at: row.get("voted_at"),
164                agenda_item_index: row
165                    .get::<Option<i32>, _>("agenda_item_index")
166                    .map(|i| i as usize),
167                prestataire_de_la_mission: row.get("prestataire_de_la_mission"),
168                kind: Self::parse_kind(&kind_str),
169            }
170        }))
171    }
172
173    async fn find_by_meeting_id(&self, meeting_id: Uuid) -> Result<Vec<Resolution>, String> {
174        let rows = sqlx::query(
175            r#"
176            SELECT id, meeting_id, title, description, resolution_type, majority_required,
177                   vote_count_pour, vote_count_contre, vote_count_abstention,
178                   total_voting_power_pour, total_voting_power_contre, total_voting_power_abstention,
179                   status, created_at, voted_at, agenda_item_index, prestataire_de_la_mission, kind
180            FROM resolutions
181            WHERE meeting_id = $1
182            ORDER BY created_at ASC
183            "#,
184        )
185        .bind(meeting_id)
186        .fetch_all(&self.pool)
187        .await
188        .map_err(|e| format!("Database error finding resolutions by meeting: {}", e))?;
189
190        Ok(rows
191            .into_iter()
192            .map(|row| {
193                let resolution_type_str: String = row.get("resolution_type");
194                let resolution_type = match resolution_type_str.as_str() {
195                    "Extraordinary" => ResolutionType::Extraordinary,
196                    _ => ResolutionType::Ordinary,
197                };
198
199                let status_str: String = row.get("status");
200                let status = match status_str.as_str() {
201                    "Adopted" => ResolutionStatus::Adopted,
202                    "Rejected" => ResolutionStatus::Rejected,
203                    _ => ResolutionStatus::Pending,
204                };
205
206                let majority_str: String = row.get("majority_required");
207                let majority_required = Self::parse_majority_type(&majority_str);
208                let kind_str: String = row.get("kind");
209
210                Resolution {
211                    id: row.get("id"),
212                    meeting_id: row.get("meeting_id"),
213                    title: row.get("title"),
214                    description: row.get("description"),
215                    resolution_type,
216                    majority_required,
217                    vote_count_pour: row.get("vote_count_pour"),
218                    vote_count_contre: row.get("vote_count_contre"),
219                    vote_count_abstention: row.get("vote_count_abstention"),
220                    total_voting_power_pour: row.get("total_voting_power_pour"),
221                    total_voting_power_contre: row.get("total_voting_power_contre"),
222                    total_voting_power_abstention: row.get("total_voting_power_abstention"),
223                    status,
224                    created_at: row.get("created_at"),
225                    voted_at: row.get("voted_at"),
226                    agenda_item_index: row
227                        .get::<Option<i32>, _>("agenda_item_index")
228                        .map(|i| i as usize),
229                    prestataire_de_la_mission: row.get("prestataire_de_la_mission"),
230                    kind: Self::parse_kind(&kind_str),
231                }
232            })
233            .collect())
234    }
235
236    async fn find_by_status(&self, status: ResolutionStatus) -> Result<Vec<Resolution>, String> {
237        let status_str = match status {
238            ResolutionStatus::Pending => "Pending",
239            ResolutionStatus::Adopted => "Adopted",
240            ResolutionStatus::Rejected => "Rejected",
241        };
242
243        let rows = sqlx::query(
244            r#"
245            SELECT id, meeting_id, title, description, resolution_type, majority_required,
246                   vote_count_pour, vote_count_contre, vote_count_abstention,
247                   total_voting_power_pour, total_voting_power_contre, total_voting_power_abstention,
248                   status, created_at, voted_at, agenda_item_index, prestataire_de_la_mission, kind
249            FROM resolutions
250            WHERE status = $1
251            ORDER BY created_at DESC
252            "#,
253        )
254        .bind(status_str)
255        .fetch_all(&self.pool)
256        .await
257        .map_err(|e| format!("Database error finding resolutions by status: {}", e))?;
258
259        Ok(rows
260            .into_iter()
261            .map(|row| {
262                let resolution_type_str: String = row.get("resolution_type");
263                let resolution_type = match resolution_type_str.as_str() {
264                    "Extraordinary" => ResolutionType::Extraordinary,
265                    _ => ResolutionType::Ordinary,
266                };
267
268                let majority_str: String = row.get("majority_required");
269                let majority_required = Self::parse_majority_type(&majority_str);
270                let kind_str: String = row.get("kind");
271
272                Resolution {
273                    id: row.get("id"),
274                    meeting_id: row.get("meeting_id"),
275                    title: row.get("title"),
276                    description: row.get("description"),
277                    resolution_type,
278                    majority_required,
279                    vote_count_pour: row.get("vote_count_pour"),
280                    vote_count_contre: row.get("vote_count_contre"),
281                    vote_count_abstention: row.get("vote_count_abstention"),
282                    total_voting_power_pour: row.get("total_voting_power_pour"),
283                    total_voting_power_contre: row.get("total_voting_power_contre"),
284                    total_voting_power_abstention: row.get("total_voting_power_abstention"),
285                    status: status.clone(),
286                    created_at: row.get("created_at"),
287                    voted_at: row.get("voted_at"),
288                    agenda_item_index: row
289                        .get::<Option<i32>, _>("agenda_item_index")
290                        .map(|i| i as usize),
291                    prestataire_de_la_mission: row.get("prestataire_de_la_mission"),
292                    kind: Self::parse_kind(&kind_str),
293                }
294            })
295            .collect())
296    }
297
298    async fn update(&self, resolution: &Resolution) -> Result<Resolution, String> {
299        let resolution_type_str = match resolution.resolution_type {
300            ResolutionType::Ordinary => "Ordinary",
301            ResolutionType::Extraordinary => "Extraordinary",
302        };
303
304        let status_str = match resolution.status {
305            ResolutionStatus::Pending => "Pending",
306            ResolutionStatus::Adopted => "Adopted",
307            ResolutionStatus::Rejected => "Rejected",
308        };
309
310        let majority_str = Self::majority_type_to_string(&resolution.majority_required);
311
312        sqlx::query(
313            r#"
314            UPDATE resolutions
315            SET meeting_id = $2, title = $3, description = $4, resolution_type = $5,
316                majority_required = $6, vote_count_pour = $7, vote_count_contre = $8,
317                vote_count_abstention = $9, total_voting_power_pour = $10,
318                total_voting_power_contre = $11, total_voting_power_abstention = $12,
319                status = $13, voted_at = $14
320            WHERE id = $1
321            "#,
322        )
323        .bind(resolution.id)
324        .bind(resolution.meeting_id)
325        .bind(&resolution.title)
326        .bind(&resolution.description)
327        .bind(resolution_type_str)
328        .bind(majority_str)
329        .bind(resolution.vote_count_pour)
330        .bind(resolution.vote_count_contre)
331        .bind(resolution.vote_count_abstention)
332        .bind(resolution.total_voting_power_pour)
333        .bind(resolution.total_voting_power_contre)
334        .bind(resolution.total_voting_power_abstention)
335        .bind(status_str)
336        .bind(resolution.voted_at)
337        .execute(&self.pool)
338        .await
339        .map_err(|e| format!("Database error updating resolution: {}", e))?;
340
341        Ok(resolution.clone())
342    }
343
344    async fn delete(&self, id: Uuid) -> Result<bool, String> {
345        let result = sqlx::query(
346            r#"
347            DELETE FROM resolutions WHERE id = $1
348            "#,
349        )
350        .bind(id)
351        .execute(&self.pool)
352        .await
353        .map_err(|e| format!("Database error deleting resolution: {}", e))?;
354
355        Ok(result.rows_affected() > 0)
356    }
357
358    async fn update_vote_counts(
359        &self,
360        resolution_id: Uuid,
361        vote_count_pour: i32,
362        vote_count_contre: i32,
363        vote_count_abstention: i32,
364        total_voting_power_pour: Decimal,
365        total_voting_power_contre: Decimal,
366        total_voting_power_abstention: Decimal,
367    ) -> Result<(), String> {
368        sqlx::query(
369            r#"
370            UPDATE resolutions
371            SET vote_count_pour = $2, vote_count_contre = $3, vote_count_abstention = $4,
372                total_voting_power_pour = $5, total_voting_power_contre = $6,
373                total_voting_power_abstention = $7
374            WHERE id = $1
375            "#,
376        )
377        .bind(resolution_id)
378        .bind(vote_count_pour)
379        .bind(vote_count_contre)
380        .bind(vote_count_abstention)
381        .bind(total_voting_power_pour)
382        .bind(total_voting_power_contre)
383        .bind(total_voting_power_abstention)
384        .execute(&self.pool)
385        .await
386        .map_err(|e| format!("Database error updating vote counts: {}", e))?;
387
388        Ok(())
389    }
390
391    async fn close_voting(
392        &self,
393        resolution_id: Uuid,
394        final_status: ResolutionStatus,
395        voix_plafonnees: Option<serde_json::Value>,
396    ) -> Result<(), String> {
397        let status_str = match final_status {
398            ResolutionStatus::Pending => "Pending",
399            ResolutionStatus::Adopted => "Adopted",
400            ResolutionStatus::Rejected => "Rejected",
401        };
402
403        sqlx::query(
404            r#"
405            UPDATE resolutions
406            SET status = $2, voted_at = CURRENT_TIMESTAMP, voix_plafonnees = $3
407            WHERE id = $1
408            "#,
409        )
410        .bind(resolution_id)
411        .bind(status_str)
412        .bind(voix_plafonnees)
413        .execute(&self.pool)
414        .await
415        .map_err(|e| format!("Database error closing voting: {}", e))?;
416
417        Ok(())
418    }
419
420    async fn get_meeting_vote_summary(&self, meeting_id: Uuid) -> Result<Vec<Resolution>, String> {
421        // Same as find_by_meeting_id, but could be enhanced with additional stats
422        self.find_by_meeting_id(meeting_id).await
423    }
424}