Skip to main content

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