alfio.manager.NotificationManager.java Source code

Java tutorial

Introduction

Here is the source code for alfio.manager.NotificationManager.java

Source

/**
 * This file is part of alf.io.
 *
 * alf.io is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * alf.io is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with alf.io.  If not, see <http://www.gnu.org/licenses/>.
 */
package alfio.manager;

import alfio.controller.support.TemplateProcessor;
import alfio.manager.support.CustomMessageManager;
import alfio.manager.support.PDFTemplateGenerator;
import alfio.manager.support.PartialTicketTextGenerator;
import alfio.manager.support.TextTemplateGenerator;
import alfio.manager.system.ConfigurationManager;
import alfio.manager.system.Mailer;
import alfio.model.*;
import alfio.model.system.Configuration;
import alfio.model.system.ConfigurationKeys;
import alfio.model.user.Organization;
import alfio.repository.*;
import alfio.repository.user.OrganizationRepository;
import alfio.util.EventUtil;
import alfio.util.Json;
import alfio.util.TemplateManager;
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.gson.*;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.commons.lang3.tuple.Triple;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static alfio.model.EmailMessage.Status.*;

@Component
@Log4j2
public class NotificationManager {

    public static final Clock UTC = Clock.systemUTC();
    private final Mailer mailer;
    private final MessageSource messageSource;
    private final EmailMessageRepository emailMessageRepository;
    private final TransactionTemplate tx;
    private final EventRepository eventRepository;
    private final OrganizationRepository organizationRepository;
    private final ConfigurationManager configurationManager;
    private final Gson gson;

    private final EnumMap<Mailer.AttachmentIdentifier, Function<Map<String, String>, byte[]>> attachmentTransformer;

    @Autowired
    public NotificationManager(Mailer mailer, MessageSource messageSource,
            PlatformTransactionManager transactionManager, EmailMessageRepository emailMessageRepository,
            EventRepository eventRepository, EventDescriptionRepository eventDescriptionRepository,
            OrganizationRepository organizationRepository, ConfigurationManager configurationManager,
            FileUploadManager fileUploadManager, TemplateManager templateManager,
            TicketReservationRepository ticketReservationRepository,
            TicketCategoryRepository ticketCategoryRepository, PassBookManager passBookManager) {
        this.messageSource = messageSource;
        this.mailer = mailer;
        this.emailMessageRepository = emailMessageRepository;
        this.eventRepository = eventRepository;
        this.organizationRepository = organizationRepository;
        this.tx = new TransactionTemplate(transactionManager);
        this.configurationManager = configurationManager;
        GsonBuilder builder = new GsonBuilder();
        builder.registerTypeAdapter(Mailer.Attachment.class, new AttachmentConverter());
        this.gson = builder.create();
        attachmentTransformer = new EnumMap<>(Mailer.AttachmentIdentifier.class);
        attachmentTransformer.put(Mailer.AttachmentIdentifier.CALENDAR_ICS,
                generateICS(eventRepository, eventDescriptionRepository, ticketCategoryRepository));
        attachmentTransformer.put(Mailer.AttachmentIdentifier.RECEIPT_PDF,
                receiptOrInvoiceFactory(eventRepository,
                        payload -> TemplateProcessor.buildReceiptPdf(payload.getLeft(), fileUploadManager,
                                payload.getMiddle(), templateManager, payload.getRight())));
        attachmentTransformer.put(Mailer.AttachmentIdentifier.INVOICE_PDF,
                receiptOrInvoiceFactory(eventRepository,
                        payload -> TemplateProcessor.buildInvoicePdf(payload.getLeft(), fileUploadManager,
                                payload.getMiddle(), templateManager, payload.getRight())));
        attachmentTransformer.put(Mailer.AttachmentIdentifier.PASSBOOK, passBookManager::getPassBook);
        attachmentTransformer.put(Mailer.AttachmentIdentifier.TICKET_PDF,
                generateTicketPDF(eventRepository, organizationRepository, configurationManager, fileUploadManager,
                        templateManager, ticketReservationRepository));
    }

    private static Function<Map<String, String>, byte[]> generateTicketPDF(EventRepository eventRepository,
            OrganizationRepository organizationRepository, ConfigurationManager configurationManager,
            FileUploadManager fileUploadManager, TemplateManager templateManager,
            TicketReservationRepository ticketReservationRepository) {
        return (model) -> {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            Ticket ticket = Json.fromJson(model.get("ticket"), Ticket.class);
            try {
                TicketReservation reservation = ticketReservationRepository
                        .findReservationById(ticket.getTicketsReservationId());
                TicketCategory ticketCategory = Json.fromJson(model.get("ticketCategory"), TicketCategory.class);
                Event event = eventRepository.findById(ticket.getEventId());
                Organization organization = organizationRepository
                        .getById(Integer.valueOf(model.get("organizationId"), 10));
                PDFTemplateGenerator pdfTemplateGenerator = TemplateProcessor.buildPDFTicket(
                        Locale.forLanguageTag(ticket.getUserLanguage()), event, reservation, ticket, ticketCategory,
                        organization, templateManager, fileUploadManager,
                        configurationManager.getShortReservationID(event, ticket.getTicketsReservationId()));
                pdfTemplateGenerator.generate().createPDF(baos);
            } catch (IOException e) {
                log.warn("was not able to generate ticket pdf for ticket with id" + ticket.getId(), e);
            }
            return baos.toByteArray();
        };
    }

    private static Function<Map<String, String>, byte[]> generateICS(EventRepository eventRepository,
            EventDescriptionRepository eventDescriptionRepository,
            TicketCategoryRepository ticketCategoryRepository) {
        return (model) -> {
            Event event;
            Locale locale;
            Integer categoryId;
            if (model.containsKey("eventId")) {
                //legacy branch, now we generate the ics as a reinterpreted ticket
                event = eventRepository.findById(Integer.valueOf(model.get("eventId"), 10));
                locale = Json.fromJson(model.get("locale"), Locale.class);
                categoryId = null;
            } else {
                Ticket ticket = Json.fromJson(model.get("ticket"), Ticket.class);
                event = eventRepository.findById(ticket.getEventId());
                locale = Locale.forLanguageTag(ticket.getUserLanguage());
                categoryId = ticket.getCategoryId();
            }
            TicketCategory category = Optional.ofNullable(categoryId).map(ticketCategoryRepository::getById)
                    .orElse(null);
            String description = eventDescriptionRepository.findDescriptionByEventIdTypeAndLocale(event.getId(),
                    EventDescription.EventDescriptionType.DESCRIPTION, locale.getLanguage()).orElse("");
            return EventUtil.getIcalForEvent(event, category, description).orElse(null);
        };
    }

    private static Function<Map<String, String>, byte[]> receiptOrInvoiceFactory(EventRepository eventRepository,
            Function<Triple<Event, Locale, Map<String, Object>>, Optional<byte[]>> pdfGenerator) {
        return (model) -> {
            String reservationId = model.get("reservationId");
            Event event = eventRepository.findById(Integer.valueOf(model.get("eventId"), 10));
            Locale language = Json.fromJson(model.get("language"), Locale.class);

            Map<String, Object> reservationEmailModel = Json.fromJson(model.get("reservationEmailModel"),
                    new TypeReference<Map<String, Object>>() {
                    });
            //FIXME hack: reservationEmailModel should be a minimal and typed container
            reservationEmailModel.put("event", event);
            Optional<byte[]> receipt = pdfGenerator.apply(Triple.of(event, language, reservationEmailModel));

            if (!receipt.isPresent()) {
                log.warn("was not able to generate the receipt for reservation id " + reservationId + " for locale "
                        + language);
            }
            return receipt.orElse(null);
        };
    }

    public void sendTicketByEmail(Ticket ticket, Event event, Locale locale, PartialTicketTextGenerator textBuilder,
            TicketReservation reservation, TicketCategory ticketCategory) throws IOException {

        Organization organization = organizationRepository.getById(event.getOrganizationId());

        List<Mailer.Attachment> attachments = new ArrayList<>();
        attachments.add(
                CustomMessageManager.generateTicketAttachment(ticket, reservation, ticketCategory, organization));

        String encodedAttachments = encodeAttachments(
                attachments.toArray(new Mailer.Attachment[attachments.size()]));
        String subject = messageSource.getMessage("ticket-email-subject", new Object[] { event.getDisplayName() },
                locale);
        String text = textBuilder.generate(ticket);
        String checksum = calculateChecksum(ticket.getEmail(), encodedAttachments, subject, text);
        String recipient = ticket.getEmail();
        //TODO handle HTML
        tx.execute(status -> emailMessageRepository.insert(event.getId(), recipient, null, subject, text,
                encodedAttachments, checksum, ZonedDateTime.now(UTC)));
    }

    public void sendSimpleEmail(Event event, String recipient, List<String> cc, String subject,
            TextTemplateGenerator textBuilder) {
        sendSimpleEmail(event, recipient, cc, subject, textBuilder, Collections.emptyList());
    }

    public List<String> getCCForEventOrganizer(Event event) {
        Configuration.ConfigurationPathKey key = Configuration.from(event.getOrganizationId(), event.getId(),
                ConfigurationKeys.MAIL_SYSTEM_NOTIFICATION_CC);
        return Stream.of(StringUtils.split(configurationManager.getStringConfigValue(key, ""), ','))
                .filter(Objects::nonNull).map(String::trim).filter(StringUtils::isNotBlank)
                .collect(Collectors.toList());
    }

    public void sendSimpleEmail(Event event, String recipient, String subject, TextTemplateGenerator textBuilder) {
        sendSimpleEmail(event, recipient, Collections.emptyList(), subject, textBuilder);
    }

    public void sendSimpleEmail(Event event, String recipient, String subject, TextTemplateGenerator textBuilder,
            List<Mailer.Attachment> attachments) {
        sendSimpleEmail(event, recipient, Collections.emptyList(), subject, textBuilder, attachments);
    }

    public void sendSimpleEmail(Event event, String recipient, List<String> cc, String subject,
            TextTemplateGenerator textBuilder, List<Mailer.Attachment> attachments) {

        String encodedAttachments = attachments.isEmpty() ? null
                : encodeAttachments(attachments.toArray(new Mailer.Attachment[attachments.size()]));
        String encodedCC = Json.toJson(cc);

        String text = textBuilder.generate();
        String checksum = calculateChecksum(recipient, encodedAttachments, subject, text);
        //in order to minimize the database size, it is worth checking if there is already another message in the table
        Optional<EmailMessage> existing = emailMessageRepository.findByEventIdAndChecksum(event.getId(), checksum);
        if (!existing.isPresent()) {
            emailMessageRepository.insert(event.getId(), recipient, encodedCC, subject, text, encodedAttachments,
                    checksum, ZonedDateTime.now(UTC));
        } else {
            emailMessageRepository.updateStatus(event.getId(), WAITING.name(), existing.get().getId());
        }
    }

    public Pair<Integer, List<LightweightMailMessage>> loadAllMessagesForEvent(int eventId, Integer page,
            String search) {
        final int pageSize = 50;
        int offset = page == null ? 0 : page * pageSize;
        String toSearch = StringUtils.trimToNull(search);
        toSearch = toSearch == null ? null : ("%" + toSearch + "%");
        return Pair.of(emailMessageRepository.countFindByEventId(eventId, toSearch),
                emailMessageRepository.findByEventId(eventId, offset, pageSize, toSearch));
    }

    public Optional<EmailMessage> loadSingleMessageForEvent(int eventId, int messageId) {
        return emailMessageRepository.findByEventIdAndMessageId(eventId, messageId);
    }

    void sendWaitingMessages() {
        Date now = new Date();
        eventRepository.findAllActiveIds(ZonedDateTime.now(UTC)).stream()
                .flatMap(id -> emailMessageRepository.loadIdsWaitingForProcessing(id, now).stream()).distinct()
                .forEach(this::processMessage);
    }

    private void processMessage(int messageId) {
        EmailMessage message = emailMessageRepository.findById(messageId);
        int eventId = message.getEventId();
        int organizationId = eventRepository.findOrganizationIdByEventId(eventId);
        if (message.getAttempts() >= configurationManager.getIntConfigValue(
                Configuration.from(organizationId, eventId, ConfigurationKeys.MAIL_ATTEMPTS_COUNT), 10)) {
            tx.execute(status -> emailMessageRepository.updateStatusAndAttempts(messageId, ERROR.name(),
                    message.getAttempts(), Arrays.asList(IN_PROCESS.name(), WAITING.name(), RETRY.name())));
            log.warn("Message with id " + messageId + " will be discarded");
            return;
        }

        try {
            int result = tx.execute(status -> emailMessageRepository.updateStatus(message.getEventId(),
                    message.getChecksum(), IN_PROCESS.name(), Arrays.asList(WAITING.name(), RETRY.name())));
            if (result > 0) {
                tx.execute(status -> {
                    sendMessage(message);
                    return null;
                });
            } else {
                log.debug(
                        "no messages have been updated on DB for the following criteria: eventId: {}, checksum: {}",
                        message.getEventId(), message.getChecksum());
            }
        } catch (Exception e) {
            tx.execute(status -> emailMessageRepository.updateStatusAndAttempts(message.getId(), RETRY.name(),
                    DateUtils.addMinutes(new Date(), message.getAttempts() + 1), message.getAttempts() + 1,
                    Arrays.asList(IN_PROCESS.name(), WAITING.name(), RETRY.name())));
            log.warn("could not send message: ", e);
        }
    }

    private void sendMessage(EmailMessage message) {
        Event event = eventRepository.findById(message.getEventId());
        mailer.send(event, message.getRecipient(), message.getCc(), message.getSubject(), message.getMessage(),
                Optional.empty(), decodeAttachments(message.getAttachments()));
        emailMessageRepository.updateStatusToSent(message.getEventId(), message.getChecksum(),
                ZonedDateTime.now(UTC), Collections.singletonList(IN_PROCESS.name()));
    }

    private String encodeAttachments(Mailer.Attachment... files) {
        return gson.toJson(files);
    }

    private Mailer.Attachment[] decodeAttachments(String input) {
        if (StringUtils.isBlank(input)) {
            return new Mailer.Attachment[0];
        }
        Mailer.Attachment[] attachments = gson.fromJson(input, Mailer.Attachment[].class);

        Set<Mailer.AttachmentIdentifier> alreadyPresents = Arrays.stream(attachments)
                .map(Mailer.Attachment::getIdentifier).filter(Objects::nonNull).collect(Collectors.toSet());
        //
        List<Mailer.Attachment> toReinterpret = Arrays.stream(attachments)
                .filter(attachment -> attachment.getIdentifier() != null
                        && !attachment.getIdentifier().reinterpretAs().isEmpty())
                .collect(Collectors.toList());

        List<Mailer.Attachment> generated = Arrays.stream(attachments)
                .map(attachment -> this.transformAttachment(attachment, attachment.getIdentifier()))
                .filter(Objects::nonNull).collect(Collectors.toList());

        List<Mailer.Attachment> reinterpreted = new ArrayList<>();
        toReinterpret.forEach(attachment -> attachment.getIdentifier().reinterpretAs().stream()
                .filter(identifier -> !alreadyPresents.contains(identifier))
                .forEach(identifier -> reinterpreted.add(this.transformAttachment(attachment, identifier))));

        generated.addAll(reinterpreted.stream().filter(Objects::nonNull).collect(Collectors.toList()));
        return generated.toArray(new Mailer.Attachment[generated.size()]);
    }

    private Mailer.Attachment transformAttachment(Mailer.Attachment attachment,
            Mailer.AttachmentIdentifier identifier) {
        if (identifier != null) {
            byte[] result = attachmentTransformer.get(identifier).apply(attachment.getModel());
            return result == null ? null
                    : new Mailer.Attachment(identifier.fileName(attachment.getFilename()), result,
                            identifier.contentType(attachment.getContentType()), null, null);
        } else {
            return attachment;
        }
    }

    private static String calculateChecksum(String recipient, String attachments, String subject, String text) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            digest.update(recipient.getBytes(StandardCharsets.UTF_8));
            digest.update(subject.getBytes(StandardCharsets.UTF_8));
            Optional.ofNullable(attachments).ifPresent(v -> digest.update(v.getBytes(StandardCharsets.UTF_8)));
            digest.update(text.getBytes(StandardCharsets.UTF_8));
            return new String(Hex.encode(digest.digest()));
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }

    private static final class AttachmentConverter
            implements JsonSerializer<Mailer.Attachment>, JsonDeserializer<Mailer.Attachment> {

        @Override
        public JsonElement serialize(Mailer.Attachment src, Type typeOfSrc, JsonSerializationContext context) {
            JsonObject obj = new JsonObject();
            obj.addProperty("filename", src.getFilename());
            obj.addProperty("source",
                    src.getSource() != null ? Base64.getEncoder().encodeToString(src.getSource()) : null);
            obj.addProperty("contentType", src.getContentType());
            obj.addProperty("identifier", src.getIdentifier() != null ? src.getIdentifier().name() : null);
            obj.addProperty("model", src.getModel() != null ? Json.toJson(src.getModel()) : null);
            return obj;
        }

        @Override
        public Mailer.Attachment deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
                throws JsonParseException {
            JsonObject jsonObject = json.getAsJsonObject();
            String filename = jsonObject.getAsJsonPrimitive("filename").getAsString();
            byte[] source = jsonObject.has("source")
                    ? Base64.getDecoder().decode(jsonObject.getAsJsonPrimitive("source").getAsString())
                    : null;
            String contentType = jsonObject.getAsJsonPrimitive("contentType").getAsString();
            Mailer.AttachmentIdentifier identifier = jsonObject.has("identifier")
                    ? Mailer.AttachmentIdentifier.valueOf(jsonObject.getAsJsonPrimitive("identifier").getAsString())
                    : null;
            Map<String, String> model = jsonObject.has("model") ? Json.fromJson(
                    jsonObject.getAsJsonPrimitive("model").getAsString(), new TypeReference<Map<String, String>>() {
                    }) : null;
            return new Mailer.Attachment(filename, source, contentType, model, identifier);
        }
    }
}