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