#include "storage/path_guide_store.hpp" #include "storage/database.hpp" #include "util/id_util.hpp" #include "util/string_util.hpp" #include #include namespace lm { namespace { nlohmann::json parsePositionsJson(const std::string& raw) { if (raw.empty()) return nlohmann::json::array(); try { const auto parsed = nlohmann::json::parse(raw); return parsed.is_array() ? parsed : nlohmann::json::array(); } catch (...) { return nlohmann::json::array(); } } nlohmann::json rowToJson(sqlite3_stmt* stmt) { auto text = [&](int col) -> std::string { if (sqlite3_column_type(stmt, col) == SQLITE_NULL) return ""; const char* v = reinterpret_cast(sqlite3_column_text(stmt, col)); return v ? std::string(v) : ""; }; const nlohmann::json positions = parsePositionsJson(text(4)); int starts_count = 0; int vias_count = 0; int goals_count = 0; for (const auto& pos : positions) { if (!pos.is_object()) continue; const std::string role = StringUtil::toLower(pos.value("role", "")); if (role == "start") ++starts_count; else if (role == "via") ++vias_count; else if (role == "goal") ++goals_count; } return {{"id", text(0)}, {"site_id", text(1)}, {"map_id", text(2)}, {"name", text(3)}, {"positions", positions}, {"starts_count", starts_count}, {"vias_count", vias_count}, {"goals_count", goals_count}, {"created_by", text(5)}, {"created_by_group", text(6)}, {"created_at", text(7)}, {"updated_at", text(8)}}; } constexpr const char* kSelect = "SELECT id, site_id, map_id, name, positions_json, created_by, created_by_group, created_at, updated_at " "FROM path_guides"; bool validRole(const std::string& role) { return role == "start" || role == "via" || role == "goal"; } std::optional normalizePositions(const nlohmann::json& raw, std::string& err) { if (!raw.is_array()) { err = "positions must be an array"; return std::nullopt; } nlohmann::json out = nlohmann::json::array(); int starts = 0; int goals = 0; int via_priority = 0; for (const auto& item : raw) { if (!item.is_object()) continue; const std::string position_id = StringUtil::trimCopy(item.value("position_id", "")); const std::string role = StringUtil::toLower(StringUtil::trimCopy(item.value("role", ""))); if (position_id.empty() || !validRole(role)) { err = "invalid position entry"; return std::nullopt; } nlohmann::json entry = {{"position_id", position_id}, {"role", role}}; if (role == "via") { int priority = via_priority + 1; if (item.contains("priority") && item["priority"].is_number_integer()) priority = item["priority"].get(); if (priority <= 0) priority = via_priority + 1; via_priority = priority; entry["priority"] = priority; } else if (role == "start") { ++starts; } else if (role == "goal") { ++goals; } out.push_back(std::move(entry)); } if (starts < 1 || goals < 1) { err = "path guide requires at least one start and one goal"; return std::nullopt; } std::sort(out.begin(), out.end(), [](const nlohmann::json& a, const nlohmann::json& b) { const std::string ra = a.value("role", ""); const std::string rb = b.value("role", ""); if (ra != rb) { if (ra == "start") return true; if (rb == "start") return false; if (ra == "via") return true; return false; } if (ra == "via") return a.value("priority", 0) < b.value("priority", 0); return a.value("position_id", "") < b.value("position_id", ""); }); return out; } } // namespace PathGuideStore::PathGuideStore(Database& db) : db_(db) {} bool PathGuideStore::findNameConflictUnlocked(const std::string& site_id, const std::string& name, const std::string& except_id) const { const std::string needle = StringUtil::toLower(StringUtil::trimCopy(name)); if (needle.empty()) return false; sqlite3_stmt* stmt = nullptr; const char* sql = except_id.empty() ? "SELECT id FROM path_guides WHERE site_id = ?1 AND lower(name) = lower(?2) LIMIT 1" : "SELECT id FROM path_guides WHERE site_id = ?1 AND lower(name) = lower(?2) AND id != ?3 LIMIT 1"; if (sqlite3_prepare_v2(db_.handle(), sql, -1, &stmt, nullptr) != SQLITE_OK) return false; sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT); if (!except_id.empty()) sqlite3_bind_text(stmt, 3, except_id.c_str(), -1, SQLITE_TRANSIENT); const bool conflict = sqlite3_step(stmt) == SQLITE_ROW; sqlite3_finalize(stmt); return conflict; } nlohmann::json PathGuideStore::list(const std::string& site_id) const { std::lock_guard lock(mu_); nlohmann::json out = nlohmann::json::array(); std::string sql = kSelect; if (!site_id.empty()) sql += " WHERE site_id = ?1"; sql += " ORDER BY site_id, map_id, name"; sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) return out; if (!site_id.empty()) sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT); while (sqlite3_step(stmt) == SQLITE_ROW) out.push_back(rowToJson(stmt)); sqlite3_finalize(stmt); return out; } std::optional PathGuideStore::find(const std::string& id) const { std::lock_guard lock(mu_); const std::string sql = std::string(kSelect) + " WHERE id = ?1"; sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) return std::nullopt; sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); std::optional out; if (sqlite3_step(stmt) == SQLITE_ROW) out = rowToJson(stmt); sqlite3_finalize(stmt); return out; } std::optional PathGuideStore::create(const nlohmann::json& payload, std::string& err) { if (!payload.is_object()) { err = "payload must be an object"; return std::nullopt; } const std::string site_id = StringUtil::trimCopy(payload.value("site_id", "")); const std::string map_id = StringUtil::trimCopy(payload.value("map_id", "")); const std::string name = StringUtil::trimCopy(payload.value("name", "")); const std::string created_by = StringUtil::trimCopy(payload.value("created_by", "")); const std::string created_by_group = StringUtil::trimCopy(payload.value("created_by_group", "")); const auto positions = normalizePositions(payload.value("positions", nlohmann::json::array()), err); if (!positions) return std::nullopt; if (site_id.empty() || map_id.empty() || name.empty()) { err = "missing required fields"; return std::nullopt; } std::lock_guard lock(mu_); if (findNameConflictUnlocked(site_id, name, "")) { err = "path guide name already exists for this site"; return std::nullopt; } const std::string id = payload.value("id", IdUtil::newId()); const std::string now = IdUtil::nowIso8601(); const std::string positions_json = positions->dump(); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), "INSERT INTO path_guides(id, site_id, map_id, name, positions_json, created_by, " "created_by_group, created_at, updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)", -1, &stmt, nullptr) != SQLITE_OK) { err = sqlite3_errmsg(db_.handle()); return std::nullopt; } sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 3, map_id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 4, name.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 5, positions_json.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 6, created_by.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 7, created_by_group.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 9, now.c_str(), -1, SQLITE_TRANSIENT); if (sqlite3_step(stmt) != SQLITE_DONE) { err = sqlite3_errmsg(db_.handle()); sqlite3_finalize(stmt); return std::nullopt; } sqlite3_finalize(stmt); nlohmann::json created = {{"id", id}, {"site_id", site_id}, {"map_id", map_id}, {"name", name}, {"positions", *positions}, {"created_by", created_by}, {"created_by_group", created_by_group}, {"created_at", now}, {"updated_at", now}}; int starts_count = 0; int vias_count = 0; int goals_count = 0; for (const auto& pos : *positions) { const std::string role = pos.value("role", ""); if (role == "start") ++starts_count; else if (role == "via") ++vias_count; else if (role == "goal") ++goals_count; } created["starts_count"] = starts_count; created["vias_count"] = vias_count; created["goals_count"] = goals_count; return created; } bool PathGuideStore::update(const std::string& id, const nlohmann::json& payload, std::string& err) { auto existing = find(id); if (!existing) { err = "path guide not found"; return false; } nlohmann::json merged = *existing; for (const char* key : {"site_id", "map_id", "name"}) { if (payload.contains(key)) merged[key] = payload[key]; } if (payload.contains("positions")) merged["positions"] = payload["positions"]; const std::string site_id = StringUtil::trimCopy(merged.value("site_id", "")); const std::string map_id = StringUtil::trimCopy(merged.value("map_id", "")); const std::string name = StringUtil::trimCopy(merged.value("name", "")); const auto positions = normalizePositions(merged.value("positions", nlohmann::json::array()), err); if (!positions) return false; if (site_id.empty() || map_id.empty() || name.empty()) { err = "missing required fields"; return false; } std::lock_guard lock(mu_); if (findNameConflictUnlocked(site_id, name, id)) { err = "path guide name already exists for this site"; return false; } const std::string now = IdUtil::nowIso8601(); const std::string positions_json = positions->dump(); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), "UPDATE path_guides SET site_id=?2, map_id=?3, name=?4, positions_json=?5, updated_at=?6 WHERE id=?1", -1, &stmt, nullptr) != SQLITE_OK) { err = sqlite3_errmsg(db_.handle()); return false; } sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 3, map_id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 4, name.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 5, positions_json.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT); const bool ok = sqlite3_step(stmt) == SQLITE_DONE; if (!ok) err = sqlite3_errmsg(db_.handle()); sqlite3_finalize(stmt); return ok; } bool PathGuideStore::remove(const std::string& id, std::string& err) { if (!find(id)) { err = "path guide not found"; return false; } std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM path_guides WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK) { err = sqlite3_errmsg(db_.handle()); return false; } sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); const bool ok = sqlite3_step(stmt) == SQLITE_DONE; if (!ok) err = sqlite3_errmsg(db_.handle()); sqlite3_finalize(stmt); return ok; } } // namespace lm