#include "smartbotic/webserver/collection_service.hpp" #include #include #include #include #include #include #include namespace smartbotic::webserver { namespace { /// System collection for storing collection settings/metadata constexpr const char* kSettingsCollection = "_collection_settings"; /// Convert timestamp to ISO 8601 string auto TimestampToString(const ::smartbotic::database::Timestamp& ts) -> std::string { if (ts.seconds() == 0 && ts.nanos() == 0) { return ""; } std::time_t time = ts.seconds(); std::tm tm{}; gmtime_r(&time, &tm); std::ostringstream oss; oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S"); if (ts.nanos() > 0) { oss << "." << std::setfill('0') << std::setw(3) << (ts.nanos() / 1000000); } oss << "Z"; return oss.str(); } } // namespace CollectionService::CollectionService(DatabaseClient& db_client) : db_client_(db_client) { // Ensure settings collection exists grpc::ClientContext ctx; ::smartbotic::database::CreateCollectionRequest req; ::smartbotic::database::CollectionMetadata resp; req.set_name(kSettingsCollection); auto status = db_client_.GetCollectionService()->CreateCollection(&ctx, req, &resp); if (status.ok()) { spdlog::info("Created system collection {}", kSettingsCollection); } else if (status.error_code() == grpc::StatusCode::ALREADY_EXISTS) { spdlog::debug("System collection {} already exists", kSettingsCollection); } else { spdlog::warn("Failed to ensure {} collection: {}", kSettingsCollection, status.error_message()); } } CollectionService::~CollectionService() = default; CollectionService::CollectionService(CollectionService&&) noexcept = default; auto CollectionService::operator=(CollectionService&& /*other*/) noexcept -> CollectionService& { // Cannot reassign reference member, so this is essentially a no-op return *this; } auto CollectionService::CreateCollection(const CreateCollectionRequest& request) -> CollectionResult { // Validate collection name if (!IsValidCollectionName(request.name)) { return {.success = false, .error = "Invalid collection name. Must be alphanumeric with underscores, 1-64 chars.", .collection = std::nullopt}; } // System collection names are reserved if (IsSystemCollection(request.name)) { return {.success = false, .error = "Collection name is reserved for system use", .collection = std::nullopt}; } std::string internal_name = GetInternalName(request.workspace_id, request.name); // Create the collection via gRPC grpc::ClientContext ctx; ::smartbotic::database::CreateCollectionRequest req; ::smartbotic::database::CollectionMetadata resp; req.set_name(internal_name); auto status = db_client_.GetCollectionService()->CreateCollection(&ctx, req, &resp); if (!status.ok()) { if (status.error_code() == grpc::StatusCode::ALREADY_EXISTS) { return {.success = false, .error = "Collection already exists", .collection = std::nullopt}; } spdlog::error("Failed to create collection {}: {}", internal_name, status.error_message()); return {.success = false, .error = "Failed to create collection: " + status.error_message(), .collection = std::nullopt}; } // Store settings in metadata collection if (!StoreSettings(request.workspace_id, request.name, request.settings)) { spdlog::warn("Collection {} created but failed to store settings", internal_name); } auto info = MetadataToInfo(request.workspace_id, resp); info.settings = request.settings; spdlog::info("Created collection {} in workspace {}", request.name, request.workspace_id); return {.success = true, .error = "", .collection = info}; } auto CollectionService::GetCollection(const std::string& workspace_id, const std::string& name) -> CollectionResult { std::string internal_name = GetInternalName(workspace_id, name); grpc::ClientContext ctx; ::smartbotic::database::GetCollectionMetadataRequest req; ::smartbotic::database::CollectionMetadata resp; req.set_name(internal_name); auto status = db_client_.GetCollectionService()->GetCollectionMetadata(&ctx, req, &resp); if (!status.ok()) { if (status.error_code() == grpc::StatusCode::NOT_FOUND) { return {.success = false, .error = "Collection not found", .collection = std::nullopt}; } spdlog::error("Failed to get collection {}: {}", internal_name, status.error_message()); return {.success = false, .error = "Failed to get collection: " + status.error_message(), .collection = std::nullopt}; } auto info = MetadataToInfo(workspace_id, resp); info.settings = LoadSettings(workspace_id, name); return {.success = true, .error = "", .collection = info}; } auto CollectionService::ListCollections(const std::string& workspace_id) -> CollectionListResult { grpc::ClientContext ctx; ::smartbotic::database::ListCollectionsRequest req; ::smartbotic::database::ListCollectionsResponse resp; req.set_page_size(1000); // Get all collections auto status = db_client_.GetCollectionService()->ListCollections(&ctx, req, &resp); if (!status.ok()) { spdlog::error("Failed to list collections: {}", status.error_message()); return {.success = false, .error = "Failed to list collections: " + status.error_message(), .collections = {}, .total_count = 0}; } std::string prefix = GetInternalName(workspace_id, ""); std::vector collections; for (const auto& meta : resp.collections()) { // Only include collections that belong to this workspace if (meta.name().rfind(prefix, 0) == 0) { auto info = MetadataToInfo(workspace_id, meta); info.settings = LoadSettings(workspace_id, info.name); collections.push_back(std::move(info)); } } int64_t count = static_cast(collections.size()); return { .success = true, .error = "", .collections = std::move(collections), .total_count = count }; } auto CollectionService::ListSystemCollections() -> CollectionListResult { grpc::ClientContext ctx; ::smartbotic::database::ListCollectionsRequest req; ::smartbotic::database::ListCollectionsResponse resp; req.set_page_size(1000); auto status = db_client_.GetCollectionService()->ListCollections(&ctx, req, &resp); if (!status.ok()) { spdlog::error("Failed to list system collections: {}", status.error_message()); return {.success = false, .error = "Failed to list collections: " + status.error_message(), .collections = {}, .total_count = 0}; } std::vector collections; for (const auto& meta : resp.collections()) { // System collections start with underscore and are not workspace-prefixed if (!meta.name().empty() && meta.name()[0] == '_') { CollectionInfo info; info.name = meta.name(); info.workspace_id = ""; // Global collection info.document_count = meta.document_count(); info.size_bytes = meta.size_bytes(); info.created_at = TimestampToString(meta.created_at()); info.updated_at = TimestampToString(meta.updated_at()); collections.push_back(std::move(info)); } } int64_t count = static_cast(collections.size()); return { .success = true, .error = "", .collections = std::move(collections), .total_count = count }; } auto CollectionService::UpdateCollection(const std::string& workspace_id, const std::string& name, const CollectionSettings& settings) -> CollectionResult { // First verify the collection exists auto get_result = GetCollection(workspace_id, name); if (!get_result.success) { return get_result; } // Update settings in metadata collection if (!StoreSettings(workspace_id, name, settings)) { return {.success = false, .error = "Failed to update collection settings", .collection = std::nullopt}; } // Return updated info auto info = *get_result.collection; info.settings = settings; spdlog::info("Updated collection {} in workspace {}", name, workspace_id); return {.success = true, .error = "", .collection = info}; } auto CollectionService::DropCollection(const std::string& workspace_id, const std::string& name, bool force) -> CollectionResult { std::string internal_name = GetInternalName(workspace_id, name); grpc::ClientContext ctx; ::smartbotic::database::DropCollectionRequest req; ::smartbotic::database::DropCollectionResponse resp; req.set_name(internal_name); req.set_force(force); auto status = db_client_.GetCollectionService()->DropCollection(&ctx, req, &resp); if (!status.ok()) { if (status.error_code() == grpc::StatusCode::NOT_FOUND) { return {.success = false, .error = "Collection not found", .collection = std::nullopt}; } if (status.error_code() == grpc::StatusCode::FAILED_PRECONDITION) { return {.success = false, .error = "Collection is not empty. Use force=true to drop anyway.", .collection = std::nullopt}; } spdlog::error("Failed to drop collection {}: {}", internal_name, status.error_message()); return {.success = false, .error = "Failed to drop collection: " + status.error_message(), .collection = std::nullopt}; } // Delete settings DeleteSettings(workspace_id, name); spdlog::info("Dropped collection {} from workspace {}", name, workspace_id); return {.success = true, .error = "", .collection = std::nullopt}; } auto CollectionService::IsValidCollectionName(const std::string& name) -> bool { if (name.empty() || name.length() > 64) { return false; } // Must start with letter or underscore, contain only alphanumeric and underscores static const std::regex pattern("^[a-zA-Z_][a-zA-Z0-9_]*$"); return std::regex_match(name, pattern); } auto CollectionService::IsSystemCollection(const std::string& name) -> bool { return !name.empty() && name[0] == '_'; } auto CollectionService::GetInternalName(const std::string& workspace_id, const std::string& name) -> std::string { return std::string(kCollectionPrefix) + workspace_id + "_" + name; } auto CollectionService::GetDisplayName(const std::string& workspace_id, const std::string& internal_name) -> std::string { std::string prefix = GetInternalName(workspace_id, ""); if (internal_name.rfind(prefix, 0) == 0) { return internal_name.substr(prefix.length()); } return internal_name; } auto CollectionService::MetadataToInfo(const std::string& workspace_id, const ::smartbotic::database::CollectionMetadata& meta) -> CollectionInfo { CollectionInfo info; info.name = GetDisplayName(workspace_id, meta.name()); info.workspace_id = workspace_id; info.document_count = meta.document_count(); info.size_bytes = meta.size_bytes(); info.created_at = TimestampToString(meta.created_at()); info.updated_at = TimestampToString(meta.updated_at()); return info; } auto CollectionService::GetCurrentTimestamp() -> std::string { auto now = std::chrono::system_clock::now(); auto time_t = std::chrono::system_clock::to_time_t(now); auto ms = std::chrono::duration_cast(now.time_since_epoch()) % 1000; std::tm tm{}; gmtime_r(&time_t, &tm); std::ostringstream oss; oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S"); oss << "." << std::setfill('0') << std::setw(3) << ms.count() << "Z"; return oss.str(); } auto CollectionService::StoreSettings(const std::string& workspace_id, const std::string& name, const CollectionSettings& settings) -> bool { std::string doc_id = workspace_id + "_" + name; nlohmann::json json_settings; json_settings["workspace_id"] = workspace_id; json_settings["collection_name"] = name; json_settings["schema"] = settings.schema; json_settings["is_system"] = settings.is_system; json_settings["encrypted_fields"] = settings.encrypted_fields; json_settings["ttl_seconds"] = settings.ttl_seconds; json_settings["updated_at"] = GetCurrentTimestamp(); // Serialize field_permissions nlohmann::json field_perms_json = nlohmann::json::array(); for (const auto& fp : settings.field_permissions) { nlohmann::json fp_json; fp_json["field_name"] = fp.field_name; fp_json["read_groups"] = fp.read_groups; fp_json["write_groups"] = fp.write_groups; field_perms_json.push_back(fp_json); } json_settings["field_permissions"] = field_perms_json; // Build the proto document using a recursive lambda to handle nested structures std::function json_to_proto; json_to_proto = [&json_to_proto](const nlohmann::json& j, ::smartbotic::database::Value* val) { if (j.is_string()) { val->set_string_value(j.get()); } else if (j.is_boolean()) { val->set_bool_value(j.get()); } else if (j.is_number_integer()) { val->set_int_value(j.get()); } else if (j.is_number_float()) { val->set_double_value(j.get()); } else if (j.is_array()) { auto* arr = val->mutable_array_value(); for (const auto& item : j) { json_to_proto(item, arr->add_values()); } } else if (j.is_object()) { auto* map = val->mutable_map_value(); for (const auto& [k, v] : j.items()) { json_to_proto(v, &(*map->mutable_fields())[k]); } } else if (j.is_null()) { val->set_null_value(true); } }; ::smartbotic::database::MapValue data; for (const auto& [key, value] : json_settings.items()) { json_to_proto(value, &(*data.mutable_fields())[key]); } // Try update first, then create { grpc::ClientContext ctx; ::smartbotic::database::UpdateDocumentRequest req; ::smartbotic::database::Document resp; req.set_collection(kSettingsCollection); req.set_id(doc_id); req.set_merge(true); *req.mutable_data() = data; auto status = db_client_.GetDocumentService()->UpdateDocument(&ctx, req, &resp); if (status.ok()) { return true; } } // Create if update failed { grpc::ClientContext ctx; ::smartbotic::database::CreateDocumentRequest req; ::smartbotic::database::Document resp; req.set_collection(kSettingsCollection); req.set_id(doc_id); *req.mutable_data() = data; auto status = db_client_.GetDocumentService()->CreateDocument(&ctx, req, &resp); if (!status.ok()) { spdlog::error("Failed to store collection settings: {}", status.error_message()); return false; } } return true; } auto CollectionService::LoadSettings(const std::string& workspace_id, const std::string& name) -> CollectionSettings { std::string doc_id = workspace_id + "_" + name; grpc::ClientContext ctx; ::smartbotic::database::GetDocumentRequest req; ::smartbotic::database::Document resp; req.set_collection(kSettingsCollection); req.set_id(doc_id); auto status = db_client_.GetDocumentService()->GetDocument(&ctx, req, &resp); if (!status.ok()) { return {}; // Return defaults if not found } CollectionSettings settings; const auto& fields = resp.data().fields(); if (auto it = fields.find("schema"); it != fields.end() && it->second.has_string_value()) { settings.schema = it->second.string_value(); } if (auto it = fields.find("is_system"); it != fields.end() && it->second.has_bool_value()) { settings.is_system = it->second.bool_value(); } if (auto it = fields.find("ttl_seconds"); it != fields.end() && it->second.has_int_value()) { settings.ttl_seconds = it->second.int_value(); } if (auto it = fields.find("encrypted_fields"); it != fields.end() && it->second.has_array_value()) { for (const auto& val : it->second.array_value().values()) { if (val.has_string_value()) { settings.encrypted_fields.push_back(val.string_value()); } } } // Load field_permissions if (auto it = fields.find("field_permissions"); it != fields.end() && it->second.has_array_value()) { for (const auto& fp_val : it->second.array_value().values()) { if (fp_val.has_map_value()) { FieldPermission fp; const auto& fp_fields = fp_val.map_value().fields(); if (auto fn_it = fp_fields.find("field_name"); fn_it != fp_fields.end() && fn_it->second.has_string_value()) { fp.field_name = fn_it->second.string_value(); } if (auto rg_it = fp_fields.find("read_groups"); rg_it != fp_fields.end() && rg_it->second.has_array_value()) { for (const auto& rg : rg_it->second.array_value().values()) { if (rg.has_string_value()) { fp.read_groups.push_back(rg.string_value()); } } } if (auto wg_it = fp_fields.find("write_groups"); wg_it != fp_fields.end() && wg_it->second.has_array_value()) { for (const auto& wg : wg_it->second.array_value().values()) { if (wg.has_string_value()) { fp.write_groups.push_back(wg.string_value()); } } } settings.field_permissions.push_back(std::move(fp)); } } } return settings; } auto CollectionService::DeleteSettings(const std::string& workspace_id, const std::string& name) -> bool { std::string doc_id = workspace_id + "_" + name; grpc::ClientContext ctx; ::smartbotic::database::DeleteDocumentRequest req; ::smartbotic::database::DeleteDocumentResponse resp; req.set_collection(kSettingsCollection); req.set_id(doc_id); auto status = db_client_.GetDocumentService()->DeleteDocument(&ctx, req, &resp); return status.ok(); } } // namespace smartbotic::webserver