1use super::{
4 map_db_error, parse_datetime_opt_row, parse_datetime_row, parse_enum_row, parse_json_row,
5 parse_uuid_row, with_immediate_transaction,
6};
7use chrono::Utc;
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use rusqlite::OptionalExtension;
11use sha2::{Digest, Sha256};
12use stateset_core::{
13 CommerceError, CreatePrintStation, EnqueuePrintJob, PairStationResult, PrintJob,
14 PrintJobFilter, PrintJobId, PrintJobStatus, PrintPayloadKind, PrintStation, PrintStationId,
15 PrintStationRepository, Result,
16};
17use uuid::Uuid;
18
19#[derive(Debug)]
20pub struct SqlitePrintStationRepository {
21 pool: Pool<SqliteConnectionManager>,
22}
23
24impl SqlitePrintStationRepository {
25 #[must_use]
26 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
27 Self { pool }
28 }
29
30 fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
31 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
32 }
33
34 fn hash_token(token: &str) -> String {
36 let mut hasher = Sha256::new();
37 hasher.update(token.as_bytes());
38 format!("{:x}", hasher.finalize())
39 }
40
41 fn row_to_station(row: &rusqlite::Row<'_>) -> rusqlite::Result<PrintStation> {
42 let printers_json: String = row.get("printers")?;
43 Ok(PrintStation {
44 id: parse_uuid_row(&row.get::<_, String>("id")?, "print_station", "id")?.into(),
45 name: row.get("name")?,
46 printers: parse_json_row(&printers_json, "print_station", "printers")?,
47 revoked: row.get::<_, i32>("revoked")? != 0,
48 last_seen_at: parse_datetime_opt_row(
49 row.get::<_, Option<String>>("last_seen_at")?,
50 "print_station",
51 "last_seen_at",
52 )?,
53 created_at: parse_datetime_row(
54 &row.get::<_, String>("created_at")?,
55 "print_station",
56 "created_at",
57 )?,
58 updated_at: parse_datetime_row(
59 &row.get::<_, String>("updated_at")?,
60 "print_station",
61 "updated_at",
62 )?,
63 })
64 }
65
66 fn row_to_job(row: &rusqlite::Row<'_>) -> rusqlite::Result<PrintJob> {
67 Ok(PrintJob {
68 id: parse_uuid_row(&row.get::<_, String>("id")?, "print_job", "id")?.into(),
69 station_id: parse_uuid_row(
70 &row.get::<_, String>("station_id")?,
71 "print_job",
72 "station_id",
73 )?
74 .into(),
75 printer_name: row.get("printer_name")?,
76 payload_kind: parse_enum_row::<PrintPayloadKind>(
77 &row.get::<_, String>("payload_kind")?,
78 "print_job",
79 "payload_kind",
80 )?,
81 payload: row.get("payload")?,
82 status: parse_enum_row::<PrintJobStatus>(
83 &row.get::<_, String>("status")?,
84 "print_job",
85 "status",
86 )?,
87 created_at: parse_datetime_row(
88 &row.get::<_, String>("created_at")?,
89 "print_job",
90 "created_at",
91 )?,
92 picked_up_at: parse_datetime_opt_row(
93 row.get::<_, Option<String>>("picked_up_at")?,
94 "print_job",
95 "picked_up_at",
96 )?,
97 })
98 }
99
100 fn conflict(msg: &str) -> rusqlite::Error {
101 rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::Conflict(msg.to_string())))
102 }
103}
104
105impl PrintStationRepository for SqlitePrintStationRepository {
106 fn pair(&self, input: CreatePrintStation) -> Result<PairStationResult> {
107 let id = PrintStationId::new();
108 let id_str = id.to_string();
109 let now = Utc::now().to_rfc3339();
110 let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
112 let token_hash = Self::hash_token(&token);
113 let printers_json = serde_json::to_string(&input.printers)
114 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
115 let station = with_immediate_transaction(&self.pool, |tx| {
116 tx.execute(
117 "INSERT INTO print_stations (id, name, token_hash, printers, revoked, created_at, updated_at)
118 VALUES (?, ?, ?, ?, 0, ?, ?)",
119 rusqlite::params![&id_str, &input.name, &token_hash, &printers_json, &now, &now],
120 )?;
121 tx.query_row(
122 "SELECT * FROM print_stations WHERE id = ?",
123 [&id_str],
124 Self::row_to_station,
125 )
126 })?;
127 Ok(PairStationResult { station, token })
128 }
129
130 fn list_stations(&self) -> Result<Vec<PrintStation>> {
131 let conn = self.conn()?;
132 let mut stmt = conn
133 .prepare("SELECT * FROM print_stations ORDER BY created_at DESC")
134 .map_err(map_db_error)?;
135 let rows = stmt
136 .query_map([], Self::row_to_station)
137 .map_err(map_db_error)?
138 .collect::<std::result::Result<Vec<_>, _>>()
139 .map_err(map_db_error)?;
140 Ok(rows)
141 }
142
143 fn get_station(&self, id: PrintStationId) -> Result<Option<PrintStation>> {
144 let conn = self.conn()?;
145 match conn.query_row(
146 "SELECT * FROM print_stations WHERE id = ?",
147 [id.to_string()],
148 Self::row_to_station,
149 ) {
150 Ok(s) => Ok(Some(s)),
151 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
152 Err(e) => Err(map_db_error(e)),
153 }
154 }
155
156 fn revoke_station(&self, id: PrintStationId) -> Result<PrintStation> {
157 let id_str = id.to_string();
158 let now = Utc::now().to_rfc3339();
159 with_immediate_transaction(&self.pool, |tx| {
160 tx.execute(
161 "UPDATE print_stations SET revoked = 1, updated_at = ? WHERE id = ?",
162 rusqlite::params![&now, &id_str],
163 )?;
164 tx.query_row(
165 "SELECT * FROM print_stations WHERE id = ?",
166 [&id_str],
167 Self::row_to_station,
168 )
169 })
170 }
171
172 fn enqueue_job(&self, station_id: PrintStationId, input: EnqueuePrintJob) -> Result<PrintJob> {
173 let station_str = station_id.to_string();
174 let job_id = PrintJobId::new();
175 let job_str = job_id.to_string();
176 let now = Utc::now().to_rfc3339();
177 with_immediate_transaction(&self.pool, |tx| {
178 let revoked: Option<i32> = tx
179 .query_row("SELECT revoked FROM print_stations WHERE id = ?", [&station_str], |r| {
180 r.get(0)
181 })
182 .optional()?;
183 match revoked {
184 None => return Err(rusqlite::Error::QueryReturnedNoRows),
185 Some(r) if r != 0 => return Err(Self::conflict("station is revoked")),
186 _ => {}
187 }
188 tx.execute(
189 "INSERT INTO print_jobs (id, station_id, printer_name, payload_kind, payload, status, created_at)
190 VALUES (?, ?, ?, ?, ?, 'queued', ?)",
191 rusqlite::params![
192 &job_str,
193 &station_str,
194 &input.printer_name,
195 input.payload_kind.to_string(),
196 &input.payload,
197 &now,
198 ],
199 )?;
200 tx.query_row("SELECT * FROM print_jobs WHERE id = ?", [&job_str], Self::row_to_job)
201 })
202 }
203
204 fn next_job(&self, station_id: PrintStationId) -> Result<Option<PrintJob>> {
205 let station_str = station_id.to_string();
206 let now = Utc::now().to_rfc3339();
207 with_immediate_transaction(&self.pool, |tx| {
208 tx.execute(
210 "UPDATE print_stations SET last_seen_at = ?, updated_at = ? WHERE id = ?",
211 rusqlite::params![&now, &now, &station_str],
212 )?;
213 let job_id: Option<String> = tx
214 .query_row(
215 "SELECT id FROM print_jobs WHERE station_id = ? AND status = 'queued' ORDER BY created_at ASC LIMIT 1",
216 [&station_str],
217 |r| r.get(0),
218 )
219 .optional()?;
220 let Some(job_id) = job_id else {
221 return Ok(None);
222 };
223 tx.execute(
224 "UPDATE print_jobs SET status = 'picked_up', picked_up_at = ? WHERE id = ?",
225 rusqlite::params![&now, &job_id],
226 )?;
227 let job =
228 tx.query_row("SELECT * FROM print_jobs WHERE id = ?", [&job_id], Self::row_to_job)?;
229 Ok(Some(job))
230 })
231 }
232
233 fn complete_job(&self, job_id: PrintJobId, success: bool) -> Result<PrintJob> {
234 let job_str = job_id.to_string();
235 let status = if success { PrintJobStatus::Printed } else { PrintJobStatus::Failed };
236 with_immediate_transaction(&self.pool, |tx| {
237 tx.execute(
238 "UPDATE print_jobs SET status = ? WHERE id = ?",
239 rusqlite::params![status.to_string(), &job_str],
240 )?;
241 tx.query_row("SELECT * FROM print_jobs WHERE id = ?", [&job_str], Self::row_to_job)
242 })
243 }
244
245 fn list_jobs(
246 &self,
247 station_id: PrintStationId,
248 filter: PrintJobFilter,
249 ) -> Result<Vec<PrintJob>> {
250 let conn = self.conn()?;
251 let mut sql = "SELECT * FROM print_jobs WHERE station_id = ?".to_string();
252 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
253 vec![Box::new(station_id.to_string())];
254 if let Some(status) = filter.status {
255 sql.push_str(" AND status = ?");
256 params.push(Box::new(status.to_string()));
257 }
258 sql.push_str(" ORDER BY created_at DESC");
259 crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
260 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
261 params.iter().map(|p| p.as_ref()).collect();
262 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
263 let rows = stmt
264 .query_map(param_refs.as_slice(), Self::row_to_job)
265 .map_err(map_db_error)?
266 .collect::<std::result::Result<Vec<_>, _>>()
267 .map_err(map_db_error)?;
268 Ok(rows)
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::DatabaseConfig;
276 use crate::sqlite::SqliteDatabase;
277
278 fn test_repo() -> SqlitePrintStationRepository {
279 let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
280 SqlitePrintStationRepository::new(db.pool().clone())
281 }
282
283 fn pair(repo: &SqlitePrintStationRepository) -> PairStationResult {
284 repo.pair(CreatePrintStation {
285 name: "Packing Bench 1".into(),
286 printers: vec!["Zebra-1".into()],
287 })
288 .expect("pair")
289 }
290
291 fn job(printer: Option<&str>) -> EnqueuePrintJob {
292 EnqueuePrintJob {
293 printer_name: printer.map(String::from),
294 payload_kind: PrintPayloadKind::Zpl,
295 payload: "^XA^FO50,50^FDhi^FS^XZ".into(),
296 }
297 }
298
299 #[test]
300 fn pair_returns_token_and_persists_hash() {
301 let repo = test_repo();
302 let result = pair(&repo);
303 assert!(!result.token.is_empty());
304 assert!(!result.station.revoked);
305 let fetched = repo.get_station(result.station.id).expect("get").expect("found");
307 assert_eq!(fetched.printers, vec!["Zebra-1".to_string()]);
308 }
309
310 #[test]
311 fn enqueue_and_pick_up_in_fifo_order() {
312 let repo = test_repo();
313 let station = pair(&repo).station;
314 repo.enqueue_job(station.id, job(Some("Zebra-1"))).expect("enqueue 1");
315 repo.enqueue_job(station.id, job(Some("Zebra-1"))).expect("enqueue 2");
316
317 let first = repo.next_job(station.id).expect("next").expect("job");
318 assert_eq!(first.status, PrintJobStatus::PickedUp);
319 assert!(first.picked_up_at.is_some());
320 let s = repo.get_station(station.id).expect("get").expect("found");
322 assert!(s.last_seen_at.is_some());
323
324 let done = repo.complete_job(first.id, true).expect("complete");
326 assert_eq!(done.status, PrintJobStatus::Printed);
327 let second = repo.next_job(station.id).expect("next").expect("job");
328 assert_ne!(second.id, first.id);
329 assert!(repo.next_job(station.id).expect("next").is_none());
331 }
332
333 #[test]
334 fn revoked_station_rejects_jobs() {
335 let repo = test_repo();
336 let station = pair(&repo).station;
337 repo.revoke_station(station.id).expect("revoke");
338 assert!(repo.enqueue_job(station.id, job(None)).is_err());
339 }
340
341 #[test]
342 fn list_jobs_filters_by_status() {
343 let repo = test_repo();
344 let station = pair(&repo).station;
345 let j = repo.enqueue_job(station.id, job(None)).expect("enqueue");
346 repo.enqueue_job(station.id, job(None)).expect("enqueue2");
347 repo.complete_job(j.id, false).expect("fail");
348 let failed = repo
349 .list_jobs(
350 station.id,
351 PrintJobFilter { status: Some(PrintJobStatus::Failed), ..Default::default() },
352 )
353 .expect("list");
354 assert_eq!(failed.len(), 1);
355 }
356}