RequestServiceImpl.java

/*
 * Copyright 2019 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.genesys.server.service.impl;

import static org.genesys.server.model.genesys.MaterialSubRequest.State.CONFIRMED;
import static org.genesys.server.model.genesys.MaterialSubRequest.State.DRAFT;
import static org.genesys.server.model.genesys.MaterialSubRequest.State.NOTCONFIRMED;

import java.io.IOException;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;

import javax.validation.Valid;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.security.SecurityContextUtil;
import org.genesys.server.exception.EasySMTAException;
import org.genesys.server.exception.InvalidApiUsageException;
import org.genesys.server.exception.NotFoundElement;
import org.genesys.server.model.genesys.Accession;
import org.genesys.server.model.genesys.MaterialRequest;
import org.genesys.server.model.genesys.MaterialSubRequest;
import org.genesys.server.model.genesys.QMaterialRequest;
import org.genesys.server.model.genesys.QMaterialSubRequest;
import org.genesys.server.model.impl.Country;
import org.genesys.server.model.impl.FaoInstitute;
import org.genesys.server.model.impl.FaoInstituteSetting;
import org.genesys.server.model.impl.User;
import org.genesys.server.model.impl.VerificationToken;
import org.genesys.server.persistence.CountryRepository;
import org.genesys.server.persistence.MaterialRequestRepository;
import org.genesys.server.persistence.MaterialSubRequestRepository;
import org.genesys.server.service.AccessionService;
import org.genesys.server.service.ArticleService;
import org.genesys.server.service.ArticleTranslationService;
import org.genesys.server.service.ContentService;
import org.genesys.server.service.EMailService;
import org.genesys.server.service.EasySMTA;
import org.genesys.server.service.GenesysService;
import org.genesys.server.service.InstituteService;
import org.genesys.server.service.RequestService;
import org.genesys.server.service.TokenVerificationService;
import org.genesys.server.service.TokenVerificationService.NoSuchVerificationTokenException;
import org.genesys.server.service.TokenVerificationService.TokenExpiredException;
import org.genesys.server.service.UserService;
import org.genesys.server.service.filter.MaterialRequestFilter;
import org.genesys.server.service.filter.MaterialSubRequestFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.access.prepost.PostAuthorize;
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 com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.jpa.impl.JPAQueryFactory;

import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;

@Service
@Transactional(readOnly = true)
@Validated
public class RequestServiceImpl implements RequestService {
	private static final String REQUEST_TOKENTYPE = "confirm-request";
	private static final String RECEIPT_TOKENTYPE = "confirm-receipt";

	private static final Logger LOG = LoggerFactory.getLogger(RequestServiceImpl.class);

	@Autowired
	private EasySMTA pidChecker;

	@Autowired
	private TokenVerificationService tokenVerificationService;

	@Autowired
	private EMailService emailService;

	@Autowired
	private ContentService contentService;
	
	@Autowired
	private ArticleService articleService;

	@Autowired
	private GenesysService genesysService;

	@Autowired
	private AccessionService accessionService;

	@Autowired
	private InstituteService instituteService;

	@Autowired
	private MaterialRequestRepository requestRepository;

	@Autowired
	private MaterialSubRequestRepository subRequestRepository;

	@Autowired
	private CountryRepository countryRepository;

	@Autowired
	private JPAQueryFactory jpaQueryFactory;

	@Value("${frontend.url}")
	private String frontendUrl;

	@Value("${mail.requests.to}")
	private String requestsEmail;

	@Autowired
	private UserService userService;

	private final ObjectMapper mapper = new ObjectMapper();


	@Override
	@Transactional
	public MaterialRequest initiateRequest(RequestInfo requestInfo, Set<Long> accessionIds, String origin, String sid, String lang) throws RequestException {
		final Set<Long> availableAccessionIds = genesysService.filterAvailableForDistribution(accessionIds);
		return initiateRequest(availableAccessionIds, requestInfo, origin, sid, lang);
	}

	@Override
	@Transactional
	public MaterialRequest initiateRequestByUuids(RequestInfo requestInfo, Set<UUID> accessionUuids, String origin, String sid, String lang) throws RequestException {
		final Set<Long> availableAccessionIds = accessionService.filterAvailableForDistributionByUuid(accessionUuids);
		return initiateRequest(availableAccessionIds, requestInfo, origin, sid, lang);
	}

	private MaterialRequest initiateRequest(Set<Long> availableAccessionIds, RequestInfo requestInfo, String origin, String sid, String lang) throws RequestException {
		final Locale locale = LocaleContextHolder.getLocale();
		LOG.error("Current locale: {}", locale);
		if (availableAccessionIds == null || availableAccessionIds.size() == 0) {
			throw new RequestException("None of the selected accessions are available for distribution");
		}

		EasySMTA.EasySMTAUserData pid;
		if (!requestInfo.isInternalRequest()) {
			// Check Easy-SMTA for PID
			try {
				pid = pidChecker.getUserData(requestInfo.getEmail());
			} catch (EasySMTAException e) {
				throw new RequestException(e.getMessage(), e);
			}
		} else {
			pid = requestInfo.getUserData();
			requestInfo.setEmail(pid.getEmail());

			Country country = countryRepository.findByCode3(pid.getCountry());
			if (country != null) {
				pid.setCountryName(country.getName(locale));
			}
		}

		final MaterialRequest request = createRequest(requestInfo, pid, availableAccessionIds, origin, sid, lang);

		return sendValidationEmail(request);
	}


	/**
	 * Create requests to holding institutes if no subrequests exist for this
	 * request.
	 * 
	 * <p>This is part of the original API and is called from {@link #validateRequest(MaterialRequest)}</p>
	 *
	 * @param materialRequest
	 * @return
	 * @throws NoPidException
	 */
	private List<MaterialSubRequest> breakup(MaterialRequest materialRequest) throws NoPidException {
		if (StringUtils.isBlank(materialRequest.getPid()) && !materialRequest.isInternalRequest()) {
			LOG.warn("Material request has no PID, will not break it up.");
			throw new NoPidException("Not breaking up request without PID.");
		}

		final List<MaterialSubRequest> existingRequests = subRequestRepository.findBySourceRequest(materialRequest);

		if (existingRequests.size() > 0) {
			// check for subrequests
			LOG.warn("Subrequests exists, will not recreate them.");
			return existingRequests;
		}

		RequestBody rb = null;
		try {
			rb = mapper.readValue(materialRequest.getBody(), RequestBody.class);
		} catch (final IOException e) {
			// FIXME Some other exception?
			throw new RuntimeException("Could not handle request JSON", e);
		}

		final ArrayList<MaterialSubRequest> subrequests = new ArrayList<MaterialSubRequest>();
		final List<FaoInstitute> holdingInstitutes = genesysService.findHoldingInstitutes(rb.accessionIds);

		for (final FaoInstitute holdingInstitute : holdingInstitutes) {
			final MaterialSubRequest subRequest = new MaterialSubRequest();
			subRequest.setInstCode(holdingInstitute.getCode());
			subRequest.setState(DRAFT.getValue());
			subRequest.setSourceRequest(materialRequest);

			final RequestBody srb = new RequestBody();
			srb.pid = rb.pid;
			srb.requestInfo = rb.requestInfo;
			srb.accessionIds = genesysService.listAccessions(holdingInstitute, rb.accessionIds);

			if (srb.accessionIds.size() == 0) {
				LOG.warn("No accessions for this institute??");
			}

			subRequest.setBody(serialize(srb));
			subrequests.add(subRequest);
		}

		return subRequestRepository.saveAll(subrequests);
	}

	MaterialRequest createRequest(RequestInfo requestInfo, EasySMTA.EasySMTAUserData pid, Set<Long> accessionIds, String origin, String sid, String lang) throws RequestException {
		final Set<Long> availableAccessionIds = genesysService.filterAvailableForDistribution(accessionIds);

		if (availableAccessionIds == null || availableAccessionIds.size() == 0) {
			throw new RequestException("None of the selected accessions are available for distribution");
		}

		MaterialRequest request = new MaterialRequest();
		request.setState(MaterialRequest.NOTVALIDATED);
		request.setEmail(requestInfo.getEmail());

		if (pid != null) {
			if (requestInfo.isInternalRequest()) {
				request.setInternalRequest(true);

				// set to null to avoid duplication of user data in the RequestBody for MaterialRequest#body
				// we don't need to save this data in the storage
				requestInfo.setUserData(null);
			}
			request.setPid(pid.getPid());
		}

		final RequestBody rb = new RequestBody(requestInfo, pid, availableAccessionIds);
		request.setBody(serialize(rb));
		
		request.setOrigin(origin);
		request.setSid(sid);
		request.setLanguage(lang);

		request = requestRepository.save(request);
		LOG.info("Persisted new material request: {}", request);
		return request;
	}


	@Override
	@Transactional
	public MaterialRequest createDraftRequest(Set<Long> accessionIds) throws RequestException {

		var availableAccessionIds = accessionService.filterAvailableById(accessionIds);

		if (availableAccessionIds == null || availableAccessionIds.size() == 0) {
			throw new RequestException("None of the selected accessions are available for distribution");
		}

		MaterialRequest request = new MaterialRequest();
		request.setState(MaterialRequest.DRAFT);

		RequestInfo requestInfo = null;
		if (SecurityContextUtil.getMe() != null) {
			User user = SecurityContextUtil.getCurrentUser();
			String email = user.getEmail();
			request.setEmail(email);
		}

		if (request.getEmail() == null) {
			// todo
			request.setEmail("Guest");
		}

		final RequestBody rb = new RequestBody(requestInfo, null, availableAccessionIds);
		request.setBody(serialize(rb));

		request = requestRepository.save(request);
		LOG.info("Persisted new DRAFT material request: {}", request);

		var subRequests = createDraftSubRequests(request);

		var savedRequest = requestRepository.getReferenceById(request.getId());
		savedRequest.setSubRequests(subRequests);
		return savedRequest;
	}

	@Override
	@Transactional
	public MaterialRequest processRequest(RequestInfo requestInfo, Long requestId) throws RequestException {

		// remove unavailable accessions from sub requests and remove sub requests with no available accessions
		MaterialRequest request = refreshRequestAccessions(requestId);
		List<MaterialSubRequest> subRequests = request.getSubRequests();

		if (CollectionUtils.isEmpty(subRequests)) {
			throw new RequestException("None of the MaterialSubRequests are available for distribution");
		}

		List<MaterialSubRequest> availableSubRequests = subRequests.stream()
			.filter(subRequest -> instituteService.getInstitute(subRequest.getInstCode()).isAllowMaterialRequests()).collect(Collectors.toList());

		if (availableSubRequests.size() == 0) {
			throw new RequestException("None of the MaterialSubRequests are available for distribution");
		}

		RequestBody rb;
		try {
			rb = mapper.readValue(request.getBody(), RequestBody.class);
		} catch (final IOException e) {
			// FIXME Some other exception?
			throw new RuntimeException("Could not handle request JSON", e);
		}

		EasySMTA.EasySMTAUserData pid;
		if (rb.pid != null) {
			pid = rb.pid;
		} else if (!requestInfo.isInternalRequest()) {
			// Check Easy-SMTA for PID
			try {
				pid = pidChecker.getUserData(requestInfo.getEmail());
			} catch (EasySMTAException e) {
				throw new RequestException(e.getMessage(), e);
			}
		} else {
			final Locale locale = LocaleContextHolder.getLocale();
			pid = requestInfo.getUserData();

			Country country = countryRepository.findByCode3(pid.getCountry());
			if (country != null) {
				pid.setCountryName(country.getName(locale));
			}
		}

		request.setEmail(requestInfo.getEmail());

		if (requestInfo.isInternalRequest()) {
			request.setInternalRequest(true);

			// set to null to avoid duplication of user data in the RequestBody for MaterialRequest#body
			// we don't need to save this data in the storage
			requestInfo.setUserData(null);
		}
		request.setPid(pid.getPid());

		rb.requestInfo = requestInfo;
		rb.pid = pid;

		request.setBody(serialize(rb));
		request.setState(MaterialRequest.NOTVALIDATED);
		request = requestRepository.save(request);

		for (var subRequest : request.getSubRequests()) {
			try {
				RequestBody srb = mapper.readValue(subRequest.getBody(), RequestBody.class);
				srb.requestInfo = rb.requestInfo;
				srb.pid = rb.pid;
				subRequest.setBody(mapper.writeValueAsString(srb));
				subRequestRepository.save(subRequest);
			} catch (final IOException e) {
				// FIXME Some other exception?
				throw new RuntimeException("Could not handle request JSON", e);
			}
		}

		//todo create a new validation email related to sub requests instead of a list of accessions?
		return sendValidationEmail(request);
	}

	@Transactional
	private List<MaterialSubRequest> createDraftSubRequests(MaterialRequest materialRequest) {
		RequestBody rb = null;
		try {
			rb = mapper.readValue(materialRequest.getBody(), RequestBody.class);
		} catch (final IOException e) {
			// FIXME Some other exception?
			throw new RuntimeException("Could not handle request JSON", e);
		}

		final ArrayList<MaterialSubRequest> subrequests = new ArrayList<MaterialSubRequest>();
		final List<FaoInstitute> holdingInstitutes = genesysService.findHoldingInstitutes(rb.accessionIds);

		for (final FaoInstitute holdingInstitute : holdingInstitutes) {
			final MaterialSubRequest subRequest = new MaterialSubRequest();
			subRequest.setInstCode(holdingInstitute.getCode());
			subRequest.setSourceRequest(materialRequest);
			subRequest.setState(DRAFT.getValue());

			final RequestBody srb = new RequestBody();
			srb.pid = rb.pid;
			srb.requestInfo = rb.requestInfo;
			srb.accessionIds = genesysService.listAccessions(holdingInstitute, rb.accessionIds);

			if (srb.accessionIds.size() == 0) {
				LOG.warn("No accessions for this institute??");
			}

			subRequest.setBody(serialize(srb));
			subrequests.add(subRequest);
		}

		return subRequestRepository.saveAllAndFlush(subrequests);
	}

	private MaterialRequest refreshRequestAccessions(Long requestId) throws RequestException {
		MaterialRequest request = get(requestId);

		// if request is not in DRAFT mode then reject
		if (request.getState() != MaterialRequest.DRAFT) {
			throw new InvalidApiUsageException("Request was already processed");
		}

		Set<Long> availableAccessionIds;
		try {
			RequestBody rb = mapper.readValue(request.getBody(), RequestBody.class);
			availableAccessionIds = accessionService.filterAvailableById(rb.accessionIds);
			rb.accessionIds = availableAccessionIds;
			request.setBody(mapper.writeValueAsString(rb));
			request = requestRepository.save(request);
		} catch (final IOException e) {
			// FIXME Some other exception?
			throw new RuntimeException("Could not handle request JSON", e);
		}

		if (availableAccessionIds == null || availableAccessionIds.size() == 0) {
			// todo update sub requests ?
			throw new RequestException("None of the selected accessions are available for distribution");
		}

		var subRequests = subRequestRepository.findAll(QMaterialSubRequest.materialSubRequest.sourceRequest().id.eq(request.getId()));

		for (var subRequest : subRequests) {
			try {
				RequestBody srb = mapper.readValue(subRequest.getBody(), RequestBody.class);
				srb.accessionIds.removeIf(srbAccessionId -> !availableAccessionIds.contains(srbAccessionId));
				if (!srb.accessionIds.isEmpty()) {
					subRequest.setBody(mapper.writeValueAsString(srb));
					subRequestRepository.save(subRequest);
				} else {
					subRequestRepository.delete(subRequest);
				}

			} catch (final IOException e) {
				// FIXME Some other exception?
				throw new RuntimeException("Could not handle request JSON", e);
			}
		}

		return requestRepository.getReferenceById(requestId);
	}

	// Send email to the user to confirm the request
	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') || (@userService.me != null && (@userService.me.id.equals(#materialRequest.createdBy) || @userService.me.email.equalsIgnoreCase(#materialRequest.email)))")
	public MaterialRequest sendValidationEmail(MaterialRequest materialRequest) {
		if (materialRequest.getState() != MaterialRequest.NOTVALIDATED) {
			LOG.warn("Not sending validation email. Request {} state {} is not NOTVALIDATED={}.", materialRequest.getId(), materialRequest.getState(), MaterialRequest.NOTVALIDATED);
			return materialRequest;
		}

		RequestBody rb = null;
		try {
			rb = mapper.readValue(materialRequest.getBody(), RequestBody.class);
		} catch (final IOException e) {
			// FIXME Some other exception?
			throw new RuntimeException("Could not handle request JSON", e);
		}

		ArticleTranslationService.TranslatedArticle article;
		if (StringUtils.isNotBlank(materialRequest.getPid()) || materialRequest.isInternalRequest()) {
			article = articleService.getGlobalArticle(ContentService.SMTP_MATERIAL_CONFIRM, Locale.ENGLISH);
		} else {
			LOG.warn("No such user in ITPGRFA system");
			article = articleService.getGlobalArticle(ContentService.SMTP_MATERIAL_CONFIRM_NO_PID, Locale.ENGLISH);
		}

		// Generate verification token+key
		final VerificationToken verificationToken = tokenVerificationService.generateToken(REQUEST_TOKENTYPE, materialRequest.getUuid());

		final Page<Accession> accessions = genesysService.listAccessions(rb.accessionIds, PageRequest.of(0, Integer.MAX_VALUE));

		// Create the root hash
		final Map<String, Object> root = new HashMap<String, Object>();
		root.put("baseUrl", frontendUrl);
		root.put("verificationToken", verificationToken);
		root.put("internal", materialRequest.isInternalRequest());
		root.put("pid", rb.pid);
		root.put("accessions", accessions.getContent());

		var body = article.getTranslation() != null ? article.getTranslation().getBody() : article.getEntity().getBody();
		var title = article.getTranslation() != null ? article.getTranslation().getTitle() : article.getEntity().getTitle();
		
		final String mailBody = contentService.processTemplate(body, root);
		final String mailSubject = "[" + materialRequest.getUuid() + "] " + title;
		LOG.debug(">>>{}", mailBody);

		// send to user
		emailService.sendMail(mailSubject, mailBody, materialRequest.getEmail());

		materialRequest.setLastReminderDate(Instant.now());
		return requestRepository.save(materialRequest);
	}

	private String serialize(RequestBody rb) {
		String x = null;
		try {
			x = mapper.writeValueAsString(rb);
			LOG.debug("Request JSON: {}" + x);
		} catch (final JsonProcessingException e) {
			e.printStackTrace();
		}
		return x;
	}

	// Rollback for any exception
	@Override
	@Transactional(rollbackFor= { NoPidException.class, EasySMTAException.class })
	public MaterialRequest validateClientRequest(String tokenUuid, String key) throws NoSuchVerificationTokenException, NoPidException, EasySMTAException, TokenExpiredException, RequestException {
		final VerificationToken consumedToken = tokenVerificationService.consumeToken(REQUEST_TOKENTYPE, tokenUuid, key);

		final MaterialRequest materialRequest = requestRepository.findByUuid(consumedToken.getData());

		if (materialRequest.getState() == MaterialRequest.DRAFT) {
			throw new RequestException("Request is DRAFT.");
		}

		if (materialRequest.getState() != MaterialRequest.NOTVALIDATED) {
			throw new RequestException("Request is already validated or dispatched.");
		}

		return validateRequest(materialRequest);
	}

	/**
	 * Send email notifications about each SubRequest to respective holding genebanks.
	 */
	@Transactional
	private void relayRequests(MaterialRequest materialRequest) {

		final List<MaterialSubRequest> subRequests = subRequestRepository.findBySourceRequest(materialRequest).stream()
			.filter(subRequest -> instituteService.getInstitute(subRequest.getInstCode()).isAllowMaterialRequests()).collect(Collectors.toList());

		if (subRequests.size() == 0) {
			LOG.info("Nothing to relay.");
			return;
		}

		LOG.info("Material subrequests {}", subRequests.size());
		materialRequest.setState(MaterialRequest.DISPATCHED);
		requestRepository.save(materialRequest);

		for (final MaterialSubRequest msr : subRequests) {
			relayRequest(msr);
		}

	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') || (@userService.me != null && (@userService.me.id.equals(#materialRequest.createdBy) || @userService.me.email.equalsIgnoreCase(#materialRequest.email)))")
	public MaterialRequest recheckPid(MaterialRequest materialRequest) throws NoPidException, EasySMTAException {
		// re-test email for PID
		EasySMTA.EasySMTAUserData pid = pidChecker.getUserData(materialRequest.getEmail());

		if (pid == null) {
			throw new NoPidException("Email not registered with PID server");
		}
		materialRequest.setPid(pid.getPid());
		requestRepository.save(materialRequest);
		try {
			final RequestBody rb = mapper.readValue(materialRequest.getBody(), RequestBody.class);
			rb.pid = pid;
			materialRequest.setBody(serialize(rb));

			// Need to update all subrequests
			for (final MaterialSubRequest subrequest : materialRequest.getSubRequests()) {
				final RequestBody rbs = mapper.readValue(subrequest.getBody(), RequestBody.class);
				rbs.pid = pid;
				subrequest.setBody(serialize(rbs));
				LOG.info("Updating subrequest: {}", subrequest);
				subRequestRepository.save(subrequest);
			}

			return requestRepository.save(materialRequest);

		} catch (final IOException e) {
			// FIXME Some other exception?
			throw new RuntimeException("Could not handle request JSON", e);
		}
	}

	/**
	 * Mark request as validated if PID checks out.
	 *
	 * @param materialRequest
	 * @return
	 * @throws EasySMTAException
	 * @throws NoPidException
	 */
	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public MaterialRequest validateRequest(MaterialRequest materialRequest) throws NoPidException, EasySMTAException {
		if (materialRequest.getState() != MaterialRequest.NOTVALIDATED) {
			LOG.warn("Cannot validate request {} with state {}. It is not NOTVALIDATED={}.", materialRequest.getId(), materialRequest.getState(), MaterialRequest.NOTVALIDATED);
			return materialRequest;
		}

		if (!materialRequest.isInternalRequest()) {
			materialRequest = recheckPid(materialRequest);
		}
		// Client email is confirmed
		materialRequest.setState(MaterialRequest.VALIDATED);
		materialRequest = requestRepository.save(materialRequest);

		breakup(materialRequest); // does nothing if MaterialSubRequests exist
		relayRequests(materialRequest);

		return materialRequest;
	}

	/**
	 * Relay sub-request to holding institute
	 */
	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public MaterialSubRequest relayRequest(MaterialSubRequest materialSubRequest) {
		LOG.info("Relaying request {}", materialSubRequest);
		if (! (materialSubRequest.getState() == DRAFT.getValue() || materialSubRequest.getState() == NOTCONFIRMED.getValue())) {
			throw new InvalidApiUsageException("The sub-request is not in DRAFT or NOTCONFIRMED state.");
		}

		final VerificationToken verificationToken = tokenVerificationService.generateToken(RECEIPT_TOKENTYPE, materialSubRequest.getUuid());

		// Recipient
		String recipient = null;
		final Locale recipientLocale = Locale.ENGLISH;

		final FaoInstitute institute = instituteService.findInstitute(materialSubRequest.getInstCode());
		final FaoInstituteSetting instMailToSetting = institute.getSettings().get("requests.mailto");
		if (instMailToSetting != null) {
			recipient = StringUtils.defaultIfBlank(instMailToSetting.getValue(), null);
		}

		String[] emailCc = null;
		if (recipient != null) {
			emailCc = new String[] { requestsEmail };
			// TODO If such user is registered, use to user's selected locale
		} else {
			LOG.info("Using default email to relay request for institute {}", materialSubRequest.getInstCode());
			recipient = requestsEmail;
		}

		materialSubRequest.setInstEmail(recipient);
		materialSubRequest = subRequestRepository.save(materialSubRequest);

		final ArticleTranslationService.TranslatedArticle article = articleService.getGlobalArticle(ContentService.SMTP_MATERIAL_REQUEST, recipientLocale);

		RequestBody rb;
		try {
			rb = mapper.readValue(materialSubRequest.getBody(), RequestBody.class);
		} catch (final IOException e) {
			throw new RuntimeException(e);
		}

		final Page<Accession> accessions = genesysService.listAccessions(rb.accessionIds, PageRequest.of(0, Integer.MAX_VALUE));

		// Create the root hash
		final Map<String, Object> root = new HashMap<String, Object>();
		root.put("baseUrl", frontendUrl);
		root.put("verificationToken", verificationToken);
		root.put("pid", rb.pid);
		root.put("requestInfo", rb.requestInfo);
		root.put("accessions", accessions.getContent());

		var body = article.getTranslation() != null ? article.getTranslation().getBody() : article.getEntity().getBody();
		var title = article.getTranslation() != null ? article.getTranslation().getTitle() : article.getEntity().getTitle();
		final String mailBody = contentService.processTemplate(body, root);
		final String mailSubject = "[" + materialSubRequest.getInstCode() + "] " + "[" + materialSubRequest.getUuid() + "] " + title;
		LOG.debug("Subrequest: {}\n\n{}", materialSubRequest.getInstEmail(), mailBody);

		// send to recipient(s)
		emailService.sendMail(mailSubject, mailBody, emailCc, emailService.toEmails(materialSubRequest.getInstEmail()));

		materialSubRequest.setLastReminderDate(Instant.now());
		materialSubRequest.setState(NOTCONFIRMED.getValue());
		return subRequestRepository.save(materialSubRequest);
	}

	@Override
	@Transactional
	public MaterialSubRequest validateReceipt(String tokenUuid, String key) throws NoSuchVerificationTokenException, TokenExpiredException {
		final VerificationToken consumedToken = tokenVerificationService.consumeToken(RECEIPT_TOKENTYPE, tokenUuid, key);

		final MaterialSubRequest materialSubRequest = subRequestRepository.findByUuid(consumedToken.getData());
		if (materialSubRequest.getState() == CONFIRMED.getValue()) {
			throw new InvalidApiUsageException("The request has already confirmed.");
		}

		materialSubRequest.setState(CONFIRMED.getValue());
		subRequestRepository.save(materialSubRequest);

		sendSubRequestConfirmedEmail(materialSubRequest);

		return materialSubRequest;
	}

	private void sendSubRequestConfirmedEmail(MaterialSubRequest materialSubRequest) {
		try {
			final ArticleTranslationService.TranslatedArticle article = articleService.getGlobalArticle(ContentService.SMTP_REQUEST_CONFIRMED, Locale.ENGLISH);

			if (article == null) {
				return;
			}

			RequestBody rb;
			try {
				rb = mapper.readValue(materialSubRequest.getBody(), RequestBody.class);
			} catch (final IOException e) {
				throw new RuntimeException(e);
			}

			final Page<Accession> accessions = genesysService.listAccessions(rb.accessionIds, PageRequest.of(0, Integer.MAX_VALUE));
			final FaoInstitute institute = instituteService.findInstitute(materialSubRequest.getInstCode());

			// Create the root hash
			final Map<String, Object> root = new HashMap<String, Object>();
			root.put("baseUrl", frontendUrl);
			root.put("pid", rb.pid);
			root.put("requestInfo", rb.requestInfo);
			root.put("accessions", accessions.getContent());
			root.put("institute", institute);
			
			var body = article.getTranslation() != null ? article.getTranslation().getBody() : article.getEntity().getBody();
			final String mailBody = contentService.processTemplate(body, root);
			final String mailSubject = String.format("[%s]: Request for material from %s", materialSubRequest.getUuid(), StringUtils.defaultIfBlank(institute.getAcronym(), institute.getFullName()));

			// send to recipient(s)
			emailService.sendMail(mailSubject, mailBody, emailService.toEmails(materialSubRequest.getInstEmail()), materialSubRequest.getSourceRequest().getEmail());
		} catch (Exception e) {
			LOG.warn("Exception in sending \"MaterialSubRequest confirmed\" email", e);
		}
	}

	public static final class RequestBody {

		public RequestInfo requestInfo;
		public Set<Long> accessionIds;
		public EasySMTA.EasySMTAUserData pid;

		public RequestBody() {
		}

		public RequestBody(RequestInfo requestInfo, EasySMTA.EasySMTAUserData pid, Set<Long> accessionIds2) {
			this.requestInfo = requestInfo;
			this.pid = pid;
			this.accessionIds = new HashSet<Long>(accessionIds2);
		}
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public Page<MaterialRequest> list(MaterialRequestFilter filter, Pageable pageable) {
		BooleanBuilder predicate = new BooleanBuilder();
		if (filter!=null) {
			predicate.and(filter.buildPredicate());
		}
		return requestRepository.findAll(predicate, pageable);
	}

	@Override
	@PreAuthorize("hasRole('REQUESTS')")
	public Page<MaterialSubRequest> listMineSubRequests(MaterialSubRequestFilter filter, Pageable pageable) {
		BooleanBuilder predicate = new BooleanBuilder();
		if (filter != null) {
			predicate.and(filter.buildPredicate());
		}

		List<FaoInstitute> institutes = instituteService.listMyInstitutes(Sort.by("code"));
		if (institutes != null) {
			Set<String> codes = institutes.stream().map(FaoInstitute::getCode).collect(Collectors.toSet());
			predicate.and(QMaterialSubRequest.materialSubRequest.instCode.in(codes));
		}

		return subRequestRepository.findAll(predicate, pageable);
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#faoInstitute, 'ADMINISTRATION') or hasPermission(#faoInstitute, 'WRITE')")
	public Page<MaterialSubRequest> list(FaoInstitute faoInstitute, MaterialSubRequestFilter filter, Pageable pageable) {
		BooleanBuilder predicate = new BooleanBuilder(QMaterialSubRequest.materialSubRequest.instCode.eq(faoInstitute.getCode()));
		if (filter != null) {
			predicate.and(filter.buildPredicate());
		}
		return subRequestRepository.findAll(predicate, pageable);
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public Page<MaterialSubRequest> listSubRequests(MaterialSubRequestFilter filter, Pageable pageable) {
		final BooleanBuilder predicate = new BooleanBuilder();
		if (filter != null) {
			predicate.and(filter.buildPredicate());
		}
		return subRequestRepository.findAll(predicate, pageable);
	}

	@Override
	@PostAuthorize("hasRole('ADMINISTRATOR') || (@userService.me != null && (@userService.me.id.equals(returnObject.createdBy) || @userService.me.email.equalsIgnoreCase(returnObject.email)))")	
	public MaterialRequest get(String uuid) {
		MaterialRequest request = requestRepository.findByUuid(uuid);
		if (request == null) {
			throw new NotFoundElement("Record not found by uuid=" + uuid);
		}
		return request;
	}

	@Override
	@PostAuthorize("hasRole('ADMINISTRATOR') || (@userService.me != null && (@userService.me.id.equals(returnObject.createdBy) || @userService.me.email.equalsIgnoreCase(returnObject.email)))")
	public MaterialRequest get(Long requestId) {
		return requestRepository.getReferenceById(requestId);
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#institute, 'ADMINISTRATION')")
	public MaterialSubRequest get(FaoInstitute institute, String uuid) {
		return subRequestRepository.findByInstCodeAndUuid(institute.getCode(), uuid);
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public MaterialSubRequest getSubrequest(String uuid) {
		return subRequestRepository.findByUuid(uuid);
	}

	@Override
	@PostAuthorize("returnObject==null || hasRole('ADMINISTRATOR')")
	public MaterialSubRequest getSubrequest(UUID uuid, Integer version) {
		if (uuid == null) {
			throw new InvalidApiUsageException("UUID of sub-request isn't provided.");
		}
		final MaterialSubRequest subRequest = subRequestRepository.findByUuid(uuid.toString());

		if (subRequest == null) {
			throw new NotFoundElement("Record not found by uuid=" + uuid);
		}

		if (version != null && !subRequest.getVersion().equals(version)) {
			LOG.warn("Sub-requests versions don't match anymore");
			throw new ConcurrencyFailureException("Object version changed to " + subRequest.getVersion() + ", you provided " + version);
		}
		return subRequest;
	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public MaterialRequest remove(String uuid) {
		MaterialRequest request  = requestRepository.findByUuid(uuid);
		subRequestRepository.deleteAll(request.getSubRequests());
		requestRepository.delete(request);

		request.setId(0L);
		return request;

	}

	@Override
	public long changeInstitute(FaoInstitute currentInstitute, FaoInstitute newInstitute) {
		LOG.warn("Migrating requests from {} to {}", currentInstitute.getCode(), newInstitute.getCode());


		// Update accession references
		var qSubRequest = QMaterialSubRequest.materialSubRequest;
		return jpaQueryFactory.update(qSubRequest)
			// Update instCode
			.set(qSubRequest.instCode, newInstitute.getCode())
			// WHERE
			.where(qSubRequest.instCode.eq(currentInstitute.getCode()))
			// Execute
			.execute();
	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#materialSubRequest, 'ADMINISTRATION') or hasPermission(#faoInstitute, 'ADMINISTRATION') or hasPermission(#faoInstitute, 'WRITE')")
	public MaterialSubRequest setProviderInfo(MaterialSubRequest materialSubRequest, FaoInstitute faoInstitute, @Valid RequestService.ProviderInfoRequest info) {
		if (materialSubRequest == null) {
			throw new InvalidApiUsageException("MaterialSubRequest isn't provided.");
		}
		if (faoInstitute == null) {
			throw new InvalidApiUsageException("FaoInstitute isn't provided.");
		}
		if (!materialSubRequest.getInstCode().equals(faoInstitute.getCode())) {
			throw new InvalidApiUsageException("Given MaterialSubRequest is not related to FaoInstitute.");
		}
		if (info.getState() == null) { // @Valid and @NotNull should take care of this
			throw new InvalidApiUsageException("State isn't provided.");
		}
		if (List.of(MaterialSubRequest.State.DRAFT, MaterialSubRequest.State.NOTCONFIRMED).contains(info.getState())) {
			throw new InvalidApiUsageException("Invalid state.");
		}
		materialSubRequest.setProviderId(info.getProviderId());
		materialSubRequest.setProviderNote(info.getProviderNote());
		materialSubRequest.setState(info.getState().getValue());
		return subRequestRepository.save(materialSubRequest);
	}

	@Override
	public MaterialRequest getRequestStatus(UUID uuid) {
		MaterialRequest request = requestRepository.findByUuid(uuid.toString());
		if (request == null) {
			throw new NotFoundElement("Record not found by uuid=" + uuid);
		}
		MaterialRequest requestStatus = new MaterialRequest();

		requestStatus.setId(request.getId());
		requestStatus.setUuid(request.getUuid());
		requestStatus.setState(request.getState());
		requestStatus.setInternalRequest(request.isInternalRequest());
		requestStatus.setActive(request.isActive());
		requestStatus.setCreatedDate(request.getCreatedDate());
		requestStatus.setLastModifiedDate(request.getLastModifiedDate());
		requestStatus.setLastReminderDate(request.getLastReminderDate());

		var subRequests = request.getSubRequests();
		if (subRequests != null) {
			requestStatus.setSubRequests(subRequests.stream()
				.map(sub -> {
					var subRequest = new MaterialSubRequest();
					subRequest.setUuid(sub.getUuid());
					subRequest.setInstCode(sub.getInstCode());
					subRequest.setLastReminderDate(sub.getLastReminderDate());
					subRequest.setState(sub.getState());
					return subRequest;
				}).collect(Collectors.toList())
			);
		}

		RequestBody rb;
		try {
			rb = mapper.readValue(request.getBody(), RequestBody.class);
		} catch (final IOException e) {
			throw new RuntimeException(e);
		}

		var body = new RequestBody();
		body.accessionIds = rb.accessionIds;
		if (rb.pid != null) {
			body.pid = new EasySMTA.EasySMTAUserData();
			body.pid.setType(rb.pid.getType());
			body.pid.setCountry(rb.pid.getCountry());
			body.pid.setOrgCountry(rb.pid.getOrgCountry());
			body.pid.setShipAddrFlag(rb.pid.getShipAddrFlag());
			body.pid.setShipCountry(rb.pid.getShipCountry());
		}

		if (rb.requestInfo != null) {
			body.requestInfo = new RequestInfo();
			body.requestInfo.setPurposeType(rb.requestInfo.getPurposeType());
			body.requestInfo.setPreacceptSMTA(rb.requestInfo.isPreacceptSMTA());
		}

		requestStatus.setBody(serialize(body));

		return requestStatus;
	}

	@Override
	@PreAuthorize("isFullyAuthenticated()")
	public Page<MaterialRequest> listMyRequests(MaterialRequestFilter filter, Pageable page) {
		BooleanBuilder predicate = new BooleanBuilder();
		if (filter != null) {
			predicate.and(filter.buildPredicate());
		}

		final User user = userService.getMe();

		if (user == null) {
			throw new InvalidApiUsageException("User not found in the system");
		}
		
		predicate.and(QMaterialRequest.materialRequest.createdBy.eq(user.getId()).or(QMaterialRequest.materialRequest.email.eq(user.getEmail())));

		return requestRepository.findAll(predicate, page);
	}

	@Override
	@Scheduled(cron = "0 0 */4 ? * MON-FRI") // Run every four hours MON-FRI
// DEV	@Scheduled(cron = "0 */4 * ? * *") // Run every four minutes
	@SchedulerLock(lockAtLeastFor = "PT10M", name = "org.genesys.server.service.impl.RequestServiceImpl.resendNotConfirmedSubRequests")
	@Transactional
	public List<MaterialSubRequest> resendNotConfirmedSubRequests() {
		LOG.warn("Resending email notifications to genebanks");
		var createdAfter = OffsetDateTime.now().minus(1, ChronoUnit.MONTHS).toInstant();
		var remindedBefore = OffsetDateTime.now().minus(5, ChronoUnit.DAYS).toInstant();

		QMaterialSubRequest msr = QMaterialSubRequest.materialSubRequest;
		var predicate = msr.state.eq(MaterialSubRequest.State.NOTCONFIRMED.getValue())
				.and(msr.createdDate.after(createdAfter)) // created within one month
				.and(msr.lastReminderDate.before(remindedBefore)); // but sent more than 5 days ago

		var notifiedSubRequests = new LinkedList<MaterialSubRequest>();
		subRequestRepository.findAll(predicate).forEach(subRequest-> {
			try {
				LOG.info("Sending reminder for subRequest id={} {}", subRequest.getId(), subRequest.getInstCode());
				notifiedSubRequests.add(relayRequest(subRequest));
			} catch (Throwable e) {
				LOG.warn("Could not send reminder for subRequest id={}: {}", subRequest.getId(), e.getMessage());
			}
		});
		return notifiedSubRequests;
	}

}