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