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
12const 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("e.project_title)
48 .bind("e.project_description)
49 .bind("e.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("e.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("e.project_title)
211 .bind("e.project_description)
212 .bind("e.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("e.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
270fn 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}