koprogo_api/infrastructure/database/repositories/
building_repository_impl.rs

1use crate::application::dto::{BuildingFilters, PageRequest};
2use crate::application::ports::BuildingRepository;
3use crate::domain::entities::Building;
4use crate::infrastructure::database::pool::DbPool;
5use async_trait::async_trait;
6use sqlx::Row;
7use uuid::Uuid;
8
9pub struct PostgresBuildingRepository {
10    pool: DbPool,
11}
12
13impl PostgresBuildingRepository {
14    pub fn new(pool: DbPool) -> Self {
15        Self { pool }
16    }
17}
18
19#[async_trait]
20impl BuildingRepository for PostgresBuildingRepository {
21    async fn create(&self, building: &Building) -> Result<Building, String> {
22        sqlx::query(
23            r#"
24            INSERT INTO buildings (id, organization_id, name, address, city, postal_code, country, total_units, total_tantiemes, construction_year, syndic_name, syndic_email, syndic_phone, syndic_address, syndic_office_hours, syndic_emergency_contact, slug, created_at, updated_at)
25            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
26            "#,
27        )
28        .bind(building.id)
29        .bind(building.organization_id)
30        .bind(&building.name)
31        .bind(&building.address)
32        .bind(&building.city)
33        .bind(&building.postal_code)
34        .bind(&building.country)
35        .bind(building.total_units)
36        .bind(building.total_tantiemes)
37        .bind(building.construction_year)
38        .bind(&building.syndic_name)
39        .bind(&building.syndic_email)
40        .bind(&building.syndic_phone)
41        .bind(&building.syndic_address)
42        .bind(&building.syndic_office_hours)
43        .bind(&building.syndic_emergency_contact)
44        .bind(&building.slug)
45        .bind(building.created_at)
46        .bind(building.updated_at)
47        .execute(&self.pool)
48        .await
49        .map_err(|e| format!("Database error: {}", e))?;
50
51        Ok(building.clone())
52    }
53
54    async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String> {
55        let row = sqlx::query(
56            r#"
57            SELECT id, organization_id, name, address, city, postal_code, country, total_units, total_tantiemes, construction_year, syndic_name, syndic_email, syndic_phone, syndic_address, syndic_office_hours, syndic_emergency_contact, slug, created_at, updated_at
58            FROM buildings
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| Building {
68            id: row.get("id"),
69            organization_id: row.get("organization_id"),
70            name: row.get("name"),
71            address: row.get("address"),
72            city: row.get("city"),
73            postal_code: row.get("postal_code"),
74            country: row.get("country"),
75            total_units: row.get("total_units"),
76            total_tantiemes: row.get("total_tantiemes"),
77            construction_year: row.get("construction_year"),
78            syndic_name: row.get("syndic_name"),
79            syndic_email: row.get("syndic_email"),
80            syndic_phone: row.get("syndic_phone"),
81            syndic_address: row.get("syndic_address"),
82            syndic_office_hours: row.get("syndic_office_hours"),
83            syndic_emergency_contact: row.get("syndic_emergency_contact"),
84            slug: row.get("slug"),
85            created_at: row.get("created_at"),
86            updated_at: row.get("updated_at"),
87        }))
88    }
89
90    async fn find_all(&self) -> Result<Vec<Building>, String> {
91        let rows = sqlx::query(
92            r#"
93            SELECT id, organization_id, name, address, city, postal_code, country, total_units, total_tantiemes, construction_year, syndic_name, syndic_email, syndic_phone, syndic_address, syndic_office_hours, syndic_emergency_contact, slug, created_at, updated_at
94            FROM buildings
95            ORDER BY created_at DESC
96            "#,
97        )
98        .fetch_all(&self.pool)
99        .await
100        .map_err(|e| format!("Database error: {}", e))?;
101
102        Ok(rows
103            .iter()
104            .map(|row| Building {
105                id: row.get("id"),
106                organization_id: row.get("organization_id"),
107                name: row.get("name"),
108                address: row.get("address"),
109                city: row.get("city"),
110                postal_code: row.get("postal_code"),
111                country: row.get("country"),
112                total_units: row.get("total_units"),
113                total_tantiemes: row.get("total_tantiemes"),
114                construction_year: row.get("construction_year"),
115                syndic_name: row.get("syndic_name"),
116                syndic_email: row.get("syndic_email"),
117                syndic_phone: row.get("syndic_phone"),
118                syndic_address: row.get("syndic_address"),
119                syndic_office_hours: row.get("syndic_office_hours"),
120                syndic_emergency_contact: row.get("syndic_emergency_contact"),
121                slug: row.get("slug"),
122                created_at: row.get("created_at"),
123                updated_at: row.get("updated_at"),
124            })
125            .collect())
126    }
127
128    async fn find_all_paginated(
129        &self,
130        page_request: &PageRequest,
131        filters: &BuildingFilters,
132    ) -> Result<(Vec<Building>, i64), String> {
133        // Validate page request
134        page_request.validate()?;
135
136        // Build WHERE clause dynamically
137        let mut where_clauses = Vec::new();
138        let mut param_count = 0;
139
140        if filters.organization_id.is_some() {
141            param_count += 1;
142            where_clauses.push(format!("organization_id = ${}", param_count));
143        }
144
145        if filters.city.is_some() {
146            param_count += 1;
147            where_clauses.push(format!("city ILIKE ${}", param_count));
148        }
149
150        if filters.construction_year.is_some() {
151            param_count += 1;
152            where_clauses.push(format!("construction_year = ${}", param_count));
153        }
154
155        if filters.min_units.is_some() {
156            param_count += 1;
157            where_clauses.push(format!("total_units >= ${}", param_count));
158        }
159
160        if filters.max_units.is_some() {
161            param_count += 1;
162            where_clauses.push(format!("total_units <= ${}", param_count));
163        }
164
165        // BUG-WF14-2: Filtrer par owner — ne montrer que les buildings où le user possède un lot
166        if filters.owner_user_id.is_some() {
167            param_count += 1;
168            where_clauses.push(format!(
169                "id IN (SELECT DISTINCT u.building_id FROM units u \
170                 INNER JOIN unit_owners uo ON uo.unit_id = u.id \
171                 INNER JOIN owners o ON o.id = uo.owner_id \
172                 WHERE o.user_id = ${} AND uo.end_date IS NULL)",
173                param_count
174            ));
175        }
176
177        let where_clause = if where_clauses.is_empty() {
178            String::new()
179        } else {
180            format!("WHERE {}", where_clauses.join(" AND "))
181        };
182
183        // Validate sort column (whitelist)
184        let allowed_columns = [
185            "name",
186            "created_at",
187            "total_units",
188            "city",
189            "construction_year",
190        ];
191        let sort_column = page_request.sort_by.as_deref().unwrap_or("created_at");
192
193        if !allowed_columns.contains(&sort_column) {
194            return Err(format!("Invalid sort column: {}", sort_column));
195        }
196
197        // Count total items
198        let count_query = format!("SELECT COUNT(*) FROM buildings {}", where_clause);
199        let mut count_query = sqlx::query_scalar::<_, i64>(&count_query);
200
201        if let Some(org_id) = filters.organization_id {
202            count_query = count_query.bind(org_id);
203        }
204        if let Some(city) = &filters.city {
205            count_query = count_query.bind(format!("%{}%", city));
206        }
207        if let Some(year) = filters.construction_year {
208            count_query = count_query.bind(year);
209        }
210        if let Some(min) = filters.min_units {
211            count_query = count_query.bind(min);
212        }
213        if let Some(max) = filters.max_units {
214            count_query = count_query.bind(max);
215        }
216        if let Some(owner_id) = filters.owner_user_id {
217            count_query = count_query.bind(owner_id);
218        }
219
220        let total_items = count_query
221            .fetch_one(&self.pool)
222            .await
223            .map_err(|e| format!("Database error: {}", e))?;
224
225        // Fetch paginated data
226        param_count += 1;
227        let limit_param = param_count;
228        param_count += 1;
229        let offset_param = param_count;
230
231        let data_query = format!(
232            "SELECT id, organization_id, name, address, city, postal_code, country, total_units, total_tantiemes, construction_year, syndic_name, syndic_email, syndic_phone, syndic_address, syndic_office_hours, syndic_emergency_contact, slug, created_at, updated_at \
233             FROM buildings {} ORDER BY {} {} LIMIT ${} OFFSET ${}",
234            where_clause,
235            sort_column,
236            page_request.order.to_sql(),
237            limit_param,
238            offset_param
239        );
240
241        let mut data_query = sqlx::query(&data_query);
242
243        if let Some(org_id) = filters.organization_id {
244            data_query = data_query.bind(org_id);
245        }
246        if let Some(city) = &filters.city {
247            data_query = data_query.bind(format!("%{}%", city));
248        }
249        if let Some(year) = filters.construction_year {
250            data_query = data_query.bind(year);
251        }
252        if let Some(min) = filters.min_units {
253            data_query = data_query.bind(min);
254        }
255        if let Some(max) = filters.max_units {
256            data_query = data_query.bind(max);
257        }
258        if let Some(owner_id) = filters.owner_user_id {
259            data_query = data_query.bind(owner_id);
260        }
261
262        data_query = data_query
263            .bind(page_request.limit())
264            .bind(page_request.offset());
265
266        let rows = data_query
267            .fetch_all(&self.pool)
268            .await
269            .map_err(|e| format!("Database error: {}", e))?;
270
271        let buildings: Vec<Building> = rows
272            .iter()
273            .map(|row| Building {
274                id: row.get("id"),
275                organization_id: row.get("organization_id"),
276                name: row.get("name"),
277                address: row.get("address"),
278                city: row.get("city"),
279                postal_code: row.get("postal_code"),
280                country: row.get("country"),
281                total_units: row.get("total_units"),
282                total_tantiemes: row.get("total_tantiemes"),
283                construction_year: row.get("construction_year"),
284                syndic_name: row.get("syndic_name"),
285                syndic_email: row.get("syndic_email"),
286                syndic_phone: row.get("syndic_phone"),
287                syndic_address: row.get("syndic_address"),
288                syndic_office_hours: row.get("syndic_office_hours"),
289                syndic_emergency_contact: row.get("syndic_emergency_contact"),
290                slug: row.get("slug"),
291                created_at: row.get("created_at"),
292                updated_at: row.get("updated_at"),
293            })
294            .collect();
295
296        Ok((buildings, total_items))
297    }
298
299    async fn update(&self, building: &Building) -> Result<Building, String> {
300        sqlx::query(
301            r#"
302            UPDATE buildings
303            SET organization_id = $2, name = $3, address = $4, city = $5, postal_code = $6, country = $7, total_units = $8, total_tantiemes = $9, construction_year = $10, syndic_name = $11, syndic_email = $12, syndic_phone = $13, syndic_address = $14, syndic_office_hours = $15, syndic_emergency_contact = $16, slug = $17, updated_at = $18
304            WHERE id = $1
305            "#,
306        )
307        .bind(building.id)
308        .bind(building.organization_id)
309        .bind(&building.name)
310        .bind(&building.address)
311        .bind(&building.city)
312        .bind(&building.postal_code)
313        .bind(&building.country)
314        .bind(building.total_units)
315        .bind(building.total_tantiemes)
316        .bind(building.construction_year)
317        .bind(&building.syndic_name)
318        .bind(&building.syndic_email)
319        .bind(&building.syndic_phone)
320        .bind(&building.syndic_address)
321        .bind(&building.syndic_office_hours)
322        .bind(&building.syndic_emergency_contact)
323        .bind(&building.slug)
324        .bind(building.updated_at)
325        .execute(&self.pool)
326        .await
327        .map_err(|e| format!("Database error: {}", e))?;
328
329        Ok(building.clone())
330    }
331
332    async fn delete(&self, id: Uuid) -> Result<bool, String> {
333        let result = sqlx::query("DELETE FROM buildings WHERE id = $1")
334            .bind(id)
335            .execute(&self.pool)
336            .await
337            .map_err(|e| format!("Database error: {}", e))?;
338
339        Ok(result.rows_affected() > 0)
340    }
341
342    async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String> {
343        let row = sqlx::query(
344            r#"
345            SELECT id, organization_id, name, address, city, postal_code, country, total_units, total_tantiemes, construction_year, syndic_name, syndic_email, syndic_phone, syndic_address, syndic_office_hours, syndic_emergency_contact, slug, created_at, updated_at
346            FROM buildings
347            WHERE slug = $1
348            "#,
349        )
350        .bind(slug)
351        .fetch_optional(&self.pool)
352        .await
353        .map_err(|e| format!("Database error: {}", e))?;
354
355        Ok(row.map(|row| Building {
356            id: row.get("id"),
357            organization_id: row.get("organization_id"),
358            name: row.get("name"),
359            address: row.get("address"),
360            city: row.get("city"),
361            postal_code: row.get("postal_code"),
362            country: row.get("country"),
363            total_units: row.get("total_units"),
364            total_tantiemes: row.get("total_tantiemes"),
365            construction_year: row.get("construction_year"),
366            syndic_name: row.get("syndic_name"),
367            syndic_email: row.get("syndic_email"),
368            syndic_phone: row.get("syndic_phone"),
369            syndic_address: row.get("syndic_address"),
370            syndic_office_hours: row.get("syndic_office_hours"),
371            syndic_emergency_contact: row.get("syndic_emergency_contact"),
372            slug: row.get("slug"),
373            created_at: row.get("created_at"),
374            updated_at: row.get("updated_at"),
375        }))
376    }
377}