001/*
002 *  Copyright 2022 Anyware Services
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 */
016package org.ametys.plugins.forms.dao;
017
018import java.util.ArrayList;
019import java.util.Comparator;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Objects;
024import java.util.Optional;
025import java.util.Set;
026import java.util.stream.Collectors;
027
028import javax.jcr.RepositoryException;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.service.Serviceable;
034import org.apache.cocoon.ProcessingException;
035import org.apache.commons.lang3.StringUtils;
036import org.apache.jackrabbit.JcrConstants;
037
038import org.ametys.cms.search.model.SystemPropertyExtensionPoint;
039import org.ametys.cms.search.ui.model.SearchUIColumn;
040import org.ametys.cms.search.ui.model.SearchUIColumnHelper;
041import org.ametys.core.observation.Event;
042import org.ametys.core.observation.ObservationManager;
043import org.ametys.core.right.RightManager;
044import org.ametys.core.right.RightManager.RightResult;
045import org.ametys.core.ui.Callable;
046import org.ametys.core.user.CurrentUserProvider;
047import org.ametys.core.user.User;
048import org.ametys.core.user.UserIdentity;
049import org.ametys.core.user.UserManager;
050import org.ametys.plugins.forms.FormEvents;
051import org.ametys.plugins.forms.actions.GetFormEntriesAction;
052import org.ametys.plugins.forms.helper.FormElementDefinitionHelper;
053import org.ametys.plugins.forms.helper.LimitedEntriesHelper;
054import org.ametys.plugins.forms.question.FormQuestionType;
055import org.ametys.plugins.forms.question.types.MultipleAwareQuestionType;
056import org.ametys.plugins.forms.question.types.RestrictiveAwareQuestionType;
057import org.ametys.plugins.forms.question.types.impl.ChoicesListQuestionType;
058import org.ametys.plugins.forms.question.types.impl.FileQuestionType;
059import org.ametys.plugins.forms.question.types.impl.MatrixQuestionType;
060import org.ametys.plugins.forms.question.types.impl.RichTextQuestionType;
061import org.ametys.plugins.forms.repository.Form;
062import org.ametys.plugins.forms.repository.FormEntry;
063import org.ametys.plugins.forms.repository.FormQuestion;
064import org.ametys.plugins.forms.rights.FormsDirectoryRightAssignmentContext;
065import org.ametys.plugins.repository.AmetysObject;
066import org.ametys.plugins.repository.AmetysObjectIterable;
067import org.ametys.plugins.repository.AmetysObjectResolver;
068import org.ametys.plugins.repository.AmetysRepositoryException;
069import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
070import org.ametys.plugins.repository.UnknownAmetysObjectException;
071import org.ametys.plugins.repository.query.expression.AndExpression;
072import org.ametys.plugins.repository.query.expression.BooleanExpression;
073import org.ametys.plugins.repository.query.expression.Expression;
074import org.ametys.plugins.workflow.support.WorkflowProvider;
075import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
076import org.ametys.runtime.authentication.AccessDeniedException;
077import org.ametys.runtime.authentication.AuthorizationRequiredException;
078import org.ametys.runtime.i18n.I18nizableText;
079import org.ametys.runtime.model.DefinitionContext;
080import org.ametys.runtime.model.Model;
081import org.ametys.runtime.model.ModelItem;
082import org.ametys.runtime.model.type.ModelItemTypeConstants;
083import org.ametys.runtime.plugin.component.AbstractLogEnabled;
084import org.ametys.web.parameters.ParametersManager;
085
086import com.opensymphony.workflow.spi.Step;
087
088/**
089 * Form entry DAO.
090 */
091public class FormEntryDAO extends AbstractLogEnabled implements Serviceable, Component
092{
093    /** The Avalon role name. */
094    public static final String ROLE = FormEntryDAO.class.getName();
095
096    /** Name for entries root jcr node */
097    public static final String ENTRIES_ROOT = "ametys-internal:form-entries";
098    
099    /** The right id to consult form entries */
100    public static final String HANDLE_FORMS_ENTRIES_RIGHT_ID = "Form_Entries_Rights_Data";
101    
102    /** The right id to delete form entries */
103    public static final String DELETE_FORMS_ENTRIES_RIGHT_ID = "Runtime_Rights_Forms_Entry_Delete";
104    
105    /** Ametys object resolver. */
106    protected AmetysObjectResolver _resolver;
107    /** The parameters manager */
108    protected ParametersManager _parametersManager;
109    /** Observer manager. */
110    protected ObservationManager _observationManager;
111    /** The current user provider. */
112    protected CurrentUserProvider _currentUserProvider;
113    /** The handling limited entries helper */
114    protected LimitedEntriesHelper _handleLimitedEntriesHelper;
115    /** The rights manager */
116    protected RightManager _rightManager;
117    /** The current user provider */
118    protected WorkflowProvider _workflowProvider;
119    /** The user manager */
120    protected UserManager _userManager;
121    /** The system property extension point */
122    protected SystemPropertyExtensionPoint _systemPropertyEP;
123    /** The form element definition helper */
124    protected FormElementDefinitionHelper _formElementDefinitionHelper;
125    
126    @Override
127    public void service(ServiceManager serviceManager) throws ServiceException
128    {
129        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
130        _parametersManager = (ParametersManager) serviceManager.lookup(ParametersManager.ROLE);
131        _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE);
132        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
133        _handleLimitedEntriesHelper = (LimitedEntriesHelper) serviceManager.lookup(LimitedEntriesHelper.ROLE);
134        _rightManager = (RightManager) serviceManager.lookup(RightManager.ROLE);
135        _workflowProvider = (WorkflowProvider) serviceManager.lookup(WorkflowProvider.ROLE);
136        _userManager = (UserManager) serviceManager.lookup(UserManager.ROLE);
137        _systemPropertyEP = (SystemPropertyExtensionPoint) serviceManager.lookup(SystemPropertyExtensionPoint.ROLE);
138        _formElementDefinitionHelper = (FormElementDefinitionHelper) serviceManager.lookup(FormElementDefinitionHelper.ROLE);
139    }
140    
141    /**
142     * Check if a user have handle data right on a form element as ametys object
143     * @param userIdentity the user
144     * @param formElement the form element
145     * @return true if the user handle data right for a form element
146     */
147    public boolean hasHandleDataRightOnForm(UserIdentity userIdentity, AmetysObject formElement)
148    {
149        return _rightManager.hasRight(userIdentity, HANDLE_FORMS_ENTRIES_RIGHT_ID, formElement) == RightResult.RIGHT_ALLOW;
150    }
151    
152    /**
153     * Check handle data right for a form element as ametys object
154     * @param formElement the form element as ametys object
155     */
156    public void checkHandleDataRight(AmetysObject formElement)
157    {
158        UserIdentity user = _currentUserProvider.getUser();
159        if (user == null)
160        {
161            // User not yet authenticated
162            throw new AuthorizationRequiredException();
163        }
164        
165        if (!hasHandleDataRightOnForm(user, formElement))
166        {
167            throw new AccessDeniedException("User '" + user + "' tried to handle form data without convenient right [" + HANDLE_FORMS_ENTRIES_RIGHT_ID + "]");
168        }
169    }
170    
171    /**
172     * Gets properties of a form entry
173     * @param id The id of the form entry
174     * @return The properties
175     */
176    @Callable (rights = Callable.NO_CHECK_REQUIRED)
177    public Map<String, Object> getFormEntryProperties (String id)
178    {
179        // Assume that no read access is checked (required for bus message target)
180        try
181        {
182            FormEntry entry = _resolver.resolveById(id);
183            return getFormEntryProperties(entry);
184        }
185        catch (UnknownAmetysObjectException e)
186        {
187            getLogger().warn("Can't find entry with id: {}. It probably has just been deleted", id, e);
188            Map<String, Object> infos = new HashMap<>();
189            infos.put("id", id);
190            return infos;
191        }
192    }
193    
194    /**
195     * Gets properties of a form entry
196     * @param entry The form entry
197     * @return The properties
198     */
199    public Map<String, Object> getFormEntryProperties (FormEntry entry)
200    {
201        Map<String, Object> properties = new HashMap<>();
202        
203        properties.put("id", entry.getId());
204        properties.put("formId", entry.getForm().getId());
205        properties.put("rights", _getUserRights(entry));
206        
207        return properties;
208    }
209    
210    /**
211     * Get user rights for the given form entry
212     * @param entry the form entry
213     * @return the set of rights
214     */
215    protected Set<String> _getUserRights (FormEntry entry)
216    {
217        UserIdentity user = _currentUserProvider.getUser();
218        return _rightManager.getUserRights(user, entry);
219    }
220    
221    /**
222     * Creates a {@link FormEntry}.
223     * @param form The parent form
224     * @return return the form entry
225     */
226    public FormEntry createEntry(Form form)
227    {
228        ModifiableTraversableAmetysObject entriesRoot;
229        if (form.hasChild(ENTRIES_ROOT))
230        {
231            entriesRoot = form.getChild(ENTRIES_ROOT);
232        }
233        else
234        {
235            entriesRoot = form.createChild(ENTRIES_ROOT, "ametys:collection");
236        }
237        // Find unique name
238        String originalName = "entry";
239        String uniqueName = originalName + "-1";
240        int index = 2;
241        while (entriesRoot.hasChild(uniqueName))
242        {
243            uniqueName = originalName + "-" + (index++);
244        }
245        FormEntry entry =  (FormEntry) entriesRoot.createChild(uniqueName, "ametys:form-entry");
246        
247        Map<String, Object> eventParams = new HashMap<>();
248        eventParams.put("form", form);
249        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _currentUserProvider.getUser(), eventParams));
250        
251        return entry;
252    }
253    
254    /**
255     * Get the search model configuration to search form entries
256     * @param formId the identifier of form
257     * @return The search model configuration
258     * @throws ProcessingException If an error occurred
259     */
260    @Callable (rights = HANDLE_FORMS_ENTRIES_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
261    public Map<String, Object> getSearchModelConfiguration (String formId) throws ProcessingException
262    {
263        Map<String, Object> result = new HashMap<>();
264        
265        Form form = _resolver.resolveById(formId);
266        result.put("criteria", _getCriteria(form));
267        result.put("columns", _getColumns(form));
268
269        result.put("searchUrlPlugin", "forms");
270        result.put("searchUrl", "form/entries.json");
271        result.put("pageSize", 50);
272        return result;
273    }
274    
275    /**
276     * Get criteria to search form entries
277     * @param form the form
278     * @return the criteria as JSON
279     */
280    protected Map<String, Object> _getCriteria(Form form)
281    {
282        // Currently, return no criteria for search entries tool
283        return Map.of();
284    }
285    
286    /**
287     * Get the columns for search form entries
288     * @param form the form
289     * @return the columns as JSON
290     * @throws ProcessingException if an error occurred
291     */
292    protected List<Map<String, Object>> _getColumns(Form form) throws ProcessingException
293    {
294        List<SearchUIColumn> columns = new ArrayList<>();
295        
296        Model formEntryModel = getFormEntryModel(form);
297        
298        ModelItem idAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_ID);
299        SearchUIColumn idColumn = SearchUIColumnHelper.createModelItemColumn(idAttribute);
300        idColumn.setWidth(80);
301        columns.add(idColumn);
302        
303        ModelItem userAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_USER);
304        SearchUIColumn userColumn = SearchUIColumnHelper.createModelItemColumn(userAttribute);
305        userColumn.setWidth(150);
306        columns.add(userColumn);
307        
308        ModelItem submitDateAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_SUBMIT_DATE);
309        SearchUIColumn submitDateColumn = SearchUIColumnHelper.createModelItemColumn(submitDateAttribute);
310        submitDateColumn.setWidth(150);
311        columns.add(submitDateColumn);
312        
313        for (ModelItem modelItem : formEntryModel.getModelItems())
314        {
315            String modelItemName = modelItem.getName();
316            if (!modelItemName.equals(FormEntry.ATTRIBUTE_IP)
317                 && !modelItemName.equals(FormEntry.ATTRIBUTE_ACTIVE)
318                 && !modelItemName.equals(FormEntry.ATTRIBUTE_SUBMIT_DATE)
319                 && !modelItemName.equals(FormEntry.ATTRIBUTE_ID)
320                 && !modelItemName.equals(FormEntry.ATTRIBUTE_USER)
321                 && !modelItemName.startsWith(ChoicesListQuestionType.OTHER_PREFIX_DATA_NAME))
322            {
323                SearchUIColumn<? extends ModelItem> column = SearchUIColumnHelper.createModelItemColumn(modelItem);
324                
325                FormQuestion question = form.getQuestion(modelItemName);
326                if (question != null)
327                {
328                    FormQuestionType type = question.getType();
329                    
330                    column.setRenderer(Optional.ofNullable(type.getJSRenderer(question))
331                                               .filter(StringUtils::isNotBlank));
332                    
333                    column.setConverter(Optional.ofNullable(type.getJSConverter(question))
334                                                .filter(StringUtils::isNotBlank));
335                    
336                    column.setSortable(_isSortable(question));
337                }
338                
339                columns.add(column);
340            }
341        }
342        
343        List<Map<String, Object>>  columnsInfo = new ArrayList<>();
344        DefinitionContext definitionContext = DefinitionContext.newInstance();
345        for (SearchUIColumn column : columns)
346        {
347            columnsInfo.add(column.toJSON(definitionContext));
348        }
349        
350        if (form.isQueueEnabled())
351        {
352            columnsInfo.add(
353                Map.of("name", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.QUEUE_STATUS,
354                        "label", new I18nizableText("plugin.forms", "PLUGINS_FORMS_QUEUE_STATUS_COLUMN_TITLE_LABEL"),
355                        "type", ModelItemTypeConstants.BOOLEAN_TYPE_ID,
356                        "path", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.QUEUE_STATUS
357                 )
358            );
359        }
360        
361        columnsInfo.add(
362            Map.of("name", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_ACTIVE,
363                    "label", new I18nizableText("plugin.forms", "PLUGINS_FORMS_ENTRY_ACTIVE_COLUMN_TITLE_LABEL"),
364                    "type", ModelItemTypeConstants.BOOLEAN_TYPE_ID,
365                    "path", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_ACTIVE,
366                    "hidden", true
367             )
368        );
369        
370        return columnsInfo;
371    }
372    
373    /**
374     * <code>true</code> if the column link to the question is sortable
375     * @param question the question
376     * @return <code>true</code> if the column link to the question is sortable
377     */
378    protected boolean _isSortable(FormQuestion question)
379    {
380        FormQuestionType type = question.getType();
381        if (type instanceof MultipleAwareQuestionType multipleType && multipleType.isMultiple(question))
382        {
383            return false;
384        }
385        
386        if (type instanceof MatrixQuestionType || type instanceof RichTextQuestionType || type instanceof FileQuestionType)
387        {
388            return false;
389        }
390        
391        return true;
392    }
393
394    /**
395     * Deletes a {@link FormEntry}.
396     * @param id The id of the form entry to delete
397     * @return The entry data
398     */
399    @Callable (rights = DELETE_FORMS_ENTRIES_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
400    public Map<String, String> deleteEntry (String id)
401    {
402        Map<String, String> result = new HashMap<>();
403        
404        FormEntry entry = _resolver.resolveById(id);
405        
406        _handleLimitedEntriesHelper.deactivateEntry(id);
407        
408        Form form = entry.getForm();
409        entry.remove();
410        
411        form.saveChanges();
412        
413        Map<String, Object> eventParams = new HashMap<>();
414        eventParams.put("form", form);
415        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _currentUserProvider.getUser(), eventParams));
416        
417        result.put("entryId", id);
418        result.put("formId", form.getId());
419        result.put("hasEntries", String.valueOf(form.hasEntries()));
420        
421        return result;
422    }
423    
424    /**
425     * Delete all entries of a form
426     * @param id The id of the form
427     * @return the deleted entries data
428     */
429    @Callable (rights = DELETE_FORMS_ENTRIES_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
430    public Map<String, Object> clearEntries(String id)
431    {
432        Map<String, Object> result = new HashMap<>();
433        List<String> entryIds = new ArrayList<>();
434        Form form = _resolver.resolveById(id);
435        
436        for (FormEntry entry: form.getEntries())
437        {
438            entryIds.add(entry.getId());
439            entry.remove();
440        }
441        form.saveChanges();
442        Map<String, Object> eventParams = new HashMap<>();
443        eventParams.put("form", form);
444        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _currentUserProvider.getUser(), eventParams));
445        
446        result.put("ids", entryIds);
447        result.put("formId", form.getId());
448        
449        return result;
450    }
451
452    /**
453     * Retrieves the current step id of the form entry
454     * @param entry The form entry
455     * @return the current step id
456     * @throws AmetysRepositoryException if an error occurs.
457     */
458    public Long getCurrentStepId(FormEntry entry) throws AmetysRepositoryException
459    {
460        AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(entry);
461        try
462        {
463            Step currentStep = (Step) workflow.getCurrentSteps(entry.getWorkflowId()).iterator().next();
464            return Long.valueOf(currentStep.getStepId());
465        }
466        catch (AmetysRepositoryException e)
467        {
468            return RestrictiveAwareQuestionType.INITIAL_WORKFLOW_ID; // can occur when entry has just been created and workflow is not yet initialized
469        }
470    }
471    
472    /**
473     * Get the form entry model
474     * @param form the form
475     * @return the form entry model
476     */
477    public Model getFormEntryModel(Form form)
478    {
479        List<ModelItem> items = new ArrayList<>();
480        for (FormQuestion question : form.getQuestions())
481        {
482            FormQuestionType type = question.getType();
483            if (!type.onlyForDisplay(question))
484            {
485                Model entryModel = question.getType().getEntryModel(question);
486                for (ModelItem modelItem : entryModel.getModelItems())
487                {
488                    items.add(modelItem);
489                }
490                
491                if (type instanceof ChoicesListQuestionType cLType)
492                {
493                    ModelItem otherFieldModel = cLType.getOtherFieldModel(question);
494                    if (otherFieldModel != null)
495                    {
496                        items.add(otherFieldModel);
497                    }
498                }
499            }
500        }
501        
502        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_ID, ModelItemTypeConstants.LONG_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_ID_LABEL", null, null));
503        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_USER, org.ametys.cms.data.type.ModelItemTypeConstants.USER_ELEMENT_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_USER_LABEL", null, null));
504        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_ACTIVE, ModelItemTypeConstants.BOOLEAN_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_ACTIVE_LABEL", null, null));
505        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_SUBMIT_DATE, ModelItemTypeConstants.DATETIME_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_SUBMISSION_DATE_LABEL", null, null));
506        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_IP, ModelItemTypeConstants.STRING_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_IP_LABEL", null, null));
507        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_ANONYMIZATION_DATE, ModelItemTypeConstants.DATETIME_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_ANONYMIZATION_DATE_LABEL", null, null));
508        
509        if (form.hasWorkflow())
510        {
511            items.add(_systemPropertyEP.getExtension("workflowName"));
512            items.add(_systemPropertyEP.getExtension("workflowStep"));
513        }
514        
515        return Model.of(
516            "form.entry.model.id",
517            "form.entry.model.family.id",
518            items.toArray(ModelItem[]::new)
519        );
520    }
521    
522    /**
523     * Get the form entries
524     * @param form the form
525     * @param onlyActiveEntries <code>true</code> to have only active entries
526     * @param sorts the list of sort
527     * @return the form entries
528     */
529    public List<FormEntry> getFormEntries(Form form, boolean onlyActiveEntries, List<Sort> sorts)
530    {
531        return getFormEntries(form, onlyActiveEntries, null, sorts);
532    }
533    
534    /**
535     * Get the form entries
536     * @param form the form
537     * @param onlyActiveEntries <code>true</code> to have only active entries
538     * @param additionalEntryFilterExpr the additional entry filter expression. Can be null.
539     * @param sorts the list of sort
540     * @return the form entries
541     */
542    public List<FormEntry> getFormEntries(Form form, boolean onlyActiveEntries, Expression additionalEntryFilterExpr, List<Sort> sorts)
543    {
544        try
545        {
546            String uuid = form.getNode().getIdentifier();
547            String xpathQuery = "//element(*, ametys:form)[@" + JcrConstants.JCR_UUID + " = '" + uuid + "']//element(*, ametys:form-entry)";
548            
549            String entryFilterQuery = _getEntryFilterQuery(onlyActiveEntries, additionalEntryFilterExpr);
550            if (onlyActiveEntries || StringUtils.isNotBlank(entryFilterQuery))
551            {
552                xpathQuery += "[" + entryFilterQuery + "]";
553            }
554            
555            String sortsAsString = "";
556            for (Sort sort : sorts)
557            {
558                if (StringUtils.isNotBlank(sortsAsString))
559                {
560                    sortsAsString += ", ";
561                }
562                
563                sortsAsString += "@ametys:" + sort.attributeName() + " " + sort.direction();
564            }
565            
566            if (StringUtils.isNotBlank(sortsAsString))
567            {
568                xpathQuery += " order by " + sortsAsString;
569            }
570            
571            AmetysObjectIterable<FormEntry> formEntries = _resolver.query(xpathQuery);
572            return formEntries.stream().collect(Collectors.toList());
573        }
574        catch (RepositoryException e)
575        {
576            getLogger().error("An error occurred getting entries of form '" + form.getId() + "'");
577        }
578        
579        return List.of();
580    }
581    
582    private String _getEntryFilterQuery(boolean onlyActiveEntries, Expression additionalEntryFilterExpr)
583    {
584        List<Expression> expressions = new ArrayList<>();
585        if (onlyActiveEntries)
586        {
587            expressions.add(new BooleanExpression(FormEntry.ATTRIBUTE_ACTIVE, true));
588        }
589        
590        if (additionalEntryFilterExpr != null)
591        {
592            expressions.add(additionalEntryFilterExpr);
593        }
594        
595        return new AndExpression(expressions).build();
596    }
597    
598    /**
599     * Get all users who answer to the form as JSON
600     * @param formId the form id
601     * @return all users as JSON
602     */
603    @Callable (rights = HANDLE_FORMS_ENTRIES_RIGHT_ID, paramIndex = 0, rightContext = FormsDirectoryRightAssignmentContext.ID)
604    public List<Map<String, String>> getFormEntriesUsers(String formId)
605    {
606        Form form = _resolver.resolveById(formId);
607        return getFormEntries(form, false, new ArrayList<>()).stream()
608                .map(FormEntry::getUser)
609                .distinct()
610                .map(_userManager::getUser)
611                .filter(Objects::nonNull)
612                .sorted(Comparator.comparing(User::getFullName, String.CASE_INSENSITIVE_ORDER))
613                .map(u -> Map.of("text", u.getFullName(), "id", UserIdentity.userIdentityToString(u.getIdentity())))
614                .toList();
615    }
616    
617    /**
618     * Record representing a sort with form attribute name and direction
619     * @param attributeName the attribute name
620     * @param direction the direction
621     */
622    public record Sort(String attributeName, String direction) { /* */ }
623}