alfio.manager.WaitingQueueManager.java Source code

Java tutorial

Introduction

Here is the source code for alfio.manager.WaitingQueueManager.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.manager.plugin.PluginManager;
import alfio.manager.system.ConfigurationManager;
import alfio.model.*;
import alfio.model.modification.TicketReservationModification;
import alfio.model.modification.TicketReservationWithOptionalCodeModification;
import alfio.model.system.Configuration;
import alfio.model.user.Organization;
import alfio.repository.EventRepository;
import alfio.repository.TicketCategoryRepository;
import alfio.repository.TicketRepository;
import alfio.repository.WaitingQueueRepository;
import alfio.repository.user.OrganizationRepository;
import alfio.util.PreReservedTicketDistributor;
import alfio.util.TemplateManager;
import alfio.util.TemplateResource;
import alfio.util.WorkingDaysAdjusters;
import ch.digitalfondue.npjt.AffectedRowCountAndKey;
import lombok.AllArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.commons.lang3.tuple.Triple;
import org.springframework.context.MessageSource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Component;

import java.time.ZonedDateTime;
import java.util.*;
import java.util.function.Supplier;
import java.util.stream.Stream;

import static alfio.model.system.ConfigurationKeys.*;
import static alfio.util.EventUtil.determineAvailableSeats;

@Component
@Log4j2
@AllArgsConstructor
public class WaitingQueueManager {

    private final WaitingQueueRepository waitingQueueRepository;
    private final TicketRepository ticketRepository;
    private final TicketCategoryRepository ticketCategoryRepository;
    private final ConfigurationManager configurationManager;
    private final EventStatisticsManager eventStatisticsManager;
    private final NamedParameterJdbcTemplate jdbc;
    private final NotificationManager notificationManager;
    private final TemplateManager templateManager;
    private final MessageSource messageSource;
    private final OrganizationRepository organizationRepository;
    private final PluginManager pluginManager;
    private final EventRepository eventRepository;
    private final ExtensionManager extensionManager;

    public boolean subscribe(Event event, CustomerName customerName, String email, Integer selectedCategoryId,
            Locale userLanguage) {
        try {
            if (configurationManager.getBooleanConfigValue(
                    Configuration.from(event.getOrganizationId(), event.getId(), STOP_WAITING_QUEUE_SUBSCRIPTIONS),
                    false)) {
                log.info("waiting queue subscription denied for event {} ({})", event.getShortName(),
                        event.getId());
                return false;
            }
            WaitingQueueSubscription.Type subscriptionType = getSubscriptionType(event);
            validateSubscriptionType(event, subscriptionType);
            validateSelectedCategoryId(event.getId(), selectedCategoryId);
            AffectedRowCountAndKey<Integer> key = waitingQueueRepository.insert(event.getId(),
                    customerName.getFullName(), customerName.getFirstName(), customerName.getLastName(), email,
                    ZonedDateTime.now(event.getZoneId()), userLanguage.getLanguage(), subscriptionType,
                    selectedCategoryId);
            notifySubscription(event, customerName, email, userLanguage, subscriptionType);
            pluginManager.handleWaitingQueueSubscription(waitingQueueRepository.loadById(key.getKey()));
            extensionManager.handleWaitingQueueSubscription(waitingQueueRepository.loadById(key.getKey()));
            return true;
        } catch (DuplicateKeyException e) {
            return true;//why are you subscribing twice?
        } catch (Exception e) {
            log.error("error during subscription", e);
            return false;
        }
    }

    private void validateSelectedCategoryId(int eventId, Integer selectedCategoryId) {
        Optional.ofNullable(selectedCategoryId).ifPresent(id -> Validate.isTrue(ticketCategoryRepository
                .findUnboundedOrderByExpirationDesc(eventId).stream().anyMatch(c -> id.equals(c.getId()))));
    }

    private void notifySubscription(Event event, CustomerName name, String email, Locale userLanguage,
            WaitingQueueSubscription.Type subscriptionType) {
        Organization organization = organizationRepository.getById(event.getOrganizationId());
        Map<String, Object> model = TemplateResource.buildModelForWaitingQueueJoined(organization, event, name);
        notificationManager.sendSimpleEmail(event, email,
                messageSource.getMessage("email-waiting-queue.subscribed.subject",
                        new Object[] { event.getDisplayName() }, userLanguage),
                () -> templateManager.renderTemplate(event, TemplateResource.WAITING_QUEUE_JOINED, model,
                        userLanguage));
        if (configurationManager.getBooleanConfigValue(
                Configuration.from(event.getOrganizationId(), event.getId(), ENABLE_WAITING_QUEUE_NOTIFICATION),
                false)) {
            String adminTemplate = messageSource.getMessage("email-waiting-queue.subscribed.admin.text",
                    new Object[] { subscriptionType, event.getDisplayName() }, Locale.ENGLISH);
            notificationManager.sendSimpleEmail(event, organization.getEmail(),
                    messageSource.getMessage("email-waiting-queue.subscribed.admin.subject",
                            new Object[] { event.getDisplayName() }, Locale.ENGLISH),
                    () -> templateManager.renderString(adminTemplate, model, Locale.ENGLISH,
                            TemplateManager.TemplateOutput.TEXT));
        }

    }

    private WaitingQueueSubscription.Type getSubscriptionType(Event event) {
        ZonedDateTime now = ZonedDateTime.now(event.getZoneId());
        return ticketCategoryRepository.findByEventId(event.getId()).stream().findFirst()
                .filter(tc -> now.isBefore(tc.getInception(event.getZoneId())))
                .map(tc -> WaitingQueueSubscription.Type.PRE_SALES).orElse(WaitingQueueSubscription.Type.SOLD_OUT);
    }

    private void validateSubscriptionType(Event event, WaitingQueueSubscription.Type type) {
        if (type == WaitingQueueSubscription.Type.PRE_SALES) {
            Validate.isTrue(configurationManager.getBooleanConfigValue(
                    Configuration.from(event.getOrganizationId(), event.getId(), ENABLE_PRE_REGISTRATION), false),
                    "PRE_SALES Waiting queue is not active");
        } else {
            Validate.isTrue(eventStatisticsManager.noSeatsAvailable().test(event),
                    "SOLD_OUT Waiting queue is not active");
        }
    }

    public List<WaitingQueueSubscription> loadAllSubscriptionsForEvent(int eventId) {
        return waitingQueueRepository.loadAllWaiting(eventId);
    }

    public Optional<WaitingQueueSubscription> updateSubscriptionStatus(int id,
            WaitingQueueSubscription.Status newStatus, WaitingQueueSubscription.Status currentStatus) {
        return Optional.of(waitingQueueRepository.updateStatus(id, newStatus, currentStatus)).filter(i -> i > 0)
                .map(i -> waitingQueueRepository.loadById(id));
    }

    public int countSubscribers(int eventId) {
        return waitingQueueRepository.countWaitingPeople(eventId);
    }

    Stream<Triple<WaitingQueueSubscription, TicketReservationWithOptionalCodeModification, ZonedDateTime>> distributeSeats(
            Event event) {
        int eventId = event.getId();
        List<WaitingQueueSubscription> subscriptions = waitingQueueRepository.loadAllWaiting(eventId);
        int waitingPeople = subscriptions.size();
        int waitingTickets = ticketRepository.countWaiting(eventId);
        if (waitingPeople == 0 && waitingTickets > 0) {
            ticketRepository.revertToFree(eventId);
        } else if (waitingPeople > 0 && waitingTickets > 0) {
            return distributeAvailableSeats(event, waitingPeople, waitingTickets);
        } else if (subscriptions.stream().anyMatch(WaitingQueueSubscription::isPreSales)
                && configurationManager.getBooleanConfigValue(
                        Configuration.from(event.getOrganizationId(), event.getId(), ENABLE_PRE_REGISTRATION),
                        false)) {
            return handlePreReservation(event, waitingPeople);
        }
        return Stream.empty();
    }

    private Stream<Triple<WaitingQueueSubscription, TicketReservationWithOptionalCodeModification, ZonedDateTime>> handlePreReservation(
            Event event, int waitingPeople) {
        List<TicketCategory> ticketCategories = ticketCategoryRepository.findAllTicketCategories(event.getId());
        // Given that this Job runs more than once in a minute, in order to ensure that all the waiting queue subscribers would get a seat *before*
        // all other people, we must process their a little bit before the sale period starts
        Optional<TicketCategory> categoryWithInceptionInFuture = ticketCategories.stream()
                .sorted(TicketCategory.COMPARATOR).findFirst().filter(t -> ZonedDateTime.now(event.getZoneId())
                        .isBefore(t.getInception(event.getZoneId()).minusMinutes(5)));
        int ticketsNeeded = Math.min(waitingPeople, eventRepository.countExistingTickets(event.getId()));
        if (ticketsNeeded > 0) {
            preReserveIfNeeded(event, ticketsNeeded);
            if (!categoryWithInceptionInFuture.isPresent()) {
                return distributeAvailableSeats(event, Ticket.TicketStatus.PRE_RESERVED, () -> ticketsNeeded);
            }
        }
        return Stream.empty();
    }

    private void preReserveIfNeeded(Event event, int ticketsNeeded) {
        int eventId = event.getId();
        int alreadyReserved = ticketRepository.countPreReservedTickets(eventId);
        if (alreadyReserved < ticketsNeeded) {
            preReserveTickets(event, ticketsNeeded, eventId, alreadyReserved);
        }
    }

    private void preReserveTickets(Event event, int ticketsNeeded, int eventId, int alreadyReserved) {
        final int toBeGenerated = Math.abs(alreadyReserved - ticketsNeeded);
        EventStatisticView eventStatisticView = eventRepository.findStatisticsFor(eventId);
        Map<Integer, TicketCategoryStatisticView> ticketCategoriesStats = ticketCategoryRepository
                .findStatisticsForEventIdByCategoryId(eventId);
        List<Pair<Integer, TicketCategoryStatisticView>> collectedTickets = ticketCategoryRepository
                .findAllTicketCategories(eventId).stream().filter(tc -> !tc.isAccessRestricted())
                .sorted(Comparator.comparing(t -> t.getExpiration(event.getZoneId())))
                .map(tc -> Pair.of(
                        determineAvailableSeats(ticketCategoriesStats.get(tc.getId()), eventStatisticView),
                        ticketCategoriesStats.get(tc.getId())))
                .collect(new PreReservedTicketDistributor(toBeGenerated));
        MapSqlParameterSource[] candidates = collectedTickets.stream()
                .flatMap(p -> selectTicketsForPreReservation(eventId, p).stream())
                .map(id -> new MapSqlParameterSource().addValue("id", id)).toArray(MapSqlParameterSource[]::new);
        jdbc.batchUpdate(ticketRepository.preReserveTicket(), candidates);
    }

    private List<Integer> selectTicketsForPreReservation(int eventId,
            Pair<Integer, TicketCategoryStatisticView> p) {
        TicketCategoryStatisticView category = p.getValue();
        Integer amount = p.getKey();
        if (category.isBounded()) {
            return ticketRepository.selectFreeTicketsForPreReservation(eventId, amount, category.getId());
        }
        return ticketRepository.selectNotAllocatedFreeTicketsForPreReservation(eventId, amount);
    }

    private Stream<Triple<WaitingQueueSubscription, TicketReservationWithOptionalCodeModification, ZonedDateTime>> distributeAvailableSeats(
            Event event, int waitingPeople, int waitingTickets) {
        return distributeAvailableSeats(event, Ticket.TicketStatus.RELEASED,
                () -> Math.min(waitingPeople, waitingTickets));
    }

    private Stream<Triple<WaitingQueueSubscription, TicketReservationWithOptionalCodeModification, ZonedDateTime>> distributeAvailableSeats(
            Event event, Ticket.TicketStatus status, Supplier<Integer> availableSeatSupplier) {
        int availableSeats = availableSeatSupplier.get();
        int eventId = event.getId();
        log.debug("processing {} subscribers from waiting queue", availableSeats);
        List<TicketCategory> unboundedCategories = ticketCategoryRepository
                .findUnboundedOrderByExpirationDesc(eventId);
        Iterator<Ticket> tickets = ticketRepository
                .selectWaitingTicketsForUpdate(eventId, status.name(), availableSeats).stream()
                .filter(t -> t.getCategoryId() != null || unboundedCategories.size() > 0).iterator();
        int expirationTimeout = configurationManager.getIntConfigValue(
                Configuration.from(event.getOrganizationId(), event.getId(), WAITING_QUEUE_RESERVATION_TIMEOUT), 4);
        ZonedDateTime expiration = ZonedDateTime.now(event.getZoneId()).plusHours(expirationTimeout)
                .with(WorkingDaysAdjusters.defaultWorkingDays());

        if (!tickets.hasNext()) {
            log.warn("Unable to assign tickets, returning an empty stream");
            return Stream.empty();
        }
        return waitingQueueRepository.loadWaiting(eventId, availableSeats).stream()
                .map(wq -> Pair.of(wq, tickets.next())).map(pair -> {
                    TicketReservationModification ticketReservation = new TicketReservationModification();
                    ticketReservation.setAmount(1);
                    Integer categoryId = Optional.ofNullable(pair.getValue().getCategoryId())
                            .orElseGet(() -> findBestCategory(unboundedCategories, pair.getKey())
                                    .orElseThrow(RuntimeException::new).getId());
                    ticketReservation.setTicketCategoryId(categoryId);
                    return Pair.of(pair.getLeft(), new TicketReservationWithOptionalCodeModification(
                            ticketReservation, Optional.<SpecialPrice>empty()));
                }).map(pair -> Triple.of(pair.getKey(), pair.getValue(), expiration));
    }

    private Optional<TicketCategory> findBestCategory(List<TicketCategory> unboundedCategories,
            WaitingQueueSubscription subscription) {
        Integer selectedCategoryId = subscription.getSelectedCategoryId();
        return unboundedCategories.stream()
                .filter(tc -> selectedCategoryId == null || selectedCategoryId.equals(tc.getId())).findFirst();
    }

    public void fireReservationConfirmed(String reservationId) {
        updateStatus(reservationId, WaitingQueueSubscription.Status.ACQUIRED.toString());
    }

    public void fireReservationExpired(String reservationId) {
        waitingQueueRepository.bulkUpdateExpiredReservations(Collections.singletonList(reservationId));
    }

    public void cleanExpiredReservations(List<String> reservationIds) {
        waitingQueueRepository.bulkUpdateExpiredReservations(reservationIds);
    }

    private void updateStatus(String reservationId, String status) {
        waitingQueueRepository.updateStatusByReservationId(reservationId, status);
    }
}