koprogo_api/infrastructure/database/repositories/
quote_repository_impl.rs

1use crate::application::ports::QuoteRepository;
2use crate::domain::entities::{Quote, QuoteStatus};
3use crate::infrastructure::database::pool::DbPool;
4use async_trait::async_trait;
5use sqlx::Row;
6use uuid::Uuid;
7
8pub struct PostgresQuoteRepository {
9    pool: DbPool,
10}
11
12/// Quote SELECT columns with cast for status enum
13const QUOTE_COLUMNS: &str = r#"
14    id, building_id, contractor_id, project_title, project_description,
15    amount_excl_vat, vat_rate, amount_incl_vat, validity_date,
16    estimated_start_date, estimated_duration_days, warranty_years,
17    contractor_rating, status::text as status_text, requested_at, submitted_at,
18    reviewed_at, decision_at, decision_by, decision_notes,
19    created_at, updated_at
20"#;
21
22impl PostgresQuoteRepository {
23    pub fn new(pool: DbPool) -> Self {
24        Self { pool }
25    }
26}
27
28#[async_trait]
29impl QuoteRepository for PostgresQuoteRepository {
30    async fn create(&self, quote: &Quote) -> Result<Quote, String> {
31        sqlx::query(
32            r#"
33            INSERT INTO quotes (
34                id, building_id, contractor_id, project_title, project_description,
35                amount_excl_vat, vat_rate, amount_incl_vat, validity_date,
36                estimated_start_date, estimated_duration_days, warranty_years,
37                contractor_rating, status, requested_at, submitted_at,
38                reviewed_at, decision_at, decision_by, decision_notes,
39                created_at, updated_at
40            )
41            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::quote_status, $15, $16, $17, $18, $19, $20, $21, $22)
42            "#,
43        )
44        .bind(quote.id)
45        .bind(quote.building_id)
46        .bind(quote.contractor_id)
47        .bind(&quote.project_title)
48        .bind(&quote.project_description)
49        .bind(quote.amount_excl_vat)
50        .bind(quote.vat_rate)
51        .bind(quote.amount_incl_vat)
52        .bind(quote.validity_date)
53        .bind(quote.estimated_start_date)
54        .bind(quote.estimated_duration_days)
55        .bind(quote.warranty_years)
56        .bind(quote.contractor_rating)
57        .bind(quote.status.to_sql())
58        .bind(quote.requested_at)
59        .bind(quote.submitted_at)
60        .bind(quote.reviewed_at)
61        .bind(quote.decision_at)
62        .bind(quote.decision_by)
63        .bind(&quote.decision_notes)
64        .bind(quote.created_at)
65        .bind(quote.updated_at)
66        .execute(&self.pool)
67        .await
68        .map_err(|e| format!("Database error: {}", e))?;
69
70        Ok(quote.clone())
71    }
72
73    async fn find_by_id(&self, id: Uuid) -> Result<Option<Quote>, String> {
74        let sql = format!("SELECT {} FROM quotes WHERE id = $1", QUOTE_COLUMNS);
75        let row = sqlx::query(&sql)
76            .bind(id)
77            .fetch_optional(&self.pool)
78            .await
79            .map_err(|e| format!("Database error: {}", e))?;
80
81        Ok(row.map(|row| map_row_to_quote(&row)))
82    }
83
84    async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Quote>, String> {
85        let sql = format!(
86            "SELECT {} FROM quotes WHERE building_id = $1 ORDER BY requested_at DESC",
87            QUOTE_COLUMNS
88        );
89        let rows = sqlx::query(&sql)
90            .bind(building_id)
91            .fetch_all(&self.pool)
92            .await
93            .map_err(|e| format!("Database error: {}", e))?;
94
95        Ok(rows.iter().map(map_row_to_quote).collect())
96    }
97
98    async fn find_by_contractor(&self, contractor_id: Uuid) -> Result<Vec<Quote>, String> {
99        let sql = format!(
100            "SELECT {} FROM quotes WHERE contractor_id = $1 ORDER BY requested_at DESC",
101            QUOTE_COLUMNS
102        );
103        let rows = sqlx::query(&sql)
104            .bind(contractor_id)
105            .fetch_all(&self.pool)
106            .await
107            .map_err(|e| format!("Database error: {}", e))?;
108
109        Ok(rows.iter().map(map_row_to_quote).collect())
110    }
111
112    async fn find_by_status(&self, building_id: Uuid, status: &str) -> Result<Vec<Quote>, String> {
113        let sql = format!(
114            "SELECT {} FROM quotes WHERE building_id = $1 AND status = $2::quote_status ORDER BY requested_at DESC",
115            QUOTE_COLUMNS
116        );
117        let rows = sqlx::query(&sql)
118            .bind(building_id)
119            .bind(status)
120            .fetch_all(&self.pool)
121            .await
122            .map_err(|e| format!("Database error: {}", e))?;
123
124        Ok(rows.iter().map(map_row_to_quote).collect())
125    }
126
127    async fn find_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<Quote>, String> {
128        if ids.is_empty() {
129            return Ok(vec![]);
130        }
131
132        let sql = format!(
133            "SELECT {} FROM quotes WHERE id = ANY($1) ORDER BY amount_incl_vat ASC",
134            QUOTE_COLUMNS
135        );
136        let rows = sqlx::query(&sql)
137            .bind(&ids)
138            .fetch_all(&self.pool)
139            .await
140            .map_err(|e| format!("Database error: {}", e))?;
141
142        Ok(rows.iter().map(map_row_to_quote).collect())
143    }
144
145    async fn find_by_project_title(
146        &self,
147        building_id: Uuid,
148        project_title: &str,
149    ) -> Result<Vec<Quote>, String> {
150        let sql = format!(
151            "SELECT {} FROM quotes WHERE building_id = $1 AND project_title ILIKE $2 ORDER BY requested_at DESC",
152            QUOTE_COLUMNS
153        );
154        let rows = sqlx::query(&sql)
155            .bind(building_id)
156            .bind(format!("%{}%", project_title))
157            .fetch_all(&self.pool)
158            .await
159            .map_err(|e| format!("Database error: {}", e))?;
160
161        Ok(rows.iter().map(map_row_to_quote).collect())
162    }
163
164    async fn find_expired(&self) -> Result<Vec<Quote>, String> {
165        let sql = format!(
166            "SELECT {} FROM quotes WHERE validity_date < NOW() AND status::text NOT IN ('Accepted', 'Rejected', 'Expired', 'Withdrawn') ORDER BY validity_date ASC",
167            QUOTE_COLUMNS
168        );
169        let rows = sqlx::query(&sql)
170            .fetch_all(&self.pool)
171            .await
172            .map_err(|e| format!("Database error: {}", e))?;
173
174        Ok(rows.iter().map(map_row_to_quote).collect())
175    }
176
177    async fn update(&self, quote: &Quote) -> Result<Quote, String> {
178        sqlx::query(
179            r#"
180            UPDATE quotes
181            SET
182                building_id = $2,
183                contractor_id = $3,
184                project_title = $4,
185                project_description = $5,
186                amount_excl_vat = $6,
187                vat_rate = $7,
188                amount_incl_vat = $8,
189                validity_date = $9,
190                estimated_start_date = $10,
191                estimated_duration_days = $11,
192                warranty_years = $12,
193                contractor_rating = $13,
194                status = $14::quote_status,
195                requested_at = $15,
196                submitted_at = $16,
197                reviewed_at = $17,
198                decision_at = $18,
199                decision_by = $19,
200                decision_notes = $20,
201                updated_at = $21
202            WHERE id = $1
203            "#,
204        )
205        .bind(quote.id)
206        .bind(quote.building_id)
207        .bind(quote.contractor_id)
208        .bind(&quote.project_title)
209        .bind(&quote.project_description)
210        .bind(quote.amount_excl_vat)
211        .bind(quote.vat_rate)
212        .bind(quote.amount_incl_vat)
213        .bind(quote.validity_date)
214        .bind(quote.estimated_start_date)
215        .bind(quote.estimated_duration_days)
216        .bind(quote.warranty_years)
217        .bind(quote.contractor_rating)
218        .bind(quote.status.to_sql())
219        .bind(quote.requested_at)
220        .bind(quote.submitted_at)
221        .bind(quote.reviewed_at)
222        .bind(quote.decision_at)
223        .bind(quote.decision_by)
224        .bind(&quote.decision_notes)
225        .bind(quote.updated_at)
226        .execute(&self.pool)
227        .await
228        .map_err(|e| format!("Database error: {}", e))?;
229
230        Ok(quote.clone())
231    }
232
233    async fn delete(&self, id: Uuid) -> Result<bool, String> {
234        let result = sqlx::query("DELETE FROM quotes WHERE id = $1")
235            .bind(id)
236            .execute(&self.pool)
237            .await
238            .map_err(|e| format!("Database error: {}", e))?;
239
240        Ok(result.rows_affected() > 0)
241    }
242
243    async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
244        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM quotes WHERE building_id = $1")
245            .bind(building_id)
246            .fetch_one(&self.pool)
247            .await
248            .map_err(|e| format!("Database error: {}", e))?;
249
250        Ok(count)
251    }
252
253    async fn count_by_status(&self, building_id: Uuid, status: &str) -> Result<i64, String> {
254        let count: i64 = sqlx::query_scalar(
255            "SELECT COUNT(*) FROM quotes WHERE building_id = $1 AND status = $2::quote_status",
256        )
257        .bind(building_id)
258        .bind(status)
259        .fetch_one(&self.pool)
260        .await
261        .map_err(|e| format!("Database error: {}", e))?;
262
263        Ok(count)
264    }
265}
266
267/// Helper function to map PostgreSQL row to Quote entity
268fn map_row_to_quote(row: &sqlx::postgres::PgRow) -> Quote {
269    let status_str: String = row.get("status_text");
270    Quote {
271        id: row.get("id"),
272        building_id: row.get("building_id"),
273        contractor_id: row.get("contractor_id"),
274        project_title: row.get("project_title"),
275        project_description: row.get("project_description"),
276        amount_excl_vat: row.get("amount_excl_vat"),
277        vat_rate: row.get("vat_rate"),
278        amount_incl_vat: row.get("amount_incl_vat"),
279        validity_date: row.get("validity_date"),
280        estimated_start_date: row.get("estimated_start_date"),
281        estimated_duration_days: row.get("estimated_duration_days"),
282        warranty_years: row.get("warranty_years"),
283        contractor_rating: row.get("contractor_rating"),
284        status: QuoteStatus::from_sql(&status_str).unwrap_or(QuoteStatus::Requested),
285        requested_at: row.get("requested_at"),
286        submitted_at: row.get("submitted_at"),
287        reviewed_at: row.get("reviewed_at"),
288        decision_at: row.get("decision_at"),
289        decision_by: row.get("decision_by"),
290        decision_notes: row.get("decision_notes"),
291        created_at: row.get("created_at"),
292        updated_at: row.get("updated_at"),
293    }
294}