koprogo_api/infrastructure/database/repositories/
portfolio_repository_impl.rs1use 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 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 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}