PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
viewer_state_repository.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
15
16#include <chrono>
17#include <cstdio>
18#include <sstream>
19
20#ifdef PACS_WITH_DATABASE_SYSTEM
21
22// =============================================================================
23// pacs_database_adapter Implementation
24// =============================================================================
25
26namespace kcenon::pacs::storage {
27
28namespace {
29
31[[nodiscard]] std::string to_timestamp_string(
32 std::chrono::system_clock::time_point tp) {
33 if (tp == std::chrono::system_clock::time_point{}) {
34 return "";
35 }
36 auto time = std::chrono::system_clock::to_time_t(tp);
37 std::tm tm{};
38#ifdef _WIN32
39 gmtime_s(&tm, &time);
40#else
41 gmtime_r(&time, &tm);
42#endif
43 auto since_epoch = tp.time_since_epoch();
44 auto secs = std::chrono::duration_cast<std::chrono::seconds>(since_epoch);
45 auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(since_epoch - secs);
46
47 char buf[32];
48 std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
49 char result[40];
50 std::snprintf(result, sizeof(result), "%s.%03d", buf, static_cast<int>(ms.count()));
51 return result;
52}
53
55[[nodiscard]] std::chrono::system_clock::time_point from_timestamp_string(
56 const std::string& str) {
57 if (str.empty()) {
58 return {};
59 }
60 std::tm tm{};
61 int ms = 0;
62 if (std::sscanf(str.c_str(), "%d-%d-%d %d:%d:%d.%d",
63 &tm.tm_year, &tm.tm_mon, &tm.tm_mday,
64 &tm.tm_hour, &tm.tm_min, &tm.tm_sec, &ms) < 6) {
65 return {};
66 }
67 tm.tm_year -= 1900;
68 tm.tm_mon -= 1;
69#ifdef _WIN32
70 auto time = _mkgmtime(&tm);
71#else
72 auto time = timegm(&tm);
73#endif
74 auto tp = std::chrono::system_clock::from_time_t(time);
75 return tp + std::chrono::milliseconds(ms);
76}
77
78} // namespace
79
80// =============================================================================
81// Construction / Destruction
82// =============================================================================
83
85 std::shared_ptr<pacs_database_adapter> db)
86 : db_(std::move(db)) {}
87
88viewer_state_repository::~viewer_state_repository() = default;
89
90viewer_state_repository::viewer_state_repository(viewer_state_repository&&) noexcept = default;
91
92auto viewer_state_repository::operator=(viewer_state_repository&&) noexcept
93 -> viewer_state_repository& = default;
94
95// =============================================================================
96// Timestamp Helpers
97// =============================================================================
98
99auto viewer_state_repository::parse_timestamp(const std::string& str) const
100 -> std::chrono::system_clock::time_point {
101 return from_timestamp_string(str);
102}
103
104auto viewer_state_repository::format_timestamp(
105 std::chrono::system_clock::time_point tp) const -> std::string {
106 return to_timestamp_string(tp);
107}
108
109// =============================================================================
110// Viewer State Operations
111// =============================================================================
112
113VoidResult viewer_state_repository::save_state(const viewer_state_record& record) {
114 if (!db_ || !db_->is_connected()) {
115 return VoidResult(kcenon::common::error_info{
116 -1, "Database not connected", "viewer_state_repository"});
117 }
118
119 auto now_str = format_timestamp(std::chrono::system_clock::now());
120
121 std::ostringstream sql;
122 sql << R"(
123 INSERT INTO viewer_states (
124 state_id, study_uid, user_id, state_json, created_at, updated_at
125 ) VALUES (
126 ')" << record.state_id << "', "
127 << "'" << record.study_uid << "', "
128 << "'" << record.user_id << "', "
129 << "'" << record.state_json << "', "
130 << "'" << now_str << "', "
131 << "'" << now_str << R"(')
132 ON CONFLICT(state_id) DO UPDATE SET
133 state_json = excluded.state_json,
134 updated_at = excluded.updated_at
135 )";
136
137 auto result = db_->open_session().insert(sql.str());
138 if (result.is_err()) {
139 return VoidResult(result.error());
140 }
141
142 return kcenon::common::ok();
143}
144
145std::optional<viewer_state_record> viewer_state_repository::find_state_by_id(
146 std::string_view state_id) const {
147 if (!db_ || !db_->is_connected()) return std::nullopt;
148
149 std::ostringstream sql;
150 sql << R"(
151 SELECT pk, state_id, study_uid, user_id, state_json, created_at, updated_at
152 FROM viewer_states WHERE state_id = ')" << state_id << "'";
153
154 auto result = db_->open_session().select(sql.str());
155 if (result.is_err() || result.value().empty()) {
156 return std::nullopt;
157 }
158
159 return map_row_to_state(result.value()[0]);
160}
161
162std::vector<viewer_state_record> viewer_state_repository::find_states_by_study(
163 std::string_view study_uid) const {
164 viewer_state_query query;
165 query.study_uid = std::string(study_uid);
166 return search_states(query);
167}
168
169std::vector<viewer_state_record> viewer_state_repository::search_states(
170 const viewer_state_query& query) const {
171 std::vector<viewer_state_record> states;
172 if (!db_ || !db_->is_connected()) return states;
173
174 std::ostringstream sql;
175 sql << R"(
176 SELECT pk, state_id, study_uid, user_id, state_json, created_at, updated_at
177 FROM viewer_states WHERE 1=1
178 )";
179
180 if (query.study_uid.has_value()) {
181 sql << " AND study_uid = '" << query.study_uid.value() << "'";
182 }
183
184 if (query.user_id.has_value()) {
185 sql << " AND user_id = '" << query.user_id.value() << "'";
186 }
187
188 sql << " ORDER BY updated_at DESC";
189
190 if (query.limit > 0) {
191 sql << " LIMIT " << query.limit << " OFFSET " << query.offset;
192 }
193
194 auto result = db_->open_session().select(sql.str());
195 if (result.is_err()) return states;
196
197 states.reserve(result.value().size());
198 for (const auto& row : result.value()) {
199 states.push_back(map_row_to_state(row));
200 }
201
202 return states;
203}
204
205VoidResult viewer_state_repository::remove_state(std::string_view state_id) {
206 if (!db_ || !db_->is_connected()) {
207 return VoidResult(kcenon::common::error_info{
208 -1, "Database not connected", "viewer_state_repository"});
209 }
210
211 std::ostringstream sql;
212 sql << "DELETE FROM viewer_states WHERE state_id = '" << state_id << "'";
213
214 auto result = db_->open_session().remove(sql.str());
215 if (result.is_err()) {
216 return VoidResult(result.error());
217 }
218
219 return kcenon::common::ok();
220}
221
222size_t viewer_state_repository::count_states() const {
223 if (!db_ || !db_->is_connected()) return 0;
224
225 auto result = db_->open_session().select("SELECT COUNT(*) as count FROM viewer_states");
226 if (result.is_err() || result.value().empty()) return 0;
227
228 return std::stoull(result.value()[0].at("count"));
229}
230
231// =============================================================================
232// Recent Studies Operations
233// =============================================================================
234
235VoidResult viewer_state_repository::record_study_access(
236 std::string_view user_id,
237 std::string_view study_uid) {
238 if (!db_ || !db_->is_connected()) {
239 return VoidResult(kcenon::common::error_info{
240 -1, "Database not connected", "viewer_state_repository"});
241 }
242
243 auto now_str = format_timestamp(std::chrono::system_clock::now());
244
245 std::ostringstream sql;
246 sql << R"(
247 INSERT INTO recent_studies (user_id, study_uid, accessed_at)
248 VALUES (')" << user_id << "', '" << study_uid << "', '" << now_str << R"(')
249 ON CONFLICT(user_id, study_uid) DO UPDATE SET
250 accessed_at = excluded.accessed_at
251 )";
252
253 auto result = db_->open_session().insert(sql.str());
254 if (result.is_err()) {
255 return VoidResult(result.error());
256 }
257
258 return kcenon::common::ok();
259}
260
261std::vector<recent_study_record> viewer_state_repository::get_recent_studies(
262 std::string_view user_id,
263 size_t limit) const {
264 std::vector<recent_study_record> studies;
265 if (!db_ || !db_->is_connected()) return studies;
266
267 std::ostringstream sql;
268 sql << R"(
269 SELECT pk, user_id, study_uid, accessed_at
270 FROM recent_studies
271 WHERE user_id = ')" << user_id << R"('
272 ORDER BY accessed_at DESC, pk DESC
273 LIMIT )" << limit;
274
275 auto result = db_->open_session().select(sql.str());
276 if (result.is_err()) return studies;
277
278 studies.reserve(result.value().size());
279 for (const auto& row : result.value()) {
280 studies.push_back(map_row_to_recent_study(row));
281 }
282
283 return studies;
284}
285
286VoidResult viewer_state_repository::clear_recent_studies(std::string_view user_id) {
287 if (!db_ || !db_->is_connected()) {
288 return VoidResult(kcenon::common::error_info{
289 -1, "Database not connected", "viewer_state_repository"});
290 }
291
292 std::ostringstream sql;
293 sql << "DELETE FROM recent_studies WHERE user_id = '" << user_id << "'";
294
295 auto result = db_->open_session().remove(sql.str());
296 if (result.is_err()) {
297 return VoidResult(result.error());
298 }
299
300 return kcenon::common::ok();
301}
302
303size_t viewer_state_repository::count_recent_studies(std::string_view user_id) const {
304 if (!db_ || !db_->is_connected()) return 0;
305
306 std::ostringstream sql;
307 sql << "SELECT COUNT(*) as count FROM recent_studies WHERE user_id = '"
308 << user_id << "'";
309
310 auto result = db_->open_session().select(sql.str());
311 if (result.is_err() || result.value().empty()) return 0;
312
313 return std::stoull(result.value()[0].at("count"));
314}
315
316// =============================================================================
317// Database Information
318// =============================================================================
319
320bool viewer_state_repository::is_valid() const noexcept {
321 return db_ && db_->is_connected();
322}
323
324// =============================================================================
325// Row Mapping
326// =============================================================================
327
328viewer_state_record viewer_state_repository::map_row_to_state(
329 const database_row& row) const {
330 viewer_state_record record;
331
332 record.pk = std::stoll(row.at("pk"));
333 record.state_id = row.at("state_id");
334 record.study_uid = row.at("study_uid");
335 record.user_id = row.at("user_id");
336 record.state_json = row.at("state_json");
337 record.created_at = parse_timestamp(row.at("created_at"));
338 record.updated_at = parse_timestamp(row.at("updated_at"));
339
340 return record;
341}
342
343recent_study_record viewer_state_repository::map_row_to_recent_study(
344 const database_row& row) const {
345 recent_study_record record;
346
347 record.pk = std::stoll(row.at("pk"));
348 record.user_id = row.at("user_id");
349 record.study_uid = row.at("study_uid");
350 record.accessed_at = parse_timestamp(row.at("accessed_at"));
351
352 return record;
353}
354
355} // namespace kcenon::pacs::storage
356
357#else // !PACS_WITH_DATABASE_SYSTEM
358
359// =============================================================================
360// Legacy SQLite Implementation
361// =============================================================================
362
363#include <sqlite3.h>
364
365namespace kcenon::pacs::storage {
366
367namespace {
368
369[[nodiscard]] std::string to_timestamp_string(
370 std::chrono::system_clock::time_point tp) {
371 if (tp == std::chrono::system_clock::time_point{}) {
372 return "";
373 }
374 auto time = std::chrono::system_clock::to_time_t(tp);
375 std::tm tm{};
376#ifdef _WIN32
377 gmtime_s(&tm, &time);
378#else
379 gmtime_r(&time, &tm);
380#endif
381 // Calculate milliseconds
382 auto since_epoch = tp.time_since_epoch();
383 auto secs = std::chrono::duration_cast<std::chrono::seconds>(since_epoch);
384 auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(since_epoch - secs);
385
386 char buf[32];
387 std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
388 // Append milliseconds
389 char result[40];
390 std::snprintf(result, sizeof(result), "%s.%03d", buf, static_cast<int>(ms.count()));
391 return result;
392}
393
394[[nodiscard]] std::chrono::system_clock::time_point from_timestamp_string(
395 const char* str) {
396 if (!str || str[0] == '\0') {
397 return {};
398 }
399 std::tm tm{};
400 int ms = 0;
401 // Try parsing with milliseconds first, then without
402 if (std::sscanf(str, "%d-%d-%d %d:%d:%d.%d",
403 &tm.tm_year, &tm.tm_mon, &tm.tm_mday,
404 &tm.tm_hour, &tm.tm_min, &tm.tm_sec, &ms) < 6) {
405 return {};
406 }
407 tm.tm_year -= 1900;
408 tm.tm_mon -= 1;
409#ifdef _WIN32
410 auto time = _mkgmtime(&tm);
411#else
412 auto time = timegm(&tm);
413#endif
414 auto tp = std::chrono::system_clock::from_time_t(time);
415 return tp + std::chrono::milliseconds(ms);
416}
417
418[[nodiscard]] std::string get_text_column(sqlite3_stmt* stmt, int col) {
419 auto text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
420 return text ? text : "";
421}
422
423[[nodiscard]] int64_t get_int64_column(sqlite3_stmt* stmt, int col, int64_t default_val = 0) {
424 if (sqlite3_column_type(stmt, col) == SQLITE_NULL) {
425 return default_val;
426 }
427 return sqlite3_column_int64(stmt, col);
428}
429
430} // namespace
431
433
435
437
438auto viewer_state_repository::operator=(viewer_state_repository&&) noexcept
439 -> viewer_state_repository& = default;
440
441// =============================================================================
442// Viewer State Operations
443// =============================================================================
444
445VoidResult viewer_state_repository::save_state(const viewer_state_record& record) {
446 if (!db_) {
447 return VoidResult(kcenon::common::error_info{
448 -1, "Database not initialized", "viewer_state_repository"});
449 }
450
451 static constexpr const char* sql = R"(
452 INSERT INTO viewer_states (
453 state_id, study_uid, user_id, state_json, created_at, updated_at
454 ) VALUES (?, ?, ?, ?, ?, ?)
455 ON CONFLICT(state_id) DO UPDATE SET
456 state_json = excluded.state_json,
457 updated_at = excluded.updated_at
458 )";
459
460 sqlite3_stmt* stmt = nullptr;
461 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
462 return VoidResult(kcenon::common::error_info{
463 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
464 "viewer_state_repository"});
465 }
466
467 auto now_str = to_timestamp_string(std::chrono::system_clock::now());
468
469 int idx = 1;
470 sqlite3_bind_text(stmt, idx++, record.state_id.c_str(), -1, SQLITE_TRANSIENT);
471 sqlite3_bind_text(stmt, idx++, record.study_uid.c_str(), -1, SQLITE_TRANSIENT);
472 sqlite3_bind_text(stmt, idx++, record.user_id.c_str(), -1, SQLITE_TRANSIENT);
473 sqlite3_bind_text(stmt, idx++, record.state_json.c_str(), -1, SQLITE_TRANSIENT);
474 sqlite3_bind_text(stmt, idx++, now_str.c_str(), -1, SQLITE_TRANSIENT);
475 sqlite3_bind_text(stmt, idx++, now_str.c_str(), -1, SQLITE_TRANSIENT);
476
477 auto rc = sqlite3_step(stmt);
478 sqlite3_finalize(stmt);
479
480 if (rc != SQLITE_DONE) {
481 return VoidResult(kcenon::common::error_info{
482 -1, "Failed to save viewer state: " + std::string(sqlite3_errmsg(db_)),
483 "viewer_state_repository"});
484 }
485
486 return kcenon::common::ok();
487}
488
489std::optional<viewer_state_record> viewer_state_repository::find_state_by_id(
490 std::string_view state_id) const {
491 if (!db_) return std::nullopt;
492
493 static constexpr const char* sql = R"(
494 SELECT pk, state_id, study_uid, user_id, state_json, created_at, updated_at
495 FROM viewer_states WHERE state_id = ?
496 )";
497
498 sqlite3_stmt* stmt = nullptr;
499 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
500 return std::nullopt;
501 }
502
503 sqlite3_bind_text(stmt, 1, state_id.data(),
504 static_cast<int>(state_id.size()), SQLITE_TRANSIENT);
505
506 std::optional<viewer_state_record> result;
507 if (sqlite3_step(stmt) == SQLITE_ROW) {
508 result = parse_state_row(stmt);
509 }
510
511 sqlite3_finalize(stmt);
512 return result;
513}
514
515std::vector<viewer_state_record> viewer_state_repository::find_states_by_study(
516 std::string_view study_uid) const {
517 viewer_state_query query;
518 query.study_uid = std::string(study_uid);
519 return search_states(query);
520}
521
522std::vector<viewer_state_record> viewer_state_repository::search_states(
523 const viewer_state_query& query) const {
524 std::vector<viewer_state_record> result;
525 if (!db_) return result;
526
527 std::ostringstream sql;
528 sql << R"(
529 SELECT pk, state_id, study_uid, user_id, state_json, created_at, updated_at
530 FROM viewer_states WHERE 1=1
531 )";
532
533 std::vector<std::pair<int, std::string>> bindings;
534 int param_idx = 1;
535
536 if (query.study_uid.has_value()) {
537 sql << " AND study_uid = ?";
538 bindings.emplace_back(param_idx++, query.study_uid.value());
539 }
540
541 if (query.user_id.has_value()) {
542 sql << " AND user_id = ?";
543 bindings.emplace_back(param_idx++, query.user_id.value());
544 }
545
546 sql << " ORDER BY updated_at DESC";
547
548 if (query.limit > 0) {
549 sql << " LIMIT " << query.limit << " OFFSET " << query.offset;
550 }
551
552 sqlite3_stmt* stmt = nullptr;
553 auto sql_str = sql.str();
554 if (sqlite3_prepare_v2(db_, sql_str.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
555 return result;
556 }
557
558 for (const auto& [idx, value] : bindings) {
559 sqlite3_bind_text(stmt, idx, value.c_str(), -1, SQLITE_TRANSIENT);
560 }
561
562 while (sqlite3_step(stmt) == SQLITE_ROW) {
563 result.push_back(parse_state_row(stmt));
564 }
565
566 sqlite3_finalize(stmt);
567 return result;
568}
569
570VoidResult viewer_state_repository::remove_state(std::string_view state_id) {
571 if (!db_) {
572 return VoidResult(kcenon::common::error_info{
573 -1, "Database not initialized", "viewer_state_repository"});
574 }
575
576 static constexpr const char* sql = "DELETE FROM viewer_states WHERE state_id = ?";
577
578 sqlite3_stmt* stmt = nullptr;
579 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
580 return VoidResult(kcenon::common::error_info{
581 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
582 "viewer_state_repository"});
583 }
584
585 sqlite3_bind_text(stmt, 1, state_id.data(),
586 static_cast<int>(state_id.size()), SQLITE_TRANSIENT);
587
588 auto rc = sqlite3_step(stmt);
589 sqlite3_finalize(stmt);
590
591 if (rc != SQLITE_DONE) {
592 return VoidResult(kcenon::common::error_info{
593 -1, "Failed to delete viewer state: " + std::string(sqlite3_errmsg(db_)),
594 "viewer_state_repository"});
595 }
596
597 return kcenon::common::ok();
598}
599
601 if (!db_) return 0;
602
603 static constexpr const char* sql = "SELECT COUNT(*) FROM viewer_states";
604
605 sqlite3_stmt* stmt = nullptr;
606 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
607 return 0;
608 }
609
610 size_t result = 0;
611 if (sqlite3_step(stmt) == SQLITE_ROW) {
612 result = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
613 }
614
615 sqlite3_finalize(stmt);
616 return result;
617}
618
619// =============================================================================
620// Recent Studies Operations
621// =============================================================================
622
624 std::string_view user_id,
625 std::string_view study_uid) {
626 if (!db_) {
627 return VoidResult(kcenon::common::error_info{
628 -1, "Database not initialized", "viewer_state_repository"});
629 }
630
631 static constexpr const char* sql = R"(
632 INSERT INTO recent_studies (user_id, study_uid, accessed_at)
633 VALUES (?, ?, ?)
634 ON CONFLICT(user_id, study_uid) DO UPDATE SET
635 accessed_at = excluded.accessed_at
636 )";
637
638 sqlite3_stmt* stmt = nullptr;
639 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
640 return VoidResult(kcenon::common::error_info{
641 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
642 "viewer_state_repository"});
643 }
644
645 auto now_str = to_timestamp_string(std::chrono::system_clock::now());
646
647 sqlite3_bind_text(stmt, 1, user_id.data(),
648 static_cast<int>(user_id.size()), SQLITE_TRANSIENT);
649 sqlite3_bind_text(stmt, 2, study_uid.data(),
650 static_cast<int>(study_uid.size()), SQLITE_TRANSIENT);
651 sqlite3_bind_text(stmt, 3, now_str.c_str(), -1, SQLITE_TRANSIENT);
652
653 auto rc = sqlite3_step(stmt);
654 sqlite3_finalize(stmt);
655
656 if (rc != SQLITE_DONE) {
657 return VoidResult(kcenon::common::error_info{
658 -1, "Failed to record study access: " + std::string(sqlite3_errmsg(db_)),
659 "viewer_state_repository"});
660 }
661
662 return kcenon::common::ok();
663}
664
665std::vector<recent_study_record> viewer_state_repository::get_recent_studies(
666 std::string_view user_id,
667 size_t limit) const {
668 std::vector<recent_study_record> result;
669 if (!db_) return result;
670
671 static constexpr const char* sql = R"(
672 SELECT pk, user_id, study_uid, accessed_at
673 FROM recent_studies
674 WHERE user_id = ?
675 ORDER BY accessed_at DESC, pk DESC
676 LIMIT ?
677 )";
678
679 sqlite3_stmt* stmt = nullptr;
680 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
681 return result;
682 }
683
684 sqlite3_bind_text(stmt, 1, user_id.data(),
685 static_cast<int>(user_id.size()), SQLITE_TRANSIENT);
686 sqlite3_bind_int64(stmt, 2, static_cast<int64_t>(limit));
687
688 while (sqlite3_step(stmt) == SQLITE_ROW) {
689 result.push_back(parse_recent_study_row(stmt));
690 }
691
692 sqlite3_finalize(stmt);
693 return result;
694}
695
696VoidResult viewer_state_repository::clear_recent_studies(std::string_view user_id) {
697 if (!db_) {
698 return VoidResult(kcenon::common::error_info{
699 -1, "Database not initialized", "viewer_state_repository"});
700 }
701
702 static constexpr const char* sql = "DELETE FROM recent_studies WHERE user_id = ?";
703
704 sqlite3_stmt* stmt = nullptr;
705 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
706 return VoidResult(kcenon::common::error_info{
707 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
708 "viewer_state_repository"});
709 }
710
711 sqlite3_bind_text(stmt, 1, user_id.data(),
712 static_cast<int>(user_id.size()), SQLITE_TRANSIENT);
713
714 auto rc = sqlite3_step(stmt);
715 sqlite3_finalize(stmt);
716
717 if (rc != SQLITE_DONE) {
718 return VoidResult(kcenon::common::error_info{
719 -1, "Failed to clear recent studies: " + std::string(sqlite3_errmsg(db_)),
720 "viewer_state_repository"});
721 }
722
723 return kcenon::common::ok();
724}
725
726size_t viewer_state_repository::count_recent_studies(std::string_view user_id) const {
727 if (!db_) return 0;
728
729 static constexpr const char* sql =
730 "SELECT COUNT(*) FROM recent_studies WHERE user_id = ?";
731
732 sqlite3_stmt* stmt = nullptr;
733 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
734 return 0;
735 }
736
737 sqlite3_bind_text(stmt, 1, user_id.data(),
738 static_cast<int>(user_id.size()), SQLITE_TRANSIENT);
739
740 size_t result = 0;
741 if (sqlite3_step(stmt) == SQLITE_ROW) {
742 result = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
743 }
744
745 sqlite3_finalize(stmt);
746 return result;
747}
748
749// =============================================================================
750// Database Information
751// =============================================================================
752
753bool viewer_state_repository::is_valid() const noexcept {
754 return db_ != nullptr;
755}
756
757// =============================================================================
758// Private Implementation
759// =============================================================================
760
762 auto* stmt = static_cast<sqlite3_stmt*>(stmt_ptr);
763 viewer_state_record record;
764
765 int col = 0;
766 record.pk = get_int64_column(stmt, col++);
767 record.state_id = get_text_column(stmt, col++);
768 record.study_uid = get_text_column(stmt, col++);
769 record.user_id = get_text_column(stmt, col++);
770 record.state_json = get_text_column(stmt, col++);
771
772 auto created_str = get_text_column(stmt, col++);
773 record.created_at = from_timestamp_string(created_str.c_str());
774
775 auto updated_str = get_text_column(stmt, col++);
776 record.updated_at = from_timestamp_string(updated_str.c_str());
777
778 return record;
779}
780
782 auto* stmt = static_cast<sqlite3_stmt*>(stmt_ptr);
783 recent_study_record record;
784
785 int col = 0;
786 record.pk = get_int64_column(stmt, col++);
787 record.user_id = get_text_column(stmt, col++);
788 record.study_uid = get_text_column(stmt, col++);
789
790 auto accessed_str = get_text_column(stmt, col++);
791 record.accessed_at = from_timestamp_string(accessed_str.c_str());
792
793 return record;
794}
795
796} // namespace kcenon::pacs::storage
797
798#endif // PACS_WITH_DATABASE_SYSTEM
Repository for viewer state persistence (legacy SQLite interface)
auto find_state_by_id(std::string_view state_id) const -> std::optional< viewer_state_record >
auto search_states(const viewer_state_query &query) const -> std::vector< viewer_state_record >
auto remove_state(std::string_view state_id) -> VoidResult
auto count_recent_studies(std::string_view user_id) const -> size_t
auto clear_recent_studies(std::string_view user_id) -> VoidResult
auto get_recent_studies(std::string_view user_id, size_t limit=20) const -> std::vector< recent_study_record >
auto parse_state_row(void *stmt) const -> viewer_state_record
auto find_states_by_study(std::string_view study_uid) const -> std::vector< viewer_state_record >
auto record_study_access(std::string_view user_id, std::string_view study_uid) -> VoidResult
auto parse_recent_study_row(void *stmt) const -> recent_study_record
@ move
C-MOVE move request/response.
const atna_coded_value query
Query (110112)
Recent study access record from the database.
Viewer state record from the database.
Repository for viewer state persistence.