Skip to main content

koprogo_api/infrastructure/database/repositories/
unit_repository_impl.rs

1use crate::application::dto::{PageRequest, UnitFilters};
2use crate::application::ports::UnitRepository;
3use crate::domain::entities::{Unit, UnitType};
4use crate::infrastructure::database::pool::DbPool;
5use async_trait::async_trait;
6use sqlx::Row;
7use uuid::Uuid;
8
9pub struct PostgresUnitRepository {
10    pool: DbPool,
11}
12
13impl PostgresUnitRepository {
14    pub fn new(pool: DbPool) -> Self {
15        Self { pool }
16    }
17}
18
19#[async_trait]
20impl UnitRepository for PostgresUnitRepository {
21    async fn create(&self, unit: &Unit) -> Result<Unit, String> {
22        let unit_type_str = match unit.unit_type {
23            UnitType::Apartment => "apartment",
24            UnitType::Parking => "parking",
25            UnitType::Cellar => "cellar",
26            UnitType::Commercial => "commercial",
27            UnitType::Other => "other",
28        };
29
30        sqlx::query(
31            r#"
32            INSERT INTO units (id, acp_id, building_id, unit_number, unit_type, floor, surface_area, quota, owner_id, created_at, updated_at)
33            VALUES ($1, $2, $3, $4, $5::unit_type, $6, $7, $8, $9, $10, $11)
34            "#,
35        )
36        .bind(unit.id)
37        .bind(unit.acp_id)
38        .bind(unit.building_id)
39        .bind(&unit.unit_number)
40        .bind(unit_type_str)
41        .bind(unit.floor)
42        .bind(unit.surface_area)
43        .bind(unit.quota)
44        .bind(unit.owner_id)
45        .bind(unit.created_at)
46        .bind(unit.updated_at)
47        .execute(&self.pool)
48        .await
49        .map_err(|e| format!("Database error: {}", e))?;
50
51        Ok(unit.clone())
52    }
53
54    async fn find_by_id(&self, id: Uuid) -> Result<Option<Unit>, String> {
55        let row = sqlx::query(
56            r#"
57            SELECT id, acp_id, building_id, unit_number, unit_type::text AS unit_type, floor, surface_area, quota, owner_id, created_at, updated_at
58            FROM units
59            WHERE id = $1
60            "#,
61        )
62        .bind(id)
63        .fetch_optional(&self.pool)
64        .await
65        .map_err(|e| format!("Database error: {}", e))?;
66
67        Ok(row.map(|row| {
68            let unit_type_str: String = row.get("unit_type");
69            let unit_type = match unit_type_str.as_str() {
70                "apartment" => UnitType::Apartment,
71                "parking" => UnitType::Parking,
72                "cellar" => UnitType::Cellar,
73                "commercial" => UnitType::Commercial,
74                _ => UnitType::Other,
75            };
76
77            Unit {
78                id: row.get("id"),
79                acp_id: row.get("acp_id"),
80                building_id: row.get("building_id"),
81                unit_number: row.get("unit_number"),
82                unit_type,
83                floor: row.get("floor"),
84                surface_area: row.get("surface_area"),
85                quota: row.get("quota"),
86                owner_id: row.get("owner_id"),
87                created_at: row.get("created_at"),
88                updated_at: row.get("updated_at"),
89            }
90        }))
91    }
92
93    async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Unit>, String> {
94        let rows = sqlx::query(
95            r#"
96            SELECT id, acp_id, building_id, unit_number, unit_type::text AS unit_type, floor, surface_area, quota, owner_id, created_at, updated_at
97            FROM units
98            WHERE building_id = $1
99            ORDER BY unit_number
100            "#,
101        )
102        .bind(building_id)
103        .fetch_all(&self.pool)
104        .await
105        .map_err(|e| format!("Database error: {}", e))?;
106
107        Ok(rows
108            .iter()
109            .map(|row| {
110                let unit_type_str: String = row.get("unit_type");
111                let unit_type = match unit_type_str.as_str() {
112                    "apartment" => UnitType::Apartment,
113                    "parking" => UnitType::Parking,
114                    "cellar" => UnitType::Cellar,
115                    "commercial" => UnitType::Commercial,
116                    _ => UnitType::Other,
117                };
118
119                Unit {
120                    id: row.get("id"),
121                    acp_id: row.get("acp_id"),
122                    building_id: row.get("building_id"),
123                    unit_number: row.get("unit_number"),
124                    unit_type,
125                    floor: row.get("floor"),
126                    surface_area: row.get("surface_area"),
127                    quota: row.get("quota"),
128                    owner_id: row.get("owner_id"),
129                    created_at: row.get("created_at"),
130                    updated_at: row.get("updated_at"),
131                }
132            })
133            .collect())
134    }
135
136    async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<Unit>, String> {
137        let rows = sqlx::query(
138            r#"
139            SELECT id, acp_id, building_id, unit_number, unit_type::text AS unit_type, floor, surface_area, quota, owner_id, created_at, updated_at
140            FROM units
141            WHERE owner_id = $1
142            ORDER BY unit_number
143            "#,
144        )
145        .bind(owner_id)
146        .fetch_all(&self.pool)
147        .await
148        .map_err(|e| format!("Database error: {}", e))?;
149
150        Ok(rows
151            .iter()
152            .map(|row| {
153                let unit_type_str: String = row.get("unit_type");
154                let unit_type = match unit_type_str.as_str() {
155                    "apartment" => UnitType::Apartment,
156                    "parking" => UnitType::Parking,
157                    "cellar" => UnitType::Cellar,
158                    "commercial" => UnitType::Commercial,
159                    _ => UnitType::Other,
160                };
161
162                Unit {
163                    id: row.get("id"),
164                    acp_id: row.get("acp_id"),
165                    building_id: row.get("building_id"),
166                    unit_number: row.get("unit_number"),
167                    unit_type,
168                    floor: row.get("floor"),
169                    surface_area: row.get("surface_area"),
170                    quota: row.get("quota"),
171                    owner_id: row.get("owner_id"),
172                    created_at: row.get("created_at"),
173                    updated_at: row.get("updated_at"),
174                }
175            })
176            .collect())
177    }
178
179    async fn find_all_paginated(
180        &self,
181        page_request: &PageRequest,
182        filters: &UnitFilters,
183    ) -> Result<(Vec<Unit>, i64), String> {
184        // Validate page request
185        page_request.validate()?;
186
187        // Build WHERE clause dynamically
188        let mut where_clauses = Vec::new();
189        let mut param_count = 0;
190
191        // Story H15 — isolation multi-tenant : scope org via l'ACP parente
192        // (la colonne units.organization_id a été DROP en migration 030000).
193        if filters.organization_id.is_some() {
194            param_count += 1;
195            where_clauses.push(format!(
196                "acp_id IN (SELECT id FROM acps WHERE organization_id = ${})",
197                param_count
198            ));
199        }
200
201        // Story H15 — scope ACP direct.
202        if filters.acp_id.is_some() {
203            param_count += 1;
204            where_clauses.push(format!("acp_id = ${}", param_count));
205        }
206
207        if filters.building_id.is_some() {
208            param_count += 1;
209            where_clauses.push(format!("building_id = ${}", param_count));
210        }
211
212        if filters.floor.is_some() {
213            param_count += 1;
214            where_clauses.push(format!("floor = ${}", param_count));
215        }
216
217        if let Some(has_owner) = filters.has_owner {
218            if has_owner {
219                where_clauses.push("owner_id IS NOT NULL".to_string());
220            } else {
221                where_clauses.push("owner_id IS NULL".to_string());
222            }
223        }
224
225        if filters.min_area.is_some() {
226            param_count += 1;
227            where_clauses.push(format!("surface_area >= ${}", param_count));
228        }
229
230        if filters.max_area.is_some() {
231            param_count += 1;
232            where_clauses.push(format!("surface_area <= ${}", param_count));
233        }
234
235        let where_clause = if where_clauses.is_empty() {
236            String::new()
237        } else {
238            format!("WHERE {}", where_clauses.join(" AND "))
239        };
240
241        // Validate sort column (whitelist)
242        let allowed_columns = ["unit_number", "floor", "surface_area", "created_at"];
243        let sort_column = page_request.sort_by.as_deref().unwrap_or("unit_number");
244
245        if !allowed_columns.contains(&sort_column) {
246            return Err(format!("Invalid sort column: {}", sort_column));
247        }
248
249        // Count total items
250        let count_query = format!("SELECT COUNT(*) FROM units {}", where_clause);
251        let mut count_query = sqlx::query_scalar::<_, i64>(&count_query);
252
253        // Bind organization_id FIRST (matches WHERE clause order: org subquery,
254        // then acp_id direct, then building_id, ...).
255        if let Some(org_id) = filters.organization_id {
256            count_query = count_query.bind(org_id);
257        }
258        if let Some(acp_id) = filters.acp_id {
259            count_query = count_query.bind(acp_id);
260        }
261        if let Some(building_id) = filters.building_id {
262            count_query = count_query.bind(building_id);
263        }
264        if let Some(floor) = filters.floor {
265            count_query = count_query.bind(floor);
266        }
267        if let Some(min_area) = filters.min_area {
268            count_query = count_query.bind(min_area);
269        }
270        if let Some(max_area) = filters.max_area {
271            count_query = count_query.bind(max_area);
272        }
273
274        let total_items = count_query
275            .fetch_one(&self.pool)
276            .await
277            .map_err(|e| format!("Database error: {}", e))?;
278
279        // Fetch paginated data
280        param_count += 1;
281        let limit_param = param_count;
282        param_count += 1;
283        let offset_param = param_count;
284
285        let data_query = format!(
286            "SELECT id, acp_id, building_id, unit_number, unit_type::text AS unit_type, floor, surface_area, quota, owner_id, created_at, updated_at \
287             FROM units {} ORDER BY {} {} LIMIT ${} OFFSET ${}",
288            where_clause,
289            sort_column,
290            page_request.order.to_sql(),
291            limit_param,
292            offset_param
293        );
294
295        let mut data_query = sqlx::query(&data_query);
296
297        // Bind organization_id FIRST (matches WHERE clause order: org subquery,
298        // then acp_id direct, then building_id, ...).
299        if let Some(org_id) = filters.organization_id {
300            data_query = data_query.bind(org_id);
301        }
302        if let Some(acp_id) = filters.acp_id {
303            data_query = data_query.bind(acp_id);
304        }
305        if let Some(building_id) = filters.building_id {
306            data_query = data_query.bind(building_id);
307        }
308        if let Some(floor) = filters.floor {
309            data_query = data_query.bind(floor);
310        }
311        if let Some(min_area) = filters.min_area {
312            data_query = data_query.bind(min_area);
313        }
314        if let Some(max_area) = filters.max_area {
315            data_query = data_query.bind(max_area);
316        }
317
318        data_query = data_query
319            .bind(page_request.limit())
320            .bind(page_request.offset());
321
322        let rows = data_query
323            .fetch_all(&self.pool)
324            .await
325            .map_err(|e| format!("Database error: {}", e))?;
326
327        let units: Vec<Unit> = rows
328            .iter()
329            .map(|row| {
330                // Try to get as String first (for enum types from PostgreSQL)
331                let unit_type_str: String = row
332                    .try_get("unit_type")
333                    .unwrap_or_else(|_| "apartment".to_string());
334                let unit_type = match unit_type_str.as_str() {
335                    "apartment" => UnitType::Apartment,
336                    "parking" => UnitType::Parking,
337                    "cellar" => UnitType::Cellar,
338                    "commercial" => UnitType::Commercial,
339                    _ => UnitType::Other,
340                };
341
342                Unit {
343                    id: row.get("id"),
344                    acp_id: row.get("acp_id"),
345                    building_id: row.get("building_id"),
346                    unit_number: row.get("unit_number"),
347                    unit_type,
348                    floor: row.get("floor"),
349                    surface_area: row.get("surface_area"),
350                    quota: row.get("quota"),
351                    owner_id: row.get("owner_id"),
352                    created_at: row.get("created_at"),
353                    updated_at: row.get("updated_at"),
354                }
355            })
356            .collect();
357
358        Ok((units, total_items))
359    }
360
361    async fn update(&self, unit: &Unit) -> Result<Unit, String> {
362        let unit_type_str = match unit.unit_type {
363            UnitType::Apartment => "apartment",
364            UnitType::Parking => "parking",
365            UnitType::Cellar => "cellar",
366            UnitType::Commercial => "commercial",
367            UnitType::Other => "other",
368        };
369
370        sqlx::query(
371            r#"
372            UPDATE units
373            SET unit_number = $2,
374                unit_type = $3::unit_type,
375                floor = $4,
376                surface_area = $5,
377                quota = $6,
378                owner_id = $7,
379                updated_at = $8
380            WHERE id = $1
381            "#,
382        )
383        .bind(unit.id)
384        .bind(&unit.unit_number)
385        .bind(unit_type_str)
386        .bind(unit.floor)
387        .bind(unit.surface_area)
388        .bind(unit.quota)
389        .bind(unit.owner_id)
390        .bind(unit.updated_at)
391        .execute(&self.pool)
392        .await
393        .map_err(|e| format!("Database error: {}", e))?;
394
395        Ok(unit.clone())
396    }
397
398    async fn delete(&self, id: Uuid) -> Result<bool, String> {
399        let result = sqlx::query("DELETE FROM units WHERE id = $1")
400            .bind(id)
401            .execute(&self.pool)
402            .await
403            .map_err(|e| format!("Database error: {}", e))?;
404
405        Ok(result.rows_affected() > 0)
406    }
407}