Skip to main content

koprogo_api/infrastructure/database/repositories/
portfolio_repository_impl.rs

1//! `PostgresPortfolioRepository` — adapter sqlx pour le port `PortfolioRepository`.
2//!
3//! Story 2.1. Pattern aligné sur `PostgresAcpRepository` (Story 1.1) :
4//! `sqlx::query` runtime (pas `query_as!` macro) — évite la dépendance
5//! compile-time à la DB en CI (cf. mémoire `use-docker-compose-for-tooling`).
6
7use crate::application::error::AppError;
8use crate::application::ports::{PortfolioBuildingEntry, PortfolioRepository};
9use crate::domain::entities::{Portfolio, PortfolioBuilding, PortfolioShare};
10use crate::infrastructure::database::pool::DbPool;
11use async_trait::async_trait;
12use sqlx::Row;
13use uuid::Uuid;
14
15pub struct PostgresPortfolioRepository {
16    pool: DbPool,
17}
18
19impl PostgresPortfolioRepository {
20    pub fn new(pool: DbPool) -> Self {
21        Self { pool }
22    }
23
24    fn row_to_portfolio(row: &sqlx::postgres::PgRow) -> Portfolio {
25        Portfolio {
26            id: row.get("id"),
27            owner_user_id: row.get("owner_user_id"),
28            name: row.get("name"),
29            description: row.get("description"),
30            created_at: row.get("created_at"),
31            updated_at: row.get("updated_at"),
32        }
33    }
34
35    fn row_to_share(row: &sqlx::postgres::PgRow) -> PortfolioShare {
36        PortfolioShare {
37            portfolio_id: row.get("portfolio_id"),
38            shared_with_user_id: row.get("shared_with_user_id"),
39            can_edit: row.get("can_edit"),
40            shared_at: row.get("shared_at"),
41        }
42    }
43}
44
45#[async_trait]
46impl PortfolioRepository for PostgresPortfolioRepository {
47    async fn create(&self, portfolio: &Portfolio) -> Result<Portfolio, AppError> {
48        sqlx::query(
49            r#"
50            INSERT INTO portfolios (
51                id, owner_user_id, name, description, created_at, updated_at
52            )
53            VALUES ($1, $2, $3, $4, $5, $6)
54            "#,
55        )
56        .bind(portfolio.id)
57        .bind(portfolio.owner_user_id)
58        .bind(&portfolio.name)
59        .bind(&portfolio.description)
60        .bind(portfolio.created_at)
61        .bind(portfolio.updated_at)
62        .execute(&self.pool)
63        .await
64        .map_err(|e| {
65            if let Some(db_err) = e.as_database_error() {
66                if db_err.is_unique_violation() {
67                    return AppError::Conflict(format!("Portfolio unique violation: {}", db_err));
68                }
69                if db_err.is_foreign_key_violation() {
70                    return AppError::Validation(format!("Portfolio FK violation: {}", db_err));
71                }
72            }
73            AppError::Database(e.to_string())
74        })?;
75        Ok(portfolio.clone())
76    }
77
78    async fn find_by_id(&self, id: Uuid) -> Result<Option<Portfolio>, AppError> {
79        let row = sqlx::query(
80            r#"
81            SELECT id, owner_user_id, name, description, created_at, updated_at
82            FROM portfolios
83            WHERE id = $1
84            "#,
85        )
86        .bind(id)
87        .fetch_optional(&self.pool)
88        .await
89        .map_err(|e| AppError::Database(e.to_string()))?;
90        Ok(row.as_ref().map(Self::row_to_portfolio))
91    }
92
93    async fn list_for_user(&self, user_id: Uuid) -> Result<Vec<Portfolio>, AppError> {
94        // UNION : portfolios owned + portfolios shared. DISTINCT pour éviter
95        // doublons si un user est shared sur son propre portfolio (cas
96        // théorique).
97        let rows = sqlx::query(
98            r#"
99            SELECT DISTINCT p.id, p.owner_user_id, p.name, p.description, p.created_at, p.updated_at
100            FROM portfolios p
101            LEFT JOIN portfolio_shares s ON s.portfolio_id = p.id
102            WHERE p.owner_user_id = $1 OR s.shared_with_user_id = $1
103            ORDER BY p.created_at DESC
104            "#,
105        )
106        .bind(user_id)
107        .fetch_all(&self.pool)
108        .await
109        .map_err(|e| AppError::Database(e.to_string()))?;
110        Ok(rows.iter().map(Self::row_to_portfolio).collect())
111    }
112
113    async fn update(&self, portfolio: &Portfolio) -> Result<Portfolio, AppError> {
114        let result = sqlx::query(
115            r#"
116            UPDATE portfolios
117            SET name = $2,
118                description = $3,
119                updated_at = $4
120            WHERE id = $1
121            "#,
122        )
123        .bind(portfolio.id)
124        .bind(&portfolio.name)
125        .bind(&portfolio.description)
126        .bind(portfolio.updated_at)
127        .execute(&self.pool)
128        .await
129        .map_err(|e| AppError::Database(e.to_string()))?;
130        if result.rows_affected() == 0 {
131            return Err(AppError::NotFound(format!(
132                "Portfolio {} not found",
133                portfolio.id
134            )));
135        }
136        Ok(portfolio.clone())
137    }
138
139    async fn delete(&self, id: Uuid) -> Result<(), AppError> {
140        let result = sqlx::query("DELETE FROM portfolios WHERE id = $1")
141            .bind(id)
142            .execute(&self.pool)
143            .await
144            .map_err(|e| AppError::Database(e.to_string()))?;
145        if result.rows_affected() == 0 {
146            return Err(AppError::NotFound(format!("Portfolio {} not found", id)));
147        }
148        Ok(())
149    }
150
151    async fn add_building(
152        &self,
153        portfolio_id: Uuid,
154        building_id: Uuid,
155        is_favorite: bool,
156    ) -> Result<PortfolioBuilding, AppError> {
157        let row = sqlx::query(
158            r#"
159            INSERT INTO portfolio_buildings (portfolio_id, building_id, is_favorite, added_at)
160            VALUES ($1, $2, $3, NOW())
161            ON CONFLICT (portfolio_id, building_id)
162            DO UPDATE SET is_favorite = EXCLUDED.is_favorite
163            RETURNING portfolio_id, building_id, is_favorite, added_at
164            "#,
165        )
166        .bind(portfolio_id)
167        .bind(building_id)
168        .bind(is_favorite)
169        .fetch_one(&self.pool)
170        .await
171        .map_err(|e| {
172            if let Some(db_err) = e.as_database_error() {
173                if db_err.is_foreign_key_violation() {
174                    return AppError::NotFound(format!(
175                        "Portfolio or Building not found: {}",
176                        db_err
177                    ));
178                }
179            }
180            AppError::Database(e.to_string())
181        })?;
182        Ok(PortfolioBuilding {
183            portfolio_id: row.get("portfolio_id"),
184            building_id: row.get("building_id"),
185            is_favorite: row.get("is_favorite"),
186            added_at: row.get("added_at"),
187        })
188    }
189
190    async fn remove_building(&self, portfolio_id: Uuid, building_id: Uuid) -> Result<(), AppError> {
191        let result = sqlx::query(
192            r#"
193            DELETE FROM portfolio_buildings
194            WHERE portfolio_id = $1 AND building_id = $2
195            "#,
196        )
197        .bind(portfolio_id)
198        .bind(building_id)
199        .execute(&self.pool)
200        .await
201        .map_err(|e| AppError::Database(e.to_string()))?;
202        if result.rows_affected() == 0 {
203            return Err(AppError::NotFound(format!(
204                "Building {} not in portfolio {}",
205                building_id, portfolio_id
206            )));
207        }
208        Ok(())
209    }
210
211    async fn list_buildings(
212        &self,
213        portfolio_id: Uuid,
214    ) -> Result<Vec<PortfolioBuildingEntry>, AppError> {
215        // Tri Story 2.1 AC @happy : favoris d'abord puis added_at DESC.
216        let rows = sqlx::query(
217            r#"
218            SELECT portfolio_id, building_id, is_favorite, added_at
219            FROM portfolio_buildings
220            WHERE portfolio_id = $1
221            ORDER BY is_favorite DESC, added_at DESC
222            "#,
223        )
224        .bind(portfolio_id)
225        .fetch_all(&self.pool)
226        .await
227        .map_err(|e| AppError::Database(e.to_string()))?;
228        Ok(rows
229            .iter()
230            .map(|r| PortfolioBuildingEntry {
231                portfolio_id: r.get("portfolio_id"),
232                building_id: r.get("building_id"),
233                is_favorite: r.get("is_favorite"),
234            })
235            .collect())
236    }
237
238    async fn share_with(
239        &self,
240        portfolio_id: Uuid,
241        shared_with_user_id: Uuid,
242        can_edit: bool,
243    ) -> Result<PortfolioShare, AppError> {
244        let row = sqlx::query(
245            r#"
246            INSERT INTO portfolio_shares
247                (portfolio_id, shared_with_user_id, can_edit, shared_at)
248            VALUES ($1, $2, $3, NOW())
249            ON CONFLICT (portfolio_id, shared_with_user_id)
250            DO UPDATE SET can_edit = EXCLUDED.can_edit
251            RETURNING portfolio_id, shared_with_user_id, can_edit, shared_at
252            "#,
253        )
254        .bind(portfolio_id)
255        .bind(shared_with_user_id)
256        .bind(can_edit)
257        .fetch_one(&self.pool)
258        .await
259        .map_err(|e| {
260            if let Some(db_err) = e.as_database_error() {
261                if db_err.is_foreign_key_violation() {
262                    return AppError::NotFound(format!("Portfolio or User not found: {}", db_err));
263                }
264            }
265            AppError::Database(e.to_string())
266        })?;
267        Ok(Self::row_to_share(&row))
268    }
269
270    async fn unshare(&self, portfolio_id: Uuid, shared_with_user_id: Uuid) -> Result<(), AppError> {
271        let result = sqlx::query(
272            r#"
273            DELETE FROM portfolio_shares
274            WHERE portfolio_id = $1 AND shared_with_user_id = $2
275            "#,
276        )
277        .bind(portfolio_id)
278        .bind(shared_with_user_id)
279        .execute(&self.pool)
280        .await
281        .map_err(|e| AppError::Database(e.to_string()))?;
282        if result.rows_affected() == 0 {
283            return Err(AppError::NotFound(format!(
284                "Share not found for user {} on portfolio {}",
285                shared_with_user_id, portfolio_id
286            )));
287        }
288        Ok(())
289    }
290
291    async fn list_shares(&self, portfolio_id: Uuid) -> Result<Vec<PortfolioShare>, AppError> {
292        let rows = sqlx::query(
293            r#"
294            SELECT portfolio_id, shared_with_user_id, can_edit, shared_at
295            FROM portfolio_shares
296            WHERE portfolio_id = $1
297            ORDER BY shared_at DESC
298            "#,
299        )
300        .bind(portfolio_id)
301        .fetch_all(&self.pool)
302        .await
303        .map_err(|e| AppError::Database(e.to_string()))?;
304        Ok(rows.iter().map(Self::row_to_share).collect())
305    }
306}