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, organization_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.organization_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, organization_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                organization_id: row.get("organization_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, organization_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                    organization_id: row.get("organization_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, organization_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                    organization_id: row.get("organization_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        // Multi-tenant isolation: ALWAYS filter by organization_id when provided
192        if filters.organization_id.is_some() {
193            param_count += 1;
194            where_clauses.push(format!("organization_id = ${}", param_count));
195        }
196
197        if filters.building_id.is_some() {
198            param_count += 1;
199            where_clauses.push(format!("building_id = ${}", param_count));
200        }
201
202        if filters.floor.is_some() {
203            param_count += 1;
204            where_clauses.push(format!("floor = ${}", param_count));
205        }
206
207        if let Some(has_owner) = filters.has_owner {
208            if has_owner {
209                where_clauses.push("owner_id IS NOT NULL".to_string());
210            } else {
211                where_clauses.push("owner_id IS NULL".to_string());
212            }
213        }
214
215        if filters.min_area.is_some() {
216            param_count += 1;
217            where_clauses.push(format!("surface_area >= ${}", param_count));
218        }
219
220        if filters.max_area.is_some() {
221            param_count += 1;
222            where_clauses.push(format!("surface_area <= ${}", param_count));
223        }
224
225        let where_clause = if where_clauses.is_empty() {
226            String::new()
227        } else {
228            format!("WHERE {}", where_clauses.join(" AND "))
229        };
230
231        // Validate sort column (whitelist)
232        let allowed_columns = ["unit_number", "floor", "surface_area", "created_at"];
233        let sort_column = page_request.sort_by.as_deref().unwrap_or("unit_number");
234
235        if !allowed_columns.contains(&sort_column) {
236            return Err(format!("Invalid sort column: {}", sort_column));
237        }
238
239        // Count total items
240        let count_query = format!("SELECT COUNT(*) FROM units {}", where_clause);
241        let mut count_query = sqlx::query_scalar::<_, i64>(&count_query);
242
243        // Bind organization_id FIRST (matches WHERE clause order)
244        if let Some(org_id) = filters.organization_id {
245            count_query = count_query.bind(org_id);
246        }
247        if let Some(building_id) = filters.building_id {
248            count_query = count_query.bind(building_id);
249        }
250        if let Some(floor) = filters.floor {
251            count_query = count_query.bind(floor);
252        }
253        if let Some(min_area) = filters.min_area {
254            count_query = count_query.bind(min_area);
255        }
256        if let Some(max_area) = filters.max_area {
257            count_query = count_query.bind(max_area);
258        }
259
260        let total_items = count_query
261            .fetch_one(&self.pool)
262            .await
263            .map_err(|e| format!("Database error: {}", e))?;
264
265        // Fetch paginated data
266        param_count += 1;
267        let limit_param = param_count;
268        param_count += 1;
269        let offset_param = param_count;
270
271        let data_query = format!(
272            "SELECT id, organization_id, building_id, unit_number, unit_type::text AS unit_type, floor, surface_area, quota, owner_id, created_at, updated_at \
273             FROM units {} ORDER BY {} {} LIMIT ${} OFFSET ${}",
274            where_clause,
275            sort_column,
276            page_request.order.to_sql(),
277            limit_param,
278            offset_param
279        );
280
281        let mut data_query = sqlx::query(&data_query);
282
283        // Bind organization_id FIRST (matches WHERE clause order)
284        if let Some(org_id) = filters.organization_id {
285            data_query = data_query.bind(org_id);
286        }
287        if let Some(building_id) = filters.building_id {
288            data_query = data_query.bind(building_id);
289        }
290        if let Some(floor) = filters.floor {
291            data_query = data_query.bind(floor);
292        }
293        if let Some(min_area) = filters.min_area {
294            data_query = data_query.bind(min_area);
295        }
296        if let Some(max_area) = filters.max_area {
297            data_query = data_query.bind(max_area);
298        }
299
300        data_query = data_query
301            .bind(page_request.limit())
302            .bind(page_request.offset());
303
304        let rows = data_query
305            .fetch_all(&self.pool)
306            .await
307            .map_err(|e| format!("Database error: {}", e))?;
308
309        let units: Vec<Unit> = rows
310            .iter()
311            .map(|row| {
312                // Try to get as String first (for enum types from PostgreSQL)
313                let unit_type_str: String = row
314                    .try_get("unit_type")
315                    .unwrap_or_else(|_| "apartment".to_string());
316                let unit_type = match unit_type_str.as_str() {
317                    "apartment" => UnitType::Apartment,
318                    "parking" => UnitType::Parking,
319                    "cellar" => UnitType::Cellar,
320                    "commercial" => UnitType::Commercial,
321                    _ => UnitType::Other,
322                };
323
324                Unit {
325                    id: row.get("id"),
326                    organization_id: row.get("organization_id"),
327                    building_id: row.get("building_id"),
328                    unit_number: row.get("unit_number"),
329                    unit_type,
330                    floor: row.get("floor"),
331                    surface_area: row.get("surface_area"),
332                    quota: row.get("quota"),
333                    owner_id: row.get("owner_id"),
334                    created_at: row.get("created_at"),
335                    updated_at: row.get("updated_at"),
336                }
337            })
338            .collect();
339
340        Ok((units, total_items))
341    }
342
343    async fn update(&self, unit: &Unit) -> Result<Unit, String> {
344        let unit_type_str = match unit.unit_type {
345            UnitType::Apartment => "apartment",
346            UnitType::Parking => "parking",
347            UnitType::Cellar => "cellar",
348            UnitType::Commercial => "commercial",
349            UnitType::Other => "other",
350        };
351
352        sqlx::query(
353            r#"
354            UPDATE units
355            SET unit_number = $2,
356                unit_type = $3::unit_type,
357                floor = $4,
358                surface_area = $5,
359                quota = $6,
360                owner_id = $7,
361                updated_at = $8
362            WHERE id = $1
363            "#,
364        )
365        .bind(unit.id)
366        .bind(&unit.unit_number)
367        .bind(unit_type_str)
368        .bind(unit.floor)
369        .bind(unit.surface_area)
370        .bind(unit.quota)
371        .bind(unit.owner_id)
372        .bind(unit.updated_at)
373        .execute(&self.pool)
374        .await
375        .map_err(|e| format!("Database error: {}", e))?;
376
377        Ok(unit.clone())
378    }
379
380    async fn delete(&self, id: Uuid) -> Result<bool, String> {
381        let result = sqlx::query("DELETE FROM units WHERE id = $1")
382            .bind(id)
383            .execute(&self.pool)
384            .await
385            .map_err(|e| format!("Database error: {}", e))?;
386
387        Ok(result.rows_affected() > 0)
388    }
389}