WorkflowServiceImpl.java

/*
 * Copyright 2024 Global Crop Diversity Trust
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.gringlobal.service.impl;

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.extern.slf4j.Slf4j;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.util.CurrentApplicationContext;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.component.elastic.AppContextHelper;
import org.gringlobal.model.AbstractAction;
import org.gringlobal.model.AccessionAction;
import org.gringlobal.model.InventoryAction;
import org.gringlobal.model.InventoryViabilityAction;
import org.gringlobal.model.OrderRequestAction;
import org.gringlobal.model.OrderRequestItemAction;
import org.gringlobal.model.workflow.QWorkflowTransition;
import org.gringlobal.model.workflow.Workflow;
import org.gringlobal.model.workflow.WorkflowActionStep;
import org.gringlobal.model.workflow.WorkflowEndStep;
import org.gringlobal.model.workflow.WorkflowStartStep;
import org.gringlobal.model.workflow.WorkflowStep;
import org.gringlobal.model.workflow.WorkflowTransition;
import org.gringlobal.persistence.WorkflowRepository;
import org.gringlobal.persistence.WorkflowStepRepository;
import org.gringlobal.service.AccessionActionService;
import org.gringlobal.service.ActionService;
import org.gringlobal.service.InventoryActionService;
import org.gringlobal.service.InventoryViabilityActionService;
import org.gringlobal.service.OrderRequestActionService;
import org.gringlobal.service.OrderRequestItemActionService;
import org.gringlobal.service.WorkflowService;
import org.gringlobal.service.filter.WorkflowFilter;
import org.hibernate.Hibernate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;

import javax.validation.ValidationException;

/**
 * The Class WorkflowServiceImpl.
 */
@Service
@Transactional(readOnly = true)
@Validated
@PreAuthorize("hasAuthority('GROUP_ADMINS')")
@Slf4j
public class WorkflowServiceImpl extends FilteredCRUDService2Impl<Workflow, WorkflowFilter, WorkflowRepository> implements WorkflowService {

	private final Cache<String, Expression> parsedExpressions = CacheBuilder.newBuilder().maximumSize(30).expireAfterAccess(10, TimeUnit.HOURS).build();

	@Autowired
	private WorkflowStepRepository workflowStepRepository;

	private Map<String, ActionService> actionServices;

	private final ExpressionParser expressionParser = new SpelExpressionParser();

	@Override
	public void afterPropertiesSet() throws Exception {
		super.afterPropertiesSet();

		actionServices = new HashMap<>();
		actionServices.put(AccessionAction.class.getName(), CurrentApplicationContext.getContext().getBean(AccessionActionService.class));
		actionServices.put(InventoryAction.class.getName(), CurrentApplicationContext.getContext().getBean(InventoryActionService.class));
		actionServices.put(InventoryViabilityAction.class.getName(), CurrentApplicationContext.getContext().getBean(InventoryViabilityActionService.class));
		actionServices.put(OrderRequestAction.class.getName(), CurrentApplicationContext.getContext().getBean(OrderRequestActionService.class));
		actionServices.put(OrderRequestItemAction.class.getName(), CurrentApplicationContext.getContext().getBean(OrderRequestItemActionService.class));
	}

	@Override
	public Workflow create(Workflow source) {
		return createFast(source);
	}

	@Override
	public Workflow createFast(Workflow source) {
		assert (CollectionUtils.isEmpty(source.getTransitions()));
		Workflow workflow = new Workflow();
		workflow.apply(source);
		if (!actionServices.containsKey(workflow.getActionType())) {
			throw new InvalidApiUsageException("Invalid workflow action type");
		}
		workflow = repository.save(source);
		return createStartEndWorkflowSteps(workflow);
	}

	private Workflow createStartEndWorkflowSteps(Workflow workflow) {
		WorkflowStartStep startStep = new WorkflowStartStep();
		startStep.setWorkflow(workflow);
		WorkflowEndStep endStep = new WorkflowEndStep();
		endStep.setWorkflow(workflow);
		workflowStepRepository.saveAll(List.of(startStep, endStep));
		return repository.getReferenceById(workflow.getId());
	}

	@Override
	public Workflow update(Workflow updated, Workflow target) {
		return updateFast(updated, target);
	}

	@Override
	@Transactional
	public Workflow updateTransitions(Workflow target, List<WorkflowTransition> transitions) {
		var update = get(target.getId());

		if (CollectionUtils.isEmpty(transitions)) {
			update.getTransitions().clear();
		} else {
			validateWorkflowTransitions(transitions, update);

			if (update.getTransitions() == null) {
				update.setTransitions(transitions);
			} else {
				update.getTransitions().removeIf(t -> {
					boolean shouldRemove = /* By origin+target */
						null == transitions.stream()
							.filter(tt -> Objects.equals(tt.getOrigin().getId(), t.getOrigin().getId()) && Objects.equals(tt.getTarget().getId(), t.getTarget().getId()))
							// Find match in existing transitions
							.findFirst().orElse(null);
					// if (shouldRemove) {
					// 	log.warn("Removing {}", t);
					// } else {
					// 	log.warn("Keeping {}", t);
					// }
					return shouldRemove;
				});

				update.getTransitions().forEach(t -> {
					transitions.stream()
						.filter(tt ->
							Objects.equals(tt.getOrigin().getId(), t.getOrigin().getId())
								&& Objects.equals(tt.getTarget().getId(), t.getTarget().getId())
								&& !StringUtils.equals(tt.getCondition(), t.getCondition())
						)
						// Find match in existing transitions
						.findFirst()
						.ifPresent(updated -> t.setCondition(updated.getCondition()));
				});

				var toAdd = transitions.stream()
					// Add missing transitions
					.filter(t -> {
						boolean shouldAdd = /* By origin + target */
							null == update.getTransitions().stream()
								.filter(tt -> Objects.equals(tt.getOrigin().getId(), t.getOrigin().getId()) && Objects.equals(tt.getTarget().getId(), t.getTarget().getId()))
								// Fiund match in existing transitions
								.findFirst().orElse(null);
						// if (shouldAdd) {
						// 	log.warn("Adding {}", t);
						// } else {
						// 	log.warn("Ignoring {}", t);
						// }
						return shouldAdd;
					})
					// Collect
					.collect(Collectors.toList());

				update.getTransitions().addAll(toAdd);
			}
		}

		return repository.save(update);
	}

	@Override
	public void validateWorkflowTransitions(List<WorkflowTransition> transitions, Workflow workflow) {
		transitions.forEach(transition -> {
			assert (Objects.equals(transition.getOrigin().getWorkflow().getId(), workflow.getId()));
			assert (Objects.equals(transition.getTarget().getWorkflow().getId(), workflow.getId()));
			assert (!Objects.equals(transition.getOrigin().getId(), transition.getTarget().getId()));
		});

		var conditions = transitions.stream()
			.map(WorkflowTransition::getCondition)
			.filter(Objects::nonNull)
			.collect(Collectors.toSet());

		if (!conditions.isEmpty()) {
			for (String condition : conditions) {
				parseExpression(condition);
			}
		}

		// Check unlinked transitions
		var originSteps = transitions.stream()
			.map(WorkflowTransition::getOrigin)
			.filter(step -> Hibernate.unproxy(step) instanceof WorkflowActionStep)
			.collect(Collectors.toSet());
		var targetSteps = transitions.stream()
			.map(WorkflowTransition::getTarget)
			.filter(step -> Hibernate.unproxy(step) instanceof WorkflowActionStep)
			.collect(Collectors.toSet());
		for (WorkflowStep originStep : originSteps) {
			if (!targetSteps.contains(originStep)) {
				throw new InvalidApiUsageException("All steps must be present as origins and as targets in transitions");
			}
		}
		for (WorkflowStep targetStep : targetSteps) {
			if (!originSteps.contains(targetStep)) {
				throw new InvalidApiUsageException("All steps must be present as origins and as targets in transitions");
			}
		}

		var startTransitions = transitions.stream()
			.filter(transition -> Hibernate.unproxy(transition.getOrigin()) instanceof WorkflowStartStep)
			.collect(Collectors.toList());
		if (startTransitions.isEmpty() || startTransitions.stream().map(WorkflowTransition::getOrigin).distinct().count() > 1) {
			throw new InvalidApiUsageException("Transitions must have one workflow start step.");
		}

		checkStepsIsNotEquals(startTransitions);

		startTransitions.forEach(startTransition -> validateTransitions(transitions, startTransition.getTarget(), 0));
	}

	private Expression parseExpression(String condition) {
		try {
			return parsedExpressions.get("parsed_spel_expr:" + condition, () -> expressionParser.parseExpression(condition));
		} catch (Exception e) {
			throw new ValidationException("Invalid SpEL expression", e);
		}
	}

	private void validateTransitions(List<WorkflowTransition> transitions, WorkflowStep targetStep, int transitionNumber) {
		// Check if target is end step
		if ((Hibernate.unproxy(targetStep) instanceof WorkflowEndStep)) {
			return;
		}
		// The longest linked list of steps must be smaller than the transitions size.
		if (transitionNumber + 1 == transitions.size()) {
			if (!(Hibernate.unproxy(targetStep) instanceof WorkflowEndStep)) {
				// Cyclic links ?
				throw new InvalidApiUsageException("Cyclic transition links");
			}
		} else {
			// Find all transitions with origin = targetStep
			var targetTransitions = transitions.stream()
				.filter(transition -> transition.getOrigin().equals(targetStep))
				.collect(Collectors.toList());

			if (targetTransitions.size() < 1) {
				throw new InvalidApiUsageException("The flow chain is broken");
			}

			checkStepsIsNotEquals(targetTransitions);

			targetTransitions.forEach(transition -> validateTransitions(transitions, transition.getTarget(), transitionNumber + 1));
		}
	}

	private void checkStepsIsNotEquals(List<WorkflowTransition> transitions) {
		var targetSteps = transitions.stream()
			.filter(transition -> Hibernate.unproxy(transition.getTarget()) instanceof WorkflowActionStep)
			.map(transition -> (WorkflowActionStep) Hibernate.unproxy(transition.getTarget()))
			.collect(Collectors.toList());
		var distinctSteps = targetSteps.stream()
			.map(step -> String.valueOf(step.getActionAssignee().getId()).concat(step.getActionNameCode()))
			.distinct()
			.count();
		if (distinctSteps != targetSteps.size()) {
			throw new InvalidApiUsageException("Equal target steps are not allowed");
		}
	}

	@Override
	public Workflow updateFast(Workflow updated, Workflow target) {
		target.apply(updated); // Doesn't update List<WorkflowTransitions>
		return repository.save(target);
	}

	@Override
	public Workflow remove(Workflow entity) {
		return super.remove(entity);
	}

	@Transactional
	@Override
	public void createNextStepAction(AbstractAction<?> completedAction) {
		if (completedAction.getWorkflowStep() == null) {
			return;
		}
		var completedStep = workflowStepRepository.getReferenceById(completedAction.getWorkflowStep().getId());
		ActionService service = actionServices.get(completedStep.getWorkflow().getActionType()); // This will pull workflow from the database
		if (service != null) {
			var targetTransitions = jpaQueryFactory.select(QWorkflowTransition.workflowTransition).from(QWorkflowTransition.workflowTransition).where(QWorkflowTransition.workflowTransition.origin().eq(completedStep)).fetch();
			var targetSteps = targetTransitions.stream()
				.filter(transition -> {
					var condition = transition.getCondition();
					if (condition != null) {
						var action = service.get(completedAction.getId());
						return parseExpression(condition).getValue(action, Boolean.class);
					}
					return true;
				})
				.map(WorkflowTransition::getTarget)
				.collect(Collectors.toList());
			targetSteps.stream()
				.map(nextStep -> Hibernate.unproxy(nextStep))
				.filter(nextStep -> nextStep instanceof WorkflowActionStep)
				.filter(nextStep -> !Objects.equals(nextStep, completedStep)) // Just in case!
				.forEach(nextStep -> {
					log.info("Completed step {} starting {}", completedStep, nextStep);
					service.createNextWorkflowStepAction((WorkflowActionStep) nextStep, completedAction);
				});
		}
	}

	@Override
	public <T extends AbstractAction<T>> List<T> startWorkflow(long workflowId, Set<Long> owningEntityIds, ActionCreator<T> actionCreator) {
		var loadedWorkflow = load(workflowId);

		var transitions = loadedWorkflow.getTransitions();
		var stepWithTransition = transitions.stream()
			.filter(transition -> Hibernate.unproxy(transition.getOrigin()) instanceof WorkflowStartStep && Hibernate.unproxy(transition.getTarget()) instanceof WorkflowActionStep)
			.collect(Collectors.toMap((t) -> (WorkflowActionStep)Hibernate.unproxy(t.getTarget()), (t) -> t));
	
		if (stepWithTransition.isEmpty()) {
			throw new IllegalArgumentException("The workflow does not contain steps to complete");
		}
		
		// Build actions by ActionCreator.buildActions
		var actions = actionCreator.buildActions(new ArrayList<>(stepWithTransition.keySet()));
		
		// Filter by condition and return to persist
		return actions.stream().filter(action -> {
			var transition = stepWithTransition.get(action.getWorkflowStep());
			var condition = transition.getCondition();
			if (condition != null) {
				return parseExpression(condition).getValue(action, Boolean.class);
			}
			return true;
		}).collect(Collectors.toList());
	}
	
	public interface ActionCreator<T extends AbstractAction<T>> {

		List<T> buildActions(List<WorkflowActionStep> steps);
	}

	@Service
	@Transactional(readOnly = true)
	@Validated
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@Slf4j
	public static class WorkflowStepServiceImpl extends CRUDService2Impl<WorkflowStep, WorkflowStepRepository> implements WorkflowStepService {

		@Autowired
		private WorkflowRepository workflowRepository;

		@Autowired
		private WorkflowService workflowService;

		@Override
		@Transactional
		public WorkflowStep createFast(WorkflowStep source) {
			if (source instanceof WorkflowStartStep || source instanceof WorkflowEndStep) {
				throw new InvalidApiUsageException("WorkflowStartStep and WorkflowEndStep are automatically created along with the workflow.");
			}

			var workflow = workflowRepository.getReferenceById(source.getWorkflow().getId());
			String actionCodeGroup;
			try {
				Class<AbstractAction<?>> actionTypeClass = (Class<AbstractAction<?>>) Class.forName(workflow.getActionType());
				actionCodeGroup = actionTypeClass.getDeclaredConstructor().newInstance().getActionGroupName();
			} catch (Exception e) {
				throw new InvalidApiUsageException("Invalid workflow action type");
			}

			if (!AppContextHelper.validateCodeValue(actionCodeGroup, ((WorkflowActionStep) source).getActionNameCode())) {
				throw new InvalidApiUsageException("Invalid action code value");
			}

			return repository.save(source);
		}

		@Override
		@Transactional
		public WorkflowStep create(WorkflowStep source) {
			return createFast(source);
		}


		@Override
		@Transactional
		public WorkflowStep updateFast(WorkflowStep updated, WorkflowStep target) {
			target.apply(updated);
			var saved = repository.saveAndFlush(target);
			var workflow = workflowRepository.getReferenceById(saved.getWorkflow().getId());
			var transitions = workflow.getTransitions();
			if (transitions.stream().anyMatch(t -> t.getOrigin().equals(saved) || t.getTarget().equals(saved))) {
				workflowService.validateWorkflowTransitions(transitions, workflow);
			}
			return saved;
		}

		@Override
		@Transactional
		public WorkflowStep update(WorkflowStep updated, WorkflowStep target) {
			return updateFast(updated, target);
		}
	}
}