/**
* 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.EventWithStatistics;
import alfio.model.modification.TicketCategoryWithStatistic;
import alfio.model.modification.TicketReservationModification;
import alfio.model.modification.TicketReservationWithOptionalCodeModification;
import alfio.model.system.Configuration;
import alfio.model.user.Organization;
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.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.beans.factory.annotation.Autowired;
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
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;
@Autowired
public WaitingQueueManager(WaitingQueueRepository waitingQueueRepository,
TicketRepository ticketRepository,
TicketCategoryRepository ticketCategoryRepository,
ConfigurationManager configurationManager,
EventStatisticsManager eventStatisticsManager,
NamedParameterJdbcTemplate jdbc,
NotificationManager notificationManager,
TemplateManager templateManager,
MessageSource messageSource,
OrganizationRepository organizationRepository,
PluginManager pluginManager) {
this.waitingQueueRepository = waitingQueueRepository;
this.ticketRepository = ticketRepository;
this.ticketCategoryRepository = ticketCategoryRepository;
this.configurationManager = configurationManager;
this.eventStatisticsManager = eventStatisticsManager;
this.notificationManager = notificationManager;
this.jdbc = jdbc;
this.templateManager = templateManager;
this.messageSource = messageSource;
this.organizationRepository = organizationRepository;
this.pluginManager = pluginManager;
}
public boolean subscribe(Event event, CustomerName customerName, String email, Integer selectedCategoryId, Locale userLanguage) {
try {
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()));
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 eventStatisticsManager.loadTicketCategories(event).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, event.getAvailableSeats());
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);
EventWithStatistics eventWithStatistics = eventStatisticsManager.fillWithStatistics(event);
List<Pair<Integer, TicketCategoryWithStatistic>> collectedTickets = eventWithStatistics.getTicketCategories().stream()
.filter(tc -> !tc.isAccessRestricted())
.sorted()
.map(tc -> Pair.of(determineAvailableSeats(tc, eventWithStatistics), tc))
.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, TicketCategoryWithStatistic> p) {
TicketCategoryWithStatistic 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);
Iterator<Ticket> tickets = ticketRepository.selectWaitingTicketsForUpdate(eventId, status.name(), availableSeats).iterator();
List<TicketCategory> unboundedCategories = ticketCategoryRepository.findUnboundedOrderByExpirationDesc(eventId);
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);
}
}