001/*
002 * (C) Copyright 2019-2020 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Salem Aouana
018 */
019
020package org.nuxeo.ecm.platform.comment.impl;
021
022import static java.lang.Boolean.TRUE;
023import static java.util.Collections.emptyList;
024import static java.util.Collections.singletonList;
025import static java.util.Collections.singletonMap;
026import static java.util.Objects.requireNonNull;
027import static java.util.stream.Collectors.collectingAndThen;
028import static java.util.stream.Collectors.toList;
029import static org.apache.commons.lang3.StringUtils.isBlank;
030import static org.apache.commons.lang3.StringUtils.isEmpty;
031import static org.nuxeo.ecm.core.api.VersioningOption.NONE;
032import static org.nuxeo.ecm.core.api.security.SecurityConstants.EVERYTHING;
033import static org.nuxeo.ecm.core.api.versioning.VersioningService.VERSIONING_OPTION;
034import static org.nuxeo.ecm.core.io.marshallers.json.document.DocumentModelJsonReader.applyDirtyPropertyValues;
035import static org.nuxeo.ecm.core.query.sql.NXQL.ECM_ANCESTORID;
036import static org.nuxeo.ecm.core.query.sql.NXQL.ECM_UUID;
037import static org.nuxeo.ecm.core.schema.FacetNames.HAS_RELATED_TEXT;
038import static org.nuxeo.ecm.core.storage.BaseDocument.RELATED_TEXT;
039import static org.nuxeo.ecm.core.storage.BaseDocument.RELATED_TEXT_ID;
040import static org.nuxeo.ecm.core.storage.BaseDocument.RELATED_TEXT_RESOURCES;
041import static org.nuxeo.ecm.platform.comment.api.CommentConstants.COMMENT_ANCESTOR_IDS_PROPERTY;
042import static org.nuxeo.ecm.platform.comment.api.CommentConstants.COMMENT_AUTHOR_PROPERTY;
043import static org.nuxeo.ecm.platform.comment.api.CommentConstants.COMMENT_CREATION_DATE_PROPERTY;
044import static org.nuxeo.ecm.platform.comment.api.CommentConstants.COMMENT_PARENT_ID_PROPERTY;
045import static org.nuxeo.ecm.platform.comment.api.CommentConstants.COMMENT_ROOT_DOC_TYPE;
046import static org.nuxeo.ecm.platform.comment.api.CommentConstants.COMMENT_SCHEMA;
047import static org.nuxeo.ecm.platform.comment.api.CommentConstants.COMMENT_TEXT_PROPERTY;
048import static org.nuxeo.ecm.platform.comment.api.ExternalEntityConstants.EXTERNAL_ENTITY_FACET;
049import static org.nuxeo.ecm.platform.dublincore.listener.DublinCoreListener.DISABLE_DUBLINCORE_LISTENER;
050import static org.nuxeo.ecm.platform.ec.notification.NotificationConstants.DISABLE_NOTIFICATION_SERVICE;
051import static org.nuxeo.ecm.platform.query.nxql.CoreQueryAndFetchPageProvider.CORE_SESSION_PROPERTY;
052
053import java.io.Serializable;
054import java.time.Instant;
055import java.util.ArrayList;
056import java.util.Collection;
057import java.util.Collections;
058import java.util.List;
059import java.util.Map;
060import java.util.Optional;
061
062import org.apache.logging.log4j.LogManager;
063import org.apache.logging.log4j.Logger;
064import org.nuxeo.ecm.core.api.CoreInstance;
065import org.nuxeo.ecm.core.api.CoreSession;
066import org.nuxeo.ecm.core.api.DocumentModel;
067import org.nuxeo.ecm.core.api.DocumentNotFoundException;
068import org.nuxeo.ecm.core.api.DocumentRef;
069import org.nuxeo.ecm.core.api.DocumentSecurityException;
070import org.nuxeo.ecm.core.api.IdRef;
071import org.nuxeo.ecm.core.api.NuxeoPrincipal;
072import org.nuxeo.ecm.core.api.PartialList;
073import org.nuxeo.ecm.core.api.SortInfo;
074import org.nuxeo.ecm.core.api.security.SecurityConstants;
075import org.nuxeo.ecm.platform.comment.api.Comment;
076import org.nuxeo.ecm.platform.comment.api.CommentEvents;
077import org.nuxeo.ecm.platform.comment.api.exceptions.CommentNotFoundException;
078import org.nuxeo.ecm.platform.comment.api.exceptions.CommentSecurityException;
079import org.nuxeo.ecm.platform.ec.notification.NotificationConstants;
080import org.nuxeo.ecm.platform.notification.api.NotificationManager;
081import org.nuxeo.ecm.platform.query.api.PageProvider;
082import org.nuxeo.ecm.platform.query.api.PageProviderService;
083import org.nuxeo.runtime.api.Framework;
084import org.nuxeo.runtime.services.config.ConfigurationService;
085
086/**
087 * Comment service implementation. The comments are linked together as a tree under a folder related to the root
088 * document that we comment.
089 *
090 * @since 11.1
091 */
092public class TreeCommentManager extends AbstractCommentManager {
093
094    private static final Logger log = LogManager.getLogger(TreeCommentManager.class);
095
096    public static final String COMMENT_RELATED_TEXT_ID = "commentRelatedTextId_%s";
097
098    /** The key to the config turning on or off autosubscription. */
099    public static final String AUTOSUBSCRIBE_CONFIG_KEY = "org.nuxeo.ecm.platform.comment.service.notification.autosubscribe";
100
101    protected static final String COMMENT_NAME = "comment";
102
103    /** @deprecated since 11.1, use {@link #GET_EXTERNAL_COMMENT_PAGE_PROVIDER_NAME} instead */
104    @Deprecated(since = "11.1")
105    @SuppressWarnings("DeprecatedIsStillUsed")
106    protected static final String GET_COMMENT_PAGE_PROVIDER_NAME = "GET_COMMENT_AS_EXTERNAL_ENTITY";
107
108    protected static final String GET_EXTERNAL_COMMENT_PAGE_PROVIDER_NAME = "GET_EXTERNAL_COMMENT_BY_ECM_ANCESTOR";
109
110    protected static final String GET_COMMENTS_FOR_DOCUMENT_PAGE_PROVIDER_NAME = "GET_COMMENTS_FOR_DOCUMENT_BY_ECM_PARENT";
111    
112    protected static final String GET_COMMENTS_FOR_DOCUMENTS_PAGE_PROVIDER_NAME = "GET_COMMENTS_FOR_DOCUMENTS_BY_COMMENT_ANCESTOR";
113
114    protected static final String SERVICE_WITHOUT_IMPLEMENTATION_MESSAGE = "This service implementation does not implement deprecated API.";
115
116    /**
117     * Counts how many comments where made on a specific document.
118     */
119    protected static final String QUERY_GET_COMMENTS_UUID_BY_COMMENT_ANCESTOR = //
120            "SELECT " + ECM_UUID + " FROM Comment WHERE " + ECM_ANCESTORID + " = '%s'";
121
122    /**
123     * Counts how many comments where made by a specific user on a specific document.
124     */
125    protected static final String QUERY_GET_COMMENTS_UUID_BY_COMMENT_ANCESTOR_AND_AUTHOR = //
126            QUERY_GET_COMMENTS_UUID_BY_COMMENT_ANCESTOR + " AND " + COMMENT_AUTHOR_PROPERTY + " = '%s'";
127
128    @Override
129    public List<DocumentModel> getComments(CoreSession session, DocumentModel doc) {
130        return getCommentDocuments(session, doc.getId(), null, null, true);
131    }
132
133    @Override
134    public Comment getComment(CoreSession session, String commentId) {
135        var commentDoc = getCommentDocumentModel(session, commentId);
136        return commentDoc.getAdapter(Comment.class);
137    }
138
139    @Override
140    public PartialList<Comment> getComments(CoreSession session, String documentId, Long pageSize,
141            Long currentPageIndex, boolean sortAscending) {
142        var result = getCommentDocuments(session, documentId, pageSize, currentPageIndex, sortAscending);
143        return result.stream()
144                     .map(doc -> doc.getAdapter(Comment.class))
145                     .collect(collectingAndThen(toList(), list -> new PartialList<>(list, result.totalSize())));
146    }
147
148    @Override
149    public List<Comment> getComments(CoreSession session, Collection<String> documentIds) {
150        PageProviderService ppService = Framework.getService(PageProviderService.class);
151
152        Map<String, Serializable> props = Map.of(CORE_SESSION_PROPERTY, (Serializable) session);
153        @SuppressWarnings("unchecked")
154        var pageProvider = (PageProvider<DocumentModel>) ppService.getPageProvider(
155                GET_COMMENTS_FOR_DOCUMENTS_PAGE_PROVIDER_NAME, null, null, null, props, new ArrayList<>(documentIds));
156        return pageProvider.getCurrentPage().stream().map(doc -> doc.getAdapter(Comment.class)).collect(toList());
157    }
158
159    @Override
160    @SuppressWarnings("removal")
161    public List<DocumentModel> getDocumentsForComment(DocumentModel comment) {
162        throw new UnsupportedOperationException(SERVICE_WITHOUT_IMPLEMENTATION_MESSAGE);
163    }
164
165    @Override
166    public DocumentModel getThreadForComment(DocumentModel comment) {
167        throw new UnsupportedOperationException(SERVICE_WITHOUT_IMPLEMENTATION_MESSAGE);
168    }
169
170    @Override
171    public Comment getExternalComment(CoreSession session, String documentId, String entityId) {
172        var commentDoc = getExternalCommentModel(session, documentId, entityId);
173        return commentDoc.getAdapter(Comment.class);
174    }
175
176    @Override
177    public Comment createComment(CoreSession session, Comment comment) {
178        var parentRef = new IdRef(comment.getParentId());
179        checkCreateCommentPermissions(session, parentRef);
180
181        fillCommentForCreation(session, comment);
182
183        return CoreInstance.doPrivileged(session, s -> {
184            DocumentModel commentedDoc = s.getDocument(parentRef);
185            // Get the location where comment will be stored
186            DocumentRef locationDocRef = getLocationRefOfCommentCreation(s, commentedDoc);
187
188            DocumentModel commentDoc = s.newDocumentModel(locationDocRef, COMMENT_NAME,
189                    comment.getDocument().getType());
190            if (comment.getDocument().hasFacet(EXTERNAL_ENTITY_FACET)) {
191                commentDoc.addFacet(EXTERNAL_ENTITY_FACET);
192            }
193            applyDirtyPropertyValues(comment.getDocument(), commentDoc);
194
195            commentDoc.setPropertyValue(COMMENT_ANCESTOR_IDS_PROPERTY,
196                    computeAncestorIds(session, comment.getParentId()));
197
198            // Create the comment document model
199            commentDoc = s.createDocument(commentDoc);
200            Comment createdComment = commentDoc.getAdapter(Comment.class);
201
202            DocumentModel topLevelDoc = getTopLevelDocument(s, commentDoc);
203            manageRelatedTextOfTopLevelDocument(s, topLevelDoc, createdComment.getId(), createdComment.getText());
204
205            handleNotificationAutoSubscriptions(s, topLevelDoc, commentDoc);
206
207            notifyEvent(s, CommentEvents.COMMENT_ADDED, commentedDoc, commentDoc);
208
209            return createdComment;
210        });
211    }
212
213    @Override
214    public DocumentModel createComment(DocumentModel commentedDoc, DocumentModel commentDoc) {
215        // Check the right permissions on document that we want to comment
216        checkCreateCommentPermissions(commentDoc.getCoreSession(), commentedDoc.getRef());
217
218        return CoreInstance.doPrivileged(commentDoc.getCoreSession(), session -> {
219            // Get the location to store the comment
220            DocumentRef locationDocRef = getLocationRefOfCommentCreation(session, commentedDoc);
221
222            DocumentModel commentModelToCreate = session.newDocumentModel(locationDocRef, COMMENT_NAME,
223                    commentDoc.getType());
224            commentModelToCreate.copyContent(commentDoc);
225
226            // Should compute ancestors and set comment:parentId for backward compatibility
227            commentModelToCreate.setPropertyValue(COMMENT_PARENT_ID_PROPERTY, commentedDoc.getId());
228            commentModelToCreate.setPropertyValue(COMMENT_ANCESTOR_IDS_PROPERTY,
229                    computeAncestorIds(session, commentedDoc.getId()));
230
231            // Create the comment doc model
232            commentModelToCreate = session.createDocument(commentModelToCreate);
233
234            DocumentModel topLevelDoc = getTopLevelDocument(session, commentModelToCreate);
235            manageRelatedTextOfTopLevelDocument(session, topLevelDoc, commentModelToCreate.getId(),
236                    (String) commentDoc.getPropertyValue(COMMENT_TEXT_PROPERTY));
237
238            handleNotificationAutoSubscriptions(session, topLevelDoc, commentDoc);
239
240            commentModelToCreate.detach(true);
241            notifyEvent(session, CommentEvents.COMMENT_ADDED, commentedDoc, commentModelToCreate);
242            return commentModelToCreate;
243        });
244    }
245
246    @Override
247    public DocumentModel createLocatedComment(DocumentModel doc, DocumentModel comment, String path) {
248        throw new UnsupportedOperationException(SERVICE_WITHOUT_IMPLEMENTATION_MESSAGE);
249    }
250
251    @Override
252    public DocumentModel createComment(DocumentModel doc, String text) {
253        throw new UnsupportedOperationException(SERVICE_WITHOUT_IMPLEMENTATION_MESSAGE);
254    }
255
256    @Override
257    @SuppressWarnings("removal")
258    public DocumentModel createComment(DocumentModel doc, String text, String author) {
259        throw new UnsupportedOperationException(SERVICE_WITHOUT_IMPLEMENTATION_MESSAGE);
260    }
261
262    @Override
263    @SuppressWarnings("removal")
264    public DocumentModel createComment(DocumentModel doc, DocumentModel parent, DocumentModel child) {
265        throw new UnsupportedOperationException(SERVICE_WITHOUT_IMPLEMENTATION_MESSAGE);
266    }
267
268    @Override
269    public Comment updateComment(CoreSession session, String commentId, Comment comment) {
270        // Get the comment doc model
271        DocumentModel commentDoc = getCommentDocumentModel(session, commentId);
272        return update(session, comment, commentDoc);
273    }
274
275    @Override
276    public Comment updateExternalComment(CoreSession session, String documentId, String entityId, Comment comment) {
277        // Get the external comment doc model
278        DocumentModel commentDoc = getExternalCommentModel(session, documentId, entityId);
279        return update(session, comment, commentDoc);
280    }
281
282    /**
283     * @param session the user session, in order to check permissions
284     * @param comment the comment holding new data
285     * @param commentDoc the {@link DocumentModel} just retrieved from DB
286     */
287    protected Comment update(CoreSession session, Comment comment, DocumentModel commentDoc) {
288        NuxeoPrincipal principal = session.getPrincipal();
289        return CoreInstance.doPrivileged(session, s -> {
290            DocumentModel topLevelDoc = getTopLevelDocument(s, commentDoc);
291            if (!principal.isAdministrator()
292                    && !commentDoc.getPropertyValue(COMMENT_AUTHOR_PROPERTY).equals(principal.getName())
293                    && !session.hasPermission(principal, topLevelDoc.getRef(), EVERYTHING)) {
294                throw new CommentSecurityException(String.format("The user %s cannot edit comments of document %s",
295                        principal.getName(), commentDoc.getPropertyValue(COMMENT_PARENT_ID_PROPERTY)));
296            }
297            if (comment.getModificationDate() == null) {
298                comment.setModificationDate(Instant.now());
299            }
300            if (comment.getDocument().hasFacet(EXTERNAL_ENTITY_FACET)) {
301                commentDoc.addFacet(EXTERNAL_ENTITY_FACET);
302            }
303            applyDirtyPropertyValues(comment.getDocument(), commentDoc);
304            var updatedDoc = s.saveDocument(commentDoc);
305            Comment updatedComment = updatedDoc.getAdapter(Comment.class);
306
307            manageRelatedTextOfTopLevelDocument(s, topLevelDoc, updatedComment.getId(), updatedComment.getText());
308            DocumentModel commentedDoc = getCommentedDocument(session, commentDoc);
309            notifyEvent(session, CommentEvents.COMMENT_UPDATED, topLevelDoc, commentedDoc, updatedDoc);
310            return updatedComment;
311        });
312    }
313
314    @Override
315    public void deleteExternalComment(CoreSession session, String documentId, String entityId) {
316        DocumentModel commentDoc = getExternalCommentModel(session, documentId, entityId);
317        removeComment(session, commentDoc.getRef());
318    }
319
320    @Override
321    public void deleteComment(CoreSession s, String commentId) {
322        removeComment(s, new IdRef(commentId));
323    }
324
325    @Override
326    @SuppressWarnings("removal")
327    public void deleteComment(DocumentModel doc, DocumentModel comment) {
328        throw new UnsupportedOperationException(SERVICE_WITHOUT_IMPLEMENTATION_MESSAGE);
329    }
330
331    /**
332     * Returns the {@link DocumentRef} of the comments location in repository for the given commented document model.
333     *
334     * @param session the session needs to be privileged
335     * @return the document model container of the comments of the given {@code commentedDoc}
336     * @since 11.1
337     */
338    protected DocumentRef getLocationRefOfCommentCreation(CoreSession session, DocumentModel commentedDoc) {
339        if (commentedDoc.hasSchema(COMMENT_SCHEMA)) {
340            // reply case, store the reply under the comment
341            return commentedDoc.getRef();
342        }
343        // regular document case, store the comment under a CommentRoot folder under the regular document
344        DocumentModel commentsFolder = session.newDocumentModel(commentedDoc.getRef(), COMMENTS_DIRECTORY,
345                COMMENT_ROOT_DOC_TYPE);
346        // no need to notify the creation of the Comments folder
347        commentsFolder.putContextData(DISABLE_NOTIFICATION_SERVICE, TRUE);
348        commentsFolder = session.getOrCreateDocument(commentsFolder);
349        session.save();
350        return commentsFolder.getRef();
351    }
352
353    @Override
354    public boolean hasFeature(Feature feature) {
355        switch (feature) {
356        case COMMENTS_LINKED_WITH_PROPERTY:
357        case COMMENTS_ARE_SPECIAL_CHILDREN:
358            return true;
359        default:
360            throw new UnsupportedOperationException(feature.name());
361        }
362    }
363
364    @Override
365    protected DocumentModel getTopLevelDocument(CoreSession session, DocumentModel commentDoc) {
366        DocumentModel docModel = commentDoc;
367        while (docModel.getParentRef() != null
368                && (docModel.hasSchema(COMMENT_SCHEMA) || COMMENT_ROOT_DOC_TYPE.equals(docModel.getType()))) {
369            docModel = session.getDocument(docModel.getParentRef());
370        }
371        return docModel;
372    }
373
374    /**
375     * Checks if the user related to the {@code session} can comments the document linked to the {@code documentRef}.
376     */
377    protected void checkCreateCommentPermissions(CoreSession session, DocumentRef documentRef) {
378        try {
379            if (!session.hasPermission(documentRef, SecurityConstants.READ)) {
380                throw new CommentSecurityException(String.format("The user %s can not create comments on document %s",
381                        session.getPrincipal().getName(), documentRef));
382            }
383        } catch (DocumentNotFoundException dnfe) {
384            throw new CommentNotFoundException(String.format("The comment %s does not exist.", documentRef), dnfe);
385        }
386    }
387
388    /**
389     * @param session the user session, in order to implicitly check permissions
390     * @return the external document model for the given {@code entityId}, if it exists, otherwise throws a
391     *         {@link CommentNotFoundException}
392     */
393    @SuppressWarnings("unchecked")
394    protected DocumentModel getExternalCommentModel(CoreSession session, String documentId, String entityId) {
395        PageProviderService ppService = Framework.getService(PageProviderService.class);
396        Map<String, Serializable> props = singletonMap(CORE_SESSION_PROPERTY, (Serializable) session);
397        PageProvider<DocumentModel> pageProvider;
398        // backward compatibility
399        if (isBlank(documentId)) {
400            pageProvider = (PageProvider<DocumentModel>) ppService.getPageProvider(GET_COMMENT_PAGE_PROVIDER_NAME,
401                    Collections.emptyList(), 1L, 0L, props, entityId);
402        } else {
403            pageProvider = (PageProvider<DocumentModel>) ppService.getPageProvider(
404                    GET_EXTERNAL_COMMENT_PAGE_PROVIDER_NAME, Collections.emptyList(), 1L, 0L, props, documentId,
405                    entityId);
406        }
407        List<DocumentModel> documents = pageProvider.getCurrentPage();
408        if (documents.isEmpty()) {
409            throw new CommentNotFoundException(String.format("The external comment %s does not exist.", entityId));
410        }
411        return documents.get(0);
412    }
413
414    /**
415     * Remove the comment of the given {@code documentRef}
416     *
417     * @param session the user session, in order to check permissions
418     * @param documentRef the documentRef of the comment document model to delete
419     */
420    protected void removeComment(CoreSession session, DocumentRef documentRef) {
421        NuxeoPrincipal principal = session.getPrincipal();
422        CoreInstance.doPrivileged(session, s -> {
423            DocumentRef ancestorRef = getTopLevelDocumentRef(s, documentRef);
424            DocumentModel commentDoc = s.getDocument(documentRef);
425            Serializable author = commentDoc.getPropertyValue(COMMENT_AUTHOR_PROPERTY);
426            if (!(principal.isAdministrator() //
427                    || author.equals(principal.getName()) //
428                    || s.hasPermission(principal, ancestorRef, EVERYTHING))) {
429                throw new CommentSecurityException(String.format(
430                        "The user %s cannot delete comments of the document %s", principal.getName(), ancestorRef));
431            }
432            Comment comment = commentDoc.getAdapter(Comment.class);
433
434            // fetch parents before deleting document
435            DocumentModel topLevelDoc = getTopLevelDocument(s, commentDoc);
436            DocumentModel commentedDoc = getCommentedDocument(s, commentDoc);
437            // nullify related text for this comment
438            manageRelatedTextOfTopLevelDocument(s, topLevelDoc, comment.getId(), null);
439            // finally delete document
440            s.removeDocument(documentRef);
441            notifyEvent(s, CommentEvents.COMMENT_REMOVED, topLevelDoc, commentedDoc, commentDoc);
442        });
443    }
444
445    /**
446     * @param session the user session, in order to implicitly check permissions
447     * @return the comment document model of the given {@code documentRef} if it exists, otherwise throws a
448     *         {@link CommentNotFoundException}
449     */
450    protected DocumentModel getCommentDocumentModel(CoreSession session, String id) {
451        try {
452            return session.getDocument(new IdRef(id));
453        } catch (DocumentNotFoundException dnfe) {
454            throw new CommentNotFoundException(String.format("The comment %s does not exist.", id), dnfe);
455        } catch (DocumentSecurityException dse) {
456            throw new CommentSecurityException(String.format("The user %s does not have access to the comment %s",
457                    session.getPrincipal().getName(), id), dse);
458        }
459    }
460
461    /**
462     * @return the page provider current page
463     */
464    @SuppressWarnings("unchecked")
465    protected PartialList<DocumentModel> getCommentDocuments(CoreSession session, String documentId, Long pageSize,
466            Long currentPageIndex, boolean sortAscending) {
467        try {
468            DocumentModel doc = session.getDocument(new IdRef(documentId));
469            // Depending on the case, the `doc` can be a comment or the top level document
470            // if it's the top level document, then we should retrieve all comments under `Comments` folder
471            // if it's a comment, then get all comments under it
472            if (!doc.hasSchema(COMMENT_SCHEMA) && session.hasChild(doc.getRef(), COMMENTS_DIRECTORY)) {
473                DocumentModel commentsFolder = session.getChild(doc.getRef(), COMMENTS_DIRECTORY);
474                documentId = commentsFolder.getId();
475            }
476
477            PageProviderService ppService = Framework.getService(PageProviderService.class);
478
479            Map<String, Serializable> props = Collections.singletonMap(CORE_SESSION_PROPERTY, (Serializable) session);
480            List<SortInfo> sortInfos = singletonList(new SortInfo(COMMENT_CREATION_DATE_PROPERTY, sortAscending));
481            var pageProvider = (PageProvider<DocumentModel>) ppService.getPageProvider(
482                    GET_COMMENTS_FOR_DOCUMENT_PAGE_PROVIDER_NAME, sortInfos, pageSize, currentPageIndex, props,
483                    documentId);
484            return new PartialList<>(pageProvider.getCurrentPage(), pageProvider.getResultsCount());
485        } catch (DocumentNotFoundException dnfe) {
486            return new PartialList<>(emptyList(), 0);
487        } catch (DocumentSecurityException dse) {
488            throw new CommentSecurityException(
489                    String.format("The user %s does not have access to the comments of document %s",
490                            session.getPrincipal().getName(), documentId),
491                    dse);
492        }
493    }
494
495    /**
496     * Manages (Add, Update or Remove) the related text {@link org.nuxeo.ecm.core.schema.FacetNames#HAS_RELATED_TEXT} of
497     * the top level document ancestor {@link #getTopLevelDocumentRef(CoreSession, DocumentRef)} for the given comment /
498     * annotation. Each action of adding, updating or removing the comment / annotation text will call this method,
499     * which allow us to make the right action on the related text of the top level document.
500     * <ul>
501     * <li>Add a new Comment / Annotation will create a separate entry</li>
502     * <li>Update a text Comment / Annotation will update this specific entry</li>
503     * <li>Remove a Comment / Annotation will remove this specific entry</li>
504     * </ul>
505     *
506     * @since 11.1
507     **/
508    protected void manageRelatedTextOfTopLevelDocument(CoreSession session, DocumentModel topLevelDoc, String commentId,
509            String commentText) {
510        requireNonNull(topLevelDoc, "Top level document is required");
511
512        // Get the Top level document model (the first document of our comments tree)
513        // which will contains the text of comments / annotations
514        topLevelDoc.addFacet(HAS_RELATED_TEXT);
515
516        // Get the related text id (the related text key is different in the case of Comment or Annotation)
517        String relatedTextId = String.format(COMMENT_RELATED_TEXT_ID, commentId);
518
519        @SuppressWarnings("unchecked")
520        List<Map<String, String>> resources = (List<Map<String, String>>) topLevelDoc.getPropertyValue(
521                RELATED_TEXT_RESOURCES);
522
523        Optional<Map<String, String>> optional = resources.stream()
524                                                          .filter(m -> relatedTextId.equals(m.get(RELATED_TEXT_ID)))
525                                                          .findAny();
526
527        if (isEmpty(commentText)) {
528            // Remove
529            optional.ifPresent(resources::remove);
530        } else {
531            optional.ifPresentOrElse( //
532                    map -> map.put(RELATED_TEXT, commentText), // Update
533                    () -> resources.add(Map.of(RELATED_TEXT_ID, relatedTextId, RELATED_TEXT, commentText))); // Creation
534        }
535
536        topLevelDoc.setPropertyValue(RELATED_TEXT_RESOURCES, (Serializable) resources);
537        topLevelDoc.putContextData(DISABLE_NOTIFICATION_SERVICE, TRUE);
538        topLevelDoc.putContextData(VERSIONING_OPTION, NONE);
539        topLevelDoc.putContextData(DISABLE_DUBLINCORE_LISTENER, TRUE);
540        session.saveDocument(topLevelDoc);
541    }
542
543    @Override
544    protected DocumentModel getCommentedDocument(CoreSession session, DocumentModel commentDoc) {
545        // if comment is a reply then its direct parent is the commented document
546        DocumentModel commentedDoc = session.getParentDocument(commentDoc.getRef());
547
548        // if direct parent is the Comments folder then the commented document is Comments parent
549        if (COMMENT_ROOT_DOC_TYPE.equals(commentedDoc.getType())) {
550            commentedDoc = session.getDocument(commentedDoc.getParentRef());
551        }
552        return commentedDoc;
553    }
554
555    /**
556     * Returns {@code true} if the document has comments.
557     *
558     * @since 11.1
559     */
560    protected boolean hasComments(CoreSession session, DocumentModel document) {
561        String query = String.format( //
562                QUERY_GET_COMMENTS_UUID_BY_COMMENT_ANCESTOR, document.getId());
563        return !session.queryProjection(query, 1, 0).isEmpty();
564    }
565
566    /**
567     * Returns {@code true} if the documents has comments from the given user.
568     *
569     * @since 11.1
570     */
571    protected boolean hasComments(CoreSession session, DocumentModel document, String user) {
572        String query = String.format( //
573                QUERY_GET_COMMENTS_UUID_BY_COMMENT_ANCESTOR_AND_AUTHOR, document.getId(), user);
574        return !session.queryProjection(query, 1, 0).isEmpty();
575    }
576
577    protected void handleNotificationAutoSubscriptions(CoreSession session, DocumentModel topLevelDoc,
578            DocumentModel commentDoc) {
579        if (Framework.getService(ConfigurationService.class).isBooleanFalse(AUTOSUBSCRIBE_CONFIG_KEY)) {
580            log.trace("autosubscription to new comments is disabled");
581            return;
582        }
583
584        NuxeoPrincipal topLevelDocumentAuthor = getAuthor(topLevelDoc);
585        if (!hasComments(session, topLevelDoc)) {
586            // Document author is subscribed on first comment by anybody
587            subscribeToNotifications(topLevelDoc, topLevelDocumentAuthor);
588        }
589
590        NuxeoPrincipal commentAuthor = getAuthor(commentDoc);
591        if (topLevelDocumentAuthor != null && topLevelDocumentAuthor.equals(commentAuthor)) {
592            // Document author is comment author. He doesn't need to be resubscribed
593            return;
594        }
595
596        if (commentAuthor != null && !hasComments(session, topLevelDoc, commentAuthor.getName())) {
597            // Comment author is writing his first comment on the document
598            subscribeToNotifications(topLevelDoc, commentAuthor);
599        }
600    }
601
602    /**
603     * Subscribes a user to notifications on the document.
604     *
605     * @since 11.1
606     */
607    protected void subscribeToNotifications(DocumentModel document, NuxeoPrincipal user) {
608        // User may have been deleted
609        if (user == null) {
610            return;
611        }
612        String subscriber = NotificationConstants.USER_PREFIX + user.getName();
613        NotificationManager notificationManager = Framework.getService(NotificationManager.class);
614        if (notificationManager != null) {
615            notificationManager.addSubscriptions(subscriber, document, false, user);
616        }
617    }
618
619}