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