Skip to main content

koprogo_api/infrastructure/database/repositories/
user_role_repository_impl.rs

1use crate::application::ports::UserRoleRepository;
2use crate::domain::entities::{UserRole, UserRoleAssignment};
3use crate::infrastructure::pool::DbPool;
4use async_trait::async_trait;
5use sqlx::Row;
6use std::collections::HashMap;
7use uuid::Uuid;
8
9pub struct PostgresUserRoleRepository {
10    pool: DbPool,
11}
12
13impl PostgresUserRoleRepository {
14    pub fn new(pool: DbPool) -> Self {
15        Self { pool }
16    }
17
18    fn map_row(row: sqlx::postgres::PgRow) -> Result<UserRoleAssignment, String> {
19        let role: UserRole = row
20            .try_get::<String, _>("role")
21            .map_err(|e| format!("Failed to read role: {}", e))?
22            .parse()
23            .map_err(|e| format!("Invalid role: {}", e))?;
24
25        // Story 3.5 — `valid_until` / `delegated_from_user_id`. `.ok()` kept
26        // defensive (all queries now project both columns) rather than
27        // `.map_err(...)?`, so a future query that forgets them degrades to
28        // `None` (permanent) instead of hard-failing.
29        let valid_until = row.try_get("valid_until").ok();
30        let delegated_from_user_id = row.try_get("delegated_from_user_id").ok();
31
32        Ok(UserRoleAssignment {
33            id: row
34                .try_get("id")
35                .map_err(|e| format!("Failed to read id: {}", e))?,
36            user_id: row
37                .try_get("user_id")
38                .map_err(|e| format!("Failed to read user_id: {}", e))?,
39            role,
40            organization_id: row
41                .try_get("organization_id")
42                .map_err(|e| format!("Failed to read organization_id: {}", e))?,
43            is_primary: row
44                .try_get("is_primary")
45                .map_err(|e| format!("Failed to read is_primary: {}", e))?,
46            valid_until,
47            delegated_from_user_id,
48            created_at: row
49                .try_get("created_at")
50                .map_err(|e| format!("Failed to read created_at: {}", e))?,
51            updated_at: row
52                .try_get("updated_at")
53                .map_err(|e| format!("Failed to read updated_at: {}", e))?,
54        })
55    }
56}
57
58#[async_trait]
59impl UserRoleRepository for PostgresUserRoleRepository {
60    async fn create(&self, assignment: &UserRoleAssignment) -> Result<UserRoleAssignment, String> {
61        let row = sqlx::query(
62            r#"
63            INSERT INTO user_roles (id, user_id, role, organization_id, is_primary, valid_until, delegated_from_user_id, created_at, updated_at)
64            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
65            RETURNING id, user_id, role, organization_id, is_primary, valid_until, delegated_from_user_id, created_at, updated_at
66            "#,
67        )
68        .bind(assignment.id)
69        .bind(assignment.user_id)
70        .bind(assignment.role.to_string())
71        .bind(assignment.organization_id)
72        .bind(assignment.is_primary)
73        .bind(assignment.valid_until)
74        .bind(assignment.delegated_from_user_id)
75        .bind(assignment.created_at)
76        .bind(assignment.updated_at)
77        .fetch_one(&self.pool)
78        .await
79        .map_err(|e| format!("Failed to create user role: {}", e))?;
80
81        Self::map_row(row)
82    }
83
84    async fn list_for_user(&self, user_id: Uuid) -> Result<Vec<UserRoleAssignment>, String> {
85        let rows = sqlx::query(
86            r#"
87            SELECT id, user_id, role, organization_id, is_primary, valid_until, delegated_from_user_id, created_at, updated_at
88            FROM user_roles
89            WHERE user_id = $1
90            ORDER BY is_primary DESC, created_at ASC
91            "#,
92        )
93        .bind(user_id)
94        .fetch_all(&self.pool)
95        .await
96        .map_err(|e| format!("Failed to list user roles: {}", e))?;
97
98        rows.into_iter()
99            .map(Self::map_row)
100            .collect::<Result<Vec<_>, _>>()
101    }
102
103    async fn list_for_users(
104        &self,
105        user_ids: &[Uuid],
106    ) -> Result<HashMap<Uuid, Vec<UserRoleAssignment>>, String> {
107        if user_ids.is_empty() {
108            return Ok(HashMap::new());
109        }
110
111        let rows = sqlx::query(
112            r#"
113            SELECT id, user_id, role, organization_id, is_primary, valid_until, delegated_from_user_id, created_at, updated_at
114            FROM user_roles
115            WHERE user_id = ANY($1)
116            ORDER BY user_id, is_primary DESC, created_at ASC
117            "#,
118        )
119        .bind(user_ids)
120        .fetch_all(&self.pool)
121        .await
122        .map_err(|e| format!("Failed to list roles for users: {}", e))?;
123
124        let mut map: HashMap<Uuid, Vec<UserRoleAssignment>> = HashMap::new();
125        for row in rows {
126            let assignment = Self::map_row(row)?;
127            map.entry(assignment.user_id).or_default().push(assignment);
128        }
129        Ok(map)
130    }
131
132    async fn replace_all(
133        &self,
134        user_id: Uuid,
135        assignments: &[UserRoleAssignment],
136    ) -> Result<(), String> {
137        let mut tx = self
138            .pool
139            .begin()
140            .await
141            .map_err(|e| format!("Failed to begin transaction: {}", e))?;
142
143        sqlx::query("DELETE FROM user_roles WHERE user_id = $1")
144            .bind(user_id)
145            .execute(&mut *tx)
146            .await
147            .map_err(|e| format!("Failed to delete old roles: {}", e))?;
148
149        for a in assignments {
150            sqlx::query(
151                r#"
152                INSERT INTO user_roles (id, user_id, role, organization_id, is_primary, valid_until, delegated_from_user_id, created_at, updated_at)
153                VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
154                "#,
155            )
156            .bind(a.id)
157            .bind(user_id)
158            .bind(a.role.to_string())
159            .bind(a.organization_id)
160            .bind(a.is_primary)
161            .bind(a.valid_until)
162            .bind(a.delegated_from_user_id)
163            .execute(&mut *tx)
164            .await
165            .map_err(|e| format!("Failed to insert role: {}", e))?;
166        }
167
168        tx.commit()
169            .await
170            .map_err(|e| format!("Failed to commit replace_all: {}", e))?;
171        Ok(())
172    }
173
174    async fn find_by_id(&self, id: Uuid) -> Result<Option<UserRoleAssignment>, String> {
175        let row = sqlx::query(
176            r#"
177            SELECT id, user_id, role, organization_id, is_primary, valid_until, delegated_from_user_id, created_at, updated_at
178            FROM user_roles
179            WHERE id = $1
180            "#,
181        )
182        .bind(id)
183        .fetch_optional(&self.pool)
184        .await
185        .map_err(|e| format!("Failed to find user role: {}", e))?;
186
187        match row {
188            Some(row) => Ok(Some(Self::map_row(row)?)),
189            None => Ok(None),
190        }
191    }
192
193    async fn set_primary_role(
194        &self,
195        user_id: Uuid,
196        role_id: Uuid,
197    ) -> Result<UserRoleAssignment, String> {
198        let mut tx = self
199            .pool
200            .begin()
201            .await
202            .map_err(|e| format!("Failed to begin transaction: {}", e))?;
203
204        sqlx::query(
205            r#"
206            UPDATE user_roles
207            SET is_primary = false, updated_at = NOW()
208            WHERE user_id = $1
209            "#,
210        )
211        .bind(user_id)
212        .execute(&mut *tx)
213        .await
214        .map_err(|e| format!("Failed to clear primary roles: {}", e))?;
215
216        let row = sqlx::query(
217            r#"
218            UPDATE user_roles
219            SET is_primary = true, updated_at = NOW()
220            WHERE id = $1 AND user_id = $2
221            RETURNING id, user_id, role, organization_id, is_primary, valid_until, delegated_from_user_id, created_at, updated_at
222            "#,
223        )
224        .bind(role_id)
225        .bind(user_id)
226        .fetch_one(&mut *tx)
227        .await
228        .map_err(|e| format!("Failed to set primary role: {}", e))?;
229
230        tx.commit()
231            .await
232            .map_err(|e| format!("Failed to commit transaction: {}", e))?;
233
234        Self::map_row(row)
235    }
236
237    async fn delete_by_id(&self, id: Uuid) -> Result<bool, String> {
238        // Story B0bis — gap fill for Story 3.1.
239        // The CRUD endpoint `DELETE /users/{user_id}/role-assignments/{id}`
240        // revokes a single row without rewriting the whole set.
241        let result = sqlx::query("DELETE FROM user_roles WHERE id = $1")
242            .bind(id)
243            .execute(&self.pool)
244            .await
245            .map_err(|e| format!("Failed to delete user role: {}", e))?;
246        Ok(result.rows_affected() > 0)
247    }
248}