org.niord.core.message.MessageService.java Source code

Java tutorial

Introduction

Here is the source code for org.niord.core.message.MessageService.java

Source

/*
 * Copyright 2016 Danish Maritime Authority.
 *
 * 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.niord.core.message;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.apache.commons.lang.StringUtils;
import org.niord.core.area.Area;
import org.niord.core.area.AreaService;
import org.niord.core.category.Category;
import org.niord.core.category.CategoryService;
import org.niord.core.chart.Chart;
import org.niord.core.chart.ChartService;
import org.niord.core.db.CriteriaHelper;
import org.niord.core.db.SpatialIntersectsPredicate;
import org.niord.core.domain.Domain;
import org.niord.core.domain.DomainService;
import org.niord.core.geojson.Feature;
import org.niord.core.geojson.FeatureCollection;
import org.niord.core.geojson.FeatureService;
import org.niord.core.message.MessageSearchParams.DateType;
import org.niord.core.message.MessageSearchParams.UserType;
import org.niord.core.message.vo.SystemMessageVo;
import org.niord.core.publication.PublicationService;
import org.niord.core.repo.RepositoryService;
import org.niord.core.service.BaseService;
import org.niord.core.user.User;
import org.niord.core.user.UserService;
import org.niord.model.DataFilter;
import org.niord.model.message.MainType;
import org.niord.model.message.MessageVo;
import org.niord.model.message.ReferenceType;
import org.niord.model.message.Status;
import org.niord.model.search.PagedSearchResultVo;
import org.slf4j.Logger;

import javax.annotation.Resource;
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.inject.Inject;
import javax.jms.JMSContext;
import javax.jms.Topic;
import javax.persistence.Tuple;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Expression;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.JoinType;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import javax.persistence.criteria.Selection;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

import static java.util.Arrays.asList;
import static org.niord.core.geojson.Feature.WGS84_SRID;
import static org.niord.core.message.MessageIdMatch.MatchType.*;
import static org.niord.core.message.MessageSearchParams.CommentsType.*;
import static org.niord.core.message.vo.SystemMessageSeriesVo.NumberSequenceType.MANUAL;
import static org.niord.model.search.PagedSearchParamsVo.SortOrder;

/**
 * Business interface for managing messages
 */
@Stateless
@SuppressWarnings("unused")
public class MessageService extends BaseService {

    @Inject
    private Logger log;

    @Inject
    JMSContext jmsContext;

    @Resource(mappedName = "java:/jms/topic/MessageStatusTopic")
    Topic messageStatusTopic;

    @Inject
    UserService userService;

    @Inject
    MessageSeriesService messageSeriesService;

    @Inject
    MessageTagService messageTagService;

    @Inject
    PublicationService publicationService;

    @Inject
    MessageLuceneIndex messageLuceneIndex;

    @Inject
    AreaService areaService;

    @Inject
    CategoryService categoryService;

    @Inject
    ChartService chartService;

    @Inject
    RepositoryService repositoryService;

    @Inject
    DomainService domainService;

    @Inject
    FeatureService featureService;

    /***************************************/
    /** Message Look-up                   **/
    /***************************************/

    /**
     * Returns the message with the given uid
     *
     * @param uid the id of the message
     * @return the message with the given id or null if not found
     */
    public Message findByUid(String uid) {
        try {
            return em.createNamedQuery("Message.findByUid", Message.class).setParameter("uid", uid)
                    .getSingleResult();
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * Returns the message with the given legacy id
     *
     * @param legacyId the id of the message
     * @return the message with the given id or null if not found
     */
    public Message findByLegacyId(String legacyId) {
        try {
            return em.createNamedQuery("Message.findByLegacyId", Message.class).setParameter("legacyId", legacyId)
                    .getSingleResult();
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * Returns the messages with the given short ID.
     *
     * @param shortId the short ID of the messages
     * @return the messages with the given short ID
     */
    public List<Message> findByShortId(String shortId) {
        return em.createNamedQuery("Message.findByShortId", Message.class).setParameter("shortId", shortId)
                .getResultList();
    }

    /**
     * Returns the messages with the given UID or short ID.
     *
     * @param messageId the UID or short ID of the messages
     * @return the messages with the given message ID
     */
    private List<Message> findByMessageId(String messageId) {
        if (StringUtils.isBlank(messageId)) {
            return Collections.emptyList();
        }
        return em.createNamedQuery("Message.findByMessageId", Message.class)
                .setParameter("msgId", messageId.toLowerCase()).getResultList();
    }

    /**
     * Resolves the "best" message with the given message id, which may be either a UID,
     * or a short ID of a message.
     * If the are multiple matching messages, priority is given to a messages of the current domain.
     *
     * @param messageId the message id to resolve
     * @return the matching message or null if none is found
     */
    public Message resolveMessage(String messageId) {
        // Sanity check
        if (StringUtils.isBlank(messageId)) {
            return null;

        }

        Message message = findByUid(messageId);
        if (message != null) {
            return message;
        }

        // Check if the message ID is a short ID
        List<Message> messages = findByShortId(messageId);
        if (!messages.isEmpty()) {
            return resolveMessage(messages);
        }

        // No joy
        return null;
    }

    /** Returns a single message from the list - preferably from the current domain */
    private Message resolveMessage(List<Message> messages) {
        // Sort messages by domain and status
        messages.sort(new MessageIdMatchComparator(domainService.currentDomain()));

        // No current domain - just return the first message
        return messages.get(0);
    }

    /**
     * Returns the list of referenced messages, optionally with the given reference type and status
     *
     * @param message the message to return referenced messages for
     * @param referenceTypes optionally, the reference types
     * @param status optionally, the status of the referenced messages
     * @return the list of referenced messages
     */
    public List<Message> getReferencedMessages(Message message, Set<ReferenceType> referenceTypes, Status status) {
        return message.getReferences().stream()
                .filter(ref -> referenceTypes == null || referenceTypes.isEmpty()
                        || referenceTypes.contains(ref.getType()))
                .map(ref -> resolveMessage(ref.getMessageId())).filter(Objects::nonNull)
                .filter(msg -> status == null || status == msg.getStatus()).collect(Collectors.toList());
    }

    /**
     * Returns a list of message IDs (UID or shortId) that - possibly partially - matches
     * real text.
     *
     * @param lang the language to return the title in
     * @param txt the text to match
     * @param maxGroupCount the max number of matching message IDs to return.
     * @param includeText whether to include the search text as a match
     * @param includeDeleted whether to include deleted messages in the result
     * @return the search result
     */
    public List<MessageIdMatch> searchMessageIds(String lang, String txt, int maxGroupCount, boolean includeText,
            boolean includeDeleted) {
        List<MessageIdMatch> result = new ArrayList<>();
        if (StringUtils.isBlank(txt)) {
            return result;
        }

        // First priority is the text itself - if requested
        if (includeText) {
            result.add(new MessageIdMatch(txt, TEXT, null));
        }

        // Check for a matching UID
        Message message = findByUid(txt);
        if (message != null) {
            result.add(new MessageIdMatch(txt, UID, message, lang));
        }

        // Search shortIds
        String searchShortIdSql = "select distinct m from Message m where lower(m.shortId) like lower(:term) ";
        if (!includeDeleted) {
            searchShortIdSql += "and m.status != 'DELETED' ";
        }
        searchShortIdSql += "order by locate(lower(:sort), lower(m.shortId)) asc, m.updated desc ";
        em.createQuery(searchShortIdSql, Message.class).setParameter("term", "%" + txt + "%")
                .setParameter("sort", txt).setMaxResults(maxGroupCount).getResultList()
                .forEach(m -> result.add(new MessageIdMatch(m.getShortId(), SHORT_ID, m, lang)));

        return result;
    }

    /***************************************/
    /** Message Persistence               **/
    /***************************************/

    /**
     * Saves the message
     *
     * @param message the message to save
     * @return the saved message
     */
    public Message saveMessage(Message message) {
        boolean wasPersisted = message.isPersisted();

        // Please see comment on Message.onPersist()
        message.onPersist();

        // Save the message
        message = saveEntity(message);

        // Save a MessageHistory entity for the message
        saveHistory(message);

        return message;
    }

    /**
     * Creates a new message as a draft message
     *
     * @param message the template for the message to create
     * @return the new message
     */
    public Message createMessage(Message message) throws Exception {

        // Validate the message
        if (message.isPersisted()) {
            throw new Exception("Message already persisted");
        }

        // Register who originally created the message
        message.setCreatedBy(userService.currentUser());

        // Validate various required fields
        if (message.getMessageSeries() == null) {
            throw new Exception("Message series not specified");
        }

        // Substitute the message series with the persisted on
        message.setMessageSeries(messageSeriesService.findBySeriesId(message.getMessageSeries().getSeriesId()));

        if (message.getType() != null) {
            message.setMainType(message.getType().getMainType());
        }
        if (message.getMainType() != null && message.getMainType() != message.getMessageSeries().getMainType()) {
            throw new Exception("Invalid main-type for message " + message.getMainType());
        }

        // Set default status
        if (message.getStatus() == null) {
            message.setStatus(Status.DRAFT);
        }

        // Remove empty geometries
        message.getParts().stream().filter(p -> p.getGeometry() != null && p.getGeometry().getFeatures().isEmpty())
                .forEach(p -> p.setGeometry(null));

        // Substitute the Area with a persisted one
        message.setAreas(persistedList(Area.class, message.getAreas()));
        message.setAreaSortOrder(areaService.computeMessageAreaSortingOrder(message));

        // Substitute the Categories with the persisted ones
        message.setCategories(persistedList(Category.class, message.getCategories()));

        // Substitute the Charts with the persisted ones
        message.setCharts(chartService.persistedCharts(message.getCharts()));

        // Persist the message
        message = saveMessage(message);
        log.info("Saved message " + message.getUid());

        em.flush();
        return message;
    }

    /**
     * Updates the given message.
     * <p>
     * Important: this function can not be used to change the status of the message.
     * For that, call {@code MessageService.updateStatus()}
     *
     * @param message the template for the message to update
     * @return the updated message
     */
    @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
    public Message updateMessage(Message message) throws Exception {

        Message original = findByUid(message.getUid());

        // Validate the message
        if (original == null) {
            throw new Exception("Message not an existing message");
        }

        // Register who last updated he message
        original.setLastUpdatedBy(userService.currentUser());

        original.setRevision(message.getRevision());

        // Validate various required fields
        if (message.getMessageSeries() == null) {
            throw new Exception("Message series not specified");
        }

        // Check that there is no attempt to update the status
        if (message.getStatus() != original.getStatus()) {
            throw new Exception("updateMessage() cannot change status");
        }

        if (message.getType() != null) {
            message.setMainType(message.getType().getMainType());
        }

        original.setMessageSeries(messageSeriesService.findBySeriesId(message.getMessageSeries().getSeriesId()));
        original.setNumber(message.getNumber());
        original.setShortId(message.getShortId());
        original.setType(message.getType());
        original.setMainType(message.getMainType());
        original.setThumbnailPath(message.getThumbnailPath());

        if (original.getMainType() != null && original.getMainType() != original.getMessageSeries().getMainType()) {
            throw new Exception("Invalid main-type for message " + original.getMainType());
        }

        // If a verified message is updated, it will become draft again
        if (original.getStatus() == Status.VERIFIED) {
            original.setStatus(Status.DRAFT);
        }

        original.setHorizontalDatum(message.getHorizontalDatum());

        // Substitute the Area with a persisted one
        original.setAreas(persistedList(Area.class, message.getAreas()));

        // Substitute the Categories with the persisted ones
        original.setCategories(persistedList(Category.class, message.getCategories()));

        // Substitute the Charts with the persisted ones
        original.setCharts(chartService.persistedCharts(message.getCharts()));

        original.setPublishDateFrom(message.getPublishDateFrom());
        original.setPublishDateTo(message.getPublishDateTo());
        original.setFollowUpDate(message.getFollowUpDate());

        original.getReferences().clear();
        message.getReferences().stream().map(this::updateReference).filter(Objects::nonNull)
                .forEach(original::addReference);

        original.setOriginalInformation(message.getOriginalInformation());

        original.getParts().clear();
        message.getParts().stream().map(this::updateMessagePart).filter(Objects::nonNull)
                .forEach(original::addPart);
        original.getParts().removeIf(part -> !part.partDefined());

        // Copy the localized description data
        original.copyDescsAndRemoveBlanks(message.getDescs());
        original.setAutoTitle(message.isAutoTitle());

        original.getAttachments().clear();
        message.getAttachments().stream().map(this::updateAttachment).filter(Objects::nonNull)
                .forEach(original::addAttachment);

        original.setSeparatePage(message.getSeparatePage());

        // For non-published messages, compute the area sort order based on associated area and message part geometry
        if (original.getStatus().isDraft()) {
            original.setAreaSortOrder(areaService.computeMessageAreaSortingOrder(original));
        }

        // Persist the message
        saveMessage(original);
        log.info("Updated message " + original);

        em.flush();
        return original;
    }

    /** Called upon saving a message. Updates the message part **/
    private MessagePart updateMessagePart(MessagePart part) {

        part.setGeometry(featureService.updateFeatureCollection(part.getGeometry()));

        if (part.isNew()) {
            return part;
        }
        MessagePart original = getByPrimaryKey(MessagePart.class, part.getId());
        if (original != null) {
            original.updateMessagePart(part);
        }
        return original;
    }

    /** Upon saving a message, the attachment captions may have been updated **/
    private Attachment updateAttachment(Attachment attachment) {
        if (attachment.isNew()) {
            return attachment;
        }
        Attachment original = getByPrimaryKey(Attachment.class, attachment.getId());
        if (original != null) {
            original.updateAttachment(attachment);
        }
        return original;
    }

    /** Called upon saving a message. Updates the reference **/
    private Reference updateReference(Reference reference) {
        if (reference.isNew()) {
            return reference;
        }
        Reference original = getByPrimaryKey(Reference.class, reference.getId());
        if (original != null) {
            original.updateReference(reference);
        }
        return original;
    }

    /** Called upon saving a message. Updates the date interval **/
    private DateInterval updateDateInterval(DateInterval dateInterval) {
        if (dateInterval.isNew()) {
            return dateInterval;
        }
        DateInterval original = getByPrimaryKey(DateInterval.class, dateInterval.getId());
        if (original != null) {
            original.updateDateInterval(dateInterval);
        }
        return original;
    }

    /**
     * Updates the status of the given message
     *
     * @param uid the UID of the message
     * @param status    the status
     */
    @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
    public Message updateStatus(String uid, Status status) throws Exception {
        Date now = new Date();
        Message message = findByUid(uid);
        Status prevStatus = message.getStatus();

        // Check that a valid status transition is requested
        if (!getValidStatusTransitions(prevStatus).contains(status)) {
            throw new Exception("Invalid status transition " + prevStatus + " -> " + status);
        }

        // Register who last updated he message
        message.setLastUpdatedBy(userService.currentUser());

        // Update the status
        message.setStatus(status);

        // When published, update dates and the message series
        if (prevStatus.isDraft() && status == Status.PUBLISHED) {

            // Update the publish date needs updating
            if (message.getPublishDateFrom() == null || message.getPublishDateFrom().after(now)) {
                message.setPublishDateFrom(now);
            }

            // If no event dates are defined, add event dates based on publish start date
            message.checkEventDateIntervalsUponPublishStart();

            // Assign a new message number and short ID
            messageSeriesService.updateMessageIdsFromMessageSeries(message, true);

        } else if (status == Status.CANCELLED || status == Status.EXPIRED) {

            // Update the publish date needs updating
            if (message.getPublishDateTo() == null || message.getPublishDateTo().before(now)) {
                message.setPublishDateTo(now);
            }

            // Update or remove open-ended event date intervals based on the publish end date.
            message.checkEventDateIntervalsUponPublishEnd();
        }

        // Add or remove the message from any message-recording publication message tags
        publicationService.updateRecordingPublications(message, prevStatus);

        message = saveMessage(message);

        // Broadcast the status change to any listener
        sendStatusUpdate(message, prevStatus);

        return message;
    }

    /**
     * Defines the set of valid status transitions from the given status
     * @param status the status to transition from
     * @return the set of valid status transitions
     */
    private Set<Status> getValidStatusTransitions(Status status) {
        switch (status) {
        case DRAFT:
            return new HashSet<>(asList(Status.VERIFIED, Status.DELETED));
        case VERIFIED:
            return new HashSet<>(asList(Status.PUBLISHED, Status.DRAFT, Status.DELETED));
        case PUBLISHED:
            return new HashSet<>(asList(Status.EXPIRED, Status.CANCELLED));
        default:
            return Collections.emptySet();
        }
    }

    /**
     * Broadcasts a JMS message to indicate that the message status has changed
     * @param message the message
     * @param prevStatus the previous status
     */
    private void sendStatusUpdate(Message message, Status prevStatus) {
        Map<String, Object> body = new HashMap<>();
        body.put("ID", message.getId());
        body.put("UID", message.getUid());
        body.put("STATUS", message.getStatus().name());
        body.put("PREV_STATUS", prevStatus.name());
        try {
            jmsContext.createProducer().send(messageStatusTopic, body);
        } catch (Exception e) {
            log.error("Failed sending JMS: " + e, e);
        }
    }

    /**
     * Creates a new un-persisted draft message template
     * @return a new message template
     */
    public Message newTemplateMessage(MainType mainType) {

        Message message = new Message();
        message.assignNewUid();
        message.setMainType(mainType);
        message.setStatus(Status.DRAFT);
        message.setAutoTitle(true);
        if (mainType == MainType.NM) {
            message.setOriginalInformation(true);
        }
        return message;
    }

    /**
     * Returns all messages updated after the given date
     *
     * @param date     the date
     * @param maxCount the max number of entries to return
     * @return all messages updated after the given date
     */
    public List<Message> findUpdatedMessages(Date date, int maxCount) {
        return em.createNamedQuery("Message.findUpdateMessages", Message.class).setParameter("date", date)
                .setMaxResults(maxCount).getResultList();
    }

    /**
     * Computes the auto-generated message fields for the given message template
     *
     * @param message the message template to compute auto-generated message fields for
     * @return the updated message template
     */
    public Message updateAutoMessageFields(Message message) {
        if (message.isAutoTitle()) {
            message.setAreas(persistedList(Area.class, message.getAreas()));
        }
        message.updateAutoMessageFields();
        return message;
    }

    /**
     * If the associated message series is of type MANUEL with a defined shortFormat and the message
     * has an associated number, then the shortId of the message is updated.
     * @param message the message to update shortId for
     * @return the update message
     */
    public Message checkUpdateShortId(Message message) {
        if (message.getMessageSeries() != null) {
            message.setMessageSeries(messageSeriesService.findBySeriesId(message.getMessageSeries().getSeriesId()));
            if (message.getNumber() != null && message.getMessageSeries().getNumberSequenceType() == MANUAL
                    && StringUtils.isNotBlank(message.getMessageSeries().getShortFormat())) {
                if (message.getPublishDateFrom() == null) {
                    message.setPublishDateFrom(new Date());
                }
                messageSeriesService.updateMessageIdsFromMessageSeries(message, false);
            }
        }
        return message;
    }

    /***************************************/
    /** Message Sorting                  **/
    /***************************************/

    /**
     * Generate a sorting order for the message within its associated area.
     * The area-sort value is based on the message center latitude and longitude, and the sorting type for
     * its first associated area.
     */
    public void computeMessageAreaSortingOrder(Message message) {
        double areaSortOrder = areaService.computeMessageAreaSortingOrder(message);
        message.setAreaSortOrder(areaSortOrder);
        saveMessage(message);
    }

    /**
     * Changes the area sort order of a message to position it between two other messages
     * @param uid the UID of the message to reorder
     * @param afterUid if defined, the message must come after this message
     * @param beforeUid if defined, the message must come before this message
     */
    public void changeAreaSortOrder(String uid, String afterUid, String beforeUid) {
        Message m = findByUid(uid);
        Message am = afterUid == null ? null : findByUid(afterUid);
        Message bm = beforeUid == null ? null : findByUid(beforeUid);

        if (m == null || (am == null && bm == null)) {
            throw new IllegalArgumentException(
                    "Invalid arguments uid=" + uid + ", afterUid=" + afterUid + ", beforeUid=" + beforeUid);
        }

        if (am == null) {
            m.setAreaSortOrder(bm.getAreaSortOrder() - 1);
            log.info("Changed area sort order of " + uid + ": moved before " + beforeUid);
        } else if (bm == null) {
            m.setAreaSortOrder(am.getAreaSortOrder() + 1);
            log.info("Changed area sort order of " + uid + ": moved after " + afterUid);
        } else {
            m.setAreaSortOrder((am.getAreaSortOrder() + bm.getAreaSortOrder()) / 2.0);
            log.info("Changed area sort order of " + uid + ": moved between " + afterUid + " and " + beforeUid);
        }
    }

    /***************************************/
    /** Message Searching                 **/
    /***************************************/

    /**
     * Main message search function
     * @param params the search parameters
     * @return the search result
     */
    public PagedSearchResultVo<Message> search(MessageSearchParams params) {

        PagedSearchResultVo<Message> result = new PagedSearchResultVo<>();

        try {

            List<Integer> pagedMsgIds = searchPagedMessageIds(params, result);

            // Fetch the cached messages
            List<Message> messages = getMessages(pagedMsgIds);
            result.setData(messages);
            result.updateSize();

        } catch (Exception e) {
            log.error("Error performing search " + params + ": " + e, e);
        }

        return result;
    }

    /**
     * Returns the message with the given IDs
     *
     * @param ids the message IDs
     * @return the message with the given IDs
     */
    private List<Message> getMessages(List<Integer> ids) {

        if (ids.isEmpty()) {
            return Collections.emptyList();
        }

        List<Message> messages = em.createNamedQuery("Message.findByIds", Message.class).setParameter("ids", ids)
                .getResultList();

        // Sort the result according to the order of the messages in the ID list
        messages.sort(Comparator.comparingInt(m -> ids.indexOf(m.getId())));

        return messages;
    }

    /**
     * Searches out the ID's of the paged result set of messages defined by the search parameters.
     * Also fills out the total result count of the message search result.
     *
     * @param param the search parameters
     * @param result the search result to update with the total result count
     * @return the paged list of message ID's
     */
    @SuppressWarnings("all")
    List<Integer> searchPagedMessageIds(MessageSearchParams param, PagedSearchResultVo<Message> result)
            throws Exception {

        CriteriaBuilder builder = em.getCriteriaBuilder();
        CriteriaQuery<Tuple> tupleQuery = builder.createTupleQuery();

        // Select messages
        Root<Message> msgRoot = tupleQuery.from(Message.class);

        // Build the predicates based on the search parameters
        CriteriaHelper<Tuple> criteriaHelper = CriteriaHelper.initWithTupleQuery(em).between(msgRoot.get("updated"),
                param.getUpdatedFrom(), param.getUpdatedTo());

        // Filter by dates
        if (param.getFrom() != null || param.getTo() != null) {
            DateType dateType = param.getDateType() != null ? param.getDateType() : DateType.PUBLISH_DATE;
            Date from = param.getFrom();
            Date to = param.getTo();
            switch (dateType) {
            case PUBLISH_DATE:
                criteriaHelper.overlaps(msgRoot.get("publishDateFrom"), msgRoot.get("publishDateTo"), from, to);
                break;
            case EVENT_DATE:
                criteriaHelper.overlaps(msgRoot.get("eventDateFrom"), msgRoot.get("eventDateTo"), from, to);
                break;
            case CREATED_DATE:
                criteriaHelper.between(msgRoot.get("created"), from, to);
                break;
            case UPDATED_DATE:
                criteriaHelper.between(msgRoot.get("updated"), from, to);
                break;
            case PUBLISH_FROM_DATE:
                criteriaHelper.between(msgRoot.get("publishDateFrom"), from, to);
                break;
            case PUBLISH_TO_DATE:
                criteriaHelper.between(msgRoot.get("publishDateTo"), from, to);
                break;
            }
        }

        // Main types and sub-types
        if (!param.getMainTypes().isEmpty()) {
            criteriaHelper.in(msgRoot.get("mainType"), param.getMainTypes());
        }
        if (!param.getTypes().isEmpty()) {
            criteriaHelper.in(msgRoot.get("type"), param.getTypes());
        }

        // Statuses
        if (!param.getStatuses().isEmpty()) {
            criteriaHelper.in(msgRoot.get("status"), param.getStatuses());
        }

        // Search the Lucene index for free text search
        if (param.requiresLuceneSearch()) {
            List<Long> ids = null;
            try {
                ids = messageLuceneIndex.searchIndex(param.getQuery(), param.getLanguage(), Integer.MAX_VALUE);
            } catch (Exception e) {
                log.warn("Error searching lucene index for query " + param.getQuery());
                ids = Collections.emptyList();
            }
            criteriaHelper.in(msgRoot.get("id"), ids);
        }

        // Message series
        if (!param.getSeriesIds().isEmpty()) {
            criteriaHelper.in(msgRoot.get("messageSeries").get("seriesId"), param.getSeriesIds());
        }

        // Filter by area, join over...
        if (!param.getAreaIds().isEmpty()) {
            Join<Message, Area> areas = msgRoot.join("areas", JoinType.LEFT);
            if (!param.getAreaIds().isEmpty()) {
                Predicate[] areaMatch = param.getAreaIds().stream().map(aid -> areaService.findByAreaId(aid))
                        .filter(Objects::nonNull).map(a -> builder.like(areas.get("lineage"), a.getLineage() + "%"))
                        .toArray(Predicate[]::new);
                criteriaHelper.add(builder.or(areaMatch));
            }
        }

        // Filter on categories
        if (!param.getCategoryIds().isEmpty()) {
            Join<Message, Category> categories = msgRoot.join("categories", JoinType.LEFT);
            Predicate[] categoryMatch = param.getCategoryIds().stream()
                    .map(cid -> categoryService.findByCategoryId(cid)).filter(Objects::nonNull)
                    .map(c -> builder.like(categories.get("lineage"), c.getLineage() + "%"))
                    .toArray(Predicate[]::new);
            criteriaHelper.add(builder.or(categoryMatch));
        }

        // Filter on charts
        if (!param.getChartNumbers().isEmpty()) {
            Join<Message, Chart> charts = msgRoot.join("charts", JoinType.LEFT);
            Predicate[] chartMatch = param.getChartNumbers().stream()
                    .map(m -> builder.equal(charts.get("chartNumber"), m)).toArray(Predicate[]::new);
            criteriaHelper.add(builder.or(chartMatch));
        }

        // Geometry
        if (param.getExtent() != null) {
            param.getExtent().setSRID(WGS84_SRID);
            Join<Message, MessagePart> partRoot = msgRoot.join("parts", JoinType.LEFT);
            Join<Message, FeatureCollection> fcRoot = partRoot.join("geometry", JoinType.LEFT);
            Join<FeatureCollection, Feature> fRoot = fcRoot.join("features", JoinType.LEFT);
            Predicate geomPredicate = new SpatialIntersectsPredicate(criteriaHelper.getCriteriaBuilder(),
                    fRoot.get("geometry"), param.getExtent());

            if (param.getIncludeNoPos() != null && param.getIncludeNoPos().booleanValue()) {
                // search for message with no geometry in addition to messages within extent
                criteriaHelper.add(builder.or(builder.equal(msgRoot.get("hasGeometry"), false), geomPredicate));
            } else {
                // Only search for messages within extent
                criteriaHelper.add(geomPredicate);
            }
        }

        // Tags
        if (!param.getTags().isEmpty()) {
            Join<Message, MessageTag> tags = msgRoot.join("tags", JoinType.LEFT);
            String[] tagIds = param.getTags().toArray(new String[param.getTags().size()]);
            Predicate[] tagMatch = messageTagService.findTags(tagIds).stream()
                    .map(t -> builder.equal(tags.get("id"), t.getId())).toArray(Predicate[]::new);
            criteriaHelper.add(builder.or(tagMatch));
        }

        // User
        if (StringUtils.isNotBlank(param.getUsername())) {
            UserType userType = param.getUserType() == null ? UserType.UPDATED_BY : param.getUserType();
            if (userType == UserType.CREATED_BY || userType == UserType.LAST_UPDATED_BY) {
                String joinCol = userType == UserType.CREATED_BY ? "createdBy" : "lastUpdatedBy";
                Join<Message, User> userRoot = msgRoot.join(joinCol, JoinType.LEFT);
                criteriaHelper.equals(userRoot.get("username"), param.getUsername());
            } else {
                Join<Message, MessageHistory> historyRoot = msgRoot.join("history", JoinType.LEFT);
                Join<MessageHistory, User> userRoot = historyRoot.join("user", JoinType.LEFT);
                criteriaHelper.equals(userRoot.get("username"), param.getUsername());
            }
        }

        // Comments
        if (param.getCommentsType() != null) {
            Join<Message, Comment> comments = msgRoot.join("comments", JoinType.LEFT);
            User user = userService.currentUser();
            Predicate own = user != null ? builder.equal(comments.get("user"), user) : null;
            Predicate exists = builder.isNotNull(comments.get("id"));
            Predicate unack = builder.isNull(comments.get("acknowledgedBy"));

            if (user != null && param.getCommentsType() == OWN) {
                criteriaHelper.add(own);
            } else if (user != null && param.getCommentsType() == OWN_UNACK) {
                criteriaHelper.add(builder.and(own, unack));
            } else if (param.getCommentsType() == ANY_UNACK) {
                criteriaHelper.add(builder.and(exists, unack));
            } else if (param.getCommentsType() == ANY) {
                criteriaHelper.add(exists);
            }
        }

        // Refenced messages
        if (StringUtils.isNotBlank(param.getMessageId())) {
            int levels = param.getReferenceLevels() == null ? 1 : param.getReferenceLevels();
            // NB: This is expensive queries - limit the levels
            levels = Math.max(0, Math.min(5, levels));
            // First, find messages referenced by the message ID
            Set<Integer> referencedIds = findReferencedMessageIds(new HashSet<>(), param.getMessageId(), levels);
            // Next, add messages referencing the message ID
            findReferencingMessageIds(referencedIds, param.getMessageId(), levels);
            criteriaHelper.in(msgRoot.get("id"), referencedIds);
        }

        // Determine the fields to fetch
        Join<Message, Area> areaRoot = null;
        Expression<?> treeSortOrder = null;
        List<Selection<?>> fields = new ArrayList<>();
        fields.add(msgRoot.get("id"));
        if (param.sortByEventDate()) {
            fields.add(msgRoot.get("eventDateFrom"));
            fields.add(msgRoot.get("eventDateTo"));
        } else if (param.sortByPublishDate()) {
            fields.add(msgRoot.get("publishDateFrom"));
            fields.add(msgRoot.get("publishDateTo"));
        } else if (param.sortByFollowUpDate()) {
            fields.add(msgRoot.get("followUpDate"));
        } else if (param.sortById()) {
            fields.add(msgRoot.get("year"));
            fields.add(msgRoot.get("number"));
            fields.add(msgRoot.get("publishDateFrom"));
        } else if (param.sortByArea()) {
            areaRoot = msgRoot.join("area", JoinType.LEFT);
            // General messages (without an associated area) should be sorted last
            treeSortOrder = builder.selectCase().when(builder.isNull(areaRoot.get("treeSortOrder")), 999999)
                    .otherwise(areaRoot.get("treeSortOrder"));
            fields.add(treeSortOrder);
            fields.add(msgRoot.get("areaSortOrder"));
            fields.add(msgRoot.get("year"));
            fields.add(msgRoot.get("number"));
        }
        Selection[] f = fields.toArray(new Selection<?>[fields.size()]);

        // Complete the query and fetch the message id's (and fields used for sorting)
        tupleQuery.multiselect(f).distinct(true).where(criteriaHelper.where());

        // Sort the query
        if (param.sortByEventDate()) {
            if (param.getSortOrder() == SortOrder.ASC) {
                tupleQuery.orderBy(builder.asc(msgRoot.get("eventDateFrom")),
                        builder.asc(msgRoot.get("eventDateTo")), builder.asc(msgRoot.get("id")));
            } else {
                tupleQuery.orderBy(builder.desc(msgRoot.get("eventDateFrom")),
                        builder.desc(msgRoot.get("eventDateTo")), builder.desc(msgRoot.get("id")));
            }
        } else if (param.sortByPublishDate()) {
            if (param.getSortOrder() == SortOrder.ASC) {
                tupleQuery.orderBy(builder.asc(msgRoot.get("publishDateFrom")),
                        builder.asc(msgRoot.get("publishDateTo")), builder.asc(msgRoot.get("id")));
            } else {
                tupleQuery.orderBy(builder.desc(msgRoot.get("publishDateFrom")),
                        builder.desc(msgRoot.get("publishDateTo")), builder.desc(msgRoot.get("id")));
            }
        } else if (param.sortByFollowUpDate()) {
            if (param.getSortOrder() == SortOrder.ASC) {
                tupleQuery.orderBy(builder.asc(msgRoot.get("followUpDate")), builder.asc(msgRoot.get("id")));
            } else {
                tupleQuery.orderBy(builder.desc(msgRoot.get("followUpDate")), builder.desc(msgRoot.get("id")));
            }
        } else if (param.sortById()) {
            if (param.getSortOrder() == SortOrder.ASC) {
                tupleQuery.orderBy(builder.asc(msgRoot.get("year")), builder.asc(msgRoot.get("number")),
                        builder.asc(msgRoot.get("publishDateFrom")), builder.asc(msgRoot.get("id")));
            } else {
                tupleQuery.orderBy(builder.desc(msgRoot.get("year")), builder.desc(msgRoot.get("number")),
                        builder.desc(msgRoot.get("publishDateFrom")), builder.desc(msgRoot.get("id")));
            }
        } else if (param.sortByArea()) {
            if (param.getSortOrder() == SortOrder.ASC) {
                tupleQuery.orderBy(builder.asc(treeSortOrder), builder.asc(msgRoot.get("areaSortOrder")),
                        builder.asc(msgRoot.get("year")), builder.asc(msgRoot.get("number")),
                        builder.asc(msgRoot.get("id")));
            } else {
                tupleQuery.orderBy(builder.desc(treeSortOrder), builder.desc(msgRoot.get("areaSortOrder")),
                        builder.desc(msgRoot.get("year")), builder.desc(msgRoot.get("number")),
                        builder.desc(msgRoot.get("id")));
            }
        }

        // Execute the query
        List<Tuple> totalResult = em.createQuery(tupleQuery).getResultList();

        // Register the total result
        result.setTotal(totalResult.size());

        List<Integer> msgIds = totalResult.stream().map(t -> (Integer) t.get(0)).collect(Collectors.toList());

        // Extract and return the paged sub-list
        int startIndex = Math.min(msgIds.size(), param.getPage() * param.getMaxSize());
        int endIndex = Math.min(msgIds.size(), startIndex + param.getMaxSize());
        return msgIds.subList(startIndex, endIndex);
    }

    /**
     * Resolves the IDs of all messages referenced by the given message ID within the given number of levels
     * @param result the result
     * @param messageId the message to find referenced messages for
     * @param levels the levels
     * @return the result
     */
    private Set<Integer> findReferencedMessageIds(Set<Integer> result, String messageId, int levels) {
        for (Message message : findByMessageId(messageId)) {
            result.add(message.getId());
            if (levels > 0) {
                for (Reference reference : message.getReferences()) {
                    findReferencedMessageIds(result, reference.getMessageId(), levels - 1);
                }
            }
        }
        return result;
    }

    /**
     * Resolves the IDs of all messages referencing the given message ID within the given number of levels
     * @param result the result
     * @param messageId the message to find referencing messages for
     * @param levels the levels
     * @return the result
     */
    private Set<Integer> findReferencingMessageIds(Set<Integer> result, String messageId, int levels) {
        for (Message message : findByMessageId(messageId)) {
            findReferencingMessageIds(result, message, levels);
        }
        return result;
    }

    /**
     * Resolves the IDs of all messages referencing the given message ID within the given number of levels
     * @param result the result
     * @param message the message to find referencing messages for
     * @param levels the levels
     * @return the result
     */
    private Set<Integer> findReferencingMessageIds(Set<Integer> result, Message message, int levels) {
        result.add(message.getId());
        if (levels > 0) {
            Set<String> messageIds = new HashSet<>();
            messageIds.add(message.getUid());
            if (message.getShortId() != null) {
                messageIds.add(message.getShortId().toLowerCase());
            }
            em.createNamedQuery("Message.findByReference", Message.class).setParameter("messageIds", messageIds)
                    .getResultList().forEach(msg -> findReferencingMessageIds(result, msg, levels - 1));
        }
        return result;
    }

    /**
     * From the given list of Message UIDs, return the UIDs of the message with the separatePage flag set.
     * Used when printing messages (generating PDFs)
     * @param uids the UIDs of the messages to search
     * @return the UIDs of the message with the separatePage flag set
     */
    public Set<String> getSeparatePageUids(Set<String> uids) {
        if (uids == null || uids.isEmpty()) {
            return Collections.emptySet();
        }
        return em.createNamedQuery("Message.separatePageUids", String.class).setParameter("uids", uids)
                .getResultList().stream().collect(Collectors.toSet());
    }

    /***************************************/
    /** Message History                   **/
    /***************************************/

    /**
     * Saves a history entity containing a snapshot of the message
     *
     * @param message the message to save a snapshot for
     */
    public void saveHistory(Message message) {

        try {
            MessageHistory hist = new MessageHistory();
            hist.setMessage(message);
            hist.setUser(userService.currentUser());
            hist.setStatus(message.getStatus());
            hist.setCreated(new Date());
            hist.setVersion(message.getVersion() + 1);

            // Create a snapshot of the message
            ObjectMapper jsonMapper = new ObjectMapper();
            jsonMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); // Use ISO-8601 format
            DataFilter dataFilter = DataFilter.get().fields("Message.details", "Message.geometry");
            MessageVo snapshot = message.toVo(SystemMessageVo.class, dataFilter);
            hist.compressSnapshot(jsonMapper.writeValueAsString(snapshot));

            saveEntity(hist);

        } catch (Exception e) {
            log.error("Error saving a history entry for message " + message.getId(), e);
            // NB: Don't propagate errors
        }
    }

    /**
     * Returns the message history for the given message ID
     *
     * @param messageId the message ID
     * @return the message history
     */
    public List<MessageHistory> getMessageHistory(Integer messageId) {
        return em.createNamedQuery("MessageHistory.findByMessageId", MessageHistory.class)
                .setParameter("messageId", messageId).getResultList();
    }

    /**
     * Returns the messages most recently edited by the current user
     * within the last month
     *
     * @param maxMessageNo max number of message to return
     * @return the messages most recently edited by the current user
     */
    public List<Message> getMostRecentlyEditedMessages(int maxMessageNo, Status... statuses) {
        User user = userService.currentUser();
        Domain domain = domainService.currentDomain();
        if (user == null || domain == null || domain.getMessageSeries().isEmpty()) {
            return Collections.emptyList();
        }
        // Only check last month
        Calendar cal = Calendar.getInstance();
        cal.add(Calendar.MONTH, -1);

        Set<Status> statusSet = new HashSet<>();
        if (statuses != null && statuses.length > 0) {
            statusSet.addAll(Arrays.asList(statuses));
        } else {
            statusSet.addAll(Arrays.asList(Status.values()));
        }

        return em.createNamedQuery("MessageHistory.findRecentChangesByUser", MessageHistory.class)
                .setParameter("user", user).setParameter("date", cal.getTime())
                .setParameter("messageSeries", domain.getMessageSeries()).setParameter("statuses", statusSet)
                .getResultList().stream().map(MessageHistory::getMessage).distinct().limit(maxMessageNo)
                .collect(Collectors.toList());
    }

    /**
     * Returns the draft messages most recently edited by the current user
     * within the last month
     *
     * @param maxMessageNo max number of draft message to return
     * @return the draft messages most recently edited by the current user
     */
    public List<Message> getMostRecentlyEditedDrafts(int maxMessageNo) {
        return getMostRecentlyEditedMessages(maxMessageNo, Status.DRAFT, Status.VERIFIED);
    }

    /***************************************/
    /** Repo methods                      **/
    /***************************************/

    /**
     * Creates a temporary repository folder for the given message
     * @param copyToTemp whether to copy all message resources to the associated temporary directory or not
     * @param message the message
     */
    public void createTempMessageRepoFolder(SystemMessageVo message, boolean copyToTemp) throws IOException {
        repositoryService.createTempEditRepoFolder(message, copyToTemp);
    }

    /**
     * Update the message repository folder from a temporary repository folder used whilst editing the message
     * @param message the message
     */
    public void updateMessageFromTempRepoFolder(SystemMessageVo message) throws IOException {
        repositoryService.updateRepoFolderFromTempEditFolder(message);
    }
}