Skip to main content

koprogo_api/infrastructure/database/repositories/
user_repository_impl.rs

1use crate::application::ports::UserRepository;
2use crate::domain::entities::{User, UserRole};
3use crate::infrastructure::pool::DbPool;
4use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6use sqlx::Row;
7use uuid::Uuid;
8
9pub struct PostgresUserRepository {
10    pool: DbPool,
11}
12
13impl PostgresUserRepository {
14    pub fn new(pool: DbPool) -> Self {
15        Self { pool }
16    }
17}
18
19const USER_COLUMNS: &str = "id, email, password_hash, first_name, last_name, role, organization_id, is_active, processing_restricted, processing_restricted_at, marketing_opt_out, marketing_opt_out_at, created_at, updated_at";
20
21fn row_to_user(row: &sqlx::postgres::PgRow) -> Result<User, String> {
22    let role_str: String = row.get("role");
23    let role = role_str
24        .parse::<UserRole>()
25        .map_err(|e| format!("Invalid role: {}", e))?;
26
27    Ok(User {
28        id: row.get("id"),
29        email: row.get("email"),
30        password_hash: row.get("password_hash"),
31        first_name: row.get("first_name"),
32        last_name: row.get("last_name"),
33        role,
34        organization_id: row.get("organization_id"),
35        is_active: row.get("is_active"),
36        processing_restricted: row.get("processing_restricted"),
37        processing_restricted_at: row.get::<Option<DateTime<Utc>>, _>("processing_restricted_at"),
38        marketing_opt_out: row.get("marketing_opt_out"),
39        marketing_opt_out_at: row.get::<Option<DateTime<Utc>>, _>("marketing_opt_out_at"),
40        created_at: row.get("created_at"),
41        updated_at: row.get("updated_at"),
42    })
43}
44
45#[async_trait]
46impl UserRepository for PostgresUserRepository {
47    async fn create(&self, user: &User) -> Result<User, String> {
48        sqlx::query!(
49            r#"
50            INSERT INTO users (id, email, password_hash, first_name, last_name, role, organization_id, is_active, created_at, updated_at)
51            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
52            "#,
53            user.id,
54            user.email,
55            user.password_hash,
56            user.first_name,
57            user.last_name,
58            user.role.to_string(),
59            user.organization_id,
60            user.is_active,
61            user.created_at,
62            user.updated_at,
63        )
64        .execute(&self.pool)
65        .await
66        .map_err(|e| {
67            if let sqlx::Error::Database(ref db_err) = e {
68                if db_err.is_unique_violation() {
69                    return "email_exists".to_string();
70                }
71            }
72            format!("Failed to create user: {}", e)
73        })?;
74
75        Ok(user.clone())
76    }
77
78    async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, String> {
79        let sql = format!("SELECT {} FROM users WHERE id = $1", USER_COLUMNS);
80        let result = sqlx::query(&sql)
81            .bind(id)
82            .fetch_optional(&self.pool)
83            .await
84            .map_err(|e| format!("Failed to find user: {}", e))?;
85
86        match result {
87            Some(row) => Ok(Some(row_to_user(&row)?)),
88            None => Ok(None),
89        }
90    }
91
92    async fn find_by_email(&self, email: &str) -> Result<Option<User>, String> {
93        let sql = format!("SELECT {} FROM users WHERE email = $1", USER_COLUMNS);
94        let result = sqlx::query(&sql)
95            .bind(email)
96            .fetch_optional(&self.pool)
97            .await
98            .map_err(|e| format!("Failed to find user by email: {}", e))?;
99
100        match result {
101            Some(row) => Ok(Some(row_to_user(&row)?)),
102            None => Ok(None),
103        }
104    }
105
106    async fn find_all(&self) -> Result<Vec<User>, String> {
107        let sql = format!(
108            "SELECT {} FROM users ORDER BY created_at DESC",
109            USER_COLUMNS
110        );
111        let rows = sqlx::query(&sql)
112            .fetch_all(&self.pool)
113            .await
114            .map_err(|e| format!("Failed to fetch users: {}", e))?;
115
116        let mut users = Vec::new();
117        for row in &rows {
118            if let Ok(user) = row_to_user(row) {
119                users.push(user);
120            }
121        }
122
123        Ok(users)
124    }
125
126    async fn find_page(
127        &self,
128        recherche: Option<String>,
129        role: Option<String>,
130        limit: i64,
131        offset: i64,
132    ) -> Result<Vec<User>, String> {
133        // `ILIKE` sur le courriel, le prénom ET le nom : un administrateur
134        // cherche l'un des trois sans savoir lequel il a sous les yeux. Même
135        // choix que pour les organisations (#943), et pour la même raison —
136        // s'en tenir au courriel rétrécirait en silence ce qu'il peut
137        // trouver.
138        //
139        // Le motif est LIÉ, jamais concaténé : une recherche est une donnée
140        // d'utilisateur.
141        let motif = recherche
142            .map(|r| r.trim().to_string())
143            .filter(|r| !r.is_empty())
144            .map(|r| format!("%{r}%"));
145
146        // Le rôle est un SECOND filtre, porté par le serveur lui aussi.
147        //
148        // Le laisser côté client aurait montré « les syndics parmi les
149        // cinquante premiers » en les présentant comme « les syndics » : un
150        // rétrécissement silencieux, et exactement ce que la pagination est
151        // censée cesser de faire.
152        let role = role
153            .map(|r| r.trim().to_string())
154            .filter(|r| !r.is_empty() && r != "all");
155
156        let sql = format!(
157            "SELECT {USER_COLUMNS} FROM users \
158             WHERE ($1::text IS NULL \
159                    OR email ILIKE $1 OR first_name ILIKE $1 OR last_name ILIKE $1) \
160               AND ($2::text IS NULL OR role = $2) \
161             ORDER BY created_at DESC \
162             LIMIT $3 OFFSET $4"
163        );
164        let rows = sqlx::query(&sql)
165            .bind(motif.as_deref())
166            .bind(role.as_deref())
167            .bind(limit)
168            .bind(offset)
169            .fetch_all(&self.pool)
170            .await
171            .map_err(|e| format!("Failed to fetch users page: {e}"))?;
172
173        let mut users = Vec::new();
174        for row in &rows {
175            if let Ok(user) = row_to_user(row) {
176                users.push(user);
177            }
178        }
179
180        Ok(users)
181    }
182
183    async fn count_matching(
184        &self,
185        recherche: Option<String>,
186        role: Option<String>,
187    ) -> Result<i64, String> {
188        let motif = recherche
189            .map(|r| r.trim().to_string())
190            .filter(|r| !r.is_empty())
191            .map(|r| format!("%{r}%"));
192        let role = role
193            .map(|r| r.trim().to_string())
194            .filter(|r| !r.is_empty() && r != "all");
195
196        // Le total DOIT porter les deux mêmes filtres que la page, sans quoi
197        // l'écran annonce « 50 sur 4 120 » alors qu'il en existe douze.
198        let total: i64 = sqlx::query_scalar(
199            "SELECT COUNT(*) FROM users \
200             WHERE ($1::text IS NULL \
201                    OR email ILIKE $1 OR first_name ILIKE $1 OR last_name ILIKE $1) \
202               AND ($2::text IS NULL OR role = $2)",
203        )
204        .bind(motif.as_deref())
205        .bind(role.as_deref())
206        .fetch_one(&self.pool)
207        .await
208        .map_err(|e| format!("Failed to count users: {e}"))?;
209
210        Ok(total)
211    }
212
213    async fn find_by_organization(&self, org_id: Uuid) -> Result<Vec<User>, String> {
214        let sql = format!(
215            "SELECT {} FROM users WHERE organization_id = $1 ORDER BY created_at DESC",
216            USER_COLUMNS
217        );
218        let rows = sqlx::query(&sql)
219            .bind(org_id)
220            .fetch_all(&self.pool)
221            .await
222            .map_err(|e| format!("Failed to fetch users by organization: {}", e))?;
223
224        let mut users = Vec::new();
225        for row in &rows {
226            if let Ok(user) = row_to_user(row) {
227                users.push(user);
228            }
229        }
230
231        Ok(users)
232    }
233
234    async fn update(&self, user: &User) -> Result<User, String> {
235        sqlx::query(
236            r#"
237            UPDATE users
238            SET email = $2, first_name = $3, last_name = $4, role = $5,
239                organization_id = $6, is_active = $7,
240                processing_restricted = $8, processing_restricted_at = $9,
241                marketing_opt_out = $10, marketing_opt_out_at = $11,
242                updated_at = $12
243            WHERE id = $1
244            "#,
245        )
246        .bind(user.id)
247        .bind(&user.email)
248        .bind(&user.first_name)
249        .bind(&user.last_name)
250        .bind(user.role.to_string())
251        .bind(user.organization_id)
252        .bind(user.is_active)
253        .bind(user.processing_restricted)
254        .bind(user.processing_restricted_at)
255        .bind(user.marketing_opt_out)
256        .bind(user.marketing_opt_out_at)
257        .bind(user.updated_at)
258        .execute(&self.pool)
259        .await
260        .map_err(|e| {
261            if let sqlx::Error::Database(ref db_err) = e {
262                if db_err.is_unique_violation() {
263                    return "email_exists".to_string();
264                }
265            }
266            format!("Failed to update user: {}", e)
267        })?;
268
269        Ok(user.clone())
270    }
271
272    async fn update_password(&self, id: Uuid, password_hash: &str) -> Result<bool, String> {
273        let result =
274            sqlx::query("UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2")
275                .bind(password_hash)
276                .bind(id)
277                .execute(&self.pool)
278                .await
279                .map_err(|e| format!("Failed to update password: {}", e))?;
280
281        Ok(result.rows_affected() > 0)
282    }
283
284    async fn activate(&self, id: Uuid) -> Result<Option<User>, String> {
285        let result = sqlx::query(
286            "UPDATE users SET is_active = true, updated_at = NOW() WHERE id = $1 RETURNING id",
287        )
288        .bind(id)
289        .fetch_optional(&self.pool)
290        .await
291        .map_err(|e| format!("Failed to activate user: {}", e))?;
292
293        if result.is_none() {
294            return Ok(None);
295        }
296        self.find_by_id(id).await
297    }
298
299    async fn deactivate(&self, id: Uuid) -> Result<Option<User>, String> {
300        let result = sqlx::query(
301            "UPDATE users SET is_active = false, updated_at = NOW() WHERE id = $1 RETURNING id",
302        )
303        .bind(id)
304        .fetch_optional(&self.pool)
305        .await
306        .map_err(|e| format!("Failed to deactivate user: {}", e))?;
307
308        if result.is_none() {
309            return Ok(None);
310        }
311        self.find_by_id(id).await
312    }
313
314    async fn delete(&self, id: Uuid) -> Result<bool, String> {
315        let result = sqlx::query!(
316            r#"
317            DELETE FROM users
318            WHERE id = $1
319            "#,
320            id
321        )
322        .execute(&self.pool)
323        .await
324        .map_err(|e| format!("Failed to delete user: {}", e))?;
325
326        Ok(result.rows_affected() > 0)
327    }
328
329    async fn count_by_organization(&self, org_id: Uuid) -> Result<i64, String> {
330        let result = sqlx::query!(
331            r#"
332            SELECT COUNT(*) as count
333            FROM users
334            WHERE organization_id = $1
335            "#,
336            org_id
337        )
338        .fetch_one(&self.pool)
339        .await
340        .map_err(|e| format!("Failed to count users: {}", e))?;
341
342        Ok(result.count.unwrap_or(0))
343    }
344}