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