1use crate::application::error::AppError;
9use crate::application::ports::{AcpRepository, ListScope};
10use crate::domain::entities::{Acp, AcpLegalStatus, AcpMetrics};
11use crate::infrastructure::database::pool::DbPool;
12use async_trait::async_trait;
13use rust_decimal::Decimal;
14use sqlx::Row;
15use uuid::Uuid;
16
17pub struct PostgresAcpRepository {
18 pool: DbPool,
19}
20
21impl PostgresAcpRepository {
22 pub fn new(pool: DbPool) -> Self {
23 Self { pool }
24 }
25
26 fn row_to_acp(row: &sqlx::postgres::PgRow) -> Acp {
27 let legal_status_str: String = row.get("legal_status");
28 Acp {
29 fenetre_ag_ordinaire: None,
32 reception_provisoire_parties_communes: None,
35 premiere_cession_de_lot: None,
38 transcription_statuts: None,
39 id: row.get("id"),
40 organization_id: row.get("organization_id"),
41 name: row.get("name"),
42 slug: row.get("slug"),
43 legal_status: AcpLegalStatus::from_db_str(&legal_status_str),
44 total_tantiemes: row.get("total_tantiemes"),
45 bce_number: row.get("bce_number"),
46 address_street: row.get("address_street"),
47 address_postal_code: row.get("address_postal_code"),
48 address_city: row.get("address_city"),
49 reserve_fund_balance: row.try_get("reserve_fund_balance").unwrap_or(Decimal::ZERO),
53 working_capital_balance: row
54 .try_get("working_capital_balance")
55 .unwrap_or(Decimal::ZERO),
56 reserve_fund_waived: row.try_get("reserve_fund_waived").unwrap_or(false),
57 created_at: row.get("created_at"),
58 updated_at: row.get("updated_at"),
59 }
60 }
61}
62
63#[async_trait]
64impl AcpRepository for PostgresAcpRepository {
65 async fn create(&self, acp: &Acp) -> Result<Acp, AppError> {
66 sqlx::query(
67 r#"
68 INSERT INTO acps (
69 id, organization_id, name, slug, legal_status, bce_number,
70 address_street, address_postal_code, address_city,
71 total_tantiemes, created_at, updated_at,
72 reserve_fund_balance, working_capital_balance, reserve_fund_waived
73 )
74 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
75 "#,
76 )
77 .bind(acp.id)
78 .bind(acp.organization_id)
79 .bind(&acp.name)
80 .bind(&acp.slug)
81 .bind(acp.legal_status.as_db_str())
82 .bind(&acp.bce_number)
83 .bind(&acp.address_street)
84 .bind(&acp.address_postal_code)
85 .bind(&acp.address_city)
86 .bind(acp.total_tantiemes)
87 .bind(acp.created_at)
88 .bind(acp.updated_at)
89 .bind(acp.reserve_fund_balance)
90 .bind(acp.working_capital_balance)
91 .bind(acp.reserve_fund_waived)
92 .execute(&self.pool)
93 .await
94 .map_err(|e| {
95 if let Some(db_err) = e.as_database_error() {
97 if db_err.is_unique_violation() {
98 return AppError::Conflict(format!("ACP unique violation: {}", db_err));
99 }
100 }
101 AppError::Database(e.to_string())
102 })?;
103
104 Ok(acp.clone())
105 }
106
107 async fn find_by_id(&self, id: Uuid) -> Result<Option<Acp>, AppError> {
108 let row = sqlx::query(
109 r#"
110 SELECT id, organization_id, name, slug, legal_status, bce_number,
111 address_street, address_postal_code, address_city,
112 total_tantiemes, created_at, updated_at,
113 reserve_fund_balance, working_capital_balance, reserve_fund_waived
114 FROM acps
115 WHERE id = $1
116 "#,
117 )
118 .bind(id)
119 .fetch_optional(&self.pool)
120 .await
121 .map_err(|e| AppError::Database(e.to_string()))?;
122
123 Ok(row.as_ref().map(Self::row_to_acp))
124 }
125
126 async fn find_by_id_with_metrics(
127 &self,
128 id: Uuid,
129 ) -> Result<Option<(Acp, AcpMetrics)>, AppError> {
130 let row = sqlx::query(
135 r#"
136 SELECT
137 a.id, a.organization_id, a.name, a.slug, a.legal_status, a.bce_number,
138 a.address_street, a.address_postal_code, a.address_city,
139 a.total_tantiemes, a.created_at, a.updated_at,
140 a.reserve_fund_balance, a.working_capital_balance, a.reserve_fund_waived,
141 (SELECT COALESCE(COUNT(u.id), 0)::INT
142 FROM buildings b JOIN units u ON u.building_id = b.id
143 WHERE b.acp_id = a.id) AS units_count,
144 (SELECT COALESCE(SUM(u.quota::NUMERIC), 0::NUMERIC)
145 FROM buildings b JOIN units u ON u.building_id = b.id
146 WHERE b.acp_id = a.id) AS quota_sum,
147 (SELECT COALESCE(SUM(b.total_units), 0)::INT
148 FROM buildings b WHERE b.acp_id = a.id) AS declared_units_total,
149 (SELECT COALESCE(COUNT(*), 0)::INT
150 FROM buildings b WHERE b.acp_id = a.id) AS buildings_count
151 FROM acps a
152 WHERE a.id = $1
153 "#,
154 )
155 .bind(id)
156 .fetch_optional(&self.pool)
157 .await
158 .map_err(|e| AppError::Database(e.to_string()))?;
159
160 Ok(row.map(|row| {
161 let acp = Self::row_to_acp(&row);
162 let metrics = AcpMetrics {
163 units_count: row.try_get("units_count").unwrap_or(0),
164 declared_units_total: row.try_get("declared_units_total").unwrap_or(0),
165 quota_sum: row.try_get("quota_sum").unwrap_or(Decimal::ZERO),
166 buildings_count: row.try_get("buildings_count").unwrap_or(0),
167 };
168 (acp, metrics)
169 }))
170 }
171
172 async fn list_with_metrics(
173 &self,
174 scope: ListScope,
175 ) -> Result<Vec<(Acp, AcpMetrics)>, AppError> {
176 const METRIQUES: &str = r#"
184 (SELECT COALESCE(COUNT(u.id), 0)::INT
185 FROM buildings b JOIN units u ON u.building_id = b.id
186 WHERE b.acp_id = a.id) AS units_count,
187 (SELECT COALESCE(SUM(u.quota::NUMERIC), 0::NUMERIC)
188 FROM buildings b JOIN units u ON u.building_id = b.id
189 WHERE b.acp_id = a.id) AS quota_sum,
190 (SELECT COALESCE(SUM(b.total_units), 0)::INT
191 FROM buildings b WHERE b.acp_id = a.id) AS declared_units_total,
192 (SELECT COALESCE(COUNT(*), 0)::INT
193 FROM buildings b WHERE b.acp_id = a.id) AS buildings_count"#;
194
195 const COLONNES: &str = r#"
196 a.id, a.organization_id, a.name, a.slug, a.legal_status, a.bce_number,
197 a.address_street, a.address_postal_code, a.address_city,
198 a.total_tantiemes, a.created_at, a.updated_at,
199 a.reserve_fund_balance, a.working_capital_balance, a.reserve_fund_waived"#;
200
201 let rows = match scope {
202 ListScope::All => {
203 let sql =
204 format!("SELECT {COLONNES},{METRIQUES} FROM acps a ORDER BY a.created_at DESC");
205 sqlx::query(&sql)
206 .fetch_all(&self.pool)
207 .await
208 .map_err(|e| AppError::Database(e.to_string()))?
209 }
210 ListScope::Organization(org_id) => {
211 let sql = format!(
212 "SELECT {COLONNES},{METRIQUES} FROM acps a \
213 WHERE a.organization_id = $1 ORDER BY a.created_at DESC"
214 );
215 sqlx::query(&sql)
216 .bind(org_id)
217 .fetch_all(&self.pool)
218 .await
219 .map_err(|e| AppError::Database(e.to_string()))?
220 }
221 ListScope::Owner(user_id) => {
222 let sql = format!(
223 "SELECT {COLONNES},{METRIQUES} FROM acps a \
224 INNER JOIN user_role_assignments ura \
225 ON ura.scope = 'acp' AND ura.scope_id = a.id \
226 WHERE ura.user_id = $1 ORDER BY a.created_at DESC"
227 );
228 sqlx::query(&sql)
229 .bind(user_id)
230 .fetch_all(&self.pool)
231 .await
232 .map_err(|e| AppError::Database(e.to_string()))?
233 }
234 };
235
236 Ok(rows
237 .into_iter()
238 .map(|row| {
239 let acp = Self::row_to_acp(&row);
240 let metrics = AcpMetrics {
241 units_count: row.try_get("units_count").unwrap_or(0),
242 declared_units_total: row.try_get("declared_units_total").unwrap_or(0),
243 quota_sum: row.try_get("quota_sum").unwrap_or(Decimal::ZERO),
244 buildings_count: row.try_get("buildings_count").unwrap_or(0),
245 };
246 (acp, metrics)
247 })
248 .collect())
249 }
250
251 async fn list(&self, scope: ListScope) -> Result<Vec<Acp>, AppError> {
252 let rows = match scope {
253 ListScope::All => sqlx::query(
254 r#"
255 SELECT id, organization_id, name, slug, legal_status, bce_number,
256 address_street, address_postal_code, address_city,
257 total_tantiemes, created_at, updated_at
258 FROM acps
259 ORDER BY created_at DESC
260 "#,
261 )
262 .fetch_all(&self.pool)
263 .await
264 .map_err(|e| AppError::Database(e.to_string()))?,
265
266 ListScope::Organization(org_id) => sqlx::query(
267 r#"
268 SELECT id, organization_id, name, slug, legal_status, bce_number,
269 address_street, address_postal_code, address_city,
270 total_tantiemes, created_at, updated_at
271 FROM acps
272 WHERE organization_id = $1
273 ORDER BY created_at DESC
274 "#,
275 )
276 .bind(org_id)
277 .fetch_all(&self.pool)
278 .await
279 .map_err(|e| AppError::Database(e.to_string()))?,
280
281 ListScope::Owner(user_id) => {
282 sqlx::query(
286 r#"
287 SELECT a.id, a.organization_id, a.name, a.slug, a.legal_status,
288 a.bce_number, a.address_street, a.address_postal_code,
289 a.address_city, a.total_tantiemes, a.created_at, a.updated_at
290 FROM acps a
291 INNER JOIN user_role_assignments ura
292 ON ura.scope = 'acp'
293 AND ura.scope_id = a.id
294 WHERE ura.user_id = $1
295 ORDER BY a.created_at DESC
296 "#,
297 )
298 .bind(user_id)
299 .fetch_all(&self.pool)
300 .await
301 .unwrap_or_else(|e| {
306 log::warn!(
307 "Owner-scope ACP listing fell back to empty (likely \
308 missing user_role_assignments scope/scope_id columns \
309 pending Story 3.5): {}",
310 e
311 );
312 Vec::new()
313 })
314 }
315 };
316
317 Ok(rows.iter().map(Self::row_to_acp).collect())
318 }
319
320 async fn update(&self, acp: &Acp) -> Result<Acp, AppError> {
321 let result = sqlx::query(
322 r#"
323 UPDATE acps
324 SET organization_id = $2,
325 name = $3,
326 slug = $4,
327 legal_status = $5,
328 bce_number = $6,
329 address_street = $7,
330 address_postal_code = $8,
331 address_city = $9,
332 total_tantiemes = $10,
333 updated_at = $11,
334 reserve_fund_balance = $12,
335 working_capital_balance = $13,
336 reserve_fund_waived = $14
337 WHERE id = $1
338 "#,
339 )
340 .bind(acp.id)
341 .bind(acp.organization_id)
342 .bind(&acp.name)
343 .bind(&acp.slug)
344 .bind(acp.legal_status.as_db_str())
345 .bind(&acp.bce_number)
346 .bind(&acp.address_street)
347 .bind(&acp.address_postal_code)
348 .bind(&acp.address_city)
349 .bind(acp.total_tantiemes)
350 .bind(acp.updated_at)
351 .bind(acp.reserve_fund_balance)
352 .bind(acp.working_capital_balance)
353 .bind(acp.reserve_fund_waived)
354 .execute(&self.pool)
355 .await
356 .map_err(|e| {
357 if let Some(db_err) = e.as_database_error() {
358 if db_err.is_unique_violation() {
359 return AppError::Conflict(format!("ACP unique violation: {}", db_err));
360 }
361 }
362 AppError::Database(e.to_string())
363 })?;
364
365 if result.rows_affected() == 0 {
366 return Err(AppError::NotFound(format!("ACP {} not found", acp.id)));
367 }
368 Ok(acp.clone())
369 }
370
371 async fn archive(&self, id: Uuid) -> Result<(), AppError> {
372 let result = sqlx::query("DELETE FROM acps WHERE id = $1")
373 .bind(id)
374 .execute(&self.pool)
375 .await
376 .map_err(|e| AppError::Database(e.to_string()))?;
377
378 if result.rows_affected() == 0 {
379 return Err(AppError::NotFound(format!("ACP {} not found", id)));
380 }
381 Ok(())
382 }
383
384 async fn count_buildings(&self, id: Uuid) -> Result<i64, AppError> {
398 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM buildings WHERE acp_id = $1")
399 .bind(id)
400 .fetch_one(&self.pool)
401 .await
402 .map_err(|e| AppError::Database(e.to_string()))?;
403 Ok(count)
404 }
405}