DefaultFormsOnlineService.java

/*
 * Copyright (C) 2000 - 2024 Silverpeas
 *
 * This program is free software: you can redistribute it and/or modify it under the terms of the
 * GNU Affero General Public License as published by the Free Software Foundation, either version 3
 * of the License, or (at your option) any later version.
 *
 * As a special exception to the terms and conditions of version 3.0 of the GPL, you may
 * redistribute this Program in connection with Free/Libre Open Source Software ("FLOSS")
 * applications as described in Silverpeas's FLOSS exception. You should have received a copy of the
 * text describing the FLOSS exception, and it is also available here:
 * "https://www.silverpeas.org/legal/floss_exception.html"
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
 * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License along with this program.
 * If not, see <https://www.gnu.org/licenses/>.
 */
package org.silverpeas.components.formsonline.model;

import org.apache.commons.fileupload.FileItem;
import org.apache.ecs.html.BR;
import org.apache.ecs.xhtml.div;
import org.silverpeas.components.formsonline.FormsOnlineComponentSettings;
import org.silverpeas.components.formsonline.model.RequestsByStatus.MergeRuleByStates;
import org.silverpeas.components.formsonline.model.RequestsByStatus.ValidationMergeRuleByStates;
import org.silverpeas.components.formsonline.notification.FormsOnlineCanceledRequestUserNotification;
import org.silverpeas.components.formsonline.notification.FormsOnlinePendingValidationRequestUserNotification;
import org.silverpeas.components.formsonline.notification.FormsOnlineProcessedRequestFollowingUserNotification;
import org.silverpeas.components.formsonline.notification.FormsOnlineProcessedRequestOtherValidatorsUserNotification;
import org.silverpeas.components.formsonline.notification.FormsOnlineProcessedRequestUserNotification;
import org.silverpeas.components.formsonline.notification.FormsOnlineValidationRequestUserNotification;
import org.silverpeas.core.admin.PaginationPage;
import org.silverpeas.core.admin.service.OrganizationController;
import org.silverpeas.core.admin.user.model.Group;
import org.silverpeas.core.admin.user.model.User;
import org.silverpeas.core.admin.user.model.UserDetail;
import org.silverpeas.core.admin.user.model.UserFull;
import org.silverpeas.core.annotation.Service;
import org.silverpeas.core.cache.service.CacheServiceProvider;
import org.silverpeas.core.contribution.ContributionStatus;
import org.silverpeas.core.contribution.attachment.AttachmentServiceProvider;
import org.silverpeas.core.contribution.attachment.model.SimpleDocument;
import org.silverpeas.core.contribution.attachment.model.SimpleDocumentMailAttachedFile;
import org.silverpeas.core.contribution.attachment.model.SimpleDocumentPK;
import org.silverpeas.core.contribution.content.form.DataRecord;
import org.silverpeas.core.contribution.content.form.Field;
import org.silverpeas.core.contribution.content.form.FieldTemplate;
import org.silverpeas.core.contribution.content.form.Form;
import org.silverpeas.core.contribution.content.form.FormException;
import org.silverpeas.core.contribution.content.form.PagesContext;
import org.silverpeas.core.contribution.content.form.RecordSet;
import org.silverpeas.core.contribution.content.form.field.FileField;
import org.silverpeas.core.contribution.model.ContributionIdentifier;
import org.silverpeas.core.contribution.template.publication.PublicationTemplate;
import org.silverpeas.core.contribution.template.publication.PublicationTemplateException;
import org.silverpeas.core.contribution.template.publication.PublicationTemplateImpl;
import org.silverpeas.core.contribution.template.publication.PublicationTemplateManager;
import org.silverpeas.core.html.PermalinkRegistry;
import org.silverpeas.core.i18n.I18NHelper;
import org.silverpeas.core.index.indexing.model.FullIndexEntry;
import org.silverpeas.core.index.indexing.model.IndexEngineProxy;
import org.silverpeas.core.index.indexing.model.IndexEntryKey;
import org.silverpeas.core.initialization.Initialization;
import org.silverpeas.core.mail.MailAddress;
import org.silverpeas.core.mail.MailSending;
import org.silverpeas.core.notification.message.MessageNotifier;
import org.silverpeas.core.notification.user.client.constant.NotifAction;
import org.silverpeas.core.security.authorization.ForbiddenRuntimeException;
import org.silverpeas.core.util.CollectionUtil;
import org.silverpeas.core.util.LocalizationBundle;
import org.silverpeas.core.util.MemoizedSupplier;
import org.silverpeas.core.util.Pair;
import org.silverpeas.core.util.SettingBundle;
import org.silverpeas.core.util.SilverpeasList;
import org.silverpeas.core.util.StringUtil;
import org.silverpeas.core.util.file.FileUploadUtil;
import org.silverpeas.core.util.logging.SilverLogger;

import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import javax.mail.MessagingException;
import javax.mail.Multipart;
import javax.mail.internet.MimeMultipart;
import javax.transaction.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static java.text.MessageFormat.format;
import static java.util.Collections.emptyList;
import static java.util.Optional.ofNullable;
import static java.util.function.Predicate.not;
import static java.util.stream.Collectors.toSet;
import static org.silverpeas.components.formsonline.model.FormDetail.*;
import static org.silverpeas.components.formsonline.model.FormInstanceValidationType.HIERARCHICAL;
import static org.silverpeas.components.formsonline.model.RequestValidationCriteria.withValidatorId;
import static org.silverpeas.components.formsonline.model.RequestsByStatus.MERGING_RULES_BY_STATES;
import static org.silverpeas.components.formsonline.model.RequestsByStatus.VALIDATION_MERGING_RULES_BY_STATES;
import static org.silverpeas.core.mail.MailContent.getHtmlBodyPartFromHtmlContent;
import static org.silverpeas.core.notification.user.builder.helper.UserNotificationHelper.buildAndSend;
import static org.silverpeas.core.util.CollectionUtil.isEmpty;
import static org.silverpeas.core.util.StringUtil.EMPTY;
import static org.silverpeas.core.util.StringUtil.isNotDefined;

@Service
@Singleton
@Named("formsOnlineService")
public class DefaultFormsOnlineService implements FormsOnlineService, Initialization {

  private static final String IN_COMPONENT_MSG_PART = " in component ";

  @Inject
  private OrganizationController organizationController;

  @Override
  public void init() {
    PermalinkRegistry.get().addUrlPart("Form");
  }

  @Override
  public List<FormDetail> getAllForms(final String appId, final String userId,
      final boolean withSendInfo) throws FormsOnlineException {
    String orderBy = organizationController.getComponentParameterValue(appId, "displaySort");
    orderBy = StringUtil.isDefined(orderBy) ? orderBy: "name asc";

    List<FormDetail> forms = getDAO().findAllForms(appId, orderBy);

    Map<Integer, Integer> numbersOfRequests = getDAO().getNumberOfRequestsByForm(appId);
    for (FormDetail form : forms) {
      Integer numberOfRequests = numbersOfRequests.get(form.getId());
      if (numberOfRequests != null) {
        form.setNbRequests(numberOfRequests);
      }
      if (withSendInfo) {
        form.setSendable(isSender(form.getPK(), userId));
      }
    }
    return forms;
  }

  private boolean isSender(FormPK pk, String userId) throws FormsOnlineException {
    return isInLists(userId, getSendersAsUsers(pk), getSendersAsGroups(pk));
  }

  private List<User> getSendersAsUsers(FormPK pk) throws FormsOnlineException {
    List<String> userIds = getDAO().getSendersAsUsers(pk);
    User[] details = organizationController.getUserDetails(userIds.toArray(new String[0]));
    return CollectionUtil.asList(details);
  }

  private List<Group> getSendersAsGroups(FormPK pk) throws FormsOnlineException {
    List<String> groupIds = getDAO().getSendersAsGroups(pk);
    Group[] groups = organizationController.getGroups(groupIds.toArray(new String[0]));
    return CollectionUtil.asList(groups);
  }

  private List<User> getReceiversAsUsers(FormPK pk, String rightType) throws FormsOnlineException {
    List<String> userIds = getDAO().getReceiversAsUsers(pk, rightType);
    User[] details = organizationController.getUserDetails(userIds.toArray(new String[0]));
    return CollectionUtil.asList(details);
  }

  private List<Group> getReceiversAsGroups(FormPK pk, String rightType) throws FormsOnlineException {
    List<String> groupIds = getDAO().getReceiversAsGroups(pk, rightType);
    Group[] groups = organizationController.getGroups(groupIds.toArray(new String[0]));
    return CollectionUtil.asList(groups);
  }

  private boolean isValidator(FormPK pk, String userId, String rightType)
      throws FormsOnlineException {
    return isInLists(userId, getReceiversAsUsers(pk, rightType),
        getReceiversAsGroups(pk, rightType));
  }

  private boolean isInLists(String userId, List<? extends User> users, List<Group> groups) {
    boolean inList = isInList(userId, users);
    if (!inList) {
      for (Group group : groups) {
        inList = group != null && isInList(userId, group.getAllUsers());
        if (inList) {
          return true;
        }
      }
    }
    return inList;
  }

  private boolean isInList(String userId, List<? extends User> users) {
    for (User user : users) {
      if (user != null && user.getId().equals(userId)) {
        return true;
      }
    }
    return false;
  }

  @Override
  public FormDetail loadForm(FormPK pk) throws FormsOnlineException {
    FormDetail form = getDAO().getForm(pk);
    setSendersAndReceivers(form);
    return form;
  }

  @Override
  @Transactional
  public FormDetail saveForm(FormDetail form,
      Map<String, Pair<List<String>, List<String>>> userAndGroupIdsByRightTypes) throws FormsOnlineException {
    FormDetail theForm = form;
    final boolean deleteAfterRequestExchange = theForm.isDeleteAfterRequestExchange();
    if (deleteAfterRequestExchange) {
      theForm.setHierarchicalValidation(false);
    } else {
      if (Optional.of(form.getState())
          .filter(s -> s.equals(STATE_PUBLISHED))
          .filter(s -> userAndGroupIdsByRightTypes.entrySet().stream()
              .filter(e -> e.getKey().equals(RECEIVERS_TYPE_FINAL))
              .map(Map.Entry::getValue)
              .anyMatch(p -> p.getFirst().isEmpty() && p.getSecond().isEmpty()))
          .isPresent()) {
        throw new FormsOnlineException(
            format("published form {0} must have final validators", form.getPK()));
      }
    }
    if (theForm.getId() == -1) {
      theForm = getDAO().createForm(theForm);
    } else {
      getDAO().updateForm(theForm);
    }
    final Map<String, Pair<List<String>, List<String>>> filteredRights = userAndGroupIdsByRightTypes.entrySet().stream()
        .collect(Collectors.toMap(Map.Entry::getKey, e -> {
          if (ALL_RECEIVER_TYPES.contains(e.getKey()) && deleteAfterRequestExchange) {
            return Pair.of(emptyList(), emptyList());
          }
          return e.getValue();
        }));
    getDAO().updateSenders(theForm.getPK(), filteredRights);
    getDAO().updateReceivers(theForm.getPK(), filteredRights);
    setSendersAndReceivers(theForm);
    index(theForm);
    return theForm;
  }

  @Override
  @Transactional
  public boolean deleteForm(FormPK pk) throws FormsOnlineException {
    // delete all associated requests
    final SilverpeasList<FormInstance> requests = getDAO().getAllRequests(pk);
    boolean reallyDeleteForm = true;
    for (FormInstance request : requests) {
      try {
        FormsOnlineService.get().deleteRequest(request.getPK());
      } catch (Exception e) {
        SilverLogger.getLogger(this).error(
            "Unable to delete request #" + request.getId() + IN_COMPONENT_MSG_PART +
                request.getComponentInstanceId(), e);
        reallyDeleteForm = false;
      }
    }
    if (reallyDeleteForm) {
      // delete form itself
      getDAO().deleteForm(pk);
      removeIndex(pk);
    }
    return reallyDeleteForm;
  }

  @Override
  @Transactional
  public void publishForm(FormPK pk) throws FormsOnlineException {
    final FormDetail form = loadForm(pk);
    form.setState(FormDetail.STATE_PUBLISHED);
    checkFormData(form);
    getDAO().updateForm(form);
    index(form);
  }

  private void checkFormData(final FormDetail form) throws FormsOnlineException {
    if (form.isPublished() &&
        !form.isDeleteAfterRequestExchange() && !form.isFinalValidation()) {
      throw new FormsOnlineException(
          format("published form {0} must have final validators", form.getPK()));
    }
  }

  @Override
  @Transactional
  public void unpublishForm(FormPK pk) throws FormsOnlineException {
    FormDetail form = getDAO().getForm(pk);
    form.setState(FormDetail.STATE_UNPUBLISHED);
    getDAO().updateForm(form);
    index(form);
  }

  @Override
  public List<FormDetail> getAvailableFormsToSend(Collection<String> appIds, String userId, String orderBy)
      throws FormsOnlineException {
    String[] userGroupIds = organizationController.getAllGroupIdsOfUser(userId);
    return getDAO().getUserAvailableForms(appIds, userId, userGroupIds, orderBy);
  }

  @Override
  public RequestsByStatus getAllUserRequests(String appId, String userId,
      final PaginationPage paginationPage)
      throws FormsOnlineException {
    RequestsByStatus requests = new RequestsByStatus(paginationPage);
    List<FormDetail> forms = getAllForms(appId, userId, false);
    for (FormDetail form : forms) {
      setSendersAndReceivers(form);
      for (final MergeRuleByStates rule : MERGING_RULES_BY_STATES) {
        final List<Integer> states = rule.getStates();
        final BiConsumer<RequestsByStatus, SilverpeasList<FormInstance>> merge = rule.getMerger();
        final SilverpeasList<FormInstance> result =
            getDAO().getSentFormInstances(form.getPK(), userId, states, paginationPage);
        //noinspection SimplifyStreamApiCallChains
        merge.accept(requests, result.stream()
            .map(l -> {
              l.setForm(form);
              return l;
            })
            .collect(SilverpeasList.collector(result)));
      }
    }
    return requests;
  }

  @Override
  public RequestsByStatus getValidatorRequests(RequestsFilter filter, String validatorId,
      final PaginationPage paginationPage) throws FormsOnlineException {
    final Map<String, Set<FormInstanceValidationType>> possibleValidatorValidationTypesByFormId =
        getValidatorFormIdsWithValidationTypes(filter.getComponentId(), validatorId, filter.getFormIds());
    final List<FormDetail> availableForms = getDAO().getForms(possibleValidatorValidationTypesByFormId.keySet());
    final Map<String, Set<FormInstanceValidationType>> possibleValidationTypesByFormId = getDAO()
        .getPossibleValidationTypesByFormId(possibleValidatorValidationTypesByFormId.keySet());
    final RequestsByStatus requests = new RequestsByStatus(paginationPage);
    final MemoizedSupplier<Set<String>> managedDomainUsersSupplier = new MemoizedSupplier<>(() -> {
      final String userDomainId = User.getById(validatorId).getDomainId();
      final User[] users = OrganizationController.get().getAllUsersInDomain(userDomainId);
      final Set<String> userIds = Stream.of(users).map(User::getId).collect(toSet());
      final HierarchicalValidatorCacheManager hvManager = HierarchicalValidatorCacheManager.get();
      hvManager.cacheHierarchicalValidatorsOf(userIds);
      return userIds.stream()
          .map(u -> Pair.of(u, hvManager.getHierarchicalValidatorOf(u)))
          .filter(p -> validatorId.equals(p.getSecond()))
          .map(Pair::getFirst)
          .collect(toSet());
    });
    for (final FormDetail form : availableForms) {
      setSendersAndReceivers(form);
      for (final ValidationMergeRuleByStates rule : VALIDATION_MERGING_RULES_BY_STATES) {
        final List<Integer> states = rule.getStates().stream()
            .filter(s -> filter.getState() < FormInstance.STATE_DRAFT || filter.getState() == s)
            .collect(Collectors.toList());
        if (states.isEmpty()) {
          continue;
        }
        final BiConsumer<RequestsByStatus, SilverpeasList<FormInstance>> merge = rule.getMerger();
        final RequestValidationCriteria validationCriteria;
        if (!filter.isAllRequests()) {
          validationCriteria = withValidatorId(validatorId, managedDomainUsersSupplier);
          final String formId = form.getPK().getId();
          final Set<FormInstanceValidationType> possibleFormValidationTypes =
              possibleValidationTypesByFormId.get(formId);
          final Set<FormInstanceValidationType> possibleValidatorValidationTypes =
              possibleValidatorValidationTypesByFormId.get(formId);
          rule.getValidationCriteriaConfigurer().accept(
              Pair.of(possibleFormValidationTypes, possibleValidatorValidationTypes),
              validationCriteria);
        } else {
          validationCriteria = null;
        }
        final Optional<FormInstanceValidationType> pendingValidationTypeFilter = filter
            .getPendingValidationType();
        final SilverpeasList<FormInstance> result = getDAO().getReceivedRequests(form, states, validationCriteria,
            ofNullable(paginationPage).filter(p -> pendingValidationTypeFilter.isEmpty()).orElse(null));
        @SuppressWarnings("SimplifyStreamApiCallChains")
        Stream<FormInstance> resultStream = result.stream().map(l -> {
          l.setForm(form);
          return l;
        });
        resultStream = filterOnValidationType(resultStream, pendingValidationTypeFilter);
        merge.accept(requests, resultStream.collect(SilverpeasList.collector(result)));
      }
    }

    return requests;
  }

  private Stream<FormInstance> filterOnValidationType(Stream<FormInstance> resultStream,
      final Optional<FormInstanceValidationType> pendingValidationTypeFilter) {
    if (pendingValidationTypeFilter.isPresent()) {
      resultStream = resultStream
          .filter(r -> {
            final FormInstanceValidationType type = pendingValidationTypeFilter.get();
            final Set<FormInstanceValidationType> possibleTypes = r.getForm().getPossibleRequestValidations().keySet();
            if (!possibleTypes.contains(type)) {
              return false;
            }
            final Optional<FormInstanceValidationType> previousType = possibleTypes
                .stream()
                .filter(t -> t.ordinal() < type.ordinal())
                .reduce((a, b) -> b);
            return previousType
                .map(p ->r.getValidations().getValidationOfType(p).filter(FormInstanceValidation::isValidated).isPresent()
                    && r.getValidations().getValidationOfType(type).isEmpty())
                .orElseGet(() -> r.getValidations().isEmpty());
          });
    }
    return resultStream;
  }

  @Override
  public Map<String, Set<FormInstanceValidationType>> getValidatorFormIdsWithValidationTypes(
      String appId, String validatorId, final Collection<String> formIds) throws FormsOnlineException {
    final String[] userGroupIds = organizationController.getAllGroupIdsOfUser(validatorId);
    final Map<String, Set<FormInstanceValidationType>> result = getDAO()
        .getValidatorFormIdsWithValidationTypes(appId, validatorId, userGroupIds, formIds);
    String orderBy = organizationController.getComponentParameterValue(appId, "displaySort");
    final List<FormDetail> forms = getDAO().findAllForms(appId, orderBy);
    final HierarchicalValidatorCacheManager hvManager = new HierarchicalValidatorCacheManager();
    for (FormDetail form : forms) {
      if (form.isHierarchicalValidation() && (isEmpty(formIds) || formIds.contains(form.getPK().getId()))) {
        final SilverpeasList<FormInstance> requests = getDAO().getAllRequests(form.getPK());
        final Set<String> creatorIds = requests.stream().map(FormInstance::getCreatorId).collect(toSet());
        hvManager.cacheHierarchicalValidatorsOf(creatorIds);
        creatorIds.stream()
            .map(hvManager::getHierarchicalValidatorOf)
            .filter(validatorId::equals)
            .findFirst()
            .ifPresent(b ->
                result.computeIfAbsent(Integer.toString(form.getId()), s -> new TreeSet<>())
                    .add(HIERARCHICAL));
      }
    }
    return result;
  }

  @Override
  public FormInstance loadRequest(RequestPK pk, String userId) throws FormsOnlineException {
    return loadRequest(pk, userId, false);
  }

  @Override
  public FormInstance loadRequest(RequestPK pk, String userId, boolean editionMode)
      throws FormsOnlineException {

    FormInstance request = getDAO().getRequest(pk);

    // recuperation de l'objet et du nom du formulaire
    FormDetail form = loadForm(request.getFormPK());
    request.setForm(form);
    String xmlFormName = form.getXmlFormName();
    String xmlFormShortName = xmlFormName.substring(xmlFormName.indexOf('/') + 1, xmlFormName.indexOf('.'));

    // creation du PublicationTemplate
    try {
      getPublicationTemplateManager()
          .addDynamicPublicationTemplate(pk.getInstanceId() + ":" + xmlFormShortName, xmlFormName);
      PublicationTemplateImpl pubTemplate = (PublicationTemplateImpl) getPublicationTemplateManager()
          .getPublicationTemplate(pk.getInstanceId() + ":" + xmlFormShortName, xmlFormName);

      // Retrieve Form and DataRecord
      Form customForm = editionMode ? pubTemplate.getUpdateForm() : pubTemplate.getViewForm();
      RecordSet recordSet = pubTemplate.getRecordSet();
      DataRecord data = recordSet.getRecord(pk.getId());
      customForm.setData(data);
      request.setFormWithData(customForm);
    } catch (PublicationTemplateException | FormException e) {
      throw new FormsOnlineException(
          "Can't load content of request #" + request.getId() + IN_COMPONENT_MSG_PART +
              pk.getInstanceId(), e);
    }

    // Check FormsOnline request states in order to display or hide comment
    setRequestStateAndValidationData(form, request, userId);

    return request;
  }

  private void setRequestStateAndValidationData(final FormDetail form, final FormInstance request,
      final String userId) throws FormsOnlineException {
    if (request.canBeValidated()) {
      final List<FormInstanceValidation> schema = request.getValidationsSchema();
      for (FormInstanceValidation validation : schema) {
        if (validation.isPendingValidation()) {
          boolean validationEnabled;
          if (validation.getValidationType().isHierarchical()) {
            validationEnabled = request.isHierarchicalValidator(userId);
          } else if (validation.getValidationType().isIntermediate()) {
            validationEnabled = isValidator(form.getPK(), userId, RECEIVERS_TYPE_INTERMEDIATE);
          } else {
            validationEnabled = isValidator(form.getPK(), userId, RECEIVERS_TYPE_FINAL);
          }
          request.setValidationEnabled(validationEnabled);
          break;
        }
      }
      // updating the status of the request if the validation is enabled
      if (request.getState() == FormInstance.STATE_UNREAD && request.isValidationEnabled()) {
        request.setState(FormInstance.STATE_READ);
        getDAO().saveRequestState(request);
      }
    }
  }

  @Override
  @Transactional
  public void saveRequest(FormPK pk, String userId, List<FileItem> items, boolean draft)
      throws FormsOnlineException {

    String requestId = FileUploadUtil.getParameter(items, "Id");
    FormInstance request;
    if (isNotDefined(requestId)) {
      request = createRequest(pk, userId, items, draft);
    } else {
      request = loadRequest(new RequestPK(requestId, pk.getInstanceId()), userId);
      updateRequest(request, items, draft);
    }

    if (!draft) {
      // Notify receivers
      notifyReceivers(request);
    }
  }

  private FormInstance createRequest(FormPK pk, String userId, List<FileItem> items,
      boolean draft) throws FormsOnlineException {
    FormInstance request = new FormInstance();
    request.setCreatorId(userId);
    request.setFormId(Integer.parseInt(pk.getId()));
    request.setInstanceId(pk.getInstanceId());
    if (draft) {
      request.setState(FormInstance.STATE_DRAFT);
    } else {
      request.setState(FormInstance.STATE_UNREAD);
    }
    request = getDAO().saveRequest(request);

    FormDetail formDetail = loadForm(pk);
    request.setForm(formDetail);

    // Retrieve data form (with DataRecord object)
    try {
      PublicationTemplate pub = getPublicationTemplate(request);
      RecordSet set = pub.getRecordSet();
      Form form = pub.getUpdateForm();
      DataRecord data = set.getEmptyRecord();
      data.setId(String.valueOf(request.getId()));

      // Save data form
      PagesContext aContext = new PagesContext("dummy", "0",
          UserDetail.getById(userId).getUserPreferences().getLanguage(), false, pk.getInstanceId(),
          userId);
      aContext.setObjectId(String.valueOf(request.getId()));
      form.update(items, data, aContext);
      set.save(data);
    } catch (Exception e) {
      throw new FormsOnlineException(
          "Can't create content of request #" + request.getId() + IN_COMPONENT_MSG_PART +
              pk.getInstanceId(), e);
    }

    return request;
  }

  private void updateRequest(FormInstance request, List<FileItem> items,
      boolean draft) throws FormsOnlineException {

    FormDetail formDetail = request.getForm();

    if (draft) {
      request.setState(FormInstance.STATE_DRAFT);
    } else {
      request.setState(FormInstance.STATE_UNREAD);
    }
    request = getDAO().saveRequest(request);

    request.setForm(formDetail);

    // Retrieve data form (with DataRecord object)
    try {
      PublicationTemplate pub = getPublicationTemplate(request);
      RecordSet set = pub.getRecordSet();
      Form form = pub.getUpdateForm();
      DataRecord data = set.getRecord(request.getId());

      // Save data form
      PagesContext aContext = new PagesContext("dummy", "0",
          UserDetail.getById(request.getCreatorId()).getUserPreferences().getLanguage(), false, request.getComponentInstanceId(), request.getCreatorId());
      aContext.setObjectId(String.valueOf(request.getId()));
      form.update(items, data, aContext);
      set.save(data);
    } catch (Exception e) {
      throw new FormsOnlineException(
          "Can't update content of request #" + request.getId() + IN_COMPONENT_MSG_PART +
              request.getComponentInstanceId(), e);
    }

  }

  @Override
  @Transactional
  public void saveNextRequestValidationStep(RequestPK pk, String validatorId, String decision,
      String comment, boolean follower) throws FormsOnlineException {
    final FormInstance request = getDAO().getRequest(pk);
    final FormDetail form = loadForm(new FormPK(request.getFormId(), pk.getInstanceId()));
    request.setForm(form);
    final FormInstanceValidation validation = request.getPendingValidation();
    if ((validation.getValidationType().isHierarchical() && !request.isHierarchicalValidator(validatorId)) ||
        (validation.getValidationType().isIntermediate() && !form.isIntermediateValidator(validatorId)) ||
        (validation.getValidationType().isFinal() && !form.isFinalValidator(validatorId))) {
      throwForbiddenException(validatorId + " can not validate the request " + request.getId());
    }
    // update state
    if ("validate".equals(decision)) {
      if (validation.getValidationType().isFinal()) {
        request.setState(FormInstance.STATE_VALIDATED);
      }
      validation.setStatus(ContributionStatus.VALIDATED);
    } else {
      request.setState(FormInstance.STATE_REFUSED);
      validation.setStatus(ContributionStatus.REFUSED);
      if (isNotDefined(comment)) {
        throw new FormsOnlineException("Missing a comment on the refused request");
      }
    }
    // validation infos
    validation.setDate(Date.from(Instant.now()));
    validation.setValidator(User.getById(validatorId));
    validation.setComment(comment);
    validation.setFollower(follower);
    request.getValidations().add(validation);
    // save modifications
    getDAO().saveRequest(request);
    // notify sender and all validators
    notifyValidation(request);
  }

  private void notifyValidation(FormInstance request) {
    final NotifAction action = request.getState() == FormInstance.STATE_REFUSED
        ? NotifAction.REFUSE
        : NotifAction.VALIDATE;
    // notify sender
    buildAndSend(new FormsOnlineValidationRequestUserNotification(request, action));
    // notify next validators the request is processed
    buildAndSend(new FormsOnlineProcessedRequestUserNotification(request, action));
    // notify validator followers of the processed request
    buildAndSend(new FormsOnlineProcessedRequestFollowingUserNotification(request, action));
    if (StringUtil.getBooleanValue(organizationController
        .getComponentParameterValue(request.getComponentInstanceId(),
            FormsOnlineComponentSettings.PARAM_WORKGROUP))) {
      // notify other validators of this validation level that the request has been processed
      buildAndSend(new FormsOnlineProcessedRequestOtherValidatorsUserNotification(request, action));
    }
  }

  @Override
  @Transactional
  public void cancelRequest(final RequestPK pk) throws FormsOnlineException {
    final FormInstance request = getDAO().getRequest(pk);
    final FormDetail form = loadForm(new FormPK(request.getFormId(), pk.getInstanceId()));
    request.setForm(form);
    final User currentRequester = User.getCurrentRequester();
    if (!request.canBeCanceledBy(currentRequester)) {
      throwForbiddenException(currentRequester.getId() + " can not cancel the request " + request.getId());
    }
    request.setState(FormInstance.STATE_CANCELED);
    getDAO().saveRequestState(request);
    // notify sender and all validators
    notifyCancellation(request);
  }

  private void notifyCancellation(final FormInstance request) {
    buildAndSend(new FormsOnlineCanceledRequestUserNotification(request));
  }

  @Override
  @Transactional
  public void deleteRequest(RequestPK pk) throws FormsOnlineException {
    try {
      // delete form data
      final FormInstance request = getDAO().getRequest(pk);
      final FormDetail form = loadForm(new FormPK(request.getFormId(), pk.getInstanceId()));
      request.setForm(form);
      final User currentRequester = User.getCurrentRequester();
      if (!request.canBeDeletedBy(currentRequester)) {
        throwForbiddenException(currentRequester.getId() + " can not delete the request " + request.getId());
      }
      PublicationTemplate pubTemplate = getPublicationTemplate(request);
      RecordSet set = pubTemplate.getRecordSet();
      DataRecord data = set.getRecord(pk.getId());
      set.delete(data.getId());
    } catch (Exception e) {
      throw new FormsOnlineException("Can't delete request #"+pk.getId()+ IN_COMPONENT_MSG_PART +
          pk.getInstanceId(), e);
    }

    // delete instance metadata
    getDAO().deleteRequest(pk);
  }

  @Override
  @Transactional
  public void archiveRequest(RequestPK pk) throws FormsOnlineException {
    final FormInstance request = getDAO().getRequest(pk);
    final FormDetail form = loadForm(new FormPK(request.getFormId(), pk.getInstanceId()));
    request.setForm(form);
    final User currentRequester = User.getCurrentRequester();
    if (!request.canBeArchivedBy(currentRequester)) {
      throwForbiddenException(currentRequester.getId() + " can not archive the request " + request.getId());
    }
    request.setState(FormInstance.STATE_ARCHIVED);
    getDAO().saveRequestState(request);
  }

  private void notifyReceivers(FormInstance request) throws FormsOnlineException {
    final FormInstanceValidations validations = request.getValidations();
    if (validations.isEmpty()) {
      sendRequestByEmail(request);
    }
    if (!request.getForm().isDeleteAfterRequestExchange()) {
      buildAndSend(new FormsOnlinePendingValidationRequestUserNotification(request));
    }
  }

  private PublicationTemplate getPublicationTemplate(FormInstance request)
      throws FormsOnlineException, PublicationTemplateException {
    FormPK formPK = new FormPK(request.getFormId(), request.getPK().getInstanceId());
    FormDetail form = getDAO().getForm(formPK);
    String xmlFormName = form.getXmlFormName();
    String xmlFormShortName = xmlFormName.substring(xmlFormName.indexOf('/') + 1, xmlFormName.indexOf('.'));
    return getPublicationTemplateManager().getPublicationTemplate(
        request.getPK().getInstanceId() + ":" + xmlFormShortName);
  }

  private void sendRequestByEmail(FormInstance request) throws FormsOnlineException {
    final FormDetail form = request.getForm();
    final Optional<String> requestExchangeReceiver = form.getRequestExchangeReceiver();
    if (requestExchangeReceiver.isPresent()) {
      final Pair<String, List<SimpleDocument>> contents = prepareMailContents(request, form);
      final String email = requestExchangeReceiver.get();
      try {
        final Multipart multipart = new MimeMultipart();
        // First HTML content
        multipart.addBodyPart(getHtmlBodyPartFromHtmlContent(contents.getFirst()));
        // Finally explicit attached files
        attachFilesToMail(multipart, contents.getSecond());
        // Sending to service exchange
        final User sender = User.getById(request.getCreatorId());
        MailSending
            .from(MailAddress.eMail(sender.geteMail()).withName(sender.getDisplayedName()))
            .to(MailAddress.eMail(email))
            .withSubject(form.getTitle())
            .withContent(multipart)
            .setReplyToRequired()
            .send();
        // Sending to sender
        final LocalizationBundle messages = FormsOnlineComponentSettings
            .getMessagesIn(sender.getUserPreferences().getLanguage());
        if (form.isDeleteAfterRequestExchange()) {
          final String title = messages
              .getStringWithParams("formsOnline.request.exchange.senderCopy", form.getTitle());
          MailSending
              .from(MailAddress.eMail(null))
              .to(MailAddress.eMail(sender.geteMail()))
              .withSubject(title)
              .withContent(multipart)
              .send();
          MessageNotifier.addSuccess(
              messages.getStringWithParams("formsOnline.request.exchange.successAndSummary", form.getTitle()));
        } else {
          MessageNotifier.addSuccess(
              messages.getStringWithParams("formsOnline.request.exchange.success", form.getTitle()));
        }
      } catch (Exception e) {
        throw new FormsOnlineException("Can't send request #" + request.getPK().getId() + " to " + email, e);
      }
      if (form.isDeleteAfterRequestExchange()) {
        deleteRequest(request.getPK());
      }
    }
  }

  private Pair<String, List<SimpleDocument>> prepareMailContents(final FormInstance request,
      final FormDetail form) throws FormsOnlineException {
    try {
      final div content = new div();
      final List<SimpleDocument> docs = new ArrayList<>();
      final PublicationTemplate template = getPublicationTemplate(request);
      final RecordSet recordSet = template.getRecordSet();
      final FieldTemplate[] fields = template.getRecordTemplate().getFieldTemplates();
      final DataRecord dataRecord = recordSet.getRecord(request.getId());
      final Map<String, String> values = dataRecord.getValues(I18NHelper.DEFAULT_LANGUAGE);
      for (final FieldTemplate field : fields) {
        final String value = values.get(field.getFieldName());
        if (StringUtil.isDefined(value)) {
          // only defined fields are sent
          content.addElement(field.getLabel(I18NHelper.DEFAULT_LANGUAGE));
          content.addElement(" : ");
          content.addElement(value);
          content.addElement(new BR());
          if (field.getTypeName().equals(FileField.TYPE)) {
            docs.addAll(getFiles(dataRecord, field, request.getComponentInstanceId()));
          }
        }
      }
      return Pair.of(content.toString(), docs);
    } catch (Exception e) {
      throw new FormsOnlineException("Can't load form '" + form.getXmlFormName() + "'", e);
    }
  }

  private List<SimpleDocument> getFiles(DataRecord dataRecord, FieldTemplate field,
      String instanceId) throws FormException {
    final List<SimpleDocument> docs = new ArrayList<>();
    if (!field.isRepeatable()) {
      String docId = dataRecord.getField(field.getFieldName()).getValue();
      final SimpleDocument doc = getDocument(docId, instanceId);
      if (doc != null) {
        docs.add(doc);
      }
    } else {
      int maxOccurrences = field.getMaximumNumberOfOccurrences();
      for (int occ = 0; occ < maxOccurrences; occ++) {
        final Field fieldOcc = dataRecord.getField(field.getFieldName(), occ);
        if (fieldOcc != null && !fieldOcc.isNull()) {
          String docId = fieldOcc.getValue();
          final SimpleDocument doc = getDocument(docId, instanceId);
          if (doc != null) {
            docs.add(doc);
          }
        }
      }
    }
    return docs;
  }

  private SimpleDocument getDocument(String documentId, String instanceId) {
    return AttachmentServiceProvider.getAttachmentService()
        .searchDocumentById(new SimpleDocumentPK(documentId, instanceId), null);
  }

  private void attachFilesToMail(Multipart mp, List<SimpleDocument> listAttachedFiles)
      throws MessagingException {
    for (SimpleDocument attachment : listAttachedFiles) {
      mp.addBodyPart(new SimpleDocumentMailAttachedFile(attachment).toBodyPart());
    }
  }

  private void throwForbiddenException(String method) {
    throw new ForbiddenRuntimeException(
        "User is not allowed to do the following operation: " + method);
  }

  private PublicationTemplateManager getPublicationTemplateManager() {
    return PublicationTemplateManager.getInstance();
  }

  private FormsOnlineDAO getDAO() {
    return new FormsOnlineDAOJdbc();
  }

  @Override
  public Optional<FormInstance> getContributionById(final ContributionIdentifier contributionId) {
    return Optional.empty();
  }

  @Override
  public SettingBundle getComponentSettings() {
    return null;
  }

  @Override
  public LocalizationBundle getComponentMessages(final String language) {
    return null;
  }

  /**
   * Is this service related to the specified component instance. The service is related to the
   * specified instance if it is a service defined by the application from which the instance
   * was spawned.
   * @param instanceId the unique instance identifier of the component.
   * @return true if the instance is spawn from the application to which the service is related.
   * False otherwise.
   */
  @Override
  public boolean isRelatedTo(final String instanceId) {
    return instanceId.startsWith("formsOnline");
  }

  private void index(FormDetail form) {
    IndexEntryKey key = getIndexEntryKey(form.getPK());
    if (form.isPublished()) {
      FullIndexEntry fie = new FullIndexEntry(key);
      fie.setTitle(form.getTitle());
      fie.setPreview(form.getDescription());
      fie.setCreationDate(form.getCreationDate());
      fie.setCreationUser(form.getCreatorId());
      IndexEngineProxy.addIndexEntry(fie);
    } else {
      IndexEngineProxy.removeIndexEntry(key);
    }
  }

  private void removeIndex(FormPK pk) {
    IndexEngineProxy.removeIndexEntry(getIndexEntryKey(pk));
  }

  private IndexEntryKey getIndexEntryKey(FormPK pk) {
    return new IndexEntryKey(pk.getInstanceId(), "FormOnline", pk.getId());
  }

  public void index(String componentId) {
    try {
      List<FormDetail> forms = getAllForms(componentId, "useless", false);
      for (FormDetail form : forms) {
        index(form);
      }
    } catch (Exception e) {
      SilverLogger.getLogger(this).error(e);
    }
  }

  private void setSendersAndReceivers(FormDetail form) throws FormsOnlineException {
    if (form != null) {
      FormPK pk = form.getPK();
      form.setSendersAsUsers(getSendersAsUsers(pk));
      form.setSendersAsGroups(getSendersAsGroups(pk));
      form.setIntermediateReceiversAsUsers(getReceiversAsUsers(pk, RECEIVERS_TYPE_INTERMEDIATE));
      form.setIntermediateReceiversAsGroups(getReceiversAsGroups(pk, RECEIVERS_TYPE_INTERMEDIATE));
      form.setReceiversAsUsers(getReceiversAsUsers(pk, RECEIVERS_TYPE_FINAL));
      form.setReceiversAsGroups(getReceiversAsGroups(pk, RECEIVERS_TYPE_FINAL));
    }
  }

  /**
   * Permits to manage a cache in order to increase performances.
   * <p>
   *   This cache is thread scoped.
   * </p>
   */
  public static class HierarchicalValidatorCacheManager {

    private static final String CACHE_KEY = HierarchicalValidatorCacheManager.class.getName();
    private final Set<String> userIds = new HashSet<>();
    private final Map<String, String> cache = new HashMap<>();

    public static HierarchicalValidatorCacheManager get() {
      return CacheServiceProvider.getThreadCacheService()
          .getCache()
          .computeIfAbsent(CACHE_KEY, HierarchicalValidatorCacheManager.class,
              HierarchicalValidatorCacheManager::new);
    }

    private HierarchicalValidatorCacheManager() {
      // hidden constructor
    }

    /**
     * Caches the hierarchical validators of users represented by given ids.
     * @param userIds set of string user ids.
     */
    public void cacheHierarchicalValidatorsOf(final Set<String> userIds) {
      userIds.stream().filter(not(cache::containsKey)).forEach(this.userIds::add);
    }

    /**
     * Gets from cached data the validator of given users.
     * <p>
     * If no data has been cached for the user, the data are retrieved.
     * </p>
     * @param userId a string user id.
     * @return the hierarchical validator of the user represented by the given id.
     */
    public String getHierarchicalValidatorOf(final String userId) {
      if (!userIds.isEmpty()) {
        UserFull.getByIds(userIds).forEach(u -> {
          final String id = u.getId();
          userIds.remove(id);
          cache.put(id, ofNullable(u.getValue("boss")).orElse(EMPTY));
        });
        userIds.forEach(i -> cache.put(i, EMPTY));
        userIds.clear();
      }
      return cache.computeIfAbsent(userId,
          i -> ofNullable(UserFull.getById(i))
              .map(u -> u.getValue("boss"))
              .orElse(StringUtil.EMPTY));
    }
  }
}