1use super::{
4 map_db_error, parse_datetime_row, parse_json_row, parse_uuid_row, with_immediate_transaction,
5};
6use chrono::Utc;
7use r2d2::Pool;
8use r2d2_sqlite::SqliteConnectionManager;
9use stateset_core::{
10 CommerceError, CreateShippingZone, Result, ShippingZone, ShippingZoneFilter, ShippingZoneId,
11 ShippingZoneRepository, UpdateShippingZone,
12};
13
14#[derive(Debug)]
15pub struct SqliteShippingZoneRepository {
16 pool: Pool<SqliteConnectionManager>,
17}
18
19impl SqliteShippingZoneRepository {
20 #[must_use]
21 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
22 Self { pool }
23 }
24
25 fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
26 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
27 }
28
29 fn row_to_zone(row: &rusqlite::Row<'_>) -> rusqlite::Result<ShippingZone> {
30 let countries_json: String = row.get("countries")?;
31 let regions_json: String = row.get("regions")?;
32 let postal_codes_json: String = row.get("postal_codes")?;
33
34 Ok(ShippingZone {
35 id: parse_uuid_row(&row.get::<_, String>("id")?, "shipping_zone", "id")?.into(),
36 name: row.get("name")?,
37 countries: parse_json_row(&countries_json, "shipping_zone", "countries")?,
38 regions: parse_json_row(®ions_json, "shipping_zone", "regions")?,
39 postal_codes: parse_json_row(&postal_codes_json, "shipping_zone", "postal_codes")?,
40 priority: row.get("priority")?,
41 is_active: row.get::<_, i32>("is_active")? != 0,
42 created_at: parse_datetime_row(
43 &row.get::<_, String>("created_at")?,
44 "shipping_zone",
45 "created_at",
46 )?,
47 updated_at: parse_datetime_row(
48 &row.get::<_, String>("updated_at")?,
49 "shipping_zone",
50 "updated_at",
51 )?,
52 })
53 }
54}
55
56impl ShippingZoneRepository for SqliteShippingZoneRepository {
57 fn create(&self, input: CreateShippingZone) -> Result<ShippingZone> {
58 let id = ShippingZoneId::new();
59 let now = Utc::now();
60 let id_str = id.to_string();
61 let now_str = now.to_rfc3339();
62
63 let countries_json = serde_json::to_string(&input.countries)
64 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
65 let regions_json = serde_json::to_string(&input.regions)
66 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
67 let postal_codes_json = serde_json::to_string(&input.postal_codes)
68 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
69
70 with_immediate_transaction(&self.pool, |tx| {
71 tx.execute(
72 "INSERT INTO shipping_zones (id, name, countries, regions, postal_codes, priority, is_active, created_at, updated_at)
73 VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)",
74 rusqlite::params![
75 &id_str,
76 &input.name,
77 &countries_json,
78 ®ions_json,
79 &postal_codes_json,
80 input.priority.unwrap_or(0),
81 &now_str,
82 &now_str,
83 ],
84 )?;
85
86 tx.query_row("SELECT * FROM shipping_zones WHERE id = ?", [&id_str], Self::row_to_zone)
87 })
88 }
89
90 fn get(&self, id: ShippingZoneId) -> Result<Option<ShippingZone>> {
91 let conn = self.conn()?;
92 match conn.query_row(
93 "SELECT * FROM shipping_zones WHERE id = ?",
94 [id.to_string()],
95 Self::row_to_zone,
96 ) {
97 Ok(z) => Ok(Some(z)),
98 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
99 Err(e) => Err(map_db_error(e)),
100 }
101 }
102
103 fn update(&self, id: ShippingZoneId, input: UpdateShippingZone) -> Result<ShippingZone> {
104 let id_str = id.to_string();
105 let now_str = Utc::now().to_rfc3339();
106
107 with_immediate_transaction(&self.pool, |tx| {
108 let mut sets = vec!["updated_at = ?".to_string()];
109 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(now_str.clone())];
110
111 if let Some(ref name) = input.name {
112 sets.push("name = ?".into());
113 params.push(Box::new(name.clone()));
114 }
115 if let Some(ref countries) = input.countries {
116 let json = serde_json::to_string(countries).map_err(|e| {
117 rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
118 e.to_string(),
119 )))
120 })?;
121 sets.push("countries = ?".into());
122 params.push(Box::new(json));
123 }
124 if let Some(ref regions) = input.regions {
125 let json = serde_json::to_string(regions).map_err(|e| {
126 rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
127 e.to_string(),
128 )))
129 })?;
130 sets.push("regions = ?".into());
131 params.push(Box::new(json));
132 }
133 if let Some(ref postal_codes) = input.postal_codes {
134 let json = serde_json::to_string(postal_codes).map_err(|e| {
135 rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
136 e.to_string(),
137 )))
138 })?;
139 sets.push("postal_codes = ?".into());
140 params.push(Box::new(json));
141 }
142 if let Some(priority) = input.priority {
143 sets.push("priority = ?".into());
144 params.push(Box::new(priority));
145 }
146 if let Some(is_active) = input.is_active {
147 sets.push("is_active = ?".into());
148 params.push(Box::new(is_active as i32));
149 }
150
151 let sql = format!("UPDATE shipping_zones SET {} WHERE id = ?", sets.join(", "));
152 params.push(Box::new(id_str.clone()));
153
154 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
155 params.iter().map(|p| p.as_ref()).collect();
156 tx.execute(&sql, param_refs.as_slice())?;
157
158 tx.query_row("SELECT * FROM shipping_zones WHERE id = ?", [&id_str], Self::row_to_zone)
159 })
160 }
161
162 fn list(&self, filter: ShippingZoneFilter) -> Result<Vec<ShippingZone>> {
163 let conn = self.conn()?;
164 let mut sql = "SELECT * FROM shipping_zones WHERE 1=1".to_string();
165 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
166
167 if let Some(ref country) = filter.country {
168 sql.push_str(" AND countries LIKE ?");
170 params.push(Box::new(format!("%\"{country}\"%")));
171 }
172 if let Some(is_active) = filter.is_active {
173 sql.push_str(" AND is_active = ?");
174 params.push(Box::new(is_active as i32));
175 }
176
177 sql.push_str(" ORDER BY priority ASC, created_at DESC");
178
179 crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
180
181 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
182 params.iter().map(|p| p.as_ref()).collect();
183 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
184 let rows = stmt
185 .query_map(param_refs.as_slice(), Self::row_to_zone)
186 .map_err(map_db_error)?
187 .collect::<std::result::Result<Vec<_>, _>>()
188 .map_err(map_db_error)?;
189 Ok(rows)
190 }
191
192 fn delete(&self, id: ShippingZoneId) -> Result<()> {
193 let conn = self.conn()?;
194 conn.execute("DELETE FROM shipping_zones WHERE id = ?", [id.to_string()])
195 .map_err(map_db_error)?;
196 Ok(())
197 }
198
199 fn find_matching_zones(
200 &self,
201 country: &str,
202 region: Option<&str>,
203 postal_code: Option<&str>,
204 ) -> Result<Vec<ShippingZone>> {
205 let all_active =
207 self.list(ShippingZoneFilter { is_active: Some(true), ..Default::default() })?;
208
209 let matched: Vec<ShippingZone> = all_active
210 .into_iter()
211 .filter(|zone| {
212 let country_match =
214 zone.countries.is_empty() || zone.countries.iter().any(|c| c == country);
215 if !country_match {
216 return false;
217 }
218
219 if !zone.regions.is_empty() {
221 if let Some(r) = region {
222 if !zone.regions.iter().any(|zr| zr == r) {
223 return false;
224 }
225 } else {
226 return false;
227 }
228 }
229
230 if !zone.postal_codes.is_empty() {
232 if let Some(pc) = postal_code {
233 if !zone.postal_codes.iter().any(|pattern| {
234 if pattern.ends_with('*') {
235 pc.starts_with(&pattern[..pattern.len() - 1])
236 } else {
237 pc == pattern
238 }
239 }) {
240 return false;
241 }
242 } else {
243 return false;
244 }
245 }
246
247 true
248 })
249 .collect();
250
251 Ok(matched)
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::DatabaseConfig;
259 use crate::sqlite::SqliteDatabase;
260
261 fn test_repo() -> SqliteShippingZoneRepository {
262 let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
263 let conn = db.conn().expect("conn");
264 conn.execute_batch(
265 "CREATE TABLE IF NOT EXISTS shipping_zones (
266 id TEXT PRIMARY KEY,
267 name TEXT NOT NULL,
268 countries TEXT NOT NULL DEFAULT '[]',
269 regions TEXT NOT NULL DEFAULT '[]',
270 postal_codes TEXT NOT NULL DEFAULT '[]',
271 priority INTEGER NOT NULL DEFAULT 0,
272 is_active INTEGER NOT NULL DEFAULT 1,
273 created_at TEXT NOT NULL DEFAULT (datetime('now')),
274 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
275 );",
276 )
277 .expect("create tables");
278 SqliteShippingZoneRepository::new(db.pool().clone())
279 }
280
281 #[test]
282 fn create_and_get_zone() {
283 let repo = test_repo();
284 let zone = repo
285 .create(CreateShippingZone {
286 name: "Domestic US".into(),
287 countries: vec!["US".into()],
288 regions: vec![],
289 postal_codes: vec![],
290 priority: Some(1),
291 })
292 .expect("create");
293
294 assert_eq!(zone.name, "Domestic US");
295 assert_eq!(zone.countries, vec!["US".to_string()]);
296 assert!(zone.is_active);
297
298 let fetched = repo.get(zone.id).expect("get").expect("found");
299 assert_eq!(fetched.id, zone.id);
300 }
301
302 #[test]
303 fn list_and_delete_zones() {
304 let repo = test_repo();
305 repo.create(CreateShippingZone {
306 name: "US".into(),
307 countries: vec!["US".into()],
308 regions: vec![],
309 postal_codes: vec![],
310 priority: None,
311 })
312 .expect("create US");
313
314 repo.create(CreateShippingZone {
315 name: "EU".into(),
316 countries: vec!["DE".into(), "FR".into()],
317 regions: vec![],
318 postal_codes: vec![],
319 priority: None,
320 })
321 .expect("create EU");
322
323 let all = repo.list(ShippingZoneFilter::default()).expect("list");
324 assert_eq!(all.len(), 2);
325
326 repo.delete(all[0].id).expect("delete");
327 let remaining = repo.list(ShippingZoneFilter::default()).expect("list after delete");
328 assert_eq!(remaining.len(), 1);
329 }
330}