#include "smartbotic/webserver/group_service.hpp" #include #include #include #include #include #include namespace smartbotic::webserver { namespace { auto SetStringValue(::smartbotic::database::MapValue* map, const std::string& key, const std::string& value) { auto* field = &(*map->mutable_fields())[key]; field->set_string_value(value); } auto SetArrayValue(::smartbotic::database::MapValue* map, const std::string& key, const std::vector& values) { auto* field = &(*map->mutable_fields())[key]; auto* array = field->mutable_array_value(); for (const auto& value : values) { array->add_values()->set_string_value(value); } } auto GetStringValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> std::string { auto it = map.fields().find(key); if (it != map.fields().end() && it->second.has_string_value()) { return it->second.string_value(); } return ""; } auto GetArrayValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> std::vector { std::vector result; auto it = map.fields().find(key); if (it != map.fields().end() && it->second.has_array_value()) { for (const auto& value : it->second.array_value().values()) { if (value.has_string_value()) { result.push_back(value.string_value()); } } } return result; } auto GetBoolValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> bool { auto it = map.fields().find(key); if (it != map.fields().end() && it->second.has_bool_value()) { return it->second.bool_value(); } return false; } auto SetBoolValue(::smartbotic::database::MapValue* map, const std::string& key, bool value) { auto* field = &(*map->mutable_fields())[key]; field->set_bool_value(value); } } // namespace GroupService::GroupService(DatabaseClient& db_client) : db_client_(db_client) {} GroupService::~GroupService() = default; // Move operations not supported due to reference member GroupService::GroupService(GroupService&& other) noexcept : db_client_(other.db_client_), initialized_(other.initialized_) {} auto GroupService::operator=(GroupService&& /*other*/) noexcept -> GroupService& { // Cannot reassign reference member, so this is essentially a no-op return *this; } auto GroupService::Initialize() -> bool { if (initialized_) { return true; } auto* collection_service = db_client_.GetCollectionService(); if (collection_service == nullptr) { spdlog::error("Collection service not available"); return false; } // Check if _groups collection exists ::smartbotic::database::GetCollectionMetadataRequest get_req; get_req.set_name(kCollectionName); grpc::ClientContext get_ctx; ::smartbotic::database::CollectionMetadata metadata; auto status = collection_service->GetCollectionMetadata(&get_ctx, get_req, &metadata); if (status.ok()) { spdlog::info("System collection {} already exists", kCollectionName); } else { // Collection doesn't exist, create it ::smartbotic::database::CreateCollectionRequest create_req; create_req.set_name(kCollectionName); // Add compound index on workspace_id + name for uniqueness within workspace auto* name_index = create_req.add_indexes(); name_index->set_collection(kCollectionName); name_index->set_index_name("workspace_name_unique"); name_index->add_fields("workspace_id"); name_index->add_fields("name"); name_index->set_unique(true); name_index->set_type(::smartbotic::database::INDEX_TYPE_HASH); grpc::ClientContext create_ctx; ::smartbotic::database::CollectionMetadata created_metadata; status = collection_service->CreateCollection(&create_ctx, create_req, &created_metadata); if (!status.ok()) { spdlog::error("Failed to create {} collection: {}", kCollectionName, status.error_message()); return false; } spdlog::info("Created system collection {}", kCollectionName); } // Ensure superadmin group exists auto superadmin_result = GetGroupByName("", kSuperadminGroupName, true); if (!superadmin_result.success) { if (!CreateSuperadminGroup()) { spdlog::error("Failed to create superadmin group"); return false; } } else { spdlog::info("Global superadmin group already exists"); } initialized_ = true; return true; } auto GroupService::CreateSuperadminGroup() -> bool { return CreateSystemGroup(kSuperadminGroupName, {"*"}); } auto GroupService::CreateSystemGroup(const std::string& name, const std::vector& permissions) -> bool { auto* doc_service = db_client_.GetDocumentService(); if (doc_service == nullptr) { return false; } // Check if group already exists auto existing = GetGroupByName("", name, true); if (existing.success) { spdlog::info("System group {} already exists", name); return true; } ::smartbotic::database::CreateDocumentRequest create_req; create_req.set_collection(kCollectionName); auto* data = create_req.mutable_data(); SetStringValue(data, "workspace_id", ""); // Global group SetStringValue(data, "name", name); SetArrayValue(data, "permissions", permissions); SetBoolValue(data, "is_system", true); // Mark as system group SetStringValue(data, "parent_group_id", ""); SetStringValue(data, "created_at", GetCurrentTimestamp()); SetStringValue(data, "updated_at", GetCurrentTimestamp()); SetStringValue(data, "deleted_at", ""); SetStringValue(data, "created_by", ""); // System-created SetStringValue(data, "updated_by", ""); grpc::ClientContext ctx; ::smartbotic::database::Document created_doc; auto status = doc_service->CreateDocument(&ctx, create_req, &created_doc); if (!status.ok()) { spdlog::error("Failed to create system group {}: {}", name, status.error_message()); return false; } spdlog::info("Created system group {} with ID {}", name, created_doc.id()); return true; } auto GroupService::CreateGroup(const CreateGroupRequest& request) -> GroupResult { if (!initialized_) { return GroupResult{.success = false, .error = "Group service not initialized", .group = std::nullopt}; } // Validate request if (request.name.empty()) { return GroupResult{.success = false, .error = "Name is required", .group = std::nullopt}; } if (request.workspace_id.empty()) { return GroupResult{.success = false, .error = "Workspace ID is required for non-global groups", .group = std::nullopt}; } // Prevent creating groups with reserved names if (request.name == kSuperadminGroupName) { return GroupResult{.success = false, .error = "Cannot create group with reserved name", .group = std::nullopt}; } // Check if name already exists in workspace if (NameExistsInWorkspace(request.workspace_id, request.name)) { return GroupResult{.success = false, .error = "Group name already exists in workspace", .group = std::nullopt}; } // Validate parent group if specified if (!request.parent_group_id.empty()) { auto parent_result = GetGroup(request.parent_group_id, false); if (!parent_result.success) { return GroupResult{.success = false, .error = "Parent group not found", .group = std::nullopt}; } } // Create the document auto* doc_service = db_client_.GetDocumentService(); if (doc_service == nullptr) { return GroupResult{.success = false, .error = "Document service not available", .group = std::nullopt}; } ::smartbotic::database::CreateDocumentRequest create_req; create_req.set_collection(kCollectionName); auto* data = create_req.mutable_data(); SetStringValue(data, "workspace_id", request.workspace_id); SetStringValue(data, "name", request.name); SetArrayValue(data, "permissions", request.permissions); SetBoolValue(data, "is_system", request.is_system); SetStringValue(data, "parent_group_id", request.parent_group_id); SetStringValue(data, "created_at", GetCurrentTimestamp()); SetStringValue(data, "updated_at", GetCurrentTimestamp()); SetStringValue(data, "deleted_at", ""); SetStringValue(data, "created_by", request.actor_id); SetStringValue(data, "updated_by", request.actor_id); grpc::ClientContext ctx; ::smartbotic::database::Document created_doc; auto status = doc_service->CreateDocument(&ctx, create_req, &created_doc); if (!status.ok()) { spdlog::error("Failed to create group: {}", status.error_message()); return GroupResult{.success = false, .error = status.error_message(), .group = std::nullopt}; } spdlog::info("Created group {} with name {} in workspace {}", created_doc.id(), request.name, request.workspace_id); return GroupResult{.success = true, .error = "", .group = DocumentToGroup(created_doc)}; } auto GroupService::GetGroup(const std::string& id, bool include_deleted) -> GroupResult { if (!initialized_) { return GroupResult{.success = false, .error = "Group service not initialized", .group = std::nullopt}; } auto* doc_service = db_client_.GetDocumentService(); if (doc_service == nullptr) { return GroupResult{.success = false, .error = "Document service not available", .group = std::nullopt}; } ::smartbotic::database::GetDocumentRequest get_req; get_req.set_collection(kCollectionName); get_req.set_id(id); grpc::ClientContext ctx; ::smartbotic::database::Document doc; auto status = doc_service->GetDocument(&ctx, get_req, &doc); if (!status.ok()) { if (status.error_code() == grpc::StatusCode::NOT_FOUND) { return GroupResult{.success = false, .error = "Group not found", .group = std::nullopt}; } return GroupResult{.success = false, .error = status.error_message(), .group = std::nullopt}; } auto group = DocumentToGroup(doc); if (!include_deleted && group.IsDeleted()) { return GroupResult{.success = false, .error = "Group not found", .group = std::nullopt}; } return GroupResult{.success = true, .error = "", .group = group}; } auto GroupService::GetGroupByName(const std::string& workspace_id, const std::string& name, bool include_deleted) -> GroupResult { if (!initialized_ && name != kSuperadminGroupName) { return GroupResult{.success = false, .error = "Group service not initialized", .group = std::nullopt}; } auto* query_service = db_client_.GetQueryService(); if (query_service == nullptr) { return GroupResult{.success = false, .error = "Query service not available", .group = std::nullopt}; } ::smartbotic::database::QueryRequest query_req; query_req.set_collection(kCollectionName); query_req.set_limit(1); // Build compound filter: workspace_id AND name auto* filter = query_req.mutable_filter(); auto* composite = filter->mutable_composite(); composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND); auto* ws_filter = composite->add_filters()->mutable_field(); ws_filter->set_field("workspace_id"); ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL); ws_filter->mutable_value()->set_string_value(workspace_id); auto* name_filter = composite->add_filters()->mutable_field(); name_filter->set_field("name"); name_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL); name_filter->mutable_value()->set_string_value(name); grpc::ClientContext ctx; ::smartbotic::database::QueryResponse response; auto status = query_service->Query(&ctx, query_req, &response); if (!status.ok()) { return GroupResult{.success = false, .error = status.error_message(), .group = std::nullopt}; } if (response.documents().empty()) { return GroupResult{.success = false, .error = "Group not found", .group = std::nullopt}; } auto group = DocumentToGroup(response.documents(0)); if (!include_deleted && group.IsDeleted()) { return GroupResult{.success = false, .error = "Group not found", .group = std::nullopt}; } return GroupResult{.success = true, .error = "", .group = group}; } auto GroupService::ListGroups(const std::string& workspace_id, int limit, int offset, bool include_deleted) -> GroupListResult { if (!initialized_) { return GroupListResult{.success = false, .error = "Group service not initialized", .groups = {}, .total_count = 0}; } auto* query_service = db_client_.GetQueryService(); if (query_service == nullptr) { return GroupListResult{.success = false, .error = "Query service not available", .groups = {}, .total_count = 0}; } ::smartbotic::database::QueryRequest query_req; query_req.set_collection(kCollectionName); query_req.set_limit(limit); query_req.set_offset(offset); // Build filter for workspace auto* filter = query_req.mutable_filter(); if (!include_deleted) { // Filter: workspace_id = X AND deleted_at = "" auto* composite = filter->mutable_composite(); composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND); auto* ws_filter = composite->add_filters()->mutable_field(); ws_filter->set_field("workspace_id"); ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL); ws_filter->mutable_value()->set_string_value(workspace_id); auto* del_filter = composite->add_filters()->mutable_field(); del_filter->set_field("deleted_at"); del_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL); del_filter->mutable_value()->set_string_value(""); } else { // Filter: workspace_id = X auto* ws_filter = filter->mutable_field(); ws_filter->set_field("workspace_id"); ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL); ws_filter->mutable_value()->set_string_value(workspace_id); } // Order by name ascending auto* order = query_req.add_order_by(); order->set_field("name"); order->set_direction(::smartbotic::database::SORT_DIRECTION_ASCENDING); grpc::ClientContext ctx; ::smartbotic::database::QueryResponse response; auto status = query_service->Query(&ctx, query_req, &response); if (!status.ok()) { return GroupListResult{.success = false, .error = status.error_message(), .groups = {}, .total_count = 0}; } GroupListResult result; result.success = true; result.total_count = response.total_count(); for (const auto& doc : response.documents()) { result.groups.push_back(DocumentToGroup(doc)); } return result; } auto GroupService::ListGroupsByIds(const std::vector& group_ids, bool include_deleted) -> GroupListResult { if (!initialized_) { return GroupListResult{.success = false, .error = "Group service not initialized", .groups = {}, .total_count = 0}; } if (group_ids.empty()) { return GroupListResult{.success = true, .error = "", .groups = {}, .total_count = 0}; } // Fetch each group by ID GroupListResult result; result.success = true; for (const auto& id : group_ids) { auto grp_result = GetGroup(id, include_deleted); if (grp_result.success && grp_result.group) { result.groups.push_back(*grp_result.group); } } result.total_count = static_cast(result.groups.size()); return result; } auto GroupService::UpdateGroup(const std::string& id, const UpdateGroupRequest& request) -> GroupResult { if (!initialized_) { return GroupResult{.success = false, .error = "Group service not initialized", .group = std::nullopt}; } // First get the existing group auto existing = GetGroup(id, false); if (!existing.success) { return existing; } // Cannot modify superadmin group if (existing.group->name == kSuperadminGroupName && existing.group->IsGlobal()) { return GroupResult{.success = false, .error = "Cannot modify superadmin group", .group = std::nullopt}; } // Check if name is being updated and if it already exists if (request.name && *request.name != existing.group->name) { if (*request.name == kSuperadminGroupName) { return GroupResult{.success = false, .error = "Cannot use reserved name", .group = std::nullopt}; } if (NameExistsInWorkspace(existing.group->workspace_id, *request.name)) { return GroupResult{.success = false, .error = "Group name already exists in workspace", .group = std::nullopt}; } } auto* doc_service = db_client_.GetDocumentService(); if (doc_service == nullptr) { return GroupResult{.success = false, .error = "Document service not available", .group = std::nullopt}; } ::smartbotic::database::UpdateDocumentRequest update_req; update_req.set_collection(kCollectionName); update_req.set_id(id); update_req.set_merge(true); // Merge with existing data auto* data = update_req.mutable_data(); if (request.name) { SetStringValue(data, "name", *request.name); } if (request.permissions) { SetArrayValue(data, "permissions", *request.permissions); } if (request.parent_group_id) { // Validate parent group if specified if (!request.parent_group_id->empty()) { auto parent_result = GetGroup(*request.parent_group_id, false); if (!parent_result.success) { return GroupResult{.success = false, .error = "Parent group not found", .group = std::nullopt}; } // Prevent circular references if (*request.parent_group_id == id) { return GroupResult{.success = false, .error = "Group cannot be its own parent", .group = std::nullopt}; } } SetStringValue(data, "parent_group_id", *request.parent_group_id); } SetStringValue(data, "updated_at", GetCurrentTimestamp()); if (!request.actor_id.empty()) { SetStringValue(data, "updated_by", request.actor_id); } grpc::ClientContext ctx; ::smartbotic::database::Document updated_doc; auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc); if (!status.ok()) { return GroupResult{.success = false, .error = status.error_message(), .group = std::nullopt}; } spdlog::info("Updated group {}", id); return GroupResult{.success = true, .error = "", .group = DocumentToGroup(updated_doc)}; } auto GroupService::DeleteGroup(const std::string& id) -> GroupResult { if (!initialized_) { return GroupResult{.success = false, .error = "Group service not initialized", .group = std::nullopt}; } // First get the existing group auto existing = GetGroup(id, false); if (!existing.success) { return existing; } // Cannot delete superadmin group if (existing.group->name == kSuperadminGroupName && existing.group->IsGlobal()) { return GroupResult{.success = false, .error = "Cannot delete superadmin group", .group = std::nullopt}; } auto* doc_service = db_client_.GetDocumentService(); if (doc_service == nullptr) { return GroupResult{.success = false, .error = "Document service not available", .group = std::nullopt}; } // Soft delete by setting deleted_at ::smartbotic::database::UpdateDocumentRequest update_req; update_req.set_collection(kCollectionName); update_req.set_id(id); update_req.set_merge(true); auto* data = update_req.mutable_data(); SetStringValue(data, "deleted_at", GetCurrentTimestamp()); SetStringValue(data, "updated_at", GetCurrentTimestamp()); grpc::ClientContext ctx; ::smartbotic::database::Document updated_doc; auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc); if (!status.ok()) { return GroupResult{.success = false, .error = status.error_message(), .group = std::nullopt}; } spdlog::info("Soft deleted group {}", id); return GroupResult{.success = true, .error = "", .group = DocumentToGroup(updated_doc)}; } auto GroupService::NameExistsInWorkspace(const std::string& workspace_id, const std::string& name) -> bool { auto result = GetGroupByName(workspace_id, name, true); return result.success; } auto GroupService::GetSuperadminGroup() -> GroupResult { return GetGroupByName("", kSuperadminGroupName, false); } auto GroupService::DocumentToGroup(const ::smartbotic::database::Document& doc) -> Group { Group group; group.id = doc.id(); group.workspace_id = GetStringValue(doc.data(), "workspace_id"); group.name = GetStringValue(doc.data(), "name"); group.permissions = GetArrayValue(doc.data(), "permissions"); group.is_system = GetBoolValue(doc.data(), "is_system"); group.parent_group_id = GetStringValue(doc.data(), "parent_group_id"); group.created_at = GetStringValue(doc.data(), "created_at"); group.updated_at = GetStringValue(doc.data(), "updated_at"); group.deleted_at = GetStringValue(doc.data(), "deleted_at"); group.created_by = GetStringValue(doc.data(), "created_by"); group.updated_by = GetStringValue(doc.data(), "updated_by"); return group; } auto GroupService::ListSystemGroups(bool include_deleted) -> GroupListResult { if (!initialized_) { return GroupListResult{.success = false, .error = "Group service not initialized", .groups = {}, .total_count = 0}; } auto* query_service = db_client_.GetQueryService(); if (query_service == nullptr) { return GroupListResult{.success = false, .error = "Query service not available", .groups = {}, .total_count = 0}; } ::smartbotic::database::QueryRequest query_req; query_req.set_collection(kCollectionName); query_req.set_limit(100); // Build filter: workspace_id = "" AND is_system = true auto* filter = query_req.mutable_filter(); auto* composite = filter->mutable_composite(); composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND); auto* ws_filter = composite->add_filters()->mutable_field(); ws_filter->set_field("workspace_id"); ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL); ws_filter->mutable_value()->set_string_value(""); auto* sys_filter = composite->add_filters()->mutable_field(); sys_filter->set_field("is_system"); sys_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL); sys_filter->mutable_value()->set_bool_value(true); if (!include_deleted) { auto* del_filter = composite->add_filters()->mutable_field(); del_filter->set_field("deleted_at"); del_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL); del_filter->mutable_value()->set_string_value(""); } // Order by name ascending auto* order = query_req.add_order_by(); order->set_field("name"); order->set_direction(::smartbotic::database::SORT_DIRECTION_ASCENDING); grpc::ClientContext ctx; ::smartbotic::database::QueryResponse response; auto status = query_service->Query(&ctx, query_req, &response); if (!status.ok()) { return GroupListResult{.success = false, .error = status.error_message(), .groups = {}, .total_count = 0}; } GroupListResult result; result.success = true; result.total_count = response.total_count(); for (const auto& doc : response.documents()) { result.groups.push_back(DocumentToGroup(doc)); } return result; } auto GroupService::ListGlobalGroups(bool include_deleted) -> GroupListResult { if (!initialized_) { return GroupListResult{.success = false, .error = "Group service not initialized", .groups = {}, .total_count = 0}; } auto* query_service = db_client_.GetQueryService(); if (query_service == nullptr) { return GroupListResult{.success = false, .error = "Query service not available", .groups = {}, .total_count = 0}; } ::smartbotic::database::QueryRequest query_req; query_req.set_collection(kCollectionName); query_req.set_limit(100); // Build filter: workspace_id = "" auto* filter = query_req.mutable_filter(); if (!include_deleted) { auto* composite = filter->mutable_composite(); composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND); auto* ws_filter = composite->add_filters()->mutable_field(); ws_filter->set_field("workspace_id"); ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL); ws_filter->mutable_value()->set_string_value(""); auto* del_filter = composite->add_filters()->mutable_field(); del_filter->set_field("deleted_at"); del_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL); del_filter->mutable_value()->set_string_value(""); } else { auto* ws_filter = filter->mutable_field(); ws_filter->set_field("workspace_id"); ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL); ws_filter->mutable_value()->set_string_value(""); } // Order by name ascending auto* order = query_req.add_order_by(); order->set_field("name"); order->set_direction(::smartbotic::database::SORT_DIRECTION_ASCENDING); grpc::ClientContext ctx; ::smartbotic::database::QueryResponse response; auto status = query_service->Query(&ctx, query_req, &response); if (!status.ok()) { return GroupListResult{.success = false, .error = status.error_message(), .groups = {}, .total_count = 0}; } GroupListResult result; result.success = true; result.total_count = response.total_count(); for (const auto& doc : response.documents()) { result.groups.push_back(DocumentToGroup(doc)); } return result; } auto GroupService::GetEffectivePermissions(const std::string& group_id) -> std::vector { std::vector permissions; std::unordered_set visited; // Prevent infinite loops std::function collect_permissions = [&](const std::string& id) { if (id.empty() || visited.contains(id)) { return; } visited.insert(id); auto result = GetGroup(id, false); if (!result.success || !result.group) { return; } // Add this group's permissions for (const auto& perm : result.group->permissions) { permissions.push_back(perm); } // Recursively get parent's permissions if (!result.group->parent_group_id.empty()) { collect_permissions(result.group->parent_group_id); } }; collect_permissions(group_id); return permissions; } auto GroupService::GetCurrentTimestamp() -> std::string { auto now = std::chrono::system_clock::now(); auto time = std::chrono::system_clock::to_time_t(now); auto ms = std::chrono::duration_cast(now.time_since_epoch()) % 1000; std::ostringstream oss; oss << std::put_time(std::gmtime(&time), "%Y-%m-%dT%H:%M:%S"); oss << '.' << std::setfill('0') << std::setw(3) << ms.count() << 'Z'; return oss.str(); } } // namespace smartbotic::webserver