koprogo_api/infrastructure/database/repositories/
user_repository_impl.rs1use 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 let motif = recherche
142 .map(|r| r.trim().to_string())
143 .filter(|r| !r.is_empty())
144 .map(|r| format!("%{r}%"));
145
146 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 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}