From 7cb68078827dacbb3b83c185c211f80ad0e17765 Mon Sep 17 00:00:00 2001 From: Sylvain Jermini Date: Mon, 23 Jul 2018 10:53:26 +0200 Subject: [PATCH 1/5] initial work for merging whitelist in master --- .../controller/ReservationController.java | 3 +- .../api/admin/GroupApiController.java | 159 ++++++++++++ .../controller/api/support/TicketHelper.java | 11 + src/main/java/alfio/manager/GroupManager.java | 229 +++++++++++++++++ .../manager/TicketReservationManager.java | 40 ++- src/main/java/alfio/model/Audit.java | 3 +- src/main/java/alfio/model/group/Group.java | 39 +++ .../java/alfio/model/group/GroupMember.java | 40 +++ .../java/alfio/model/group/LinkedGroup.java | 57 +++++ .../alfio/model/group/WhitelistedTicket.java | 40 +++ .../modification/GroupMemberModification.java | 37 +++ .../model/modification/GroupModification.java | 46 ++++ .../modification/LinkedGroupModification.java | 50 ++++ .../alfio/repository/GroupRepository.java | 121 +++++++++ .../alfio/repository/TicketRepository.java | 3 + src/main/java/alfio/util/ErrorsCode.java | 1 + src/main/java/alfio/util/Validator.java | 53 ++++ .../db/PGSQL/V22_1.15.5.1__CREATE_GROUP.sql | 67 +++++ .../resources/alfio/i18n/public.properties | 2 + .../resources/alfio/i18n/public_it.properties | 4 +- .../webapp/WEB-INF/templates/admin/index.ms | 4 +- .../partials/configuration/category.html | 63 ++++- .../admin/partials/configuration/event.html | 47 ++++ .../admin/partials/event/detail.html | 7 + .../additional-service/additional-service.js | 4 +- .../feature/configuration/configuration.js | 111 +++++++-- .../resources/js/admin/feature/group/all.html | 21 ++ .../js/admin/feature/group/edit.html | 80 ++++++ .../js/admin/feature/group/groups.js | 231 ++++++++++++++++++ .../js/admin/feature/group/list.html | 7 + .../js/admin/ng-app/admin-application.js | 26 +- .../manager/GroupManagerIntegrationTest.java | 140 +++++++++++ ...cketReservationManagerIntegrationTest.java | 8 +- .../manager/TicketReservationManagerTest.java | 17 +- .../TicketReservationManagerUnitTest.java | 5 +- .../WaitingQueueManagerIntegrationTest.java | 12 +- .../system/DataMigratorIntegrationTest.java | 2 +- 37 files changed, 1738 insertions(+), 52 deletions(-) create mode 100644 src/main/java/alfio/controller/api/admin/GroupApiController.java create mode 100644 src/main/java/alfio/manager/GroupManager.java create mode 100644 src/main/java/alfio/model/group/Group.java create mode 100644 src/main/java/alfio/model/group/GroupMember.java create mode 100644 src/main/java/alfio/model/group/LinkedGroup.java create mode 100644 src/main/java/alfio/model/group/WhitelistedTicket.java create mode 100644 src/main/java/alfio/model/modification/GroupMemberModification.java create mode 100644 src/main/java/alfio/model/modification/GroupModification.java create mode 100644 src/main/java/alfio/model/modification/LinkedGroupModification.java create mode 100644 src/main/java/alfio/repository/GroupRepository.java create mode 100644 src/main/resources/alfio/db/PGSQL/V22_1.15.5.1__CREATE_GROUP.sql create mode 100644 src/main/webapp/resources/js/admin/feature/group/all.html create mode 100644 src/main/webapp/resources/js/admin/feature/group/edit.html create mode 100644 src/main/webapp/resources/js/admin/feature/group/groups.js create mode 100644 src/main/webapp/resources/js/admin/feature/group/list.html create mode 100644 src/test/java/alfio/manager/GroupManagerIntegrationTest.java diff --git a/src/main/java/alfio/controller/ReservationController.java b/src/main/java/alfio/controller/ReservationController.java index 550ebef774..185d13df93 100644 --- a/src/main/java/alfio/controller/ReservationController.java +++ b/src/main/java/alfio/controller/ReservationController.java @@ -593,7 +593,8 @@ public String handleReservation(@PathVariable("eventName") String eventName, final PaymentResult status = ticketReservationManager.confirm(paymentForm.getToken(), paymentForm.getPaypalPayerID(), event, reservationId, ticketReservation.getEmail(), customerName, locale, ticketReservation.getBillingAddress(), ticketReservation.getCustomerReference(), reservationCost, SessionUtil.retrieveSpecialPriceSessionId(request), Optional.ofNullable(paymentForm.getPaymentMethod()), invoiceRequested, ticketReservation.getVatCountryCode(), - ticketReservation.getVatNr(), ticketReservation.getVatStatus(), paymentForm.getTermAndConditionsAccepted(), Optional.ofNullable(paymentForm.getPrivacyPolicyAccepted()).orElse(false)); + ticketReservation.getVatNr(), ticketReservation.getVatStatus(), paymentForm.getTermAndConditionsAccepted(), Optional.ofNullable(paymentForm.getPrivacyPolicyAccepted()).orElse(false), + Collections.emptyMap()); //FIXME if(!status.isSuccessful()) { String errorMessageCode = status.getErrorCode().get(); diff --git a/src/main/java/alfio/controller/api/admin/GroupApiController.java b/src/main/java/alfio/controller/api/admin/GroupApiController.java new file mode 100644 index 0000000000..672fba7d7f --- /dev/null +++ b/src/main/java/alfio/controller/api/admin/GroupApiController.java @@ -0,0 +1,159 @@ +/** + * 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 . + */ +package alfio.controller.api.admin; + +import alfio.manager.EventManager; +import alfio.manager.GroupManager; +import alfio.manager.user.UserManager; +import alfio.model.group.Group; +import alfio.model.group.LinkedGroup; +import alfio.model.modification.GroupModification; +import alfio.model.modification.LinkedGroupModification; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import static alfio.util.OptionalWrapper.optionally; + +@RestController +@RequestMapping("/admin/api/group") +@RequiredArgsConstructor +public class GroupApiController { + + private final GroupManager groupManager; + private final UserManager userManager; + private final EventManager eventManager; + + @GetMapping("/{organizationId}") + public ResponseEntity> loadAllGroupsForOrganization(@PathVariable("organizationId") int organizationId, Principal principal) { + if(notOwner(principal.getName(), organizationId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + return ResponseEntity.ok(groupManager.getAllForOrganization(organizationId)); + } + + @GetMapping("/{organizationId}/detail/{listId}") + public ResponseEntity loadDetail(@PathVariable("organizationId") int organizationId, @PathVariable("listId") int listId, Principal principal) { + if(notOwner(principal.getName(), organizationId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + return groupManager.loadComplete(listId).map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping("/{organizationId}/update/{listId}") + public ResponseEntity updateGroup(@PathVariable("organizationId") int organizationId, + @PathVariable("listId") int listId, + @RequestBody GroupModification modification, + Principal principal) { + if(notOwner(principal.getName(), organizationId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + return groupManager.update(listId, modification).map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping("/{organizationId}/new") + public ResponseEntity createNew(@PathVariable("organizationId") int organizationId, @RequestBody GroupModification request, Principal principal) { + if(notOwner(principal.getName(), organizationId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + if(request.getOrganizationId() != organizationId) { + return ResponseEntity.badRequest().build(); + } + return ResponseEntity.ok(groupManager.createNew(request)); + } + + @GetMapping("/event/{eventName}/all") + public ResponseEntity> findLinked(@PathVariable("eventName") String eventName, + Principal principal) { + return eventManager.getOptionalByName(eventName, principal.getName()) + .map(event -> ResponseEntity.ok(groupManager.getLinksForEvent(event.getId()))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @GetMapping("/event/{eventName}") + public ResponseEntity findActiveGroup(@PathVariable("eventName") String eventName, + Principal principal) { + return eventManager.getOptionalByName(eventName, principal.getName()) + .map(event -> { + Optional configuration = groupManager.getLinksForEvent(event.getId()).stream() + .filter(c -> c.getTicketCategoryId() == null) + .findFirst(); + return configuration.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.noContent().build()); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @GetMapping("/event/{eventName}/category/{categoryId}") + public ResponseEntity findActiveGroup(@PathVariable("eventName") String eventName, + @PathVariable("categoryId") int categoryId, + Principal principal) { + return eventManager.getOptionalByName(eventName, principal.getName()) + .map(event -> { + Optional configuration = groupManager.findLinks(event.getId(), categoryId) + .stream() + .filter(c -> c.getTicketCategoryId() != null && c.getTicketCategoryId() == categoryId) + .findFirst(); + return configuration.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.noContent().build()); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping("/{listId}/link") + public ResponseEntity linkGroup(@PathVariable("listId") int listId, @RequestBody LinkedGroupModification body, Principal principal) { + if(body == null || listId != body.getGroupId()) { + return ResponseEntity.badRequest().build(); + } + + return optionally(() -> eventManager.getSingleEventById(body.getEventId(), principal.getName())) + .map(event -> { + Optional existing = groupManager.getLinksForEvent(event.getId()) + .stream() + .filter(c -> c.getGroupId() == listId && Objects.equals(body.getTicketCategoryId(), c.getTicketCategoryId())) + .findFirst(); + LinkedGroup link; + if(existing.isPresent()) { + link = groupManager.updateLink(existing.get().getId(), body); + } else { + link = groupManager.createLink(listId, event.getId(), body); + } + return ResponseEntity.ok(link.getId()); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{organizationId}/link/{configurationId}") + public ResponseEntity unlinkGroup(@PathVariable("organizationId") int organizationId, @PathVariable("configurationId") int configurationId, Principal principal) { + if(optionally(() -> userManager.findUserByUsername(principal.getName())).filter(u -> userManager.isOwnerOfOrganization(u, organizationId)).isPresent()) { + groupManager.disableLink(configurationId); + return ResponseEntity.ok("OK"); + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + private boolean notOwner(String username, int organizationId) { + return !optionally(() -> userManager.findUserByUsername(username)) + .filter(user -> userManager.isOwnerOfOrganization(user, organizationId)) + .isPresent(); + } + +} diff --git a/src/main/java/alfio/controller/api/support/TicketHelper.java b/src/main/java/alfio/controller/api/support/TicketHelper.java index 0b9101d514..1d06839bce 100644 --- a/src/main/java/alfio/controller/api/support/TicketHelper.java +++ b/src/main/java/alfio/controller/api/support/TicketHelper.java @@ -21,6 +21,7 @@ import alfio.manager.EuVatChecker; import alfio.manager.EuVatChecker.SameCountryValidator; import alfio.manager.FileUploadManager; +import alfio.manager.GroupManager; import alfio.manager.TicketReservationManager; import alfio.manager.support.PartialTicketTextGenerator; import alfio.model.*; @@ -35,6 +36,7 @@ import alfio.util.LocaleUtil; import alfio.util.TemplateManager; import alfio.util.Validator; +import alfio.util.Validator.AdvancedTicketAssignmentValidator; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; @@ -67,6 +69,7 @@ public class TicketHelper { private final TicketFieldRepository ticketFieldRepository; private final AdditionalServiceItemRepository additionalServiceItemRepository; private final EuVatChecker vatChecker; + private final GroupManager groupManager; public List findTicketFieldConfigurationAndValue(Ticket ticket) { @@ -110,7 +113,12 @@ private Triple assignTicket(UpdateTicketOwnerFo final TicketReservation ticketReservation = result.getMiddle(); List fieldConf = ticketFieldRepository.findAdditionalFieldsForEvent(event.getId()); + AdvancedTicketAssignmentValidator advancedValidator = new AdvancedTicketAssignmentValidator(new SameCountryValidator(vatChecker, event.getOrganizationId(), event.getId(), ticketReservation.getId()), + new GroupManager.WhitelistValidator(event.getId(), groupManager)); + + Validator.AdvancedValidationContext context = new Validator.AdvancedValidationContext(updateTicketOwner, fieldConf, t.getCategoryId(), t.getUuid(), formPrefix); ValidationResult validationResult = Validator.validateTicketAssignment(updateTicketOwner, fieldConf, bindingResult, event, formPrefix, new SameCountryValidator(vatChecker, event.getOrganizationId(), event.getId(), ticketReservation.getId())) + .or(Validator.performAdvancedValidation(advancedValidator, context, bindingResult.orElse(null))) .ifSuccess(() -> updateTicketOwner(updateTicketOwner, request, t, event, ticketReservation, userDetails)); return Triple.of(validationResult, event, ticketRepository.findByUUID(t.getUuid())); } @@ -213,6 +221,9 @@ private void updateTicketOwner(UpdateTicketOwnerForm updateTicketOwner, HttpServ getConfirmationTextBuilder(request, event, ticketReservation, t, category), getOwnerChangeTextBuilder(request, t, event), userDetails); + if(t.hasBeenSold() && !groupManager.findLinks(event.getId(), t.getCategoryId()).isEmpty()) { + ticketRepository.forbidReassignment(Collections.singletonList(t.getId())); + } } private PartialTicketTextGenerator getOwnerChangeTextBuilder(HttpServletRequest request, Ticket t, Event event) { diff --git a/src/main/java/alfio/manager/GroupManager.java b/src/main/java/alfio/manager/GroupManager.java new file mode 100644 index 0000000000..c10749be19 --- /dev/null +++ b/src/main/java/alfio/manager/GroupManager.java @@ -0,0 +1,229 @@ +/** + * 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 . + */ +package alfio.manager; + +import alfio.model.Audit; +import alfio.model.Ticket; +import alfio.model.group.Group; +import alfio.model.group.GroupMember; +import alfio.model.group.LinkedGroup; +import alfio.model.modification.GroupMemberModification; +import alfio.model.modification.GroupModification; +import alfio.model.modification.LinkedGroupModification; +import alfio.repository.AuditingRepository; +import alfio.repository.GroupRepository; +import alfio.repository.TicketRepository; +import ch.digitalfondue.npjt.AffectedRowCountAndKey; +import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Validate; +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 java.util.*; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static alfio.model.group.LinkedGroup.MatchType.FULL; +import static alfio.model.group.LinkedGroup.Type.*; +import static java.util.Collections.singletonList; + +@AllArgsConstructor +@Transactional +@Component +@Log4j2 +public class GroupManager { + + private final GroupRepository groupRepository; + private final NamedParameterJdbcTemplate jdbcTemplate; + private final TicketRepository ticketRepository; + private final AuditingRepository auditingRepository; + + public int createNew(GroupModification input) { + Group wl = createNew(input.getName(), input.getDescription(), input.getOrganizationId()); + insertMembers(wl.getId(), input.getItems()); + return wl.getId(); + } + + public Group createNew(String name, String description, int organizationId) { + AffectedRowCountAndKey insert = groupRepository.insert(name, description, organizationId); + return groupRepository.getById(insert.getKey()); + } + + public LinkedGroup createLink(int groupId, + int eventId, + LinkedGroupModification modification) { + Objects.requireNonNull(groupRepository.getById(groupId), "Group not found"); + Validate.isTrue(modification.getType() != LIMITED_QUANTITY || modification.getMaxAllocation() != null, "Missing max allocation"); + AffectedRowCountAndKey configuration = groupRepository.createConfiguration(groupId, eventId, + modification.getTicketCategoryId(), modification.getType(), modification.getMatchType(), modification.getMaxAllocation()); + return groupRepository.getConfiguration(configuration.getKey()); + } + + public LinkedGroup updateLink(int id, LinkedGroupModification modification) { + LinkedGroup original = groupRepository.getConfigurationForUpdate(id); + if(requiresCleanState(modification, original)) { + Validate.isTrue(groupRepository.countWhitelistedTicketsForConfiguration(original.getId()) == 0, "Cannot update as there are already confirmed tickets."); + } + groupRepository.updateConfiguration(id, modification.getGroupId(), original.getEventId(), modification.getTicketCategoryId(), modification.getType(), modification.getMatchType(), modification.getMaxAllocation()); + return groupRepository.getConfiguration(id); + } + + private boolean requiresCleanState(LinkedGroupModification modification, LinkedGroup original) { + return (original.getType() == UNLIMITED && modification.getType() != UNLIMITED) + || original.getGroupId() != modification.getGroupId() + || (modification.getType() == LIMITED_QUANTITY && modification.getMaxAllocation() != null && original.getMaxAllocation() != null && modification.getMaxAllocation().compareTo(original.getMaxAllocation()) < 0); + } + + public boolean isGroupLinked(int eventId, int categoryId) { + return CollectionUtils.isNotEmpty(findLinks(eventId, categoryId)); + } + + public List getAllForOrganization(int organizationId) { + return groupRepository.getAllForOrganization(organizationId); + } + + public Optional loadComplete(int id) { + return groupRepository.getOptionalById(id) + .map(wl -> { + List items = groupRepository.getItems(wl.getId()).stream().map(i -> new GroupMemberModification(i.getId(), i.getValue(), i.getDescription())).collect(Collectors.toList()); + return new GroupModification(wl.getId(), wl.getName(), wl.getDescription(), wl.getOrganizationId(), items); + }); + } + + public Optional findById(int groupId, int organizationId) { + return groupRepository.getOptionalById(groupId).filter(w -> w.getOrganizationId() == organizationId); + } + + public boolean isAllowed(String value, int eventId, int categoryId) { + + List configurations = findLinks(eventId, categoryId); + if(CollectionUtils.isEmpty(configurations)) { + return true; + } + LinkedGroup configuration = configurations.get(0); + return getMatchingMember(configuration, value).isPresent(); + } + + public List getLinksForEvent(int eventId) { + return groupRepository.findActiveConfigurationsForEvent(eventId); + } + + public List findLinks(int eventId, int categoryId) { + return groupRepository.findActiveConfigurationsFor(eventId, categoryId); + } + + public int insertMembers(int groupId, List members) { + MapSqlParameterSource[] params = members.stream() + .map(i -> new MapSqlParameterSource("groupId", groupId).addValue("value", i.getValue()).addValue("description", i.getDescription())) + .toArray(MapSqlParameterSource[]::new); + return Arrays.stream(jdbcTemplate.batchUpdate(groupRepository.insertItemTemplate(), params)).sum(); + } + + boolean acquireMemberForTicket(Ticket ticket, String email) { + List configurations = findLinks(ticket.getEventId(), ticket.getCategoryId()); + if(CollectionUtils.isEmpty(configurations)) { + return true; + } + LinkedGroup configuration = configurations.get(0); + Optional optionalItem = getMatchingMember(configuration, StringUtils.defaultString(StringUtils.trimToNull(ticket.getEmail()), email)); + if(!optionalItem.isPresent()) { + return false; + } + GroupMember item = optionalItem.get(); + boolean preventDuplication = configuration.getType() == ONCE_PER_VALUE; + boolean limitAssignments = preventDuplication || configuration.getType() == LIMITED_QUANTITY; + if(limitAssignments) { + //reload and lock configuration + configuration = groupRepository.getConfigurationForUpdate(configuration.getId()); + int existing = groupRepository.countExistingWhitelistedTickets(item.getId(), configuration.getId()); + int expected = preventDuplication ? 1 : Optional.ofNullable(configuration.getMaxAllocation()).orElse(0); + if(existing >= expected) { + return false; + } + } + groupRepository.insertWhitelistedTicket(item.getId(), configuration.getId(), ticket.getId(), preventDuplication ? true : null); + Map modifications = new HashMap<>(); + modifications.put("itemId", item.getId()); + modifications.put("configurationId", configuration.getId()); + modifications.put("ticketId", ticket.getId()); + auditingRepository.insert(ticket.getTicketsReservationId(), null, ticket.getEventId(), Audit.EventType.GROUP_MEMBER_ACQUIRED, new Date(), Audit.EntityType.TICKET, ticket.getUuid(), singletonList(modifications)); + return true; + } + + private Optional getMatchingMember(LinkedGroup configuration, String email) { + String trimmed = StringUtils.trimToEmpty(email); + Optional exactMatch = groupRepository.findItemByValueExactMatch(configuration.getGroupId(), trimmed); + if(exactMatch.isPresent() || configuration.getMatchType() == FULL) { + return exactMatch; + } + String partial = StringUtils.substringAfterLast(trimmed, "@"); + return partial.length() > 0 ? groupRepository.findItemEndsWith(configuration.getId(), configuration.getGroupId(), "%@"+partial) : Optional.empty(); + } + + public void deleteWhitelistedTicketsForReservation(String reservationId) { + List tickets = ticketRepository.findTicketsInReservation(reservationId).stream().map(Ticket::getId).collect(Collectors.toList()); + if(!tickets.isEmpty()) { + int result = groupRepository.deleteExistingWhitelistedTickets(tickets); + log.trace("deleted {} whitelisted tickets for reservation {}", result, reservationId); + } + } + + public void disableLink(int linkId) { + Validate.isTrue(groupRepository.disableConfiguration(linkId) == 1, "Error while disabling link"); + } + + public Optional update(int listId, GroupModification modification) { + + if(!groupRepository.getOptionalById(listId).isPresent() || CollectionUtils.isEmpty(modification.getItems())) { + return Optional.empty(); + } + + List existingItems = groupRepository.getItems(listId); + List notPresent = modification.getItems().stream() + .filter(i -> i.getId() == null && existingItems.stream().noneMatch(ali -> ali.getValue().equals(i.getValue()))) + .collect(Collectors.toList()); + if(!notPresent.isEmpty()) { + insertMembers(listId, notPresent); + } + groupRepository.update(listId, modification.getName(), modification.getDescription()); + return loadComplete(listId); + } + + @RequiredArgsConstructor + public static class WhitelistValidator implements Predicate { + + private final int eventId; + private final GroupManager groupManager; + + @Override + public boolean test(WhitelistValidationItem item) { + return groupManager.isAllowed(item.value, eventId, item.categoryId); + } + } + + @RequiredArgsConstructor + public static class WhitelistValidationItem { + private final int categoryId; + private final String value; + } +} diff --git a/src/main/java/alfio/manager/TicketReservationManager.java b/src/main/java/alfio/manager/TicketReservationManager.java index de9d96bd54..d57558aaab 100644 --- a/src/main/java/alfio/manager/TicketReservationManager.java +++ b/src/main/java/alfio/manager/TicketReservationManager.java @@ -32,6 +32,7 @@ import alfio.model.decorator.AdditionalServiceItemPriceContainer; import alfio.model.decorator.AdditionalServicePriceContainer; import alfio.model.decorator.TicketPriceContainer; +import alfio.model.group.LinkedGroup; import alfio.model.modification.ASReservationWithOptionalCodeModification; import alfio.model.modification.AdditionalServiceReservationModification; import alfio.model.modification.TicketReservationWithOptionalCodeModification; @@ -121,6 +122,7 @@ public class TicketReservationManager { private final UserRepository userRepository; private final ExtensionManager extensionManager; private final TicketSearchRepository ticketSearchRepository; + private final GroupManager groupManager; public static class NotEnoughTicketsException extends RuntimeException { @@ -160,7 +162,8 @@ public TicketReservationManager(EventRepository eventRepository, InvoiceSequencesRepository invoiceSequencesRepository, AuditingRepository auditingRepository, UserRepository userRepository, - ExtensionManager extensionManager, TicketSearchRepository ticketSearchRepository) { + ExtensionManager extensionManager, TicketSearchRepository ticketSearchRepository, + GroupManager groupManager) { this.eventRepository = eventRepository; this.organizationRepository = organizationRepository; this.ticketRepository = ticketRepository; @@ -186,6 +189,7 @@ public TicketReservationManager(EventRepository eventRepository, this.userRepository = userRepository; this.extensionManager = extensionManager; this.ticketSearchRepository = ticketSearchRepository; + this.groupManager = groupManager; } /** @@ -342,8 +346,14 @@ public PaymentResult confirm(String gatewayToken, String payerId, Event event, S String email, CustomerName customerName, Locale userLanguage, String billingAddress, String customerReference, TotalPrice reservationCost, Optional specialPriceSessionId, Optional method, boolean invoiceRequested, String vatCountryCode, String vatNr, PriceContainer.VatStatus vatStatus, - boolean tcAccepted, boolean privacyPolicyAccepted) { + boolean tcAccepted, boolean privacyPolicyAccepted, Map ticketEmails) { PaymentProxy paymentProxy = evaluatePaymentProxy(method, reservationCost); + + if(!acquiregroupMembers(reservationId, event, ticketEmails)) { + groupManager.deleteWhitelistedTicketsForReservation(reservationId); + return PaymentResult.unsuccessful("error.STEP2_WHITELIST"); + } + if(!initPaymentProcess(reservationCost, paymentProxy, reservationId, email, customerName, userLanguage, billingAddress, customerReference)) { return PaymentResult.unsuccessful("error.STEP2_UNABLE_TO_TRANSITION"); } @@ -433,6 +443,21 @@ private boolean initPaymentProcess(TotalPrice reservationCost, PaymentProxy paym return true; } + private boolean acquiregroupMembers(String reservationId, Event event, Map ticketEmails) { + int eventId = event.getId(); + List linkedGroups = groupManager.getLinksForEvent(eventId); + if(!linkedGroups.isEmpty()) { + List ticketsInReservation = ticketRepository.findTicketsInReservation(reservationId); + return requiresNewTransactionTemplate.execute(status -> + ticketsInReservation + .stream() + .filter(ticket -> linkedGroups.stream().anyMatch(c -> c.getTicketCategoryId() == null || c.getTicketCategoryId().equals(ticket.getCategoryId()))) + .map(t -> groupManager.acquireMemberForTicket(t, ticketEmails.get(t.getUuid()))) + .reduce(true, Boolean::logicalAnd)); + } + return true; + } + public void confirmOfflinePayment(Event event, String reservationId, String username) { TicketReservation ticketReservation = findById(reservationId).orElseThrow(IllegalArgumentException::new); ticketReservationRepository.lockReservationForUpdate(reservationId); @@ -672,12 +697,15 @@ private void acquireItems(TicketStatus ticketStatus, AdditionalServiceItemStatus String userLanguage, String billingAddress, String customerReference, int eventId) { Map preUpdateTicket = ticketRepository.findTicketsInReservation(reservationId).stream().collect(toMap(Ticket::getId, Function.identity())); int updatedTickets = ticketRepository.updateTicketsStatusWithReservationId(reservationId, ticketStatus.toString()); - Map postUpdateTicket = ticketRepository.findTicketsInReservation(reservationId).stream().collect(toMap(Ticket::getId, Function.identity())); + List ticketsInReservation = ticketRepository.findTicketsInReservation(reservationId); + Map postUpdateTicket = ticketsInReservation.stream().collect(toMap(Ticket::getId, Function.identity())); postUpdateTicket.forEach((id, ticket) -> { auditUpdateTicket(preUpdateTicket.get(id), Collections.emptyMap(), ticket, Collections.emptyMap(), eventId); }); + Set categoriesId = ticketsInReservation.stream().map(Ticket::getCategoryId).collect(Collectors.toSet()); + int updatedAS = additionalServiceItemRepository.updateItemsStatusWithReservationUUID(reservationId, asStatus); Validate.isTrue(updatedTickets + updatedAS > 0, "no items have been updated"); specialPriceRepository.updateStatusForReservation(singletonList(reservationId), Status.TAKEN.toString()); @@ -983,6 +1011,7 @@ private void cancelReservation(String reservationId, boolean expired) { int updatedTickets = ticketRepository.findTicketsInReservation(reservationId).stream().mapToInt(t -> ticketRepository.releaseExpiredTicket(reservationId, event.getId(), t.getId())).sum(); Validate.isTrue(updatedTickets + updatedAS > 0, "no items have been updated"); waitingQueueManager.fireReservationExpired(reservationId); + groupManager.deleteWhitelistedTicketsForReservation(reservationId); deleteReservation(event, reservationId, expired); auditingRepository.insert(reservationId, null, event.getId(), expired ? Audit.EventType.CANCEL_RESERVATION_EXPIRED : Audit.EventType.CANCEL_RESERVATION, new Date(), Audit.EntityType.RESERVATION, reservationId); } @@ -1058,6 +1087,11 @@ public void updateTicketOwner(Ticket ticket, Optional userDetails) { Ticket preUpdateTicket = ticketRepository.findByUUID(ticket.getUuid()); + if(preUpdateTicket.getLockedAssignment()) { + log.warn("trying to update assignee for a locked ticket ({})", preUpdateTicket.getId()); + return; + } + Map preUpdateTicketFields = ticketFieldRepository.findAllByTicketId(ticket.getId()).stream().collect(Collectors.toMap(TicketFieldValue::getName, TicketFieldValue::getValue)); String newEmail = updateTicketOwner.getEmail().trim(); diff --git a/src/main/java/alfio/model/Audit.java b/src/main/java/alfio/model/Audit.java index 8fd7d4efd1..abd3bf9737 100644 --- a/src/main/java/alfio/model/Audit.java +++ b/src/main/java/alfio/model/Audit.java @@ -51,7 +51,8 @@ public enum EventType { TERMS_CONDITION_ACCEPTED, PRIVACY_POLICY_ACCEPTED, VAT_VALIDATION_SUCCESSFUL, - VAT_FORMAL_VALIDATION_SUCCESSFUL + VAT_FORMAL_VALIDATION_SUCCESSFUL, + GROUP_MEMBER_ACQUIRED } private final String reservationId; diff --git a/src/main/java/alfio/model/group/Group.java b/src/main/java/alfio/model/group/Group.java new file mode 100644 index 0000000000..f9295dec0c --- /dev/null +++ b/src/main/java/alfio/model/group/Group.java @@ -0,0 +1,39 @@ +/** + * 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 . + */ +package alfio.model.group; + +import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; +import lombok.Getter; + +@Getter +public class Group { + private final int id; + private final String name; + private final String description; + private final Integer organizationId; + + + public Group(@Column("id") int id, + @Column("name") String name, + @Column("description") String description, + @Column("organization_id_fk") Integer organizationId) { + this.id = id; + this.name = name; + this.description = description; + this.organizationId = organizationId; + } +} diff --git a/src/main/java/alfio/model/group/GroupMember.java b/src/main/java/alfio/model/group/GroupMember.java new file mode 100644 index 0000000000..8fb71b4957 --- /dev/null +++ b/src/main/java/alfio/model/group/GroupMember.java @@ -0,0 +1,40 @@ +/** + * 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 . + */ +package alfio.model.group; + +import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; +import lombok.Getter; + +@Getter +public class GroupMember { + + private final int id; + private final int groupId; + private final String value; + private final String description; + + + public GroupMember(@Column("id") int id, + @Column("a_group_id_fk") int groupId, + @Column("value") String value, + @Column("description") String description) { + this.id = id; + this.groupId = groupId; + this.value = value; + this.description = description; + } +} diff --git a/src/main/java/alfio/model/group/LinkedGroup.java b/src/main/java/alfio/model/group/LinkedGroup.java new file mode 100644 index 0000000000..3f06b0c452 --- /dev/null +++ b/src/main/java/alfio/model/group/LinkedGroup.java @@ -0,0 +1,57 @@ +/** + * 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 . + */ +package alfio.model.group; + +import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; +import lombok.Getter; + +@Getter +public class LinkedGroup { + + + public enum Type { + ONCE_PER_VALUE, LIMITED_QUANTITY, UNLIMITED + } + + public enum MatchType { + FULL, EMAIL_DOMAIN + } + + private final int id; + private final int groupId; + private final Integer eventId; + private final Integer ticketCategoryId; + private final Type type; + private final MatchType matchType; + private final Integer maxAllocation; + + public LinkedGroup(@Column("id") int id, + @Column("a_group_id_fk") int groupId, + @Column("event_id_fk") Integer eventId, + @Column("ticket_category_id_fk") Integer ticketCategoryId, + @Column("type") Type type, + @Column("match_type") MatchType matchType, + @Column("max_allocation") Integer maxAllocation) { + this.id = id; + this.groupId = groupId; + this.eventId = eventId; + this.ticketCategoryId = ticketCategoryId; + this.type = type; + this.matchType = matchType; + this.maxAllocation = maxAllocation; + } +} diff --git a/src/main/java/alfio/model/group/WhitelistedTicket.java b/src/main/java/alfio/model/group/WhitelistedTicket.java new file mode 100644 index 0000000000..d5561a380a --- /dev/null +++ b/src/main/java/alfio/model/group/WhitelistedTicket.java @@ -0,0 +1,40 @@ +/** + * 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 . + */ +package alfio.model.group; + +import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; +import lombok.Getter; + +@Getter +public class WhitelistedTicket { + + private final int groupMemberId; + private final int groupLinkId; + private final int ticketId; + private final boolean requiresUniqueValue; + + public WhitelistedTicket(@Column("group_member_id_fk") int groupMemberId, + @Column("group_link_id_fk") int groupLinkId, + @Column("ticket_id_fk") int ticketId, + @Column("requires_unique_value") Boolean requiresUniqueValue) { + this.groupMemberId = groupMemberId; + this.groupLinkId = groupLinkId; + this.ticketId = ticketId; + this.requiresUniqueValue = requiresUniqueValue != null ? requiresUniqueValue : false; + } + +} diff --git a/src/main/java/alfio/model/modification/GroupMemberModification.java b/src/main/java/alfio/model/modification/GroupMemberModification.java new file mode 100644 index 0000000000..10e67b06d0 --- /dev/null +++ b/src/main/java/alfio/model/modification/GroupMemberModification.java @@ -0,0 +1,37 @@ +/** + * 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 . + */ +package alfio.model.modification; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class GroupMemberModification { + private final Integer id; + private final String value; + private final String description; + + @JsonCreator + public GroupMemberModification(@JsonProperty("id") Integer id, + @JsonProperty("value") String value, + @JsonProperty("description") String description) { + this.id = id; + this.value = value; + this.description = description; + } +} diff --git a/src/main/java/alfio/model/modification/GroupModification.java b/src/main/java/alfio/model/modification/GroupModification.java new file mode 100644 index 0000000000..8135d19ee3 --- /dev/null +++ b/src/main/java/alfio/model/modification/GroupModification.java @@ -0,0 +1,46 @@ +/** + * 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 . + */ +package alfio.model.modification; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +import java.util.List; + +@Getter +public class GroupModification { + private final Integer id; + private final String name; + private final String description; + private final int organizationId; + private final List items; + + + @JsonCreator + public GroupModification(@JsonProperty("id") Integer id, + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("organizationId") int organizationId, + @JsonProperty("items") List items) { + this.id = id; + this.name = name; + this.description = description; + this.organizationId = organizationId; + this.items = items; + } +} diff --git a/src/main/java/alfio/model/modification/LinkedGroupModification.java b/src/main/java/alfio/model/modification/LinkedGroupModification.java new file mode 100644 index 0000000000..95d0d4eff7 --- /dev/null +++ b/src/main/java/alfio/model/modification/LinkedGroupModification.java @@ -0,0 +1,50 @@ +/** + * 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 . + */ +package alfio.model.modification; + +import alfio.model.group.LinkedGroup; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class LinkedGroupModification { + private final Integer id; + private final int groupId; + private final int eventId; + private final Integer ticketCategoryId; + private final LinkedGroup.Type type; + private final LinkedGroup.MatchType matchType; + private final Integer maxAllocation; + + @JsonCreator + public LinkedGroupModification(@JsonProperty("id") Integer id, + @JsonProperty("groupId") int groupId, + @JsonProperty("eventId") int eventId, + @JsonProperty("ticketCategoryId") Integer ticketCategoryId, + @JsonProperty("type") LinkedGroup.Type type, + @JsonProperty("matchType") LinkedGroup.MatchType matchType, + @JsonProperty("maxAllocation") Integer maxAllocation) { + this.id = id; + this.groupId = groupId; + this.eventId = eventId; + this.ticketCategoryId = ticketCategoryId; + this.type = type; + this.matchType = matchType; + this.maxAllocation = maxAllocation; + } +} diff --git a/src/main/java/alfio/repository/GroupRepository.java b/src/main/java/alfio/repository/GroupRepository.java new file mode 100644 index 0000000000..3016309eab --- /dev/null +++ b/src/main/java/alfio/repository/GroupRepository.java @@ -0,0 +1,121 @@ +/** + * 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 . + */ +package alfio.repository; + +import alfio.model.group.Group; +import alfio.model.group.GroupMember; +import alfio.model.group.LinkedGroup; +import alfio.model.group.WhitelistedTicket; +import ch.digitalfondue.npjt.*; + +import java.util.List; +import java.util.Optional; + +@QueryRepository +public interface GroupRepository { + + String BY_EVENT_ID = "select * from group_link_active where event_id_fk = :eventId"; + + @Query("insert into a_group(name, description, organization_id_fk) values(:name, :description, :orgId)") + @AutoGeneratedKey("id") + AffectedRowCountAndKey insert(@Bind("name") String name, + @Bind("description") String description, + @Bind("orgId") int organizationId); + + @Query("select * from a_group where id = :id") + Group getById(@Bind("id") int id); + + @Query("update a_group set name = :name, description = :description where id = :id") + int update(@Bind("id") int id, @Bind("name") String name, @Bind("description") String description); + + @Query("select * from a_group where id = :id") + Optional getOptionalById(@Bind("id") int id); + + @Query("select * from a_group where organization_id_fk = :organizationId") + List getAllForOrganization(@Bind("organizationId") int organizationId); + + @Query("insert into group_link(a_group_id_fk, event_id_fk, ticket_category_id_fk, type, match_type, max_allocation)" + + " values(:groupId, :eventId, :ticketCategoryId, :type, :matchType, :maxAllocation)") + @AutoGeneratedKey("id") + AffectedRowCountAndKey createConfiguration(@Bind("groupId") int groupId, + @Bind("eventId") int eventId, + @Bind("ticketCategoryId") Integer ticketCategoryId, + @Bind("type") LinkedGroup.Type type, + @Bind("matchType") LinkedGroup.MatchType matchType, + @Bind("maxAllocation") Integer maxAllocation); + + @Query(type = QueryType.TEMPLATE, + value = "insert into group_member(a_group_id_fk, value, description) values(:groupId, :value, :description)") + String insertItemTemplate(); + + @Query("select * from group_member where a_group_id_fk = :groupId order by value") + List getItems(@Bind("groupId") int groupId); + + @Query("insert into whitelisted_ticket(group_member_id_fk, group_link_id_fk, ticket_id_fk, requires_unique_value)" + + " values(:itemId, :configurationId, :ticketId, :requiresUniqueValue)") + int insertWhitelistedTicket(@Bind("itemId") int itemId, @Bind("configurationId") int configurationId, @Bind("ticketId") int ticketId, @Bind("requiresUniqueValue") Boolean requiresUniqueValue); + + @Query(BY_EVENT_ID + + " and ticket_category_id_fk = :categoryId" + + " union all select * from group_link_active where event_id_fk = :eventId and ticket_category_id_fk is null") + List findActiveConfigurationsFor(@Bind("eventId") int eventId, @Bind("categoryId") int categoryId); + + @Query(BY_EVENT_ID) + List findActiveConfigurationsForEvent(@Bind("eventId") int eventId); + + @Query("select * from group_link_active where id = :configurationId") + LinkedGroup getConfiguration(@Bind("configurationId") int configurationId); + + @Query("select * from group_link where id = :configurationId for update") + LinkedGroup getConfigurationForUpdate(@Bind("configurationId") int configurationId); + + @Query("select count(*) from whitelisted_ticket where group_link_id_fk = :configurationId") + int countWhitelistedTicketsForConfiguration(@Bind("configurationId") int configurationId); + + @Query("update group_link set a_group_id_fk = :groupId, event_id_fk = :eventId, ticket_category_id_fk = :categoryId, type = :type, match_type = :matchType, max_allocation = :maxAllocation where id = :id") + int updateConfiguration(@Bind("id") int configurationId, + @Bind("groupId") int groupId, + @Bind("eventId") int eventId, + @Bind("categoryId") Integer categoryId, + @Bind("type") LinkedGroup.Type type, + @Bind("matchType") LinkedGroup.MatchType matchType, + @Bind("maxAllocation") Integer maxAllocation); + + @Query("update group_link set active = false where id = :id") + int disableConfiguration(@Bind("id") int id); + + @Query("select * from group_member wi where wi.a_group_id_fk = :groupId and wi.value = lower(:value)") + Optional findItemByValueExactMatch(@Bind("groupId") int groupId, @Bind("value") String value); + + @Query("select * from group_member wi where wi.a_group_id_fk = :groupId and wi.value like lower(:value) limit 1") + Optional findItemEndsWith(@Bind("configurationId") int configurationId, + @Bind("groupId") int groupId, + @Bind("value") String value); + + @Query("select * from whitelisted_ticket where a_group_id_fk = :itemId and group_link_id_fk = :configurationId") + Optional findExistingWhitelistedTicket(@Bind("itemId") int itemId, + @Bind("configurationId") int configurationId); + + @Query("select count(*) from whitelisted_ticket where group_member_id_fk = :itemId and group_link_id_fk = :configurationId") + int countExistingWhitelistedTickets(@Bind("itemId") int itemId, + @Bind("configurationId") int configurationId); + + @Query("delete from whitelisted_ticket where ticket_id_fk in (:ticketIds)") + int deleteExistingWhitelistedTickets(@Bind("ticketIds") List ticketIds); + + +} diff --git a/src/main/java/alfio/repository/TicketRepository.java b/src/main/java/alfio/repository/TicketRepository.java index defa7b9d75..013465c068 100644 --- a/src/main/java/alfio/repository/TicketRepository.java +++ b/src/main/java/alfio/repository/TicketRepository.java @@ -140,6 +140,9 @@ public interface TicketRepository { @Query("update ticket set locked_assignment = :lockedAssignment where id = :id and category_id = :categoryId") int toggleTicketLocking(@Bind("id") int ticketId, @Bind("categoryId") int categoryId, @Bind("lockedAssignment") boolean locked); + @Query("update ticket set locked_assignment = true where id in (:ids)") + int forbidReassignment(@Bind("ids") List ticketIds); + @Query("update ticket set ext_reference = :extReference, locked_assignment = :lockedAssignment where id = :id and category_id = :categoryId") int updateExternalReferenceAndLocking(@Bind("id") int ticketId, @Bind("categoryId") int categoryId, @Bind("extReference") String extReference, @Bind("lockedAssignment") boolean locked); diff --git a/src/main/java/alfio/util/ErrorsCode.java b/src/main/java/alfio/util/ErrorsCode.java index fe34c6eb34..eb7f5d8144 100644 --- a/src/main/java/alfio/util/ErrorsCode.java +++ b/src/main/java/alfio/util/ErrorsCode.java @@ -45,6 +45,7 @@ public interface ErrorsCode { String STEP_2_MAX_LENGTH_BILLING_ADDRESS = "error.STEP_2_MAX_LENGTH_BILLING_ADDRESS"; String STEP_2_EMPTY_BILLING_ADDRESS = "error.STEP_2_EMPTY_BILLING_ADDRESS"; String STEP_2_INVALID_VAT = "error.STEP_2_INVALID_VAT"; + String STEP_2_WHITELIST_CHECK_FAILED = "error.STEP_2_WHITELIST_CHECK_FAILED"; String STEP_2_INVALID_HMAC = "error.STEP_2_INVALID_HMAC"; String STEP_2_PAYMENT_REQUEST_CREATION = "error.STEP_2_PAYMENT_REQUEST_CREATION"; diff --git a/src/main/java/alfio/util/Validator.java b/src/main/java/alfio/util/Validator.java index e92943e76b..766dfff982 100644 --- a/src/main/java/alfio/util/Validator.java +++ b/src/main/java/alfio/util/Validator.java @@ -19,6 +19,7 @@ import alfio.controller.form.UpdateTicketOwnerForm; import alfio.controller.form.WaitingQueueSubscriptionForm; import alfio.manager.EuVatChecker; +import alfio.manager.GroupManager; import alfio.model.ContentLanguage; import alfio.model.Event; import alfio.model.TicketFieldConfiguration; @@ -27,10 +28,13 @@ import alfio.model.modification.TicketCategoryModification; import alfio.model.modification.support.LocationDescriptor; import alfio.model.result.ErrorCode; +import alfio.model.result.Result; import alfio.model.result.ValidationResult; import lombok.Data; +import lombok.RequiredArgsConstructor; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Validate; import org.springframework.validation.Errors; import org.springframework.validation.ValidationUtils; @@ -39,6 +43,7 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.function.Function; import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -175,6 +180,19 @@ public static ValidationResult evaluateValidationResult(Errors errors) { return ValidationResult.success(); } + public static ValidationResult performAdvancedValidation(AdvancedTicketAssignmentValidator advancedValidator, AdvancedValidationContext context, Errors errors) { + if(errors == null) { + return ValidationResult.success(); + } + Result advancedValidation = advancedValidator.apply(context); + if(!advancedValidation.isSuccess()) { + ErrorCode error = advancedValidation.getFirstErrorOrNull(); + Validate.notNull(error, "unexpected error"); + errors.rejectValue(StringUtils.defaultString(context.prefix) + error.getDescription(), error.getCode()); + } + return evaluateValidationResult(errors); + } + public static ValidationResult validateTicketAssignment(UpdateTicketOwnerForm form, List additionalFieldsForEvent, Optional errorsOptional, @@ -358,4 +376,39 @@ public static ValidationResult validateAdditionalFields(List> { + + private final EuVatChecker.SameCountryValidator vatValidator; + private final GroupManager.WhitelistValidator whitelistValidator; + + + @Override + public Result apply(AdvancedValidationContext context) { + + Optional vatField = context.ticketFieldConfigurations.stream() + .filter(TicketFieldConfiguration::isEuVat) + .filter(f -> context.updateTicketOwnerForm.getAdditional() !=null && context.updateTicketOwnerForm.getAdditional().containsKey(f.getName())) + .findFirst(); + + Optional vatNr = vatField.map(c -> context.updateTicketOwnerForm.getAdditional().get(c.getName()).get(0)); + String vatFieldName = vatField.map(TicketFieldConfiguration::getName).orElse(""); + + return new Result.Builder() + .checkPrecondition(() -> !vatNr.isPresent() || vatValidator.test(vatNr.get()), ErrorCode.custom(ErrorsCode.STEP_2_INVALID_VAT, "additional['"+vatFieldName+"']")) + .checkPrecondition(() -> whitelistValidator.test(new GroupManager.WhitelistValidationItem(context.categoryId, context.updateTicketOwnerForm.getEmail())), ErrorCode.custom(ErrorsCode.STEP_2_WHITELIST_CHECK_FAILED, "email")) + .build(() -> null); + } + } + + @RequiredArgsConstructor + public static class AdvancedValidationContext { + private final UpdateTicketOwnerForm updateTicketOwnerForm; + private final List ticketFieldConfigurations; + private final int categoryId; + private final String ticketUuid; + private final String prefix; + } + } diff --git a/src/main/resources/alfio/db/PGSQL/V22_1.15.5.1__CREATE_GROUP.sql b/src/main/resources/alfio/db/PGSQL/V22_1.15.5.1__CREATE_GROUP.sql new file mode 100644 index 0000000000..579e1dfcfb --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/V22_1.15.5.1__CREATE_GROUP.sql @@ -0,0 +1,67 @@ +-- +-- 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 . +-- + +create table a_group ( + id serial primary key not null, + name varchar(255) not null, + description varchar(2048), + organization_id_fk integer not null +); + +alter table a_group add constraint "a_group_org_id_fk" foreign key(organization_id_fk) references organization(id); +alter table a_group add constraint "a_group_unique_name_org_id" unique(name, organization_id_fk); + +create table group_member ( + id serial primary key not null, + a_group_id_fk integer not null, + value varchar(255), + description varchar(2048) +); + +alter table group_member add constraint "group_member_a_group_id_fk" foreign key(a_group_id_fk) references a_group(id); +alter table group_member add constraint "group_member_unique_value" unique(a_group_id_fk, value); + +create table group_link ( + id serial primary key not null, + a_group_id_fk integer not null, + event_id_fk integer not null, + ticket_category_id_fk integer, + type varchar(255), + match_type varchar(255), + max_allocation integer, + active boolean not null default true +); + +alter table group_link add constraint "group_link_a_group_id_fk" foreign key(a_group_id_fk) references a_group(id); +alter table group_link add constraint "group_link_event_id_fk" foreign key(event_id_fk) references event(id); +alter table group_link add constraint "group_link_ticket_category_id_fk" foreign key(ticket_category_id_fk) references ticket_category(id); + +create table whitelisted_ticket ( + group_member_id_fk integer not null, + group_link_id_fk integer not null, + ticket_id_fk integer not null, + requires_unique_value boolean +); + +alter table whitelisted_ticket add constraint "whitelisted_ticket_group_member_id_fk" foreign key(group_member_id_fk) references group_member(id); +alter table whitelisted_ticket add constraint "whitelisted_ticket_group_link_id_fk" foreign key(group_link_id_fk) references group_link(id); +alter table whitelisted_ticket add constraint "whitelisted_ticket_ticket_id_fk" foreign key(ticket_id_fk) references ticket(id); +alter table whitelisted_ticket add constraint "whitelisted_ticket_unique_item_id" unique(group_member_id_fk, group_link_id_fk, requires_unique_value); + +create view group_link_active as select * from group_link where active = true; + + diff --git a/src/main/resources/alfio/i18n/public.properties b/src/main/resources/alfio/i18n/public.properties index c0f248da33..7911a1ecce 100644 --- a/src/main/resources/alfio/i18n/public.properties +++ b/src/main/resources/alfio/i18n/public.properties @@ -174,6 +174,8 @@ error.STEP2_STRIPE_abort=A network error has occurred, and you have not been cha error.STEP2_STRIPE_rate_limit=A network error has occurred, and you have not been charged. Please try again. error.STEP2_STRIPE_unexpected=An unexpected error has occurred. Please contact the event''s organizers in order to get assistance. error.STEP2_UNABLE_TO_TRANSITION=An unexpected error has occurred, and you have not been charged. Please try again. +error.STEP2_WHITELIST=One or more email addresses have already been used or are not allowed for this event. +error.STEP_2_WHITELIST_CHECK_FAILED=Cannot assign a ticket using the provided email addresses: access is limited. error.generic=An unexpected error has occurred. Please contact the event''s organizers in order to get assistance. error.STEP_2_PAYMENT_PROCESSING_ERROR=Payment processing error: {0} diff --git a/src/main/resources/alfio/i18n/public_it.properties b/src/main/resources/alfio/i18n/public_it.properties index f13aebebd0..368b19134e 100644 --- a/src/main/resources/alfio/i18n/public_it.properties +++ b/src/main/resources/alfio/i18n/public_it.properties @@ -349,6 +349,8 @@ reservation.add-company-billing-details=Acquisto come azienda (\u00E8 richiesto error.STEP_2_EMPTY_BILLING_ADDRESS=L''indirizzo di fatturazione \u00E8 obbligatorio common.customer-reference=Numero di riferimento error.STEP_2_INVALID_VAT=Il numero IVA immesso non risulta valido. +error.STEP2_WHITELIST=Uno o pi\u00F9 indirizzi email sono gi\u00E0 stati usati in precedenza o non sono autorizzati per questo evento. +error.STEP_2_WHITELIST_CHECK_FAILED=L''accesso ad una delle categorie selezionate \u00E8 limitato e uno o pi\u00F9 indirizzi email forniti non sono autorizzati a prenotare. error.vatVIESDown=[IT] An error occurred contacting the EU VIES vat checker. Please try again. breadcrumb.step4=[IT]-Thank You common.back=[IT]-Back @@ -361,4 +363,4 @@ reservation-page.if-applicable=[IT]-if applicable error.emptyField=[IT]-This field is required error.tooLong=[IT]-The value is too long reservation-page.skipVatNr=[IT]-I don''t have a {0} number -breadcrumb.step3.free=[IT]-Overview \ No newline at end of file +breadcrumb.step3.free=[IT]-Overview diff --git a/src/main/webapp/WEB-INF/templates/admin/index.ms b/src/main/webapp/WEB-INF/templates/admin/index.ms index c51a5a99a5..419e02cdf7 100644 --- a/src/main/webapp/WEB-INF/templates/admin/index.ms +++ b/src/main/webapp/WEB-INF/templates/admin/index.ms @@ -87,6 +87,7 @@ + @@ -122,6 +123,7 @@
  • Api Key
  • Configuration
  • Extension
  • +
  • Groups
  • {{/isOwner}}