From 7e92d6bc96dd82990a3acce6cc8f199a37d1ecd3 Mon Sep 17 00:00:00 2001 From: Jean-Christophe Date: Tue, 4 Mar 2025 14:54:49 +0100 Subject: [PATCH 1/5] chore: prepare develop-b2school version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 278ea3a9a3..63882a2bac 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ - 6.5-SNAPSHOT + 6.5-develop-b2school-SNAPSHOT 4.13.2 1.19.3 2.0-SNAPSHOT From cfa1af66ca2c73573100fd4fcb0402ea63d4f997 Mon Sep 17 00:00:00 2001 From: Zakaria BANI Date: Tue, 17 Sep 2024 17:17:32 +0200 Subject: [PATCH 2/5] feat(communication): MOZO-206, new search visible API for messaging --- .../org/entcore/common/user/UserUtils.java | 118 ++++++++++- .../controllers/CommunicationController.java | 34 ++- .../services/CommunicationService.java | 7 +- .../impl/DefaultCommunicationService.java | 194 ++++++++++++++++-- .../services/impl/OptimComTest.java | 2 +- .../service/impl/SqlConversationService.java | 3 + .../public/ts/controllers/directory.ts | 2 +- 7 files changed, 335 insertions(+), 25 deletions(-) diff --git a/common/src/main/java/org/entcore/common/user/UserUtils.java b/common/src/main/java/org/entcore/common/user/UserUtils.java index 28686d4579..1522549e55 100644 --- a/common/src/main/java/org/entcore/common/user/UserUtils.java +++ b/common/src/main/java/org/entcore/common/user/UserUtils.java @@ -20,16 +20,10 @@ package org.entcore.common.user; import com.fasterxml.jackson.databind.ObjectMapper; - import fr.wseduc.mongodb.MongoDb; import fr.wseduc.webutils.I18n; import fr.wseduc.webutils.Utils; -import static fr.wseduc.webutils.Utils.getOrElse; -import static fr.wseduc.webutils.Utils.handlerToAsyncHandler; -import static fr.wseduc.webutils.Utils.isEmpty; -import static fr.wseduc.webutils.Utils.isNotEmpty; import fr.wseduc.webutils.http.Renders; -import static fr.wseduc.webutils.http.Renders.unauthorized; import fr.wseduc.webutils.request.CookieHelper; import fr.wseduc.webutils.security.JWT; import fr.wseduc.webutils.security.SecureHttpServerRequest; @@ -49,7 +43,6 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import static org.entcore.common.http.filter.AppOAuthResourceProvider.getTokenId; import io.vertx.core.shareddata.LocalMap; import org.entcore.common.neo4j.Neo4j; @@ -58,6 +51,9 @@ import org.entcore.common.utils.StringUtils; import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -66,6 +62,13 @@ import java.util.Set; import java.util.stream.Collectors; +import static fr.wseduc.webutils.Utils.getOrElse; +import static fr.wseduc.webutils.Utils.handlerToAsyncHandler; +import static fr.wseduc.webutils.Utils.isEmpty; +import static fr.wseduc.webutils.Utils.isNotEmpty; +import static fr.wseduc.webutils.http.Renders.unauthorized; +import static org.entcore.common.http.filter.AppOAuthResourceProvider.getTokenId; + public class UserUtils { private static final Vertx vertx = Vertx.currentContext().owner(); @@ -235,6 +238,7 @@ public static void findVisibles(EventBus eb, String userId, String customReturn, public void handle(AsyncResult> res) { if (res.succeeded()) { JsonArray r = res.result().body(); + log.info("UserUtils.findVisibles - r.size = " + r.size()); if (acceptLanguage != null) { translateGroupsNames(r, acceptLanguage); } @@ -348,6 +352,106 @@ private static void formatPositions(JsonObject dbResult) { dbResult.put("positions", positions); } + public static JsonArray mapObjectToContact(final String profile, final JsonArray shareBookmarks, final JsonArray visible, final String acceptLanguage) { + final List usedInAll = Arrays.asList("TO", "CC", "CCI"); + final List usedInCCI = Collections.singletonList("CCI"); + + /* + final JsonArray sb = new JsonArray(); + if (shareBookmarks != null) { + for (String id: shareBookmarks.fieldNames()) { + final JsonArray value = shareBookmarks.getJsonArray(id); + if (value == null || value.size() < 2) { + continue; + } + final JsonObject r = new fr.wseduc.webutils.collections.JsonObject(); + r.put("id", id); + r.put("displayName", value.remove(0)); + r.put("type", "ShareBookmark"); + sb.add(r); + } + } + + final JsonArray res = !sb.isEmpty() ? sortShareBookmarksByName(sb) : new JsonArray(); + */ + + final JsonArray res = new JsonArray(); + for (Object o: shareBookmarks) { + if (!(o instanceof JsonObject)) continue; + JsonObject j = (JsonObject) o; + j.put("type", "ShareBookmark"); + j.put("usedIn", usedInAll); + res.add(j); + } + + for (Object o: visible) { + if (!(o instanceof JsonObject)) continue; + JsonObject j = (JsonObject) o; + if (j.getString("name") != null) { + j.remove("profile"); + j.remove("children"); + j.remove("classrooms"); + j.remove("disciplines"); + j.remove("functions"); + j.remove("relatives"); + + Object gt = j.remove("groupType"); + Object gp = j.remove("groupProfile"); + if (gt instanceof Iterable) { + for (Object gti: (Iterable) gt) { + if (gti != null && !"Group".equals(gti) && gti.toString().endsWith("Group")) { + j.put("groupType", gti); + if ("ProfileGroup".equals(gti)) { + j.put("profile", gp); + } + break; + } + } + } + + UserUtils.groupDisplayName(j, acceptLanguage); + j.put("displayName", j.getString("name")); + + if (j.getString("groupType").equals("ManualGroup") && j.getString("subType").equals("BroadcastGroup")) { + j.put("type", "BroadcastGroup"); + j.put("usedIn", usedInCCI); + } else { + j.put("type", "Group"); + j.put("usedIn", usedInAll); + } + } else { + j.put("type", "User"); + j.put("usedIn", usedInAll); + j.remove("groupProfile"); + j.remove("groupType"); + j.remove("nbUsers"); + if (profile.equals("Student")) { + j.remove("relatives"); + } + } + + j.remove("name"); + j.remove("groupDisplayName"); + j.remove("sortDisplayName"); + j.remove("sortWeight"); + j.remove("subjects"); + j.remove("subType"); + j.remove("sorted_children_names"); + j.remove("sorted_functions"); + j.remove("sorted_disciplines"); + + res.add(j); + } + return res; + } + + private static JsonArray sortShareBookmarksByName(JsonArray sb) { + List list = sb.getList(); + list.sort(Comparator.comparing(o -> o.getString("displayName"))); + + return new JsonArray(list); + } + public static void findUsersCanSeeMe(final EventBus eb, HttpServerRequest request, final Handler handler) { JsonObject m = new JsonObject() diff --git a/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java b/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java index f00c1b0a4c..b7b5a0246c 100644 --- a/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java +++ b/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java @@ -32,14 +32,19 @@ import fr.wseduc.webutils.http.BaseController; import fr.wseduc.webutils.http.Renders; import fr.wseduc.webutils.request.RequestUtils; +import io.vertx.core.CompositeFuture; import io.vertx.core.Handler; +import io.vertx.core.Promise; import io.vertx.core.eventbus.Message; import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.json.Json; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import org.entcore.common.http.filter.AdminFilter; import org.entcore.common.http.filter.ResourceFilter; +import org.entcore.common.neo4j.Neo4j; import org.entcore.common.user.UserUtils; +import org.entcore.common.utils.StringUtils; import org.entcore.common.validation.StringValidation; import org.entcore.communication.filters.CommunicationDiscoverVisibleFilter; import org.entcore.communication.services.CommunicationService; @@ -47,8 +52,11 @@ import java.util.List; +import static fr.wseduc.webutils.Utils.getOrElse; import static fr.wseduc.webutils.Utils.isNotEmpty; import static org.entcore.common.http.response.DefaultResponseHandler.*; +import static org.entcore.common.neo4j.Neo4jResult.fullNodeMergeHandler; +import static org.entcore.common.neo4j.Neo4jResult.validResultHandler; public class CommunicationController extends BaseController { @@ -269,7 +277,7 @@ public void searchVisible(HttpServerRequest request) { "visibles.displayName as displayName, visibles.groupDisplayName as groupDisplayName, " + "HEAD(visibles.profiles) as profile, subjects" + nbUsers + groupTypes; communicationService.visibleUsers(user.getUserId(), null, expectedTypes, true, true, false, - preFilter, customReturn, params, user.getType(), visibles -> { + preFilter, customReturn, params, user.getType(), false, visibles -> { if (visibles.isRight()) { renderJson(request, UserUtils.translateAndGroupVisible(visibles.right().getValue(), @@ -875,4 +883,28 @@ public void addDiscoverVisibleGroupUsers(HttpServerRequest request) { }); } + @Get("/visible/search") + @SecuredAction(value = "", type = ActionType.AUTHENTICATED) + public void searchVisibleContacts(HttpServerRequest request) { + UserUtils.getAuthenticatedUserInfos(eb, request) + .onSuccess(userInfos -> { + final String query = request.params().get("query"); + final boolean isAdmin = userInfos.isADML() || userInfos.isADMC(); + + // if Admin query param is mandatory ?? + /* + if (isAdmin && StringUtils.isEmpty(query)) { + badRequest(request, "query.param.required"); + } + //*/ + + communicationService.searchVisibleContacts(userInfos, query, I18n.acceptLanguage(request), res -> { + if (res.isRight()) { + renderJson(request, res.right().getValue()); + } else { + leftToResponse(request, res.left()); + } + }); + }).onFailure(e -> log.error("An error occurred when retrieving authenticated user infos")); + } } diff --git a/communication/src/main/java/org/entcore/communication/services/CommunicationService.java b/communication/src/main/java/org/entcore/communication/services/CommunicationService.java index d1f3b5e689..ee6abfaee7 100644 --- a/communication/src/main/java/org/entcore/communication/services/CommunicationService.java +++ b/communication/src/main/java/org/entcore/communication/services/CommunicationService.java @@ -106,11 +106,12 @@ void applyDefaultRules(JsonArray structureIds, final Integer transactionId, fina void removeRules(String structureId, Handler> handler); void visibleUsers(String userId, String structureId, JsonArray expectedTypes, boolean itSelf, boolean myGroup, - boolean profile, String preFilter, String customReturn, JsonObject additionnalParams, + boolean profile, String preFilter, String customReturn, JsonObject additionalParams, Handler> handler); void visibleUsers(String userId, String structureId, JsonArray expectedTypes, boolean itSelf, boolean myGroup, - boolean profile, String preFilter, String customReturn, JsonObject additionnalParams, String userProfile, + boolean profile, String preFilter, String customReturn, JsonObject additionalParams, String userProfile, + boolean reverseUnion, Handler> handler); void usersCanSeeMe(String userId, final Handler> handler); @@ -167,5 +168,7 @@ void visibleManualGroups(String userId, String customReturn, JsonObject addition void addDiscoverVisibleGroupUsers(UserInfos user, String groupId, JsonObject body, HttpServerRequest request, Handler> handler); void getDiscoverVisibleAcceptedProfile(Handler> handler); + + void searchVisibleContacts(UserInfos user, String search, String language, Handler> results); } diff --git a/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java b/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java index f72bfccf09..1af8807fee 100644 --- a/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java +++ b/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java @@ -21,6 +21,7 @@ import fr.wseduc.webutils.Either; import fr.wseduc.webutils.collections.Joiner; +import io.vertx.core.CompositeFuture; import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Promise; @@ -35,20 +36,26 @@ import org.entcore.common.notification.TimelineHelper; import org.entcore.common.user.DefaultFunctions; import org.entcore.common.user.UserInfos; +import org.entcore.common.user.UserUtils; +import org.entcore.common.utils.StringUtils; import org.entcore.common.validation.StringValidation; import org.entcore.communication.services.CommunicationService; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; -import static org.entcore.common.neo4j.Neo4jResult.*; +import static org.entcore.common.neo4j.Neo4jResult.fullNodeMergeHandler; +import static org.entcore.common.neo4j.Neo4jResult.validEmptyHandler; +import static org.entcore.common.neo4j.Neo4jResult.validResultHandler; +import static org.entcore.common.neo4j.Neo4jResult.validUniqueResult; +import static org.entcore.common.neo4j.Neo4jResult.validUniqueResultHandler; public class DefaultCommunicationService implements CommunicationService { @@ -576,15 +583,16 @@ public void removeRules(String structureId, Handler> @Override public void visibleUsers(String userId, String structureId, JsonArray expectedTypes, boolean itSelf, - boolean myGroup, boolean profile, String preFilter, String customReturn, JsonObject additionnalParams, + boolean myGroup, boolean profile, String preFilter, String customReturn, JsonObject additionalParams, final Handler> handler) { - visibleUsers(userId, structureId, expectedTypes, itSelf, myGroup, profile, preFilter, customReturn, additionnalParams, null, handler); + visibleUsers(userId, structureId, expectedTypes, itSelf, myGroup, profile, preFilter, customReturn, additionalParams, null, false, handler); } @Override - public void visibleUsers(String userId, String structureId, JsonArray expectedTypes, boolean itSelf, - boolean myGroup, boolean profile, String preFilter, String customReturn, JsonObject additionnalParams, String userProfile, - final Handler> handler) { + public void visibleUsers( + String userId, String structureId, JsonArray expectedTypes, boolean itSelf, boolean myGroup, boolean profile, + String preFilter, String customReturn, JsonObject additionalParams, String userProfile, boolean reverseUnion, + final Handler> handler) { StringBuilder query = new StringBuilder(); JsonObject params = new JsonObject(); String condition = itSelf ? "" : "AND m.id <> {userId} "; @@ -616,7 +624,7 @@ public void visibleUsers(String userId, String structureId, JsonArray expectedTy } } query.append(condition); - if (expectedTypes != null && expectedTypes.size() > 0) { + if (expectedTypes != null && !expectedTypes.isEmpty()) { query.append("AND ("); StringBuilder types = new StringBuilder(); for (Object o: expectedTypes) { @@ -669,18 +677,23 @@ public void visibleUsers(String userId, String structureId, JsonArray expectedTy } } params.put("userId", userId); - if (additionnalParams != null) { - params.mergeIn(additionnalParams); + if (additionalParams != null) { + params.mergeIn(additionalParams); } String q; if (union != null) { - q = query.append(" union ").append(union.toString()).toString(); + if (reverseUnion) { + q = union.append(" union ").append(query).toString(); + } else { + q = query.append(" union ").append(union).toString(); + } } else { q = query.toString(); } neo4j.execute(q, params, validResultHandler(handler)); } + @Override public void usersCanSeeMe(String userId, Handler> handler) { String query = @@ -1489,4 +1502,159 @@ public void getDiscoverVisibleAcceptedProfile(Handler> handler.handle(new Either.Right<>(discoverVisibleExpectedProfile)); } + @Override + public void searchVisibleContacts(UserInfos user, String search, String language, Handler> handler) { + String match = "MATCH (visibles) " + + + "OPTIONAL MATCH visibles-[:RELATED]->(parent: User) " + + "WITH DISTINCT visibles, collect({id: parent.id, displayName: parent.displayName}) as relatives " + + + "OPTIONAL MATCH visibles-[:IN]->(pg:ProfileGroup)-[:DEPENDS]->(c:Class) " + + "WITH DISTINCT visibles, relatives, collect({id: c.id, name: c.name}) as classrooms " + + + "OPTIONAL MATCH visibles<-[:RELATED]-(child: User) " + + "WITH visibles, relatives, classrooms, child " + + "ORDER BY child.displayName " + + "WITH visibles, relatives, classrooms, collect({id: child.id, displayName: child.displayName}) AS children, collect(distinct child.displayName) AS sorted_children " + + + "OPTIONAL MATCH visibles-[:IN]->(fg:FuncGroup) " + + "WITH visibles, relatives, classrooms, children, sorted_children, fg " + + "ORDER BY fg.filter " + + "WITH visibles, relatives, classrooms, children, sorted_children, collect({id: fg.id, name: fg.filter}) AS functions, collect(distinct fg.filter) AS sorted_functions " + + + "OPTIONAL MATCH visibles-[:IN]->(dg:DisciplineGroup) " + + "WITH visibles, relatives, classrooms, children, sorted_children, functions, sorted_functions, dg " + + "ORDER BY dg.filter " + + "WITH DISTINCT visibles, relatives, classrooms, children, sorted_children, functions, sorted_functions, collect({id: dg.id, name: dg.filter}) as disciplines, collect(distinct dg.filter) AS sorted_disciplines " + + + "WITH visibles, relatives, classrooms, children, functions, disciplines, " + + "reduce(s = '', name IN sorted_children | s + name) AS sorted_children_names, " + + "reduce(s = '', name IN sorted_functions | s + name) AS sorted_functions, " + + "reduce(s = '', name IN sorted_disciplines | s + name) AS sorted_disciplines "; + + String preFilter = ""; + JsonObject params = new JsonObject(); + + if (!StringUtils.isEmpty(search)) { + preFilter = "AND m.displayNameSearchField CONTAINS {search} "; + String sanitizedSearch = StringValidation.sanitize(search); + params.put("search", sanitizedSearch); + } + + final String customReturn = match + + "RETURN DISTINCT visibles.id as id, visibles.name as name, " + + "visibles.displayName as displayName, visibles.groupDisplayName as groupDisplayName, " + + "HEAD(visibles.profiles) as profile, visibles.nbUsers as nbUsers, " + + "labels(visibles) as groupType, visibles.filter as groupProfile, visibles.subType as subType, " + + "filter(x IN coalesce(children, []) WHERE x.id IS NOT NULL) as children, " + + "filter(x IN coalesce(relatives, []) WHERE x.id IS NOT NULL) as relatives, " + + "filter(x IN coalesce(classrooms, []) WHERE x.id IS NOT NULL) as classrooms, " + + "filter(x IN coalesce(functions, []) WHERE x.id IS NOT NULL) as functions, " + + "filter(x IN coalesce(disciplines, []) WHERE x.id IS NOT NULL) as disciplines, " + + "sorted_children_names, sorted_functions, sorted_disciplines, " + + "CASE " + + "WHEN visibles.displayName IS NOT NULL THEN visibles.displayName " + + "WHEN visibles.name IS NOT NULL THEN visibles.name " + + "ELSE '' " + + "END as sortDisplayName, " + + "CASE " + + "WHEN HEAD(visibles.profiles) = 'Teacher' THEN 1 " + + "WHEN HEAD(visibles.profiles) = 'Personnel' THEN 2 " + + "WHEN HEAD(visibles.profiles) = 'Relative' THEN 3 " + + "WHEN HEAD(visibles.profiles) = 'Student' THEN 4 " + + "WHEN HEAD(visibles.profiles) = 'Guest' THEN 5 " + + "WHEN visibles.subType = 'BroadcastGroup' THEN 12 " + + "WHEN 'ManualGroup' IN labels(visibles) THEN 6 " + + "WHEN (visibles:ProfileGroup)-[:DEPENDS]->(:Class) THEN 7 " + + "WHEN (visibles:ProfileGroup)-[:DEPENDS]->(:Structure) THEN 11 " + + "WHEN 'FunctionalGroup' IN labels(visibles) THEN 8 " + + "WHEN 'FuncGroup' IN labels(visibles) OR 'FunctionGroup' IN labels(visibles) OR 'DirectionGroup' IN labels(visibles) THEN 9 " + + "WHEN 'DisciplineGroup' IN labels(visibles) THEN 10 " + + "ELSE 13 " + + "END as sortWeight " + + "ORDER BY " + + "sortWeight, " + + "toLower(sortDisplayName), " + + "sorted_children_names, " + + "sorted_functions, " + + "sorted_disciplines "; + + Promise> getVisiblePromise = Promise.promise(); + visibleUsers( + user.getUserId(), + null, + null, + true, + true, + false, + preFilter, + customReturn, + params, + user.getType(), + true, + getVisiblePromise::complete); + + // Share bookmarks + final Promise> getShareBookmarksPromise = Promise.promise(); + + /* + final String queryShareBookmarks = "MATCH (:User {id:{userId}})-[:HAS_SB]->(bm:ShareBookmark) return bm"; + Neo4j.getInstance() + .execute( + queryShareBookmarks, + new JsonObject().put("userId", userInfos.getUserId()), + fullNodeMergeHandler("bm", getShareBookmarksPromise::complete) + ); + */ + + String sbFilter = ""; + JsonObject sbParams = new JsonObject().put("userId", user.getUserId()); + if (!StringUtils.isEmpty(search)) { + sbFilter = " AND lower(sbValue[0]) contains {search} "; + sbParams.put("search", StringValidation.sanitize(search)); + } + String queryShareBookmarks = "MATCH (:User {id: {userId}})-[:HAS_SB]->(sb:ShareBookmark) " + + "WITH sb, keys(sb) AS ids " + + "UNWIND ids AS id " + + "WITH sb, id, sb[id] AS sbValue " + + "WHERE size(sbValue) >= 2 " + sbFilter + + "WITH sb, id, sb[id] AS sbValue ORDER BY sbValue[0] " + + "RETURN id as id, sbValue[0] as displayName "; + + Neo4j.getInstance().execute(queryShareBookmarks, sbParams, validResultHandler(getShareBookmarksPromise::complete)); + + CompositeFuture.join(getVisiblePromise.future(), getShareBookmarksPromise.future()) + .onComplete(ar -> { + + if (ar.failed()) { + handler.handle(new Either.Left<>(ar.cause().getMessage())); + } + + final Either resVisible = getVisiblePromise.future().result(); + final Either resShareBookmark = getShareBookmarksPromise.future().result(); + + if (resVisible.isLeft()) { + log.error(resVisible.left().getValue()); + } + + if (resShareBookmark.isLeft()) { + log.error(resShareBookmark.left().getValue()); + } + + JsonArray visible = resVisible.isLeft() ? new JsonArray() : resVisible.right().getValue(); + JsonArray shareBookmarks = resShareBookmark.isLeft() ? new JsonArray() : resShareBookmark.right().getValue(); + + handler.handle(new Either.Right<>( + UserUtils.mapObjectToContact( + user.getType(), + shareBookmarks, + visible, + language + ) + )); + }); + + } + + } diff --git a/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java b/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java index 57514bf30b..91721f9b96 100644 --- a/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java +++ b/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java @@ -162,7 +162,7 @@ private Future getComRules(CommunicationService communicationService, Stri vertx.setTimer(i * 50L, h -> { final long start = System.currentTimeMillis(); communicationService.visibleUsers(userId, null, null, true, true, - false, null, CUSTOM_RETURN, new JsonObject(), userProfile, visibles -> { + false, null, CUSTOM_RETURN, new JsonObject(), userProfile, false, visibles -> { if (visibles.isRight()) { final JsonObject j = new JsonObject() .put("visibles", visibles.right().getValue()) diff --git a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java index 4dd0439552..52cf4b9055 100644 --- a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java +++ b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java @@ -980,6 +980,9 @@ public void handle(JsonArray visibles) { JsonArray users = new fr.wseduc.webutils.collections.JsonArray(); JsonArray groups = new fr.wseduc.webutils.collections.JsonArray(); visible.put("groups", groups).put("users", users); + + logger.info("callFindVisibles Count = " + visibles.size()); + for (Object o: visibles) { if (!(o instanceof JsonObject)) continue; JsonObject j = (JsonObject) o; diff --git a/directory/src/main/resources/public/ts/controllers/directory.ts b/directory/src/main/resources/public/ts/controllers/directory.ts index bfaf193db7..d789129ca8 100644 --- a/directory/src/main/resources/public/ts/controllers/directory.ts +++ b/directory/src/main/resources/public/ts/controllers/directory.ts @@ -229,7 +229,7 @@ export const directoryController = ng.controller('DirectoryController',['$scope' $scope.generateCriteriaOptions = function(filters) { var test; return { - structures: $scope.criteria.structures.map((element) => { + structures: $scope.criteria.structures?.map((element) => { return { label: element.name, type: element.id }; }), classes: $scope.criteria.classes && $scope.criteria.classes.map((element) => { From a749048ec60fda8c52a0ac3e7a6371f6b7706cef Mon Sep 17 00:00:00 2001 From: Junior BERNARD Date: Fri, 4 Oct 2024 13:58:49 +0200 Subject: [PATCH 3/5] feat: MOZO-206 light version of search visibles --- .../LegacySearchVisibleRequest.java | 42 +++ .../org/entcore/common/user/UserUtils.java | 11 +- .../entcore/communication/Communication.java | 2 +- .../controllers/CommunicationController.java | 43 ++- .../services/CommunicationService.java | 13 +- .../impl/DefaultCommunicationService.java | 346 +++++++++++++++++- .../impl/DefaultCommunicationServiceTest.java | 2 +- .../services/impl/OptimComTest.java | 2 +- .../service/impl/SqlConversationService.java | 103 ++++-- 9 files changed, 487 insertions(+), 77 deletions(-) create mode 100644 common/src/main/java/org/entcore/common/conversation/LegacySearchVisibleRequest.java diff --git a/common/src/main/java/org/entcore/common/conversation/LegacySearchVisibleRequest.java b/common/src/main/java/org/entcore/common/conversation/LegacySearchVisibleRequest.java new file mode 100644 index 0000000000..38b3b60097 --- /dev/null +++ b/common/src/main/java/org/entcore/common/conversation/LegacySearchVisibleRequest.java @@ -0,0 +1,42 @@ +package org.entcore.common.conversation; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class LegacySearchVisibleRequest { + private final String userId; + private final String search; + private final String language; + private final String parentMessageId; + + @JsonCreator + public LegacySearchVisibleRequest(@JsonProperty("userId") final String userId, + @JsonProperty("search") final String search, + @JsonProperty("language") final String language, + @JsonProperty("parentMessageId") final String parentMessageId) { + this.userId = userId; + this.search = search; + this.language = language; + this.parentMessageId = parentMessageId; + } + + public String getUserId() { + return userId; + } + + public String getSearch() { + return search; + } + + public String getLanguage() { + return language; + } + + public String getParentMessageId() { + return parentMessageId; + } +} diff --git a/common/src/main/java/org/entcore/common/user/UserUtils.java b/common/src/main/java/org/entcore/common/user/UserUtils.java index 1522549e55..7e588cc88a 100644 --- a/common/src/main/java/org/entcore/common/user/UserUtils.java +++ b/common/src/main/java/org/entcore/common/user/UserUtils.java @@ -238,7 +238,7 @@ public static void findVisibles(EventBus eb, String userId, String customReturn, public void handle(AsyncResult> res) { if (res.succeeded()) { JsonArray r = res.result().body(); - log.info("UserUtils.findVisibles - r.size = " + r.size()); + log.info("UserUtils.findVisibles - r.size = " + r.size()); // TODO JBER : exposer métrique if (acceptLanguage != null) { translateGroupsNames(r, acceptLanguage); } @@ -412,7 +412,7 @@ public static JsonArray mapObjectToContact(final String profile, final JsonArray UserUtils.groupDisplayName(j, acceptLanguage); j.put("displayName", j.getString("name")); - if (j.getString("groupType").equals("ManualGroup") && j.getString("subType").equals("BroadcastGroup")) { + if ("ManualGroup".equals(j.getString("groupType")) && "BroadcastGroup".equals(j.getString("subType"))) { j.put("type", "BroadcastGroup"); j.put("usedIn", usedInCCI); } else { @@ -771,6 +771,13 @@ public void handle(JsonObject session) { }); } + /** + * Fetch the user's session information and return an unauthorized response if the user has no session. Therefore, + * there is no need to handle a failure of the returned Future. + * @param eb Event bus to be used to fetch the user's session + * @param request Caller's request + * @return The user's session information + */ public static Future getAuthenticatedUserInfos(EventBus eb, HttpServerRequest request) { final Promise promise = Promise.promise(); getSession(eb, request, session -> { diff --git a/communication/src/main/java/org/entcore/communication/Communication.java b/communication/src/main/java/org/entcore/communication/Communication.java index de27682ff3..8d07be1b1a 100644 --- a/communication/src/main/java/org/entcore/communication/Communication.java +++ b/communication/src/main/java/org/entcore/communication/Communication.java @@ -35,7 +35,7 @@ public void start(final Promise startPromise) throws Exception { TimelineHelper helper = new TimelineHelper(vertx, vertx.eventBus(), config); CommunicationController communicationController = new CommunicationController(); - communicationController.setCommunicationService(new DefaultCommunicationService(helper, config.getJsonArray("discoverVisibleExpectedProfile", new JsonArray()))); + communicationController.setCommunicationService(new DefaultCommunicationService(vertx, helper, config)); addController(communicationController); setDefaultResourceFilter(new CommunicationFilter()); diff --git a/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java b/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java index b7b5a0246c..a76c29e7e9 100644 --- a/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java +++ b/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java @@ -883,28 +883,47 @@ public void addDiscoverVisibleGroupUsers(HttpServerRequest request) { }); } + /** + * Search entities (users, groups) visible by the requester. + * This endpoint can serve 2 different types of results based on the configuration : + * - the old format that used to be served by the module conversation + * - the new format which returns more data + * @param request Caller's HTTP request + */ @Get("/visible/search") @SecuredAction(value = "", type = ActionType.AUTHENTICATED) public void searchVisibleContacts(HttpServerRequest request) { UserUtils.getAuthenticatedUserInfos(eb, request) - .onSuccess(userInfos -> { - final String query = request.params().get("query"); - final boolean isAdmin = userInfos.isADML() || userInfos.isADMC(); + .onSuccess(userInfos -> { + final String query = request.params().get("query"); + communicationService.searchVisibles(userInfos, query, I18n.acceptLanguage(request)) + .onSuccess(visibles -> renderJson(request, visibles)) + .onFailure(th -> renderError(request, new JsonObject().put("error", th.getMessage()))); + }); + } + + @Get("/visible/search-optimized") + @SecuredAction(value = "", type = ActionType.AUTHENTICATED) + public void searchVisibleContactsOptimized(HttpServerRequest request) { + UserUtils.getAuthenticatedUserInfos(eb, request) + .onSuccess(userInfos -> { + final String query = request.params().get("query"); + final boolean isAdmin = userInfos.isADML() || userInfos.isADMC(); - // if Admin query param is mandatory ?? + // if Admin query param is mandatory ?? /* if (isAdmin && StringUtils.isEmpty(query)) { badRequest(request, "query.param.required"); } //*/ - communicationService.searchVisibleContacts(userInfos, query, I18n.acceptLanguage(request), res -> { - if (res.isRight()) { - renderJson(request, res.right().getValue()); - } else { - leftToResponse(request, res.left()); - } - }); - }).onFailure(e -> log.error("An error occurred when retrieving authenticated user infos")); + communicationService.searchVisibleContactsOptimized(userInfos, query, I18n.acceptLanguage(request), res -> { + if (res.isRight()) { + renderJson(request, res.right().getValue()); + } else { + leftToResponse(request, res.left()); + } + }); + }).onFailure(e -> log.error("An error occurred when retrieving authenticated user infos")); } } diff --git a/communication/src/main/java/org/entcore/communication/services/CommunicationService.java b/communication/src/main/java/org/entcore/communication/services/CommunicationService.java index ee6abfaee7..cda9694b65 100644 --- a/communication/src/main/java/org/entcore/communication/services/CommunicationService.java +++ b/communication/src/main/java/org/entcore/communication/services/CommunicationService.java @@ -21,6 +21,7 @@ import fr.wseduc.webutils.Either; +import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonArray; @@ -168,7 +169,15 @@ void visibleManualGroups(String userId, String customReturn, JsonObject addition void addDiscoverVisibleGroupUsers(UserInfos user, String groupId, JsonObject body, HttpServerRequest request, Handler> handler); void getDiscoverVisibleAcceptedProfile(Handler> handler); - - void searchVisibleContacts(UserInfos user, String search, String language, Handler> results); + + + /** + * Search visible users. + * @param user Requester + * @param search Keyword to filter the search results + * @param language User's language + */ + Future searchVisibles(UserInfos user, String search, String language); + } diff --git a/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java b/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java index 1af8807fee..ea4c1f7c51 100644 --- a/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java +++ b/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java @@ -21,16 +21,16 @@ import fr.wseduc.webutils.Either; import fr.wseduc.webutils.collections.Joiner; -import io.vertx.core.CompositeFuture; -import io.vertx.core.Future; -import io.vertx.core.Handler; -import io.vertx.core.Promise; +import io.vertx.codegen.annotations.Nullable; +import io.vertx.core.*; +import io.vertx.core.eventbus.EventBus; import io.vertx.core.eventbus.Message; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; +import org.entcore.common.conversation.LegacySearchVisibleRequest; import org.entcore.common.neo4j.Neo4j; import org.entcore.common.neo4j.StatementsBuilder; import org.entcore.common.notification.TimelineHelper; @@ -51,6 +51,7 @@ import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; +import static io.vertx.core.json.JsonObject.mapFrom; import static org.entcore.common.neo4j.Neo4jResult.fullNodeMergeHandler; import static org.entcore.common.neo4j.Neo4jResult.validEmptyHandler; import static org.entcore.common.neo4j.Neo4jResult.validResultHandler; @@ -66,10 +67,14 @@ public class DefaultCommunicationService implements CommunicationService { private final TimelineHelper notifyTimeline; final JsonArray discoverVisibleExpectedProfile = new JsonArray(); + private final String visiblesSearchType; + private final EventBus eventBus; - public DefaultCommunicationService(TimelineHelper notifyTimeline, JsonArray discoverVisibleExpectedProfile) { + public DefaultCommunicationService(final Vertx vertx, final TimelineHelper notifyTimeline, final JsonObject config) { this.notifyTimeline = notifyTimeline; - this.discoverVisibleExpectedProfile.addAll(discoverVisibleExpectedProfile); + this.discoverVisibleExpectedProfile.addAll(config.getJsonArray("discoverVisibleExpectedProfile", new JsonArray())); + this.visiblesSearchType = config.getString("visibles-search-type", "light"); + this.eventBus = vertx.eventBus(); } @Override @@ -1503,7 +1508,61 @@ public void getDiscoverVisibleAcceptedProfile(Handler> } @Override - public void searchVisibleContacts(UserInfos user, String search, String language, Handler> handler) { + public Future searchVisibles(UserInfos user, String search, String language) { + final Future visibles; + switch (this.visiblesSearchType) { + case "legacy": + visibles = legacySearchVisible(user, search, language); + break; + case "complete": + visibles = searchVisibleContacts(user, search, language); + break; + case "optimized": + visibles = searchVisibleContactsOptimized(user, search, language); + break; + default: + visibles = searchVisibleContactsLight(user, search, language); + } + return visibles; + } + + private Future legacySearchVisible(final UserInfos user, final String search, final String language) { + final Promise promise = Promise.promise(); + final LegacySearchVisibleRequest payload = new LegacySearchVisibleRequest( + user.getUserId(), + search, language, + null + ); + eventBus.request("conversation.legacy.search.visible", mapFrom(payload), e -> { + if(e.succeeded()) { + final JsonObject legacyResult = (JsonObject) e.result().body(); + final JsonArray adaptedLegacyresult = adaptLegacyResultFormat(legacyResult); + promise.complete(adaptedLegacyresult); + } else { + promise.fail(e.cause()); + } + }); + return promise.future(); + } + + private JsonArray adaptLegacyResultFormat( + final String userProfile, + final String language, + final JsonObject legacyResult) { + final JsonArray visibles = new JsonArray(); + // TODO jber check output result + visibles.addAll(legacyResult.getJsonArray("users", new JsonArray())); + visibles.addAll(legacyResult.getJsonArray("groups", new JsonArray())); + return UserUtils.mapObjectToContact( + userProfile, + new JsonArray(), + visibles, + language + ); + } + + public Future searchVisibleContacts(UserInfos user, String search, String language) { + final Promise promise = Promise.promise(); String match = "MATCH (visibles) " + "OPTIONAL MATCH visibles-[:RELATED]->(parent: User) " + @@ -1577,7 +1636,7 @@ public void searchVisibleContacts(UserInfos user, String search, String language "toLower(sortDisplayName), " + "sorted_children_names, " + "sorted_functions, " + - "sorted_disciplines "; + " sorted_disciplines "; Promise> getVisiblePromise = Promise.promise(); visibleUsers( @@ -1627,8 +1686,124 @@ public void searchVisibleContacts(UserInfos user, String search, String language .onComplete(ar -> { if (ar.failed()) { - handler.handle(new Either.Left<>(ar.cause().getMessage())); + promise.fail(ar.cause()); + } else { + + final Either resVisible = getVisiblePromise.future().result(); + final Either resShareBookmark = getShareBookmarksPromise.future().result(); + + if (resVisible.isLeft()) { + log.error(resVisible.left().getValue()); + } + + if (resShareBookmark.isLeft()) { + log.error(resShareBookmark.left().getValue()); + } + + JsonArray visible = resVisible.isLeft() ? new JsonArray() : resVisible.right().getValue(); + JsonArray shareBookmarks = resShareBookmark.isLeft() ? new JsonArray() : resShareBookmark.right().getValue(); + + promise.complete( + UserUtils.mapObjectToContact( + user.getType(), + shareBookmarks, + visible, + language + ) + ); } + }); + return promise.future(); + } + + public Future searchVisibleContactsLight(UserInfos user, String search, String language) { + final Promise promise = Promise.promise(); + String match = "MATCH (visibles) " + + + "OPTIONAL MATCH visibles-[:RELATED]->(parent: User) " + + "WITH DISTINCT visibles, collect({id: parent.id, displayName: parent.displayName}) as relatives " + + + "OPTIONAL MATCH visibles<-[:RELATED]-(child: User) " + + "WITH visibles, relatives, child " + + "ORDER BY child.displayName " + + "WITH visibles, relatives, collect({id: child.id, displayName: child.displayName}) AS children, collect(distinct child.displayName) AS sorted_children " + + "WITH visibles, relatives, children, reduce(s = '', name IN sorted_children | s + name) AS sorted_children_names "; + + String preFilter = ""; + JsonObject params = new JsonObject(); + + if (!StringUtils.isEmpty(search)) { + preFilter = "AND m.displayNameSearchField CONTAINS {search} "; + String sanitizedSearch = StringValidation.sanitize(search); + params.put("search", sanitizedSearch); + } + + final String customReturn = match + + "RETURN DISTINCT visibles.id as id, visibles.name as name, " + + "visibles.displayName as displayName, visibles.groupDisplayName as groupDisplayName, " + + "HEAD(visibles.profiles) as profile, visibles.nbUsers as nbUsers, " + + "labels(visibles) as groupType, visibles.filter as groupProfile, visibles.subType as subType, " + + "filter(x IN coalesce(children, []) WHERE x.id IS NOT NULL) as children, " + + "filter(x IN coalesce(relatives, []) WHERE x.id IS NOT NULL) as relatives, " + + "sorted_children_names, " + + "CASE " + + "WHEN visibles.displayName IS NOT NULL THEN visibles.displayName " + + "WHEN visibles.name IS NOT NULL THEN visibles.name " + + "ELSE '' " + + "END as sortDisplayName " + + "ORDER BY " + + "toLower(sortDisplayName) "; + + Promise> getVisiblePromise = Promise.promise(); + visibleUsers( + user.getUserId(), + null, + null, + true, + true, + false, + preFilter, + customReturn, + params, + user.getType(), + true, + getVisiblePromise::complete); + + // Share bookmarks + final Promise> getShareBookmarksPromise = Promise.promise(); + + /* + final String queryShareBookmarks = "MATCH (:User {id:{userId}})-[:HAS_SB]->(bm:ShareBookmark) return bm"; + Neo4j.getInstance() + .execute( + queryShareBookmarks, + new JsonObject().put("userId", userInfos.getUserId()), + fullNodeMergeHandler("bm", getShareBookmarksPromise::complete) + ); + */ + + String sbFilter = ""; + JsonObject sbParams = new JsonObject().put("userId", user.getUserId()); + if (!StringUtils.isEmpty(search)) { + sbFilter = " AND lower(sbValue[0]) contains {search} "; + sbParams.put("search", StringValidation.sanitize(search)); + } + String queryShareBookmarks = "MATCH (:User {id: {userId}})-[:HAS_SB]->(sb:ShareBookmark) " + + "WITH sb, keys(sb) AS ids " + + "UNWIND ids AS id " + + "WITH sb, id, sb[id] AS sbValue " + + "WHERE size(sbValue) >= 2 " + sbFilter + + "WITH sb, id, sb[id] AS sbValue ORDER BY sbValue[0] " + + "RETURN id as id, sbValue[0] as displayName "; + + Neo4j.getInstance().execute(queryShareBookmarks, sbParams, validResultHandler(getShareBookmarksPromise::complete)); + + CompositeFuture.join(getVisiblePromise.future(), getShareBookmarksPromise.future()) + .onComplete(ar -> { + + if (ar.failed()) { + promise.fail(ar.cause().getMessage()); + } else { final Either resVisible = getVisiblePromise.future().result(); final Either resShareBookmark = getShareBookmarksPromise.future().result(); @@ -1644,16 +1819,151 @@ public void searchVisibleContacts(UserInfos user, String search, String language JsonArray visible = resVisible.isLeft() ? new JsonArray() : resVisible.right().getValue(); JsonArray shareBookmarks = resShareBookmark.isLeft() ? new JsonArray() : resShareBookmark.right().getValue(); - handler.handle(new Either.Right<>( - UserUtils.mapObjectToContact( - user.getType(), - shareBookmarks, - visible, - language - ) - )); - }); + promise.complete( + UserUtils.mapObjectToContact( + user.getType(), + shareBookmarks, + visible, + language + ) + ); + } + }); + return promise.future(); + } + + + public Future searchVisibleContactsOptimized(UserInfos user, String search, String language) { + final Promise promise = Promise.promise(); + String match = "MATCH (visibles) " + + "WITH visibles, HEAD(visibles.profiles) AS primaryProfile " + + "OPTIONAL MATCH (visibles)-[:RELATED]-(related:User) " + + "WITH visibles, primaryProfile, " + + " collect(CASE \n" + + " WHEN primaryProfile == 'Student' THEN {id: related.id, displayName: related.displayName, role: 'parent'} \n" + + " WHEN primaryProfile != 'Student' THEN {id: related.id, displayName: related.displayName, role: 'child'}\n" + + " END) AS relatedUsers\n" + + "WITH visibles, \n" + + " [x IN relatedUsers WHERE x.role = 'parent'] AS relatives, \n" + + " [x IN relatedUsers WHERE x.role = 'child'] AS children " + + "OPTIONAL MATCH (visibles)-[:IN]->(pg:ProfileGroup)-[:DEPENDS]->(c:Class) " + + "WITH visibles, relatives, children \n" + + "OPTIONAL MATCH (visibles)-[:IN]->(fdg:Group) WHERE fdg:FuncGroup OR fdg:DisciplineGroup " + + "WITH visibles, " + + " relatives, " + + " children, " + + " collect(DISTINCT {id: c.id, name: c.name}) as classrooms, " + + " collect(CASE " + + " WHEN fdg:FuncGroup AND fdg.id IS NOT NULL THEN {id: fdg.id, name: fdg.filter, type: 'function'} " + + " WHEN fdg:DisciplineGroup AND fdg.id IS NOT NULL THEN {id: fdg.id, name: fdg.filter, typ: 'discipline'}" + + " END) AS groups " + + "WITH visibles, " + + " relatives," + + " children," + + " classrooms, " + + " [g IN groups WHERE g.type = 'function'] AS functions, " + + " [g IN groups WHERE g.type = 'discipline'] AS disciplines "; + + String preFilter = ""; + JsonObject params = new JsonObject(); + + if (!StringUtils.isEmpty(search)) { + preFilter = "AND m.displayNameSearchField CONTAINS {search} "; + String sanitizedSearch = StringValidation.sanitize(search); + params.put("search", sanitizedSearch); + } + + final String customReturn = match + + " WITH visibles, \n" + + " labels(visibles) AS visibleLabels, \n" + + " HEAD(visibles.profiles) AS primaryProfile, \n" + + " filter(x IN coalesce(children, []) WHERE x.id IS NOT NULL) as validChildren, \n" + + " filter(x IN coalesce(relatives, []) WHERE x.id IS NOT NULL) as validRelatives, \n" + + " filter(x IN coalesce(classrooms, []) WHERE x.id IS NOT NULL) as validClassrooms, \n" + + " filter(x IN coalesce(functions, []) WHERE x.id IS NOT NULL) as validFunctions, \n" + + " filter(x IN coalesce(disciplines, []) WHERE x.id IS NOT NULL) as validDisciplines\n" + + "RETURN DISTINCT \n" + + " visibles.id as id, \n" + + " visibles.name as name, \n" + + " visibles.displayName as displayName, \n" + + " visibles.groupDisplayName as groupDisplayName, \n" + + " primaryProfile as profile, \n" + + " visibles.nbUsers as nbUsers, \n" + + " visibleLabels as groupType, \n" + + " visibles.filter as groupProfile, \n" + + " visibles.subType as subType, \n" + + " validChildren as children, \n" + + " validRelatives as relatives, \n" + + " validClassrooms as classrooms, \n" + + " validFunctions as functions, \n" + + " validDisciplines as disciplines \n"; + + Promise> getVisiblePromise = Promise.promise(); + visibleUsers( + user.getUserId(), + null, + null, + true, + true, + false, + preFilter, + customReturn, + params, + user.getType(), + true, + getVisiblePromise::complete); + + // Share bookmarks + final Promise> getShareBookmarksPromise = Promise.promise(); + String sbFilter = ""; + JsonObject sbParams = new JsonObject().put("userId", user.getUserId()); + if (!StringUtils.isEmpty(search)) { + sbFilter = " AND lower(sbValue[0]) contains {search} "; + sbParams.put("search", StringValidation.sanitize(search)); + } + String queryShareBookmarks = "MATCH (:User {id: {userId}})-[:HAS_SB]->(sb:ShareBookmark) " + + "WITH sb, keys(sb) AS ids " + + "UNWIND ids AS id " + + "WITH sb, id, sb[id] AS sbValue " + + "WHERE size(sbValue) >= 2 " + sbFilter + + "WITH sb, id, sb[id] AS sbValue ORDER BY sbValue[0] " + + "RETURN id as id, sbValue[0] as displayName "; + + Neo4j.getInstance().execute(queryShareBookmarks, sbParams, validResultHandler(getShareBookmarksPromise::complete)); + + CompositeFuture.join(getVisiblePromise.future(), getShareBookmarksPromise.future()) + .onComplete(ar -> { + + if (ar.failed()) { + promise.fail(ar.cause()); + } else { + + final Either resVisible = getVisiblePromise.future().result(); + final Either resShareBookmark = getShareBookmarksPromise.future().result(); + + if (resVisible.isLeft()) { + log.error(resVisible.left().getValue()); + } + + if (resShareBookmark.isLeft()) { + log.error(resShareBookmark.left().getValue()); + } + + JsonArray visible = resVisible.isLeft() ? new JsonArray() : resVisible.right().getValue(); + JsonArray shareBookmarks = resShareBookmark.isLeft() ? new JsonArray() : resShareBookmark.right().getValue(); + + promise.complete( + UserUtils.mapObjectToContact( + user.getType(), + shareBookmarks, + visible, + language + ) + ); + } + }); + return promise.future(); } diff --git a/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java b/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java index 70ff43a5db..7a59fe9511 100644 --- a/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java +++ b/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java @@ -42,7 +42,7 @@ private UserInfos mockUserNoAdmin() { @Before public void prepare() { - this.service = new DefaultCommunicationService(new TimelineHelper(Vertx.vertx(), Vertx.vertx().eventBus(), new JsonObject()), new JsonArray()); + this.service = new DefaultCommunicationService(new TimelineHelper(Vertx.vertx(), Vertx.vertx().eventBus(), new JsonObject()), new JsonObject()); this.userNoAdmin = test.directory().generateUser("notused"); this.userNoAdmin.setFunctions(new HashMap(0)); } diff --git a/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java b/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java index 91721f9b96..dbc333de57 100644 --- a/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java +++ b/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java @@ -77,7 +77,7 @@ public static void setUpAll(TestContext context) throws Exception { //@Before public void setUp(TestContext context) { vertx = test.vertx(); - defaultComService = new DefaultCommunicationService(new TimelineHelper(vertx, vertx.eventBus(), new JsonObject()), new JsonArray()); + defaultComService = new DefaultCommunicationService(new TimelineHelper(vertx, vertx.eventBus(), new JsonObject()), new JsonObject()); } //@Test diff --git a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java index 52cf4b9055..0ca4d95285 100644 --- a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java +++ b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java @@ -34,6 +34,7 @@ import io.vertx.core.http.HttpServerRequest; import org.entcore.common.editor.IContentTransformerEventRecorder; +import org.entcore.common.conversation.LegacySearchVisibleRequest; import org.entcore.common.sql.Sql; import org.entcore.common.sql.SqlResult; import org.entcore.common.sql.SqlStatementsBuilder; @@ -96,6 +97,17 @@ public SqlConversationService(Vertx vertx, String schema, IContentTransformerCli optimizedThreadList = vertx.getOrCreateContext().config().getBoolean("optimized-thread-list", false); this.contentTransformerClient = contentTransformerClient; this.contentTransformerEventRecorder = contentTransformerEventRecorder; + eb.consumer("conversation.legacy.search.visible", message -> { + final JsonObject payload = (JsonObject) message.body(); + final LegacySearchVisibleRequest request = payload.mapTo(LegacySearchVisibleRequest.class); + this.doFindVisibleRecipients(request.getParentMessageId(), request.getUserId(), + request.getLanguage(), request.getSearch()) + .onSuccess(message::reply) + .onFailure(th -> { + log.warn("An error occurred while finding visibles", th); + message.fail(500, th.getMessage()); + }); + }); } public SqlConversationService setSendTimeout(int sendTimeout) { @@ -318,7 +330,7 @@ public void handle(Either event) { @Override public void list(String folder, Boolean unread, UserInfos user, int page, int pageSize, final String searchText, Handler> results) { - list(folder, + list(folder, ConversationService.isSystemFolder(folder) ? null : "", // `restrain` can only applies to user's folders. unread, user, page, pageSize, searchText, results ); @@ -919,12 +931,22 @@ public void count(String folder, String restrain, Boolean unread, UserInfos user sql.prepared(query, values, SqlResult.validUniqueResultHandler(result)); } + + @Override public void findVisibleRecipients(final String parentMessageId, final UserInfos user, final String acceptLanguage, final String search, final Handler> result) { - if (validationParamsError(user, result)) + if (validationParamsError(user, result)) { return; + } + doFindVisibleRecipients(parentMessageId, user.getUserId(), acceptLanguage, search) + .onSuccess(data -> result.handle(new Either.Right<>(data))) + .onFailure(th -> result.handle(new Either.Left<>(th.getMessage()))); + } + private Future doFindVisibleRecipients(final String parentMessageId, final String userId, + final String acceptLanguage, final String search) { + final Promise promise = Promise.promise(); final JsonObject visible = new JsonObject(); final JsonObject params = new JsonObject(); @@ -944,7 +966,7 @@ public void findVisibleRecipients(final String parentMessageId, final UserInfos SqlResult.validUniqueResultHandler(new Handler>() { public void handle(Either event) { if(event.isLeft()){ - result.handle(event); + promise.fail(event.left().getValue()); return; } @@ -960,7 +982,7 @@ public void handle(Either event) { "RETURN DISTINCT visibles.id as id, visibles.name as name, " + "visibles.displayName as displayName, visibles.groupDisplayName as groupDisplayName, " + "visibles.profiles[0] as profile, visibles.structureName as structureName, visibles.filter as groupProfile "; - callFindVisibles(user, acceptLanguage, result, visible, params, preFilter, customReturn); + callFindVisibles(userId, acceptLanguage, visible, params, preFilter, customReturn).onComplete(promise); } })); } else { @@ -968,44 +990,45 @@ public void handle(Either event) { "RETURN DISTINCT visibles.id as id, visibles.name as name, " + "visibles.displayName as displayName, visibles.groupDisplayName as groupDisplayName, " + "visibles.profiles[0] as profile, visibles.structureName as structureName, visibles.filter as groupProfile"; - callFindVisibles(user, acceptLanguage, result, visible, params, preFilter, customReturn); + callFindVisibles(userId, acceptLanguage, visible, params, preFilter, customReturn).onComplete(promise); } + return promise.future(); } - private void callFindVisibles(UserInfos user, final String acceptLanguage, final Handler> result, + private Future callFindVisibles(final String userId, final String acceptLanguage, final JsonObject visible, JsonObject params, String preFilter, String customReturn) { - findVisibles(eb, user.getUserId(), customReturn, params, true, true, false, acceptLanguage, preFilter, new Handler() { - @Override - public void handle(JsonArray visibles) { - JsonArray users = new fr.wseduc.webutils.collections.JsonArray(); - JsonArray groups = new fr.wseduc.webutils.collections.JsonArray(); - visible.put("groups", groups).put("users", users); - - logger.info("callFindVisibles Count = " + visibles.size()); - - for (Object o: visibles) { - if (!(o instanceof JsonObject)) continue; - JsonObject j = (JsonObject) o; - // NOTE: the management rule below is "if a visible JsonObject has a non-null *name* field, then it is a Group". - // TODO It should be defined more clearly. See #39835 - if (j.getString("name") != null) { - if( j.getString("groupProfile") == null ) { - // This is a Manual group, without a clearly defined "profile" (neither Student nor Teacher nor...) => Set it as "Manual" - j.put("groupProfile", "Manual"); - } - j.remove("displayName"); - UserUtils.groupDisplayName(j, acceptLanguage); - j.put("profile", j.remove("groupProfile")); // JCBE: set the *profile* field for this Group. - groups.add(j); - } else { - j.remove("name"); - j.remove("groupProfile"); // JCBE: remove this unused and empty data for a User. - users.add(j); - } - } - result.handle(new Either.Right(visible)); - } - }); + final Promise promise = Promise.promise(); + findVisibles(eb, userId, customReturn, params, true, true, false, acceptLanguage, preFilter, visibles -> { + JsonArray users = new fr.wseduc.webutils.collections.JsonArray(); + JsonArray groups = new fr.wseduc.webutils.collections.JsonArray(); + visible.put("groups", groups).put("users", users); + + logger.info("callFindVisibles Count = " + visibles.size()); + + for (Object o: visibles) { + if (!(o instanceof JsonObject)) continue; + JsonObject j = (JsonObject) o; + // NOTE: the management rule below is "if a visible JsonObject has a non-null *name* field, then it is a Group". + // TODO It should be defined more clearly. See #39835 + + if (j.getString("name") != null) { + if( j.getString("groupProfile") == null ) { + // This is a Manual group, without a clearly defined "profile" (neither Student nor Teacher nor...) => Set it as "Manual" + j.put("groupProfile", "Manual"); + } + j.remove("displayName"); + UserUtils.groupDisplayName(j, acceptLanguage); + j.put("profile", j.remove("groupProfile")); // JCBE: set the *profile* field for this Group. + groups.add(j); + } else { + j.remove("name"); + j.remove("groupProfile"); // JCBE: remove this unused and empty data for a User. + users.add(j); + } + } + promise.complete(visible); + }); + return promise.future(); } @Override @@ -1229,8 +1252,8 @@ public void getFolderTree(final UserInfos user, int depth, final Optional getFolder(final String folderId) { Promise promise = Promise.promise(); sql.prepared( - "SELECT f.* FROM " + folderTable + " AS f WHERE f.id = ?", - new JsonArray().add(folderId), + "SELECT f.* FROM " + folderTable + " AS f WHERE f.id = ?", + new JsonArray().add(folderId), SqlResult.validUniqueResultHandler(either -> { if( either.isLeft() ) { promise.fail(either.left().getValue()); From 7ca5500eb11e3b88280399d4e20b65b4c3a03a10 Mon Sep 17 00:00:00 2001 From: mariusestaque Date: Tue, 18 Feb 2025 17:51:29 +0100 Subject: [PATCH 4/5] feat: WB-3801, adapt legacy visible result to new api format --- .../controllers/CommunicationController.java | 25 --- .../impl/DefaultCommunicationService.java | 171 +++++++++--------- .../impl/DefaultCommunicationServiceTest.java | 2 +- .../services/impl/OptimComTest.java | 2 +- .../communication/_search-visibles-legacy.ts | 63 +++++++ .../communication/_search-visibles-light.ts | 63 +++++++ .../js/it/scenarios/communication/_utils.ts | 23 +++ .../communication/search_visibles.sh | 37 ++++ 8 files changed, 270 insertions(+), 116 deletions(-) create mode 100644 tests/src/test/js/it/scenarios/communication/_search-visibles-legacy.ts create mode 100644 tests/src/test/js/it/scenarios/communication/_search-visibles-light.ts create mode 100644 tests/src/test/js/it/scenarios/communication/_utils.ts create mode 100755 tests/src/test/js/it/scenarios/communication/search_visibles.sh diff --git a/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java b/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java index a76c29e7e9..22abde6ca0 100644 --- a/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java +++ b/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java @@ -901,29 +901,4 @@ public void searchVisibleContacts(HttpServerRequest request) { .onFailure(th -> renderError(request, new JsonObject().put("error", th.getMessage()))); }); } - - @Get("/visible/search-optimized") - @SecuredAction(value = "", type = ActionType.AUTHENTICATED) - public void searchVisibleContactsOptimized(HttpServerRequest request) { - UserUtils.getAuthenticatedUserInfos(eb, request) - .onSuccess(userInfos -> { - final String query = request.params().get("query"); - final boolean isAdmin = userInfos.isADML() || userInfos.isADMC(); - - // if Admin query param is mandatory ?? - /* - if (isAdmin && StringUtils.isEmpty(query)) { - badRequest(request, "query.param.required"); - } - //*/ - - communicationService.searchVisibleContactsOptimized(userInfos, query, I18n.acceptLanguage(request), res -> { - if (res.isRight()) { - renderJson(request, res.right().getValue()); - } else { - leftToResponse(request, res.left()); - } - }); - }).onFailure(e -> log.error("An error occurred when retrieving authenticated user infos")); - } } diff --git a/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java b/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java index ea4c1f7c51..5a6ecaf917 100644 --- a/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java +++ b/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java @@ -21,7 +21,6 @@ import fr.wseduc.webutils.Either; import fr.wseduc.webutils.collections.Joiner; -import io.vertx.codegen.annotations.Nullable; import io.vertx.core.*; import io.vertx.core.eventbus.EventBus; import io.vertx.core.eventbus.Message; @@ -1528,39 +1527,80 @@ public Future searchVisibles(UserInfos user, String search, String la private Future legacySearchVisible(final UserInfos user, final String search, final String language) { final Promise promise = Promise.promise(); + // legacy visibles final LegacySearchVisibleRequest payload = new LegacySearchVisibleRequest( user.getUserId(), search, language, null ); + Promise legacyVisiblesPromise = Promise.promise(); eventBus.request("conversation.legacy.search.visible", mapFrom(payload), e -> { if(e.succeeded()) { - final JsonObject legacyResult = (JsonObject) e.result().body(); - final JsonArray adaptedLegacyresult = adaptLegacyResultFormat(legacyResult); - promise.complete(adaptedLegacyresult); + legacyVisiblesPromise.complete((JsonObject) e.result().body()); } else { - promise.fail(e.cause()); + legacyVisiblesPromise.fail(e.cause()); } }); + // Share bookmarks + final Future> shareBookmarksFuture = getShareBookmarks(user, search); + + CompositeFuture.join(legacyVisiblesPromise.future(), shareBookmarksFuture) + .onComplete(ar -> { + if (ar.failed()) { + promise.fail(ar.cause().getMessage()); + } else { + + final JsonObject legacyResult = legacyVisiblesPromise.future().result(); + final Either resShareBookmark = shareBookmarksFuture.result(); + + if (resShareBookmark.isLeft()) { + log.error(resShareBookmark.left().getValue()); + } + JsonArray shareBookmarks = resShareBookmark.isLeft() ? new JsonArray() : resShareBookmark.right().getValue(); + + final JsonArray adaptedLegacyResult = adaptLegacyResultFormat(user.getType(), language, legacyResult, shareBookmarks); + promise.complete(adaptedLegacyResult); + } + }); return promise.future(); } private JsonArray adaptLegacyResultFormat( final String userProfile, final String language, - final JsonObject legacyResult) { + final JsonObject legacyResult, + final JsonArray shareBookmarks) { final JsonArray visibles = new JsonArray(); - // TODO jber check output result - visibles.addAll(legacyResult.getJsonArray("users", new JsonArray())); - visibles.addAll(legacyResult.getJsonArray("groups", new JsonArray())); + visibles.addAll(adaptLegacyUsersResult(legacyResult.getJsonArray("users", new JsonArray()))); + visibles.addAll(adaptLegacyGroupsResult(legacyResult.getJsonArray("groups", new JsonArray()))); return UserUtils.mapObjectToContact( userProfile, - new JsonArray(), + shareBookmarks, visibles, language ); } + private JsonArray adaptLegacyUsersResult(final JsonArray users) { + users.stream() + .map(user -> (JsonObject) user) + .forEach(user -> { + user.remove("structureName"); + user.put("name", null).put("groupType", new JsonArray().add("Visible").add("User")); + }); + return users; + } + + private JsonArray adaptLegacyGroupsResult(final JsonArray groups) { + groups.stream() + .map(group -> (JsonObject) group) + .forEach(group -> { + group.remove("structureName"); + group.put("groupType", new JsonArray().add("Visible").add("Group").add("ProfileGroup")).put("groupProfile", group.remove("profile")); + }); + return groups; + } + public Future searchVisibleContacts(UserInfos user, String search, String language) { final Promise promise = Promise.promise(); String match = "MATCH (visibles) " + @@ -1654,35 +1694,9 @@ public Future searchVisibleContacts(UserInfos user, String search, St getVisiblePromise::complete); // Share bookmarks - final Promise> getShareBookmarksPromise = Promise.promise(); - - /* - final String queryShareBookmarks = "MATCH (:User {id:{userId}})-[:HAS_SB]->(bm:ShareBookmark) return bm"; - Neo4j.getInstance() - .execute( - queryShareBookmarks, - new JsonObject().put("userId", userInfos.getUserId()), - fullNodeMergeHandler("bm", getShareBookmarksPromise::complete) - ); - */ - - String sbFilter = ""; - JsonObject sbParams = new JsonObject().put("userId", user.getUserId()); - if (!StringUtils.isEmpty(search)) { - sbFilter = " AND lower(sbValue[0]) contains {search} "; - sbParams.put("search", StringValidation.sanitize(search)); - } - String queryShareBookmarks = "MATCH (:User {id: {userId}})-[:HAS_SB]->(sb:ShareBookmark) " + - "WITH sb, keys(sb) AS ids " + - "UNWIND ids AS id " + - "WITH sb, id, sb[id] AS sbValue " + - "WHERE size(sbValue) >= 2 " + sbFilter + - "WITH sb, id, sb[id] AS sbValue ORDER BY sbValue[0] " + - "RETURN id as id, sbValue[0] as displayName "; - - Neo4j.getInstance().execute(queryShareBookmarks, sbParams, validResultHandler(getShareBookmarksPromise::complete)); + final Future> shareBookmarksFuture = getShareBookmarks(user, search); - CompositeFuture.join(getVisiblePromise.future(), getShareBookmarksPromise.future()) + CompositeFuture.join(getVisiblePromise.future(), shareBookmarksFuture) .onComplete(ar -> { if (ar.failed()) { @@ -1690,7 +1704,7 @@ public Future searchVisibleContacts(UserInfos user, String search, St } else { final Either resVisible = getVisiblePromise.future().result(); - final Either resShareBookmark = getShareBookmarksPromise.future().result(); + final Either resShareBookmark = shareBookmarksFuture.result(); if (resVisible.isLeft()) { log.error(resVisible.left().getValue()); @@ -1770,35 +1784,9 @@ public Future searchVisibleContactsLight(UserInfos user, String searc getVisiblePromise::complete); // Share bookmarks - final Promise> getShareBookmarksPromise = Promise.promise(); - - /* - final String queryShareBookmarks = "MATCH (:User {id:{userId}})-[:HAS_SB]->(bm:ShareBookmark) return bm"; - Neo4j.getInstance() - .execute( - queryShareBookmarks, - new JsonObject().put("userId", userInfos.getUserId()), - fullNodeMergeHandler("bm", getShareBookmarksPromise::complete) - ); - */ + final Future> shareBookmarksFuture = getShareBookmarks(user, search); - String sbFilter = ""; - JsonObject sbParams = new JsonObject().put("userId", user.getUserId()); - if (!StringUtils.isEmpty(search)) { - sbFilter = " AND lower(sbValue[0]) contains {search} "; - sbParams.put("search", StringValidation.sanitize(search)); - } - String queryShareBookmarks = "MATCH (:User {id: {userId}})-[:HAS_SB]->(sb:ShareBookmark) " + - "WITH sb, keys(sb) AS ids " + - "UNWIND ids AS id " + - "WITH sb, id, sb[id] AS sbValue " + - "WHERE size(sbValue) >= 2 " + sbFilter + - "WITH sb, id, sb[id] AS sbValue ORDER BY sbValue[0] " + - "RETURN id as id, sbValue[0] as displayName "; - - Neo4j.getInstance().execute(queryShareBookmarks, sbParams, validResultHandler(getShareBookmarksPromise::complete)); - - CompositeFuture.join(getVisiblePromise.future(), getShareBookmarksPromise.future()) + CompositeFuture.join(getVisiblePromise.future(), shareBookmarksFuture) .onComplete(ar -> { if (ar.failed()) { @@ -1806,7 +1794,7 @@ public Future searchVisibleContactsLight(UserInfos user, String searc } else { final Either resVisible = getVisiblePromise.future().result(); - final Either resShareBookmark = getShareBookmarksPromise.future().result(); + final Either resShareBookmark = shareBookmarksFuture.result(); if (resVisible.isLeft()) { log.error(resVisible.left().getValue()); @@ -1840,8 +1828,8 @@ public Future searchVisibleContactsOptimized(UserInfos user, String s "OPTIONAL MATCH (visibles)-[:RELATED]-(related:User) " + "WITH visibles, primaryProfile, " + " collect(CASE \n" + - " WHEN primaryProfile == 'Student' THEN {id: related.id, displayName: related.displayName, role: 'parent'} \n" + - " WHEN primaryProfile != 'Student' THEN {id: related.id, displayName: related.displayName, role: 'child'}\n" + + " WHEN primaryProfile = 'Student' THEN {id: related.id, displayName: related.displayName, role: 'parent'} \n" + + " WHEN primaryProfile <> 'Student' THEN {id: related.id, displayName: related.displayName, role: 'child'}\n" + " END) AS relatedUsers\n" + "WITH visibles, \n" + " [x IN relatedUsers WHERE x.role = 'parent'] AS relatives, \n" + @@ -1914,25 +1902,9 @@ public Future searchVisibleContactsOptimized(UserInfos user, String s getVisiblePromise::complete); // Share bookmarks - final Promise> getShareBookmarksPromise = Promise.promise(); - - String sbFilter = ""; - JsonObject sbParams = new JsonObject().put("userId", user.getUserId()); - if (!StringUtils.isEmpty(search)) { - sbFilter = " AND lower(sbValue[0]) contains {search} "; - sbParams.put("search", StringValidation.sanitize(search)); - } - String queryShareBookmarks = "MATCH (:User {id: {userId}})-[:HAS_SB]->(sb:ShareBookmark) " + - "WITH sb, keys(sb) AS ids " + - "UNWIND ids AS id " + - "WITH sb, id, sb[id] AS sbValue " + - "WHERE size(sbValue) >= 2 " + sbFilter + - "WITH sb, id, sb[id] AS sbValue ORDER BY sbValue[0] " + - "RETURN id as id, sbValue[0] as displayName "; - - Neo4j.getInstance().execute(queryShareBookmarks, sbParams, validResultHandler(getShareBookmarksPromise::complete)); + final Future> shareBookmarksFuture = getShareBookmarks(user, search); - CompositeFuture.join(getVisiblePromise.future(), getShareBookmarksPromise.future()) + CompositeFuture.join(getVisiblePromise.future(), shareBookmarksFuture) .onComplete(ar -> { if (ar.failed()) { @@ -1940,7 +1912,7 @@ public Future searchVisibleContactsOptimized(UserInfos user, String s } else { final Either resVisible = getVisiblePromise.future().result(); - final Either resShareBookmark = getShareBookmarksPromise.future().result(); + final Either resShareBookmark = shareBookmarksFuture.result(); if (resVisible.isLeft()) { log.error(resVisible.left().getValue()); @@ -1966,5 +1938,26 @@ public Future searchVisibleContactsOptimized(UserInfos user, String s return promise.future(); } + private static Future> getShareBookmarks(UserInfos user, String search) { + final Promise> getShareBookmarksPromise = Promise.promise(); + + String sbFilter = ""; + JsonObject sbParams = new JsonObject().put("userId", user.getUserId()); + if (!StringUtils.isEmpty(search)) { + sbFilter = " AND lower(sbValue[0]) contains {search} "; + sbParams.put("search", StringValidation.sanitize(search)); + } + String queryShareBookmarks = "MATCH (:User {id: {userId}})-[:HAS_SB]->(sb:ShareBookmark) " + + "WITH sb, keys(sb) AS ids " + + "UNWIND ids AS id " + + "WITH sb, id, sb[id] AS sbValue " + + "WHERE size(sbValue) >= 2 " + sbFilter + + "WITH sb, id, sb[id] AS sbValue ORDER BY sbValue[0] " + + "RETURN id as id, sbValue[0] as displayName "; + + Neo4j.getInstance().execute(queryShareBookmarks, sbParams, validResultHandler(getShareBookmarksPromise::complete)); + return getShareBookmarksPromise.future(); + } + } diff --git a/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java b/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java index 7a59fe9511..7dcd44020e 100644 --- a/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java +++ b/communication/src/test/java/org/entcore/communication/services/impl/DefaultCommunicationServiceTest.java @@ -42,7 +42,7 @@ private UserInfos mockUserNoAdmin() { @Before public void prepare() { - this.service = new DefaultCommunicationService(new TimelineHelper(Vertx.vertx(), Vertx.vertx().eventBus(), new JsonObject()), new JsonObject()); + this.service = new DefaultCommunicationService(Vertx.vertx(), new TimelineHelper(Vertx.vertx(), Vertx.vertx().eventBus(), new JsonObject()), new JsonObject()); this.userNoAdmin = test.directory().generateUser("notused"); this.userNoAdmin.setFunctions(new HashMap(0)); } diff --git a/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java b/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java index dbc333de57..39eaacc6aa 100644 --- a/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java +++ b/communication/src/test/java/org/entcore/communication/services/impl/OptimComTest.java @@ -77,7 +77,7 @@ public static void setUpAll(TestContext context) throws Exception { //@Before public void setUp(TestContext context) { vertx = test.vertx(); - defaultComService = new DefaultCommunicationService(new TimelineHelper(vertx, vertx.eventBus(), new JsonObject()), new JsonObject()); + defaultComService = new DefaultCommunicationService(Vertx.vertx(), new TimelineHelper(vertx, vertx.eventBus(), new JsonObject()), new JsonObject()); } //@Test diff --git a/tests/src/test/js/it/scenarios/communication/_search-visibles-legacy.ts b/tests/src/test/js/it/scenarios/communication/_search-visibles-legacy.ts new file mode 100644 index 0000000000..77908ed758 --- /dev/null +++ b/tests/src/test/js/it/scenarios/communication/_search-visibles-legacy.ts @@ -0,0 +1,63 @@ +import { sleep } from "k6"; +import { describe } from "https://jslib.k6.io/k6chaijs/4.3.4.0/index.js"; + +import { + authenticateWeb, + getAdmlsOrMakThem, + triggerImport, + initStructure, + searchVisibles, +} from "../../../node_modules/edifice-k6-commons/dist/index.js"; +import { checkSearchVisible } from "./_utils.ts"; + +const aafImport = (__ENV.AAF_IMPORT || "true") === "true"; +const aafImportPause = parseInt(__ENV.AAF_IMPORT_PAUSE || "10"); +const maxDuration = __ENV.MAX_DURATION || "1m"; +const schoolName = __ENV.DATA_SCHOOL_NAME || "Search visibles - Tests"; +const gracefulStop = parseInt(__ENV.GRACEFUL_STOP || "2s"); + +export const options = { + setupTimeout: "1h", + thresholds: { + checks: ["rate == 1.00"], + }, + scenarios: { + searchVisiblesLegacy: { + exec: 'searchVisiblesLegacy', + executor: "per-vu-iterations", + vus: 1, + maxDuration: maxDuration, + gracefulStop, + }, + }, +}; + +export function setup() { + let structure; + describe("[Search visibles legacy] Initialize data", () => { + authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD); + structure = initStructure(schoolName) + }); + if(aafImport) { + triggerImport() + sleep(aafImportPause) + } + return {structure} +} + +/* For this test to complete successfully, recette must run with the following configuration in communication config section of ent-core.json : + - "visibles-search-type": "legacy" + */ +export function searchVisiblesLegacy(data) { + const {structure} = data; + describe('[Communication] Test - Search visibles legacy', () => { + authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD); + + const admlTeacher = getAdmlsOrMakThem(structure, 'Teacher', 1, [])[0] + authenticateWeb(admlTeacher.login) + const res = searchVisibles(); + const expectedUserFields = ["id", "displayName", "profile", "type", "usedIn"]; + const expectedGroupFields = ["id", "displayName", "profile", "type", "groupType", "usedIn"]; + checkSearchVisible(res, expectedUserFields, expectedGroupFields, 'Search visibles legacy') + }); +} \ No newline at end of file diff --git a/tests/src/test/js/it/scenarios/communication/_search-visibles-light.ts b/tests/src/test/js/it/scenarios/communication/_search-visibles-light.ts new file mode 100644 index 0000000000..9f071dfba6 --- /dev/null +++ b/tests/src/test/js/it/scenarios/communication/_search-visibles-light.ts @@ -0,0 +1,63 @@ +import { sleep } from "k6"; +import { describe } from "https://jslib.k6.io/k6chaijs/4.3.4.0/index.js"; + +import { + authenticateWeb, + getAdmlsOrMakThem, + triggerImport, + initStructure, + searchVisibles, +} from "../../../node_modules/edifice-k6-commons/dist/index.js"; +import { checkSearchVisible } from "./_utils.ts"; + +const aafImport = (__ENV.AAF_IMPORT || "true") === "true"; +const aafImportPause = parseInt(__ENV.AAF_IMPORT_PAUSE || "10"); +const maxDuration = __ENV.MAX_DURATION || "1m"; +const schoolName = __ENV.DATA_SCHOOL_NAME || "Search visibles - Tests"; +const gracefulStop = parseInt(__ENV.GRACEFUL_STOP || "2s"); + +export const options = { + setupTimeout: "1h", + thresholds: { + checks: ["rate == 1.00"], + }, + scenarios: { + searchVisiblesLight: { + exec: 'searchVisiblesLight', + executor: "per-vu-iterations", + vus: 1, + maxDuration: maxDuration, + gracefulStop, + }, + }, +}; + +export function setup() { + let structure; + describe("[Search visibles light] Initialize data", () => { + authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD); + structure = initStructure(schoolName) + }); + if(aafImport) { + triggerImport() + sleep(aafImportPause) + } + return {structure} +} + +/* For this test to complete successfully, recette must run with the following configuration in communication config section of ent-core.json : + - "visibles-search-type": "light" + */ +export function searchVisiblesLight(data) { + const {structure} = data; + describe('[Communication] Test - Search visibles light', () => { + authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD); + + const admlTeacher = getAdmlsOrMakThem(structure, 'Teacher', 1, [])[0] + authenticateWeb(admlTeacher.login) + const res = searchVisibles(); + const expectedUserFields = ["id", "displayName", "profile", "children", "relatives", "type", "usedIn"]; + const expectedGroupFields = ["id", "displayName", "profile", "nbUsers", "type", "groupType", "usedIn"]; + checkSearchVisible(res, expectedUserFields, expectedGroupFields, 'Search visibles light') + }); +} diff --git a/tests/src/test/js/it/scenarios/communication/_utils.ts b/tests/src/test/js/it/scenarios/communication/_utils.ts new file mode 100644 index 0000000000..4ced44741d --- /dev/null +++ b/tests/src/test/js/it/scenarios/communication/_utils.ts @@ -0,0 +1,23 @@ +import { check } from "k6"; +import { + Visible, + } from "../../../node_modules/edifice-k6-commons/dist/index.js"; + + +export function checkSearchVisible(res, expectedUserFields:string[], expectedGroupFields:string[], checkName) { + const checks = {} + const visibles: Visible[] = JSON.parse(res.body); + checks[`${checkName} - HTTP status`] = (r) => r.status === 200 + checks[`${checkName} - Visible user has expected fields`] = () => { + const visibleUser:Visible = visibles.filter(v => v.type === 'User')[0] + return expectedUserFields.every(field => visibleUser.hasOwnProperty(field)) + } + checks[`${checkName} - Visible group has expected fields`] = () => { + const visibleGroup:Visible = visibles.filter(v => v.type === 'Group')[0] + return expectedGroupFields.every(field => visibleGroup.hasOwnProperty(field)) + } + const ok = check(res, checks); + if(!ok) { + console.error(checkName, res) + } +} \ No newline at end of file diff --git a/tests/src/test/js/it/scenarios/communication/search_visibles.sh b/tests/src/test/js/it/scenarios/communication/search_visibles.sh new file mode 100755 index 0000000000..c9e21bd585 --- /dev/null +++ b/tests/src/test/js/it/scenarios/communication/search_visibles.sh @@ -0,0 +1,37 @@ +#!/bin/bash +data_dir="$1" +sb_dir="$2" +current_dir="$(pwd)" +exit_code=0 +# set DEBUG_SUSPEND=n in docker-compose.yml +sed -i 's/DEBUG_SUSPEND=y/DEBUG_SUSPEND=n/' $sb_dir/docker-compose.yml + +# set "visibles-search-type": "legacy" in ent-core.json +sed -i 's/"visibles-search-type.*"/"visibles-search-type": "legacy"/' $sb_dir/ent-core.json +# restart vertx container +cd $sb_dir +docker compose up -d --force-recreate vertx +# wait for vertx to restart +sleep 15 +# run k6 test for _search-visibles-legacy.ts +cd $current_dir +docker compose run --rm k6 run --compatibility-mode=experimental_enhanced file:///home/k6/src/it/scenarios/communication/_search-visibles-legacy.ts +if [ $? -ne 0 ]; then + exit_code=1 +fi + +# set "visibles-search-type": "light" in ent-core.json +sed -i 's/"visibles-search-type.*"/"visibles-search-type": "light"/' $sb_dir/ent-core.json +# restart vertx container +cd $sb_dir +docker compose up -d --force-recreate vertx +# wait for vertx to restart +sleep 15 +# run k6 test for _search-visibles-light.ts +cd $current_dir +docker compose run --rm k6 run --compatibility-mode=experimental_enhanced file:///home/k6/src/it/scenarios/communication/_search-visibles-light.ts +if [ $? -ne 0 ]; then + exit_code=1 +fi + +exit $exit_code From 3bcd9644fefd4890b911eb4e6a8990a33bbb999b Mon Sep 17 00:00:00 2001 From: mariusestaque Date: Mon, 24 Feb 2025 16:43:20 +0100 Subject: [PATCH 5/5] feat: WB-3801, fix after merge --- .../conversation/service/impl/SqlConversationService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java index 0ca4d95285..8ddf490314 100644 --- a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java +++ b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java @@ -1003,7 +1003,7 @@ private Future callFindVisibles(final String userId, final String ac JsonArray groups = new fr.wseduc.webutils.collections.JsonArray(); visible.put("groups", groups).put("users", users); - logger.info("callFindVisibles Count = " + visibles.size()); + log.info("callFindVisibles Count = " + visibles.size()); for (Object o: visibles) { if (!(o instanceof JsonObject)) continue;