Skip to main content

koprogo_api/infrastructure/database/repositories/
vote_repository_impl.rs

1use crate::application::ports::VoteRepository;
2use crate::domain::entities::{Vote, VoteAuthMethod, VoteChoice};
3use crate::infrastructure::database::pool::DbPool;
4use async_trait::async_trait;
5use rust_decimal::Decimal;
6use sqlx::Row;
7use uuid::Uuid;
8
9pub struct PostgresVoteRepository {
10    pool: DbPool,
11}
12
13impl PostgresVoteRepository {
14    pub fn new(pool: DbPool) -> Self {
15        Self { pool }
16    }
17
18    /// Story 4.2 — reconstruction d'un `Vote` à partir d'une ligne, factorisée
19    /// pour ne pas répéter le décodage `auth_method` (et son erreur possible,
20    /// une valeur en base qui ne correspondrait à aucune variante connue) à
21    /// chaque requête `SELECT`.
22    fn vote_from_row(row: sqlx::postgres::PgRow) -> Result<Vote, String> {
23        let vote_choice_str: String = row.get("vote_choice");
24        let vote_choice = match vote_choice_str.as_str() {
25            "Contre" => VoteChoice::Contre,
26            "Abstention" => VoteChoice::Abstention,
27            _ => VoteChoice::Pour,
28        };
29        let auth_method_str: String = row.get("auth_method");
30        let auth_method = VoteAuthMethod::from_db_string(&auth_method_str)?;
31
32        Ok(Vote {
33            id: row.get("id"),
34            resolution_id: row.get("resolution_id"),
35            owner_id: row.get("owner_id"),
36            unit_id: row.get("unit_id"),
37            vote_choice,
38            voting_power: row.get("voting_power"),
39            proxy_owner_id: row.get("proxy_owner_id"),
40            voted_at: row.get("voted_at"),
41            auth_method,
42        })
43    }
44}
45
46#[async_trait]
47impl VoteRepository for PostgresVoteRepository {
48    async fn create(&self, vote: &Vote) -> Result<Vote, String> {
49        let vote_choice_str = match vote.vote_choice {
50            VoteChoice::Pour => "Pour",
51            VoteChoice::Contre => "Contre",
52            VoteChoice::Abstention => "Abstention",
53        };
54
55        sqlx::query(
56            r#"
57            INSERT INTO votes (
58                id, resolution_id, owner_id, unit_id, vote_choice,
59                voting_power, proxy_owner_id, voted_at, auth_method
60            )
61            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
62            "#,
63        )
64        .bind(vote.id)
65        .bind(vote.resolution_id)
66        .bind(vote.owner_id)
67        .bind(vote.unit_id)
68        .bind(vote_choice_str)
69        .bind(vote.voting_power)
70        .bind(vote.proxy_owner_id)
71        .bind(vote.voted_at)
72        .bind(vote.auth_method.to_db_str())
73        .execute(&self.pool)
74        .await
75        .map_err(|e| format!("Database error creating vote: {}", e))?;
76
77        Ok(vote.clone())
78    }
79
80    async fn find_by_id(&self, id: Uuid) -> Result<Option<Vote>, String> {
81        let row = sqlx::query(
82            r#"
83            SELECT id, resolution_id, owner_id, unit_id, vote_choice,
84                   voting_power, proxy_owner_id, voted_at, auth_method
85            FROM votes
86            WHERE id = $1
87            "#,
88        )
89        .bind(id)
90        .fetch_optional(&self.pool)
91        .await
92        .map_err(|e| format!("Database error finding vote: {}", e))?;
93
94        row.map(Self::vote_from_row).transpose()
95    }
96
97    async fn find_by_resolution_id(&self, resolution_id: Uuid) -> Result<Vec<Vote>, String> {
98        let rows = sqlx::query(
99            r#"
100            SELECT id, resolution_id, owner_id, unit_id, vote_choice,
101                   voting_power, proxy_owner_id, voted_at, auth_method
102            FROM votes
103            WHERE resolution_id = $1
104            ORDER BY voted_at ASC
105            "#,
106        )
107        .bind(resolution_id)
108        .fetch_all(&self.pool)
109        .await
110        .map_err(|e| format!("Database error finding votes by resolution: {}", e))?;
111
112        rows.into_iter().map(Self::vote_from_row).collect()
113    }
114
115    async fn find_by_owner_id(&self, owner_id: Uuid) -> Result<Vec<Vote>, String> {
116        let rows = sqlx::query(
117            r#"
118            SELECT id, resolution_id, owner_id, unit_id, vote_choice,
119                   voting_power, proxy_owner_id, voted_at, auth_method
120            FROM votes
121            WHERE owner_id = $1
122            ORDER BY voted_at DESC
123            "#,
124        )
125        .bind(owner_id)
126        .fetch_all(&self.pool)
127        .await
128        .map_err(|e| format!("Database error finding votes by owner: {}", e))?;
129
130        rows.into_iter().map(Self::vote_from_row).collect()
131    }
132
133    async fn find_by_resolution_and_unit(
134        &self,
135        resolution_id: Uuid,
136        unit_id: Uuid,
137    ) -> Result<Option<Vote>, String> {
138        let row = sqlx::query(
139            r#"
140            SELECT id, resolution_id, owner_id, unit_id, vote_choice,
141                   voting_power, proxy_owner_id, voted_at, auth_method
142            FROM votes
143            WHERE resolution_id = $1 AND unit_id = $2
144            "#,
145        )
146        .bind(resolution_id)
147        .bind(unit_id)
148        .fetch_optional(&self.pool)
149        .await
150        .map_err(|e| format!("Database error finding vote by resolution and unit: {}", e))?;
151
152        row.map(Self::vote_from_row).transpose()
153    }
154
155    async fn has_voted(&self, resolution_id: Uuid, unit_id: Uuid) -> Result<bool, String> {
156        let row = sqlx::query(
157            r#"
158            SELECT EXISTS(SELECT 1 FROM votes WHERE resolution_id = $1 AND unit_id = $2) AS has_voted
159            "#,
160        )
161        .bind(resolution_id)
162        .bind(unit_id)
163        .fetch_one(&self.pool)
164        .await
165        .map_err(|e| format!("Database error checking if voted: {}", e))?;
166
167        Ok(row.get("has_voted"))
168    }
169
170    async fn update(&self, vote: &Vote) -> Result<Vote, String> {
171        let vote_choice_str = match vote.vote_choice {
172            VoteChoice::Pour => "Pour",
173            VoteChoice::Contre => "Contre",
174            VoteChoice::Abstention => "Abstention",
175        };
176
177        sqlx::query(
178            r#"
179            UPDATE votes
180            SET resolution_id = $2, owner_id = $3, unit_id = $4, vote_choice = $5,
181                voting_power = $6, proxy_owner_id = $7, voted_at = $8, auth_method = $9
182            WHERE id = $1
183            "#,
184        )
185        .bind(vote.id)
186        .bind(vote.resolution_id)
187        .bind(vote.owner_id)
188        .bind(vote.unit_id)
189        .bind(vote_choice_str)
190        .bind(vote.voting_power)
191        .bind(vote.proxy_owner_id)
192        .bind(vote.voted_at)
193        .bind(vote.auth_method.to_db_str())
194        .execute(&self.pool)
195        .await
196        .map_err(|e| format!("Database error updating vote: {}", e))?;
197
198        Ok(vote.clone())
199    }
200
201    async fn delete(&self, id: Uuid) -> Result<bool, String> {
202        let result = sqlx::query(
203            r#"
204            DELETE FROM votes WHERE id = $1
205            "#,
206        )
207        .bind(id)
208        .execute(&self.pool)
209        .await
210        .map_err(|e| format!("Database error deleting vote: {}", e))?;
211
212        Ok(result.rows_affected() > 0)
213    }
214
215    async fn count_by_resolution_and_choice(
216        &self,
217        resolution_id: Uuid,
218    ) -> Result<(i32, i32, i32), String> {
219        let row = sqlx::query(
220            r#"
221            SELECT
222                COUNT(*) FILTER (WHERE vote_choice = 'Pour') AS pour_count,
223                COUNT(*) FILTER (WHERE vote_choice = 'Contre') AS contre_count,
224                COUNT(*) FILTER (WHERE vote_choice = 'Abstention') AS abstention_count
225            FROM votes
226            WHERE resolution_id = $1
227            "#,
228        )
229        .bind(resolution_id)
230        .fetch_one(&self.pool)
231        .await
232        .map_err(|e| format!("Database error counting votes: {}", e))?;
233
234        let pour_count: Option<i64> = row.get("pour_count");
235        let contre_count: Option<i64> = row.get("contre_count");
236        let abstention_count: Option<i64> = row.get("abstention_count");
237
238        Ok((
239            pour_count.unwrap_or(0) as i32,
240            contre_count.unwrap_or(0) as i32,
241            abstention_count.unwrap_or(0) as i32,
242        ))
243    }
244
245    async fn sum_voting_power_by_resolution(
246        &self,
247        resolution_id: Uuid,
248    ) -> Result<(Decimal, Decimal, Decimal), String> {
249        let row = sqlx::query(
250            r#"
251            SELECT
252                COALESCE(SUM(voting_power) FILTER (WHERE vote_choice = 'Pour'), 0)::NUMERIC(10,4) AS pour_power,
253                COALESCE(SUM(voting_power) FILTER (WHERE vote_choice = 'Contre'), 0)::NUMERIC(10,4) AS contre_power,
254                COALESCE(SUM(voting_power) FILTER (WHERE vote_choice = 'Abstention'), 0)::NUMERIC(10,4) AS abstention_power
255            FROM votes
256            WHERE resolution_id = $1
257            "#,
258        )
259        .bind(resolution_id)
260        .fetch_one(&self.pool)
261        .await
262        .map_err(|e| format!("Database error summing voting power: {}", e))?;
263
264        // NUMERIC -> Decimal direct (ADR-0008, exact — no IEEE754 round-trip)
265        Ok((
266            row.get::<Decimal, _>("pour_power"),
267            row.get::<Decimal, _>("contre_power"),
268            row.get::<Decimal, _>("abstention_power"),
269        ))
270    }
271
272    /// Count proxy votes held by a mandataire on a given resolution (Art. 3.87 §7 CC)
273    async fn count_proxy_votes_for_mandataire(
274        &self,
275        resolution_id: Uuid,
276        proxy_owner_id: Uuid,
277    ) -> Result<(i64, Decimal), String> {
278        let row = sqlx::query(
279            r#"
280            SELECT
281                COUNT(*)::BIGINT AS proxy_count,
282                COALESCE(SUM(voting_power), 0)::NUMERIC(10,4) AS total_proxy_power
283            FROM votes
284            WHERE resolution_id = $1
285              AND proxy_owner_id = $2
286            "#,
287        )
288        .bind(resolution_id)
289        .bind(proxy_owner_id)
290        .fetch_one(&self.pool)
291        .await
292        .map_err(|e| format!("Database error counting proxies: {}", e))?;
293
294        Ok((
295            row.get::<i64, _>("proxy_count"),
296            row.get::<Decimal, _>("total_proxy_power"),
297        ))
298    }
299}