Skip to main content

koprogo_api/infrastructure/database/repositories/
technical_inspection_repository_impl.rs

1use crate::application::dto::{PageRequest, TechnicalInspectionFilters};
2use crate::application::ports::TechnicalInspectionRepository;
3use crate::domain::entities::{InspectionStatus, InspectionType, TechnicalInspection};
4use crate::infrastructure::database::pool::DbPool;
5use async_trait::async_trait;
6use sqlx::Row;
7use uuid::Uuid;
8
9pub struct PostgresTechnicalInspectionRepository {
10    pool: DbPool,
11}
12
13impl PostgresTechnicalInspectionRepository {
14    pub fn new(pool: DbPool) -> Self {
15        Self { pool }
16    }
17}
18
19#[async_trait]
20impl TechnicalInspectionRepository for PostgresTechnicalInspectionRepository {
21    async fn create(
22        &self,
23        inspection: &TechnicalInspection,
24    ) -> Result<TechnicalInspection, String> {
25        sqlx::query(
26            r#"
27            INSERT INTO technical_inspections (
28                id, organization_id, building_id, title, description, inspection_type,
29                inspector_name, inspector_company, inspector_certification,
30                inspection_date, next_due_date, status, result_summary, defects_found,
31                recommendations, compliant, compliance_certificate_number,
32                compliance_valid_until, cost, invoice_number, reports, photos,
33                certificates, notes, created_at, updated_at
34            )
35            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)
36            "#,
37        )
38        .bind(inspection.id)
39        .bind(inspection.organization_id)
40        .bind(inspection.building_id)
41        .bind(&inspection.title)
42        .bind(&inspection.description)
43        .bind(inspection_type_to_sql(&inspection.inspection_type))
44        .bind(&inspection.inspector_name)
45        .bind(&inspection.inspector_company)
46        .bind(&inspection.inspector_certification)
47        .bind(inspection.inspection_date)
48        .bind(inspection.next_due_date)
49        .bind(inspection_status_to_sql(&inspection.status))
50        .bind(&inspection.result_summary)
51        .bind(&inspection.defects_found)
52        .bind(&inspection.recommendations)
53        .bind(inspection.compliant)
54        .bind(&inspection.compliance_certificate_number)
55        .bind(inspection.compliance_valid_until)
56        .bind(inspection.cost)
57        .bind(&inspection.invoice_number)
58        .bind(serde_json::to_value(&inspection.reports).unwrap_or(serde_json::json!([])))
59        .bind(serde_json::to_value(&inspection.photos).unwrap_or(serde_json::json!([])))
60        .bind(serde_json::to_value(&inspection.certificates).unwrap_or(serde_json::json!([])))
61        .bind(&inspection.notes)
62        .bind(inspection.created_at)
63        .bind(inspection.updated_at)
64        .execute(&self.pool)
65        .await
66        .map_err(|e| format!("Database error creating technical inspection: {}", e))?;
67
68        Ok(inspection.clone())
69    }
70
71    async fn find_by_id(&self, id: Uuid) -> Result<Option<TechnicalInspection>, String> {
72        let row = sqlx::query(
73            r#"
74            SELECT
75                id, organization_id, building_id, title, description, inspection_type,
76                inspector_name, inspector_company, inspector_certification,
77                inspection_date, next_due_date, status, result_summary, defects_found,
78                recommendations, compliant, compliance_certificate_number,
79                compliance_valid_until, cost, invoice_number, reports, photos,
80                certificates, notes, created_at, updated_at
81            FROM technical_inspections
82            WHERE id = $1
83            "#,
84        )
85        .bind(id)
86        .fetch_optional(&self.pool)
87        .await
88        .map_err(|e| format!("Database error finding technical inspection: {}", e))?;
89
90        Ok(row.map(|r| map_row_to_technical_inspection(&r)))
91    }
92
93    async fn find_by_building(
94        &self,
95        building_id: Uuid,
96    ) -> Result<Vec<TechnicalInspection>, String> {
97        let rows = sqlx::query(
98            r#"
99            SELECT
100                id, organization_id, building_id, title, description, inspection_type,
101                inspector_name, inspector_company, inspector_certification,
102                inspection_date, next_due_date, status, result_summary, defects_found,
103                recommendations, compliant, compliance_certificate_number,
104                compliance_valid_until, cost, invoice_number, reports, photos,
105                certificates, notes, created_at, updated_at
106            FROM technical_inspections
107            WHERE building_id = $1
108            ORDER BY inspection_date DESC
109            "#,
110        )
111        .bind(building_id)
112        .fetch_all(&self.pool)
113        .await
114        .map_err(|e| format!("Database error finding inspections by building: {}", e))?;
115
116        Ok(rows.iter().map(map_row_to_technical_inspection).collect())
117    }
118
119    async fn find_by_organization(
120        &self,
121        organization_id: Uuid,
122    ) -> Result<Vec<TechnicalInspection>, String> {
123        let rows = sqlx::query(
124            r#"
125            SELECT
126                id, organization_id, building_id, title, description, inspection_type,
127                inspector_name, inspector_company, inspector_certification,
128                inspection_date, next_due_date, status, result_summary, defects_found,
129                recommendations, compliant, compliance_certificate_number,
130                compliance_valid_until, cost, invoice_number, reports, photos,
131                certificates, notes, created_at, updated_at
132            FROM technical_inspections
133            WHERE organization_id = $1
134            ORDER BY inspection_date DESC
135            "#,
136        )
137        .bind(organization_id)
138        .fetch_all(&self.pool)
139        .await
140        .map_err(|e| format!("Database error finding inspections by organization: {}", e))?;
141
142        Ok(rows.iter().map(map_row_to_technical_inspection).collect())
143    }
144
145    async fn find_all_paginated(
146        &self,
147        page_request: &PageRequest,
148        filters: &TechnicalInspectionFilters,
149    ) -> Result<(Vec<TechnicalInspection>, i64), String> {
150        let offset = page_request.offset();
151        let limit = page_request.limit();
152
153        // Build WHERE clause based on filters
154        let mut where_clauses = vec![];
155        let mut bind_count = 0;
156
157        // #882 : `organization_id` était un champ du filtre, mais jamais
158        // traduit en clause SQL — le fournir n'avait donc aucun effet, et
159        // l'absence en avait moins encore : la liste rendait TOUS les
160        // contrôles techniques de l'instance.
161        #[allow(unused_variables)]
162        if let Some(organization_id) = filters.organization_id {
163            bind_count += 1;
164            where_clauses.push(format!("organization_id = ${}", bind_count));
165        }
166
167        #[allow(unused_variables)]
168        if let Some(building_id) = filters.building_id {
169            bind_count += 1;
170            where_clauses.push(format!("building_id = ${}", bind_count));
171        }
172
173        #[allow(unused_variables)]
174        if let Some(ref inspection_type) = filters.inspection_type {
175            bind_count += 1;
176            where_clauses.push(format!("inspection_type = ${}", bind_count));
177        }
178
179        #[allow(unused_variables)]
180        if let Some(ref status) = filters.status {
181            bind_count += 1;
182            where_clauses.push(format!("status = ${}", bind_count));
183        }
184
185        let where_clause = if where_clauses.is_empty() {
186            String::new()
187        } else {
188            format!("WHERE {}", where_clauses.join(" AND "))
189        };
190
191        // Count total
192        let count_query = format!(
193            "SELECT COUNT(*) FROM technical_inspections {}",
194            where_clause
195        );
196        let mut count_query = sqlx::query_scalar::<_, i64>(&count_query);
197
198        if let Some(organization_id) = filters.organization_id {
199            count_query = count_query.bind(organization_id);
200        }
201        if let Some(building_id) = filters.building_id {
202            count_query = count_query.bind(building_id);
203        }
204        if let Some(ref inspection_type) = filters.inspection_type {
205            count_query = count_query.bind(inspection_type);
206        }
207        if let Some(ref status) = filters.status {
208            count_query = count_query.bind(status);
209        }
210
211        let total = count_query
212            .fetch_one(&self.pool)
213            .await
214            .map_err(|e| format!("Database error counting inspections: {}", e))?;
215
216        // Fetch paginated results
217        let select_query = format!(
218            r#"
219            SELECT
220                id, organization_id, building_id, title, description, inspection_type,
221                inspector_name, inspector_company, inspector_certification,
222                inspection_date, next_due_date, status, result_summary, defects_found,
223                recommendations, compliant, compliance_certificate_number,
224                compliance_valid_until, cost, invoice_number, reports, photos,
225                certificates, notes, created_at, updated_at
226            FROM technical_inspections
227            {}
228            ORDER BY inspection_date DESC
229            LIMIT ${}
230            OFFSET ${}
231            "#,
232            where_clause,
233            bind_count + 1,
234            bind_count + 2
235        );
236
237        let mut select_query = sqlx::query(&select_query);
238
239        if let Some(organization_id) = filters.organization_id {
240            select_query = select_query.bind(organization_id);
241        }
242        if let Some(building_id) = filters.building_id {
243            select_query = select_query.bind(building_id);
244        }
245        if let Some(ref inspection_type) = filters.inspection_type {
246            select_query = select_query.bind(inspection_type);
247        }
248        if let Some(ref status) = filters.status {
249            select_query = select_query.bind(status);
250        }
251
252        let rows = select_query
253            .bind(limit)
254            .bind(offset)
255            .fetch_all(&self.pool)
256            .await
257            .map_err(|e| format!("Database error fetching inspections: {}", e))?;
258
259        let inspections = rows.iter().map(map_row_to_technical_inspection).collect();
260
261        Ok((inspections, total))
262    }
263
264    async fn find_overdue(&self, building_id: Uuid) -> Result<Vec<TechnicalInspection>, String> {
265        let rows = sqlx::query(
266            r#"
267            SELECT
268                id, organization_id, building_id, title, description, inspection_type,
269                inspector_name, inspector_company, inspector_certification,
270                inspection_date, next_due_date, status, result_summary, defects_found,
271                recommendations, compliant, compliance_certificate_number,
272                compliance_valid_until, cost, invoice_number, reports, photos,
273                certificates, notes, created_at, updated_at
274            FROM technical_inspections
275            WHERE building_id = $1
276              AND next_due_date < NOW()
277              AND status = 'pending'
278            ORDER BY next_due_date ASC
279            "#,
280        )
281        .bind(building_id)
282        .fetch_all(&self.pool)
283        .await
284        .map_err(|e| format!("Database error finding overdue inspections: {}", e))?;
285
286        Ok(rows.iter().map(map_row_to_technical_inspection).collect())
287    }
288
289    async fn find_upcoming(
290        &self,
291        building_id: Uuid,
292        days: i32,
293    ) -> Result<Vec<TechnicalInspection>, String> {
294        let rows = sqlx::query(
295            r#"
296            SELECT
297                id, organization_id, building_id, title, description, inspection_type,
298                inspector_name, inspector_company, inspector_certification,
299                inspection_date, next_due_date, status, result_summary, defects_found,
300                recommendations, compliant, compliance_certificate_number,
301                compliance_valid_until, cost, invoice_number, reports, photos,
302                certificates, notes, created_at, updated_at
303            FROM technical_inspections
304            WHERE building_id = $1
305              AND next_due_date > NOW()
306              AND next_due_date <= NOW() + INTERVAL '1 day' * $2
307              AND status = 'pending'
308            ORDER BY next_due_date ASC
309            "#,
310        )
311        .bind(building_id)
312        .bind(days)
313        .fetch_all(&self.pool)
314        .await
315        .map_err(|e| format!("Database error finding upcoming inspections: {}", e))?;
316
317        Ok(rows.iter().map(map_row_to_technical_inspection).collect())
318    }
319
320    async fn find_by_type(
321        &self,
322        building_id: Uuid,
323        inspection_type: &str,
324    ) -> Result<Vec<TechnicalInspection>, String> {
325        let rows = sqlx::query(
326            r#"
327            SELECT
328                id, organization_id, building_id, title, description, inspection_type,
329                inspector_name, inspector_company, inspector_certification,
330                inspection_date, next_due_date, status, result_summary, defects_found,
331                recommendations, compliant, compliance_certificate_number,
332                compliance_valid_until, cost, invoice_number, reports, photos,
333                certificates, notes, created_at, updated_at
334            FROM technical_inspections
335            WHERE building_id = $1
336              AND inspection_type = $2
337            ORDER BY inspection_date DESC
338            "#,
339        )
340        .bind(building_id)
341        .bind(inspection_type)
342        .fetch_all(&self.pool)
343        .await
344        .map_err(|e| format!("Database error finding inspections by type: {}", e))?;
345
346        Ok(rows.iter().map(map_row_to_technical_inspection).collect())
347    }
348
349    async fn update(
350        &self,
351        inspection: &TechnicalInspection,
352    ) -> Result<TechnicalInspection, String> {
353        sqlx::query(
354            r#"
355            UPDATE technical_inspections
356            SET
357                building_id = $2,
358                title = $3,
359                description = $4,
360                inspection_type = $5,
361                inspector_name = $6,
362                inspector_company = $7,
363                inspector_certification = $8,
364                inspection_date = $9,
365                next_due_date = $10,
366                status = $11,
367                result_summary = $12,
368                defects_found = $13,
369                recommendations = $14,
370                compliant = $15,
371                compliance_certificate_number = $16,
372                compliance_valid_until = $17,
373                cost = $18,
374                invoice_number = $19,
375                reports = $20,
376                photos = $21,
377                certificates = $22,
378                notes = $23,
379                updated_at = $24
380            WHERE id = $1
381            "#,
382        )
383        .bind(inspection.id)
384        .bind(inspection.building_id)
385        .bind(&inspection.title)
386        .bind(&inspection.description)
387        .bind(inspection_type_to_sql(&inspection.inspection_type))
388        .bind(&inspection.inspector_name)
389        .bind(&inspection.inspector_company)
390        .bind(&inspection.inspector_certification)
391        .bind(inspection.inspection_date)
392        .bind(inspection.next_due_date)
393        .bind(inspection_status_to_sql(&inspection.status))
394        .bind(&inspection.result_summary)
395        .bind(&inspection.defects_found)
396        .bind(&inspection.recommendations)
397        .bind(inspection.compliant)
398        .bind(&inspection.compliance_certificate_number)
399        .bind(inspection.compliance_valid_until)
400        .bind(inspection.cost)
401        .bind(&inspection.invoice_number)
402        .bind(serde_json::to_value(&inspection.reports).unwrap_or(serde_json::json!([])))
403        .bind(serde_json::to_value(&inspection.photos).unwrap_or(serde_json::json!([])))
404        .bind(serde_json::to_value(&inspection.certificates).unwrap_or(serde_json::json!([])))
405        .bind(&inspection.notes)
406        .bind(inspection.updated_at)
407        .execute(&self.pool)
408        .await
409        .map_err(|e| format!("Database error updating technical inspection: {}", e))?;
410
411        Ok(inspection.clone())
412    }
413
414    async fn delete(&self, id: Uuid) -> Result<bool, String> {
415        let result = sqlx::query("DELETE FROM technical_inspections WHERE id = $1")
416            .bind(id)
417            .execute(&self.pool)
418            .await
419            .map_err(|e| format!("Database error deleting technical inspection: {}", e))?;
420
421        Ok(result.rows_affected() > 0)
422    }
423}
424
425/// Helper function to map PostgreSQL row to TechnicalInspection entity
426fn map_row_to_technical_inspection(row: &sqlx::postgres::PgRow) -> TechnicalInspection {
427    let inspection_type_str: String = row.get("inspection_type");
428    let inspection_type = inspection_type_from_sql(&inspection_type_str);
429
430    let status_str: String = row.get("status");
431    let status = inspection_status_from_sql(&status_str);
432
433    let reports: Vec<String> = row
434        .get::<serde_json::Value, _>("reports")
435        .as_array()
436        .map(|arr| {
437            arr.iter()
438                .filter_map(|v| v.as_str().map(String::from))
439                .collect()
440        })
441        .unwrap_or_default();
442
443    let photos: Vec<String> = row
444        .get::<serde_json::Value, _>("photos")
445        .as_array()
446        .map(|arr| {
447            arr.iter()
448                .filter_map(|v| v.as_str().map(String::from))
449                .collect()
450        })
451        .unwrap_or_default();
452
453    let certificates: Vec<String> = row
454        .get::<serde_json::Value, _>("certificates")
455        .as_array()
456        .map(|arr| {
457            arr.iter()
458                .filter_map(|v| v.as_str().map(String::from))
459                .collect()
460        })
461        .unwrap_or_default();
462
463    TechnicalInspection {
464        id: row.get("id"),
465        organization_id: row.get("organization_id"),
466        building_id: row.get("building_id"),
467        inspection_type,
468        title: row.get("title"),
469        description: row.get("description"),
470        inspector_name: row.get("inspector_name"),
471        inspector_company: row.get("inspector_company"),
472        inspector_certification: row.get("inspector_certification"),
473        inspection_date: row.get("inspection_date"),
474        next_due_date: row.get("next_due_date"),
475        status,
476        result_summary: row.get("result_summary"),
477        defects_found: row.get("defects_found"),
478        recommendations: row.get("recommendations"),
479        compliant: row.get("compliant"),
480        compliance_certificate_number: row.get("compliance_certificate_number"),
481        compliance_valid_until: row.get("compliance_valid_until"),
482        cost: row.get("cost"),
483        invoice_number: row.get("invoice_number"),
484        reports,
485        photos,
486        certificates,
487        notes: row.get("notes"),
488        created_at: row.get("created_at"),
489        updated_at: row.get("updated_at"),
490    }
491}
492
493/// Convert InspectionType to SQL string
494fn inspection_type_to_sql(inspection_type: &InspectionType) -> String {
495    match inspection_type {
496        InspectionType::Elevator => "elevator".to_string(),
497        InspectionType::Boiler => "boiler".to_string(),
498        InspectionType::Electrical => "electrical".to_string(),
499        InspectionType::FireExtinguisher => "fire_extinguisher".to_string(),
500        InspectionType::FireAlarm => "fire_alarm".to_string(),
501        InspectionType::GasInstallation => "gas_installation".to_string(),
502        InspectionType::RoofStructure => "roof".to_string(),
503        InspectionType::Facade => "facade".to_string(),
504        InspectionType::WaterQuality => "water_tank".to_string(),
505        InspectionType::Other { name: _ } => {
506            // Store custom inspection types as "other" in the DB
507            // The name is stored in the title field
508            "other".to_string()
509        }
510    }
511}
512
513/// Convert SQL string to InspectionType
514fn inspection_type_from_sql(s: &str) -> InspectionType {
515    match s {
516        "elevator" => InspectionType::Elevator,
517        "boiler" => InspectionType::Boiler,
518        "electrical" => InspectionType::Electrical,
519        "fire_extinguisher" => InspectionType::FireExtinguisher,
520        "fire_alarm" => InspectionType::FireAlarm,
521        "gas_installation" => InspectionType::GasInstallation,
522        "roof" => InspectionType::RoofStructure,
523        "facade" => InspectionType::Facade,
524        "water_tank" => InspectionType::WaterQuality,
525        "drainage" => InspectionType::Other {
526            name: "Drainage".to_string(),
527        },
528        "emergency_lighting" => InspectionType::Other {
529            name: "Emergency Lighting".to_string(),
530        },
531        "other" => InspectionType::Other {
532            name: "Other".to_string(),
533        },
534        _ => InspectionType::Other {
535            name: s.to_string(),
536        },
537    }
538}
539
540/// Convert InspectionStatus to SQL string
541fn inspection_status_to_sql(status: &InspectionStatus) -> &'static str {
542    match status {
543        InspectionStatus::Scheduled => "pending",
544        InspectionStatus::InProgress => "pending", // Map to pending in DB
545        InspectionStatus::Completed => "completed",
546        InspectionStatus::Failed => "failed",
547        InspectionStatus::Overdue => "pending", // Map to pending in DB
548        InspectionStatus::Cancelled => "failed", // Map to failed in DB
549    }
550}
551
552/// Convert SQL string to InspectionStatus
553fn inspection_status_from_sql(s: &str) -> InspectionStatus {
554    match s {
555        "pending" => InspectionStatus::Scheduled,
556        "completed" => InspectionStatus::Completed,
557        "failed" => InspectionStatus::Failed,
558        "passed_with_remarks" => InspectionStatus::Completed, // Map to completed
559        _ => InspectionStatus::Scheduled,
560    }
561}