/** * 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.location.LocationManager; import alfio.manager.plugin.PluginManager; import alfio.manager.support.CategoryEvaluator; import alfio.manager.system.ConfigurationManager; import alfio.manager.user.UserManager; import alfio.model.*; import alfio.model.PromoCodeDiscount.DiscountType; import alfio.model.Ticket.TicketStatus; import alfio.model.TicketFieldConfiguration.Context; import alfio.model.modification.*; import alfio.model.modification.EventModification.AdditionalField; import alfio.model.result.ErrorCode; import alfio.model.result.Result; import alfio.model.system.Configuration; import alfio.model.system.ConfigurationKeys; import alfio.model.transaction.PaymentProxy; import alfio.model.user.Organization; import alfio.repository.*; import alfio.util.Json; import alfio.util.MonetaryUtil; import ch.digitalfondue.npjt.AffectedRowCountAndKey; import lombok.Data; import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Validate; import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Triple; import org.flywaydb.core.Flyway; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import java.math.BigDecimal; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; import static alfio.util.EventUtil.*; import static alfio.util.OptionalWrapper.optionally; import static java.lang.String.format; import static java.util.Collections.singletonList; import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toList; @Component @Transactional @Log4j2 public class EventManager { private static final Predicate<TicketCategory> IS_CATEGORY_BOUNDED = TicketCategory::isBounded; private final UserManager userManager; private final EventRepository eventRepository; private final EventDescriptionRepository eventDescriptionRepository; private final EventStatisticsManager eventStatisticsManager; private final TicketCategoryRepository ticketCategoryRepository; private final TicketCategoryDescriptionRepository ticketCategoryDescriptionRepository; private final TicketRepository ticketRepository; private final SpecialPriceRepository specialPriceRepository; private final PromoCodeDiscountRepository promoCodeRepository; private final LocationManager locationManager; private final NamedParameterJdbcTemplate jdbc; private final ConfigurationManager configurationManager; private final PluginManager pluginManager; private final TicketFieldRepository ticketFieldRepository; private final EventDeleterRepository eventDeleterRepository; private final AdditionalServiceRepository additionalServiceRepository; private final AdditionalServiceTextRepository additionalServiceTextRepository; private final InvoiceSequencesRepository invoiceSequencesRepository; private final Flyway flyway; @Autowired public EventManager(UserManager userManager, EventRepository eventRepository, EventDescriptionRepository eventDescriptionRepository, EventStatisticsManager eventStatisticsManager, TicketCategoryRepository ticketCategoryRepository, TicketCategoryDescriptionRepository ticketCategoryDescriptionRepository, TicketRepository ticketRepository, SpecialPriceRepository specialPriceRepository, PromoCodeDiscountRepository promoCodeRepository, LocationManager locationManager, NamedParameterJdbcTemplate jdbc, ConfigurationManager configurationManager, PluginManager pluginManager, TicketFieldRepository ticketFieldRepository, EventDeleterRepository eventDeleterRepository, AdditionalServiceRepository additionalServiceRepository, AdditionalServiceTextRepository additionalServiceTextRepository, InvoiceSequencesRepository invoiceSequencesRepository, Flyway flyway) { this.userManager = userManager; this.eventRepository = eventRepository; this.eventDescriptionRepository = eventDescriptionRepository; this.eventStatisticsManager = eventStatisticsManager; this.ticketCategoryRepository = ticketCategoryRepository; this.ticketCategoryDescriptionRepository = ticketCategoryDescriptionRepository; this.ticketRepository = ticketRepository; this.specialPriceRepository = specialPriceRepository; this.promoCodeRepository = promoCodeRepository; this.locationManager = locationManager; this.jdbc = jdbc; this.configurationManager = configurationManager; this.pluginManager = pluginManager; this.ticketFieldRepository = ticketFieldRepository; this.eventDeleterRepository = eventDeleterRepository; this.additionalServiceRepository = additionalServiceRepository; this.additionalServiceTextRepository = additionalServiceTextRepository; this.invoiceSequencesRepository = invoiceSequencesRepository; this.flyway = flyway; } public Event getSingleEvent(String eventName, String username) { final Event event = eventRepository.findByShortName(eventName); checkOwnership(event, username, event.getOrganizationId()); return event; } public Event getSingleEventById(int eventId, String username) { final Event event = eventRepository.findById(eventId); checkOwnership(event, username, event.getOrganizationId()); return event; } public void checkOwnership(Event event, String username, int organizationId) { Validate.isTrue(organizationId == event.getOrganizationId(), "invalid organizationId"); userManager.findUserOrganizations(username) .stream() .filter(o -> o.getId() == organizationId) .findAny() .orElseThrow(IllegalArgumentException::new); } public List<TicketCategory> loadTicketCategories(Event event) { return ticketCategoryRepository.findByEventId(event.getId()); } public Organization loadOrganizer(Event event, String username) { return userManager.findOrganizationById(event.getOrganizationId(), username); } /** * Internal method used by automated jobs * @return */ Organization loadOrganizerUsingSystemPrincipal(Event event) { return loadOrganizer(event, UserManager.ADMIN_USERNAME); } public Event findEventByTicketCategory(TicketCategory ticketCategory) { return eventRepository.findById(ticketCategory.getEventId()); } public Event findEventByAdditionalService(AdditionalService additionalService) { return eventRepository.findById(additionalService.getEventId()); } public void createEvent(EventModification em) { int eventId = insertEvent(em); Event event = eventRepository.findById(eventId); createOrUpdateEventDescription(eventId, em); createAllAdditionalServices(eventId, em.getAdditionalServices(), event.getZoneId()); createAdditionalFields(event, em); createCategoriesForEvent(em, event); createAllTicketsForEvent(event); initPlugins(event); } public void toggleActiveFlag(int id, String username, boolean activate) { Event event = eventRepository.findById(id); checkOwnership(event, username, event.getOrganizationId()); eventRepository.updateEventStatus(id, activate ? Event.Status.PUBLIC : Event.Status.DRAFT); } private void createAllAdditionalServices(int eventId, List<EventModification.AdditionalService> additionalServices, ZoneId zoneId) { Optional.ofNullable(additionalServices) .ifPresent(list -> list.forEach(as -> { AffectedRowCountAndKey<Integer> service = additionalServiceRepository.insert(eventId, Optional.ofNullable(as.getPrice()).map(MonetaryUtil::unitToCents).orElse(0), as.isFixPrice(), as.getOrdinal(), as.getAvailableQuantity(), as.getMaxQtyPerOrder(), as.getInception().toZonedDateTime(zoneId), as.getExpiration().toZonedDateTime(zoneId), as.getVat(), as.getVatType()); as.getTitle().forEach(insertAdditionalServiceDescription(service.getKey())); as.getDescription().forEach(insertAdditionalServiceDescription(service.getKey())); })); } private Consumer<EventModification.AdditionalServiceText> insertAdditionalServiceDescription(int serviceId) { return t -> additionalServiceTextRepository.insert(serviceId, t.getLocale(), t.getType(), t.getValue()); } private void initPlugins(Event event) { pluginManager.installPlugins(event); } private void createOrUpdateEventDescription(int eventId, EventModification em) { eventDescriptionRepository.delete(eventId, EventDescription.EventDescriptionType.DESCRIPTION); Set<String> validLocales = ContentLanguage.findAllFor(em.getLocales()).stream() .map(ContentLanguage::getLanguage) .collect(Collectors.toSet()); Optional.ofNullable(em.getDescription()).ifPresent(descriptions -> descriptions.forEach((locale, description) -> { if (validLocales.contains(locale)) { eventDescriptionRepository.insert(eventId, locale, EventDescription.EventDescriptionType.DESCRIPTION, description); } })); } private void createAdditionalFields(Event event, EventModification em) { if (!CollectionUtils.isEmpty(em.getTicketFields())) { em.getTicketFields().forEach(f -> { insertAdditionalField(event, f, f.getOrder()); }); } } private void insertAdditionalField(Event event, AdditionalField f, int order) { List<String> restrictedValues = Optional.ofNullable(f.getRestrictedValues()).orElseGet(Collections::emptyList).stream().map(EventModification.RestrictedValue::getValue).collect(Collectors.toList()); String serializedRestrictedValues = "select".equals(f.getType()) ? Json.GSON.toJson(restrictedValues) : null; Optional<EventModification.AdditionalService> linkedAdditionalService = Optional.ofNullable(f.getLinkedAdditionalService()); Integer additionalServiceId = linkedAdditionalService.map(as -> Optional.ofNullable(as.getId()).orElseGet(() -> findAdditionalService(event, as))).orElse(-1); Context context = linkedAdditionalService.isPresent() ? Context.ADDITIONAL_SERVICE : Context.ATTENDEE; int configurationId = ticketFieldRepository.insertConfiguration(event.getId(), f.getName(), order, f.getType(), serializedRestrictedValues, f.getMaxLength(), f.getMinLength(), f.isRequired(), context, additionalServiceId).getKey(); f.getDescription().forEach((locale, value) -> ticketFieldRepository.insertDescription(configurationId, locale, Json.GSON.toJson(value))); } private Integer findAdditionalService(Event event, EventModification.AdditionalService as) { ZoneId utc = ZoneId.of("UTC"); int eventId = event.getId(); String checksum = new AdditionalService(0, eventId, as.isFixPrice(), as.getOrdinal(), as.getAvailableQuantity(), as.getMaxQtyPerOrder(), as.getInception().toZonedDateTime(event.getZoneId()).withZoneSameInstant(utc), as.getExpiration().toZonedDateTime(event.getZoneId()).withZoneSameInstant(utc), as.getVat(), as.getVatType(), Optional.ofNullable(as.getPrice()).map(MonetaryUtil::unitToCents).orElse(0)).getChecksum(); return additionalServiceRepository.loadAllForEvent(eventId).stream().filter(as1 -> as1.getChecksum().equals(checksum)).findFirst().map(AdditionalService::getId).orElse(null); } public void updateEventHeader(Event original, EventModification em, String username) { checkOwnership(original, username, em.getOrganizationId()); int eventId = original.getId(); final GeolocationResult geolocation = geolocate(em.getLocation()); final ZoneId zoneId = geolocation.getZoneId(); final ZonedDateTime begin = em.getBegin().toZonedDateTime(zoneId); final ZonedDateTime end = em.getEnd().toZonedDateTime(zoneId); eventRepository.updateHeader(eventId, em.getDisplayName(), em.getWebsiteUrl(), em.getExternalUrl(), em.getTermsAndConditionsUrl(), em.getImageUrl(), em.getFileBlobId(), em.getLocation(), geolocation.getLatitude(), geolocation.getLongitude(), begin, end, geolocation.getTimeZone(), em.getOrganizationId(), em.getLocales()); createOrUpdateEventDescription(eventId, em); if(!original.getBegin().equals(begin) || !original.getEnd().equals(end)) { fixOutOfRangeCategories(em, username, zoneId, end); } } public void updateEventPrices(Event original, EventModification em, String username) { checkOwnership(original, username, em.getOrganizationId()); int eventId = original.getId(); final EventWithStatistics eventWithStatistics = eventStatisticsManager.fillWithStatistics(original); int seatsDifference = em.getAvailableSeats() - original.getAvailableSeats(); if(seatsDifference < 0) { int allocatedSeats = eventWithStatistics.getTicketCategories().stream() .filter(TicketCategoryWithStatistic::isBounded) .mapToInt(TicketCategoryWithStatistic::getMaxTickets) .sum(); if(em.getAvailableSeats() < allocatedSeats) { throw new IllegalArgumentException(format("cannot reduce max tickets to %d. There are already %d tickets allocated. Try updating categories first.", em.getAvailableSeats(), allocatedSeats)); } } String paymentProxies = collectPaymentProxies(em); BigDecimal vat = em.isFreeOfCharge() ? BigDecimal.ZERO : em.getVatPercentage(); eventRepository.updatePrices(em.getCurrency(), em.getAvailableSeats(), em.isVatIncluded(), vat, paymentProxies, eventId, em.getVatStatus(), em.getPriceInCents()); if(seatsDifference != 0) { Event modified = eventRepository.findById(eventId); if(seatsDifference > 0) { final MapSqlParameterSource[] params = generateEmptyTickets(modified, Date.from(ZonedDateTime.now(modified.getZoneId()).toInstant()), seatsDifference).toArray(MapSqlParameterSource[]::new); jdbc.batchUpdate(ticketRepository.bulkTicketInitialization(), params); } else { List<Integer> ids = ticketRepository.selectNotAllocatedTicketsForUpdate(eventId, Math.abs(seatsDifference), singletonList(TicketStatus.FREE.name())); Validate.isTrue(ids.size() == Math.abs(seatsDifference), "cannot lock enough tickets for deletion."); int invalidatedTickets = ticketRepository.invalidateTickets(ids); Validate.isTrue(ids.size() == invalidatedTickets, String.format("error during ticket invalidation: expected %d, got %d", ids.size(), invalidatedTickets)); } } } /** * This method has been modified to use the new Result<T> mechanism. * It will be replaced by {@link #insertCategory(Event, TicketCategoryModification, String)} in the next releases */ public void insertCategory(int eventId, TicketCategoryModification tcm, String username) { final Event event = eventRepository.findById(eventId); Result<Integer> result = insertCategory(event, tcm, username); failIfError(result); } public Result<Integer> insertCategory(Event event, TicketCategoryModification tcm, String username) { return optionally(() -> { checkOwnership(event, username, event.getOrganizationId()); return true; }).map(b -> { int eventId = event.getId(); int sum = ticketCategoryRepository.getTicketAllocation(eventId); int notBoundedTickets = ticketRepository.countFreeTicketsForUnbounded(eventId); int requestedTickets = tcm.isBounded() ? tcm.getMaxTickets() : 1; return new Result.Builder<>(() -> insertCategory(tcm, event)) .addValidation(() -> sum + requestedTickets <= event.getAvailableSeats(), ErrorCode.CategoryError.NOT_ENOUGH_SEATS) .addValidation(() -> requestedTickets <= notBoundedTickets, ErrorCode.CategoryError.ALL_TICKETS_ASSIGNED) .addValidation(() -> tcm.getExpiration().toZonedDateTime(event.getZoneId()).isBefore(event.getEnd()), ErrorCode.CategoryError.EXPIRATION_AFTER_EVENT_END) .build(); }).orElseGet(() -> Result.error(ErrorCode.EventError.ACCESS_DENIED)); } /** * This method has been modified to use the new Result<T> mechanism. * It will be replaced by {@link #updateCategory(int, Event, TicketCategoryModification, String)} in the next releases */ public void updateCategory(int categoryId, int eventId, TicketCategoryModification tcm, String username) { final Event event = eventRepository.findById(eventId); checkOwnership(event, username, event.getOrganizationId()); Result<TicketCategory> result = updateCategory(categoryId, event, tcm, username); failIfError(result); } private <T> void failIfError(Result<T> result) { if(!result.isSuccess()) { Optional<ErrorCode> firstError = result.getErrors().stream().findFirst(); if(firstError.isPresent()) { throw new IllegalArgumentException(firstError.get().getDescription()); } throw new IllegalArgumentException("unknown error"); } } public Result<TicketCategory> updateCategory(int categoryId, Event event, TicketCategoryModification tcm, String username) { checkOwnership(event, username, event.getOrganizationId()); int eventId = event.getId(); final List<TicketCategory> categories = ticketCategoryRepository.findByEventId(eventId); return categories.stream().filter(tc -> tc.getId() == categoryId).findFirst() .map(existing -> new Result.Builder<>(() -> { updateCategory(tcm, event.isFreeOfCharge(), event.getZoneId(), event); return ticketCategoryRepository.getById(categoryId, eventId); }) .addValidation(() -> tcm.getExpiration().toZonedDateTime(event.getZoneId()).isBefore(event.getEnd()), ErrorCode.CategoryError.EXPIRATION_AFTER_EVENT_END) .addValidation(() -> tcm.getMaxTickets() - existing.getMaxTickets() + categories.stream().mapToInt(TicketCategory::getMaxTickets).sum() <= event.getAvailableSeats(), ErrorCode.CategoryError.NOT_ENOUGH_SEATS) .addValidation(() -> tcm.isTokenGenerationRequested() == existing.isAccessRestricted() || ticketRepository.countConfirmedAndPendingTickets(eventId, categoryId) == 0, ErrorCode.custom("", "cannot update category: there are tickets already sold.")) .addValidation(() -> tcm.isBounded() == existing.isBounded() || ticketRepository.countPendingOrReleasedForCategory(eventId, existing.getId()) == 0, ErrorCode.custom("", "It is not safe to change allocation strategy right now because there are pending reservations.")) .addValidation(() -> !existing.isAccessRestricted() || tcm.isBounded() == existing.isAccessRestricted(), ErrorCode.custom("", "Dynamic allocation is not compatible with restricted access")) .addValidation(() -> { if(tcm.isBounded() && !existing.isBounded()) { int newSize = tcm.getMaxTickets(); int confirmed = ticketRepository.countConfirmedForCategory(eventId, existing.getId()); return newSize >= confirmed; } else { return true; } }, ErrorCode.custom("", "Not enough tickets")) .build() ) .orElseGet(() -> Result.error(ErrorCode.CategoryError.NOT_FOUND)); } void fixOutOfRangeCategories(EventModification em, String username, ZoneId zoneId, ZonedDateTime end) { eventStatisticsManager.getSingleEventWithStatistics(em.getShortName(), username).getTicketCategories().stream() .map(tc -> Triple.of(tc, tc.getInception(zoneId), tc.getExpiration(zoneId))) .filter(t -> t.getRight().isAfter(end)) .forEach(t -> fixTicketCategoryDates(end, t.getLeft(), t.getMiddle(), t.getRight())); } private void fixTicketCategoryDates(ZonedDateTime end, TicketCategoryWithStatistic tc, ZonedDateTime inception, ZonedDateTime expiration) { final ZonedDateTime newExpiration = ObjectUtils.min(end, expiration); Objects.requireNonNull(newExpiration); Validate.isTrue(inception.isBefore(newExpiration), format("Cannot fix dates for category \"%s\" (id: %d), try updating that category first.", tc.getName(), tc.getId())); ticketCategoryRepository.fixDates(tc.getId(), inception, newExpiration); } private GeolocationResult geolocate(String location) { Pair<String, String> coordinates = locationManager.geocode(location); return new GeolocationResult(coordinates, locationManager.getTimezone(coordinates)); } public void reallocateTickets(int srcCategoryId, int targetCategoryId, int eventId) { Event event = eventRepository.findById(eventId); reallocateTickets(eventStatisticsManager.loadTicketCategoryWithStats(srcCategoryId, event), Optional.of(ticketCategoryRepository.getById(targetCategoryId, event.getId())), event); } void reallocateTickets(TicketCategoryWithStatistic src, Optional<TicketCategory> target, Event event) { int notSoldTickets = src.getNotSoldTickets(); if(notSoldTickets == 0) { log.debug("since all the ticket have been sold, ticket moving is not needed anymore."); return; } List<Integer> lockedTickets = ticketRepository.selectTicketInCategoryForUpdate(event.getId(), src.getId(), notSoldTickets, singletonList(TicketStatus.FREE.name())); int locked = lockedTickets.size(); if(locked != notSoldTickets) { throw new IllegalStateException(String.format("Expected %d free tickets, got %d.", notSoldTickets, locked)); } ticketCategoryRepository.updateSeatsAvailability(src.getId(), src.getSoldTickets()); if(target.isPresent()) { TicketCategory targetCategory = target.get(); ticketCategoryRepository.updateSeatsAvailability(targetCategory.getId(), targetCategory.getMaxTickets() + locked); ticketRepository.moveToAnotherCategory(lockedTickets, targetCategory.getId(), targetCategory.getSrcPriceCts()); insertTokens(targetCategory, locked); } else { int result = ticketRepository.unbindTicketsFromCategory(event.getId(), src.getId(), lockedTickets); Validate.isTrue(result == locked, String.format("Expected %d modified tickets, got %d.", locked, result)); } specialPriceRepository.cancelExpiredTokens(src.getId()); } public void unbindTickets(String eventName, int categoryId, String username) { Event event = getSingleEvent(eventName, username); Validate.isTrue(ticketCategoryRepository.countUnboundedCategoriesByEventId(event.getId()) > 0, "cannot unbind tickets: there aren't any unbounded categories"); TicketCategoryWithStatistic ticketCategory = eventStatisticsManager.loadTicketCategoryWithStats(categoryId, event); Validate.isTrue(ticketCategory.isBounded(), "cannot unbind tickets from an unbounded category!"); reallocateTickets(ticketCategory, Optional.empty(), event); } MapSqlParameterSource[] prepareTicketsBulkInsertParameters(ZonedDateTime creation, Event event) { //FIXME: the date should be inserted as ZonedDateTime ! Date creationDate = Date.from(creation.toInstant()); List<TicketCategory> categories = ticketCategoryRepository.findByEventId(event.getId()); Stream<MapSqlParameterSource> boundedTickets = categories.stream() .filter(IS_CATEGORY_BOUNDED) .flatMap(tc -> generateTicketsForCategory(tc, event, creationDate, 0)); int existingTickets = categories.stream() .filter(IS_CATEGORY_BOUNDED) .mapToInt(TicketCategory::getMaxTickets) .sum(); if(existingTickets >= event.getAvailableSeats()) { return boundedTickets.toArray(MapSqlParameterSource[]::new); } return Stream.concat(boundedTickets, generateEmptyTickets(event, creationDate, event.getAvailableSeats() - existingTickets)).toArray(MapSqlParameterSource[]::new); } private Stream<MapSqlParameterSource> generateTicketsForCategory(TicketCategory tc, Event event, Date creationDate, int existing) { Optional<TicketCategory> filteredTC = Optional.of(tc).filter(TicketCategory::isBounded); int missingTickets = filteredTC.map(c -> Math.abs(c.getMaxTickets() - existing)).orElseGet(() -> event.getAvailableSeats() - existing); return generateStreamForTicketCreation(missingTickets) .map(ps -> buildTicketParams(event.getId(), creationDate, filteredTC, tc.getSrcPriceCts(), ps)); } private void createCategoriesForEvent(EventModification em, Event event) { boolean freeOfCharge = em.isFreeOfCharge(); ZoneId zoneId = TimeZone.getTimeZone(event.getTimeZone()).toZoneId(); int eventId = event.getId(); int requestedSeats = em.getTicketCategories().stream() .filter(TicketCategoryModification::isBounded) .mapToInt(TicketCategoryModification::getMaxTickets) .sum(); int notAssignedTickets = em.getAvailableSeats() - requestedSeats; Validate.isTrue(notAssignedTickets >= 0, "Total categories' seats cannot be more than the actual event seats"); Validate.isTrue(notAssignedTickets > 0 || em.getTicketCategories().stream().noneMatch(tc -> !tc.isBounded()), "Cannot add an unbounded category if there aren't any free tickets"); em.getTicketCategories().forEach(tc -> { final int price = evaluatePrice(tc.getPriceInCents(), freeOfCharge); final int maxTickets = tc.isBounded() ? tc.getMaxTickets() : 0; final AffectedRowCountAndKey<Integer> category = ticketCategoryRepository.insert(tc.getInception().toZonedDateTime(zoneId), tc.getExpiration().toZonedDateTime(zoneId), tc.getName(), maxTickets, tc.isTokenGenerationRequested(), eventId, tc.isBounded(), price); insertOrUpdateTicketCategoryDescription(category.getKey(), tc, event); if (tc.isTokenGenerationRequested()) { final TicketCategory ticketCategory = ticketCategoryRepository.getById(category.getKey(), event.getId()); final MapSqlParameterSource[] args = prepareTokenBulkInsertParameters(ticketCategory, ticketCategory.getMaxTickets()); jdbc.batchUpdate(specialPriceRepository.bulkInsert(), args); } }); } private Integer insertCategory(TicketCategoryModification tc, Event event) { ZoneId zoneId = event.getZoneId(); int eventId = event.getId(); final int price = evaluatePrice(tc.getPriceInCents(), event.isFreeOfCharge()); final AffectedRowCountAndKey<Integer> category = ticketCategoryRepository.insert(tc.getInception().toZonedDateTime(zoneId), tc.getExpiration().toZonedDateTime(zoneId), tc.getName(), tc.isBounded() ? tc.getMaxTickets() : 0, tc.isTokenGenerationRequested(), eventId, tc.isBounded(), price); TicketCategory ticketCategory = ticketCategoryRepository.getById(category.getKey(), eventId); if(tc.isBounded()) { List<Integer> lockedTickets = ticketRepository.selectNotAllocatedTicketsForUpdate(eventId, ticketCategory.getMaxTickets(), singletonList(TicketStatus.FREE.name())); jdbc.batchUpdate(ticketRepository.bulkTicketUpdate(), lockedTickets.stream().map(id -> new MapSqlParameterSource("id", id).addValue("categoryId", ticketCategory.getId()).addValue("srcPriceCts", ticketCategory.getSrcPriceCts())).toArray(MapSqlParameterSource[]::new)); if(tc.isTokenGenerationRequested()) { insertTokens(ticketCategory); } } insertOrUpdateTicketCategoryDescription(category.getKey(), tc, event); return category.getKey(); } private void insertTokens(TicketCategory ticketCategory) { insertTokens(ticketCategory, ticketCategory.getMaxTickets()); } private void insertTokens(TicketCategory ticketCategory, int requiredTokens) { final MapSqlParameterSource[] args = prepareTokenBulkInsertParameters(ticketCategory, requiredTokens); jdbc.batchUpdate(specialPriceRepository.bulkInsert(), args); } private void insertOrUpdateTicketCategoryDescription(int tcId, TicketCategoryModification tc, Event event) { ticketCategoryDescriptionRepository.delete(tcId); Set<String> eventLang = ContentLanguage.findAllFor(event.getLocales()).stream().map(ContentLanguage::getLanguage).collect(Collectors.toSet()); Optional.ofNullable(tc.getDescription()).ifPresent(descriptions -> descriptions.forEach((locale, desc) -> { if (eventLang.contains(locale)) { ticketCategoryDescriptionRepository.insert(tcId, locale, desc); } })); } private void updateCategory(TicketCategoryModification tc, boolean freeOfCharge, ZoneId zoneId, Event event) { int eventId = event.getId(); final int price = evaluatePrice(tc.getPriceInCents(), freeOfCharge); TicketCategory original = ticketCategoryRepository.getById(tc.getId(), eventId); ticketCategoryRepository.update(tc.getId(), tc.getName(), tc.getInception().toZonedDateTime(zoneId), tc.getExpiration().toZonedDateTime(zoneId), tc.getMaxTickets(), tc.isTokenGenerationRequested(), price); TicketCategory updated = ticketCategoryRepository.getById(tc.getId(), eventId); int addedTickets = 0; if(original.isBounded() ^ tc.isBounded()) { handleTicketAllocationStrategyChange(event, original, tc); } else { addedTickets = updated.getMaxTickets() - original.getMaxTickets(); handleTicketNumberModification(event, original, updated, addedTickets); } handleTokenModification(original, updated, addedTickets); handlePriceChange(event, original, updated); insertOrUpdateTicketCategoryDescription(tc.getId(), tc, event); } private void handleTicketAllocationStrategyChange(Event event, TicketCategory original, TicketCategoryModification updated) { if(updated.isBounded()) { //the ticket allocation strategy has been changed to "bounded", //therefore we have to link the tickets which have not yet been acquired to this category int eventId = event.getId(); int newSize = updated.getMaxTickets(); int confirmed = ticketRepository.countConfirmedForCategory(eventId, original.getId()); int addedTickets = newSize - confirmed; List<Integer> ids = ticketRepository.selectNotAllocatedTicketsForUpdate(eventId, addedTickets, singletonList(TicketStatus.FREE.name())); Validate.isTrue(ids.size() == addedTickets, "not enough tickets"); Validate.isTrue(ticketRepository.moveToAnotherCategory(ids, original.getId(), updated.getPriceInCents()) == ids.size(), "not enough tickets"); } else { reallocateTickets(eventStatisticsManager.loadTicketCategoryWithStats(original.getId(), event), Optional.empty(), event); } ticketCategoryRepository.updateBoundedFlag(original.getId(), updated.isBounded()); } void handlePriceChange(Event event, TicketCategory original, TicketCategory updated) { if(original.getSrcPriceCts() == updated.getSrcPriceCts()) { return; } final List<Integer> ids = ticketRepository.selectTicketInCategoryForUpdate(event.getId(), updated.getId(), updated.getMaxTickets(), singletonList(TicketStatus.FREE.name())); if(ids.size() < updated.getMaxTickets()) { throw new IllegalStateException("Tickets have already been sold (or are in the process of being sold) for this category. Therefore price update is not allowed."); } //there's no need to calculate final price, vat etc, since these values will be update at the time of reservation ticketRepository.updateTicketPrice(updated.getId(), event.getId(), updated.getSrcPriceCts(), 0, 0, 0); } void handleTokenModification(TicketCategory original, TicketCategory updated, int addedTickets) { if(original.isAccessRestricted() ^ updated.isAccessRestricted()) { if(updated.isAccessRestricted()) { final MapSqlParameterSource[] args = prepareTokenBulkInsertParameters(updated, updated.getMaxTickets()); jdbc.batchUpdate(specialPriceRepository.bulkInsert(), args); } else { specialPriceRepository.cancelExpiredTokens(updated.getId()); } } else if(updated.isAccessRestricted() && addedTickets != 0) { if(addedTickets > 0) { jdbc.batchUpdate(specialPriceRepository.bulkInsert(), prepareTokenBulkInsertParameters(updated, addedTickets)); } else { int absDifference = Math.abs(addedTickets); final List<Integer> ids = specialPriceRepository.lockTokens(updated.getId(), absDifference); Validate.isTrue(ids.size() - absDifference == 0, "not enough tokens"); specialPriceRepository.cancelTokens(ids); } } } void handleTicketNumberModification(Event event, TicketCategory original, TicketCategory updated, int addedTickets) { if(addedTickets == 0) { log.debug("ticket handling not required since the number of ticket wasn't modified"); return; } log.debug("modification detected in ticket number. The difference is: {}", addedTickets); if(addedTickets > 0) { //the updated category contains more tickets than the older one List<Integer> lockedTickets = ticketRepository.selectNotAllocatedTicketsForUpdate(event.getId(), addedTickets, singletonList(TicketStatus.FREE.name())); jdbc.batchUpdate(ticketRepository.bulkTicketUpdate(), lockedTickets.stream() .map(id -> new MapSqlParameterSource("id", id).addValue("categoryId", updated.getId()).addValue("srcPriceCts", updated.getSrcPriceCts())) .toArray(MapSqlParameterSource[]::new)); } else { int absDifference = Math.abs(addedTickets); final List<Integer> ids = ticketRepository.lockTicketsToInvalidate(event.getId(), updated.getId(), absDifference); int actualDifference = ids.size(); if(actualDifference < absDifference) { throw new IllegalStateException("Cannot invalidate "+absDifference+" tickets. There are only "+actualDifference+" free tickets"); } ticketRepository.invalidateTickets(ids); final MapSqlParameterSource[] params = generateEmptyTickets(event, Date.from(ZonedDateTime.now(event.getZoneId()).toInstant()), Math.abs(addedTickets)).toArray(MapSqlParameterSource[]::new); jdbc.batchUpdate(ticketRepository.bulkTicketInitialization(), params); } } private MapSqlParameterSource[] prepareTokenBulkInsertParameters(TicketCategory tc, int limit) { return generateStreamForTicketCreation(limit) .map(ps -> { ps.addValue("code", UUID.randomUUID().toString()); ps.addValue("priceInCents", tc.getSrcPriceCts()); ps.addValue("ticketCategoryId", tc.getId()); ps.addValue("status", SpecialPrice.Status.WAITING.name()); return ps; }) .toArray(MapSqlParameterSource[]::new); } private void createAllTicketsForEvent(Event event) { final MapSqlParameterSource[] params = prepareTicketsBulkInsertParameters(ZonedDateTime.now(event.getZoneId()), event); jdbc.batchUpdate(ticketRepository.bulkTicketInitialization(), params); } private int insertEvent(EventModification em) { String paymentProxies = collectPaymentProxies(em); BigDecimal vat = !em.isInternal() || em.isFreeOfCharge() ? BigDecimal.ZERO : em.getVatPercentage(); String privateKey = UUID.randomUUID().toString(); final GeolocationResult result = geolocate(em.getLocation()); String currentVersion = flyway.info().current().getVersion().getVersion(); return eventRepository.insert(em.getShortName(), em.getEventType(), em.getDisplayName(), em.getWebsiteUrl(), em.getExternalUrl(), em.isInternal() ? em.getTermsAndConditionsUrl() : "", em.getImageUrl(), em.getFileBlobId(), em.getLocation(), result.getLatitude(), result.getLongitude(), em.getBegin().toZonedDateTime(result.getZoneId()), em.getEnd().toZonedDateTime(result.getZoneId()), result.getTimeZone(), em.getCurrency(), em.getAvailableSeats(), em.isInternal() && em.isVatIncluded(), vat, paymentProxies, privateKey, em.getOrganizationId(), em.getLocales(), em.getVatStatus(), em.getPriceInCents(), currentVersion, Event.Status.DRAFT).getKey(); } private String collectPaymentProxies(EventModification em) { return em.getAllowedPaymentProxies() .stream() .map(PaymentProxy::name) .collect(joining(",")); } public TicketCategory getTicketCategoryById(int id, int eventId) { return ticketCategoryRepository.getById(id, eventId); } public AdditionalService getAdditionalServiceById(int id, int eventId) { return additionalServiceRepository.getById(id, eventId); } public boolean toggleTicketLocking(String eventName, int categoryId, int ticketId, String username) { Event event = getSingleEvent(eventName, username); checkOwnership(event, username, event.getOrganizationId()); ticketCategoryRepository.findByEventId(event.getId()).stream().filter(tc -> tc.getId() == categoryId).findFirst().orElseThrow(IllegalArgumentException::new); Ticket ticket = ticketRepository.findById(ticketId, categoryId); Validate.isTrue(ticketRepository.toggleTicketLocking(ticketId, categoryId, !ticket.getLockedAssignment()) == 1, "unwanted result from ticket locking"); return true; } public void addPromoCode(String promoCode, Integer eventId, Integer organizationId, ZonedDateTime start, ZonedDateTime end, int discountAmount, DiscountType discountType, List<Integer> categoriesId) { Validate.isTrue(promoCode.length() >= 7, "min length is 7 chars"); Validate.isTrue((eventId != null && organizationId == null) || (eventId == null && organizationId != null), "eventId or organizationId must be not null"); if(DiscountType.PERCENTAGE == discountType) { Validate.inclusiveBetween(0, 100, discountAmount, "percentage discount must be between 0 and 100"); } if(DiscountType.FIXED_AMOUNT == discountType) { Validate.isTrue(discountAmount >= 0, "fixed discount amount cannot be less than zero"); } // categoriesId = Optional.ofNullable(categoriesId).orElse(Collections.emptyList()).stream().filter(Objects::nonNull).collect(toList()); // promoCodeRepository.addPromoCode(promoCode, eventId, organizationId, start, end, discountAmount, discountType.toString(), Json.GSON.toJson(categoriesId)); } public void deletePromoCode(int promoCodeId) { promoCodeRepository.deletePromoCode(promoCodeId); } public void updatePromoCode(String promoCodeName, int eventId, ZonedDateTime start, ZonedDateTime end) { promoCodeRepository.updateEventPromoCode(eventId, promoCodeName, start, end); } public List<PromoCodeDiscountWithFormattedTime> findPromoCodesInEvent(int eventId) { ZoneId zoneId = eventRepository.findById(eventId).getZoneId(); return promoCodeRepository.findAllInEvent(eventId).stream().map((p) -> new PromoCodeDiscountWithFormattedTime(p, zoneId)).collect(toList()); } public String getEventUrl(Event event) { return StringUtils.removeEnd(configurationManager.getRequiredValue(Configuration.from(event.getOrganizationId(), event.getId(), ConfigurationKeys.BASE_URL)), "/") + "/event/" + event.getShortName() + "/"; } public List<TicketCSVInfo> findAllConfirmedTicketsForCSV(String eventName, String username) { Event event = getSingleEvent(eventName, username); checkOwnership(event, username, event.getOrganizationId()); return ticketRepository.findAllConfirmedForCSV(event.getId()); } public List<Event> getPublishedEvents() { return getActiveEventsStream().filter(e -> e.getStatus() == Event.Status.PUBLIC).collect(toList()); } public List<Event> getActiveEvents() { return getActiveEventsStream().collect(toList()); } private Stream<Event> getActiveEventsStream() { return eventRepository.findAll().stream() .filter(e -> e.getEnd().truncatedTo(ChronoUnit.DAYS).plusDays(1).isAfter(ZonedDateTime.now(e.getZoneId()).truncatedTo(ChronoUnit.DAYS))); } public Function<Ticket, Boolean> checkTicketCancellationPrerequisites() { return CategoryEvaluator.ticketCancellationAvailabilityChecker(ticketCategoryRepository); } void resetReleasedTickets(Event event) { int reverted = ticketRepository.revertToFree(event.getId()); if(reverted > 0) { log.debug("Reverted {} tickets to FREE for event {}", reverted, event.getId()); } } public void updateTicketFieldDescriptions(Map<String, TicketFieldDescriptionModification> descriptions) { descriptions.forEach((locale, value) -> { String description = Json.GSON.toJson(value.getDescription()); if(0 == ticketFieldRepository.updateDescription(value.getTicketFieldConfigurationId(), locale, description)) { ticketFieldRepository.insertDescription(value.getTicketFieldConfigurationId(), locale, description); } }); } public void addAdditionalField(Event event, AdditionalField field) { Integer order = ticketFieldRepository.findMaxOrderValue(event.getId()); insertAdditionalField(event, field, order == null ? 0 : order + 1); } public void deleteAdditionalField(int ticketFieldConfigurationId) { ticketFieldRepository.deleteValues(ticketFieldConfigurationId); ticketFieldRepository.deleteDescription(ticketFieldConfigurationId); ticketFieldRepository.deleteField(ticketFieldConfigurationId); } public void swapAdditionalFieldPosition(int eventId, int id1, int id2) { TicketFieldConfiguration field1 = ticketFieldRepository.findById(id1); TicketFieldConfiguration field2 = ticketFieldRepository.findById(id2); Assert.isTrue(eventId == field1.getEventId(), "eventId does not match field1.eventId"); Assert.isTrue(eventId == field2.getEventId(), "eventId does not match field2.eventId"); ticketFieldRepository.updateFieldOrder(id1, field2.getOrder()); ticketFieldRepository.updateFieldOrder(id2, field1.getOrder()); } public void deleteEvent(int eventId, String username) { final Event event = eventRepository.findById(eventId); checkOwnership(event, username, event.getOrganizationId()); eventDeleterRepository.deleteWaitingQueue(eventId); eventDeleterRepository.deletePluginLog(eventId); eventDeleterRepository.deletePluginConfiguration(eventId); eventDeleterRepository.deleteConfigurationEvent(eventId); eventDeleterRepository.deleteConfigurationTicketCategory(eventId); eventDeleterRepository.deleteEmailMessage(eventId); eventDeleterRepository.deleteTicketFieldValue(eventId); eventDeleterRepository.deleteFieldDescription(eventId); eventDeleterRepository.deleteAdditionalServiceFieldValue(eventId); eventDeleterRepository.deleteAdditionalServiceDescriptions(eventId); eventDeleterRepository.deleteAdditionalServiceItems(eventId); eventDeleterRepository.deleteTicketFieldConfiguration(eventId); eventDeleterRepository.deleteAdditionalServices(eventId); eventDeleterRepository.deleteEventMigration(eventId); eventDeleterRepository.deleteSponsorScan(eventId); eventDeleterRepository.deleteTicket(eventId); eventDeleterRepository.deleteReservation(eventId); eventDeleterRepository.deletePromoCode(eventId); eventDeleterRepository.deleteTicketCategoryText(eventId); eventDeleterRepository.deleteTicketCategory(eventId); eventDeleterRepository.deleteEventDescription(eventId); eventDeleterRepository.deleteResources(eventId); eventDeleterRepository.deleteScanAudit(eventId); eventDeleterRepository.deleteEvent(eventId); } @Data private static final class GeolocationResult { private final Pair<String, String> coordinates; private final TimeZone tz; public String getLatitude() { return coordinates.getLeft(); } public String getLongitude() { return coordinates.getRight(); } public String getTimeZone() { return tz.getID(); } public ZoneId getZoneId() { return tz.toZoneId(); } } }