koprogo_api/infrastructure/database/repositories/
quote_repository_impl.rs1use 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
12const 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("e.project_title)
48 .bind("e.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("e.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("e.project_title)
209 .bind("e.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("e.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
267fn 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}