From 1742097b83a9f0347fed548153fe5fc225e3804c Mon Sep 17 00:00:00 2001 From: Celestino Bellone <3385346+cbellone@users.noreply.github.com> Date: Tue, 31 Jul 2018 20:27:33 +0200 Subject: [PATCH] Merge whitelist to master (#496) * initial work for merging whitelist in master * disable postpone assignment option if one or more categories are linked to groups * #487 - remove members + remove groups * #487 - display "linked to group" message on event level only if the group is not linked directly to a category * #487 - remove unnecessary ticket->email map --- .../controller/ReservationController.java | 14 +- .../api/admin/GroupApiController.java | 184 +++++++++++++ .../controller/api/support/TicketHelper.java | 11 + .../form/ContactAndTicketsForm.java | 12 +- src/main/java/alfio/manager/GroupManager.java | 255 +++++++++++++++++ .../manager/TicketReservationManager.java | 47 +++- 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 | 129 +++++++++ .../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 | 73 +++++ .../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 | 82 ++++++ .../js/admin/feature/group/groups.js | 257 ++++++++++++++++++ .../js/admin/feature/group/list.html | 7 + .../js/admin/ng-app/admin-application.js | 27 +- .../manager/GroupManagerIntegrationTest.java | 140 ++++++++++ .../manager/TicketReservationManagerTest.java | 7 +- .../TicketReservationManagerUnitTest.java | 5 +- 35 files changed, 1831 insertions(+), 51 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..0732c67d61 100644 --- a/src/main/java/alfio/controller/ReservationController.java +++ b/src/main/java/alfio/controller/ReservationController.java @@ -119,17 +119,10 @@ public String showBookingPage(@PathVariable("eventName") String eventName, List ticketsInReservation = ticketReservationManager.findTicketsInReservation(reservationId); model.addAttribute("postponeAssignment", false) - .addAttribute("showPostpone", !forceAssignment && ticketsInReservation.size() > 1); + .addAttribute("showPostpone", !forceAssignment && ticketsInReservation.size() > 1 && ticketReservationManager.containsCategoriesLinkedToGroups(reservationId, event.getId())); - try { - model.addAttribute("delayForOfflinePayment", Math.max(1, TicketReservationManager.getOfflinePaymentWaitingPeriod(event, configurationManager))); - } catch (TicketReservationManager.OfflinePaymentException e) { - if(event.getAllowedPaymentProxies().contains(PaymentProxy.OFFLINE)) { - log.error("Already started event {} has been found with OFFLINE payment enabled" , event.getDisplayName() , e); - } - model.addAttribute("delayForOfflinePayment", 0); - } + addDelayForOffline(model, event); OrderSummary orderSummary = ticketReservationManager.orderSummaryForReservationId(reservationId, event, locale); @@ -250,7 +243,8 @@ public String validateToOverview(@PathVariable("eventName") String eventName, @P final TotalPrice reservationCost = ticketReservationManager.totalReservationCostWithVAT(reservationId); Configuration.ConfigurationPathKey forceAssignmentKey = Configuration.from(event.getOrganizationId(), event.getId(), ConfigurationKeys.FORCE_TICKET_OWNER_ASSIGNMENT_AT_RESERVATION); boolean forceAssignment = configurationManager.getBooleanConfigValue(forceAssignmentKey, false); - if(forceAssignment) { + + if(forceAssignment || ticketReservationManager.containsCategoriesLinkedToGroups(reservationId, event.getId())) { contactAndTicketsForm.setPostponeAssignment(false); } 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..6b3e362fd9 --- /dev/null +++ b/src/main/java/alfio/controller/api/admin/GroupApiController.java @@ -0,0 +1,184 @@ +/** + * 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.Collections; +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("/for/{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("/for/{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("/for/{organizationId}/update/{groupId}") + public ResponseEntity updateGroup(@PathVariable("organizationId") int organizationId, + @PathVariable("groupId") 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("/for/{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("/for/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("/for/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("/for/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("/{groupId}/link") + public ResponseEntity linkGroup(@PathVariable("groupId") int groupId, @RequestBody LinkedGroupModification body, Principal principal) { + if(body == null || groupId != 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 -> Objects.equals(body.getTicketCategoryId(), c.getTicketCategoryId())) + .findFirst(); + LinkedGroup link; + if(existing.isPresent()) { + link = groupManager.updateLink(existing.get().getId(), body); + } else { + link = groupManager.createLink(groupId, event.getId(), body); + } + return ResponseEntity.ok(link.getId()); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @DeleteMapping("/for/{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.FORBIDDEN).build(); + } + + @DeleteMapping("/for/{organizationId}/id/{groupId}/member/{memberId}") + public ResponseEntity deactivateMember(@PathVariable("groupId") int groupId, + @PathVariable("memberId") int memberId, + @PathVariable("organizationId") int organizationId, + Principal principal) { + if(notOwner(principal.getName(), organizationId) || !groupManager.findById(groupId, organizationId).isPresent()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + return ResponseEntity.ok(groupManager.deactivateMembers(Collections.singletonList(memberId), groupId)); + + } + + @DeleteMapping("/for/{organizationId}/id/{groupId}") + public ResponseEntity deactivateGroup(@PathVariable("groupId") int groupId, + @PathVariable("organizationId") int organizationId, + Principal principal) { + if(notOwner(principal.getName(), organizationId) || !groupManager.findById(groupId, organizationId).isPresent()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + return ResponseEntity.ok(groupManager.deactivateGroup(groupId)); + } + + 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/controller/form/ContactAndTicketsForm.java b/src/main/java/alfio/controller/form/ContactAndTicketsForm.java index 77827b7f7a..207c62f2fb 100644 --- a/src/main/java/alfio/controller/form/ContactAndTicketsForm.java +++ b/src/main/java/alfio/controller/form/ContactAndTicketsForm.java @@ -17,7 +17,10 @@ package alfio.controller.form; import alfio.manager.EuVatChecker; -import alfio.model.*; +import alfio.model.Event; +import alfio.model.TicketFieldConfiguration; +import alfio.model.TicketReservation; +import alfio.model.TicketReservationAdditionalInfo; import alfio.model.result.ValidationResult; import alfio.util.ErrorsCode; import alfio.util.Validator; @@ -27,7 +30,10 @@ import org.springframework.validation.ValidationUtils; import java.io.Serializable; -import java.util.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import static alfio.util.ErrorsCode.STEP_2_INVALID_VAT; @@ -121,8 +127,6 @@ public void validate(BindingResult bindingResult, Event event, List. + */ +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) { + List configurations = findLinks(ticket.getEventId(), ticket.getCategoryId()); + if(CollectionUtils.isEmpty(configurations)) { + return true; + } + LinkedGroup configuration = configurations.get(0); + Optional optionalItem = getMatchingMember(configuration, ticket.getEmail()); + 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.disableLink(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); + } + + public boolean deactivateMembers(List memberIds, int groupId) { + if(memberIds.isEmpty()) { + return false; + } + MapSqlParameterSource[] params = memberIds.stream().map(i -> toParameterSource(groupId, i)).toArray(MapSqlParameterSource[]::new); + jdbcTemplate.batchUpdate(groupRepository.deactivateGroupMember(), params); + return true; + } + + public boolean deactivateGroup(int groupId) { + List members = groupRepository.getItems(groupId).stream().map(GroupMember::getId).collect(Collectors.toList()); + if(!members.isEmpty()) { + Validate.isTrue(deactivateMembers(members, groupId), "error while disabling group members"); + } + groupRepository.disableAllLinks(groupId); + Validate.isTrue(groupRepository.deactivateGroup(groupId) == 1, "unexpected error while disabling group"); + return true; + } + + + private static MapSqlParameterSource toParameterSource(int groupId, Integer itemId) { + return new MapSqlParameterSource("groupId", groupId) + .addValue("memberId", itemId) + .addValue("disabledPlaceholder", UUID.randomUUID().toString()); + } + + @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..04cf3f8f73 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; } /** @@ -344,6 +348,12 @@ public PaymentResult confirm(String gatewayToken, String payerId, Event event, S boolean invoiceRequested, String vatCountryCode, String vatNr, PriceContainer.VatStatus vatStatus, boolean tcAccepted, boolean privacyPolicyAccepted) { PaymentProxy paymentProxy = evaluatePaymentProxy(method, reservationCost); + + if(!acquireGroupMembers(reservationId, event)) { + 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"); } @@ -408,6 +418,15 @@ public PaymentResult confirm(String gatewayToken, String payerId, Event event, S } + public boolean containsCategoriesLinkedToGroups(String reservationId, int eventId) { + List allLinks = groupManager.getLinksForEvent(eventId); + if(allLinks.isEmpty()) { + return false; + } + return ticketRepository.findTicketsInReservation(reservationId).stream() + .anyMatch(t -> allLinks.stream().anyMatch(lg -> lg.getTicketCategoryId() == null || lg.getTicketCategoryId().equals(t.getCategoryId()))); + } + private PaymentProxy evaluatePaymentProxy(Optional method, TotalPrice reservationCost) { if(method.isPresent()) { return method.get(); @@ -433,6 +452,21 @@ private boolean initPaymentProcess(TotalPrice reservationCost, PaymentProxy paym return true; } + private boolean acquireGroupMembers(String reservationId, Event event) { + int eventId = event.getId(); + List linkedGroups = groupManager.getLinksForEvent(eventId); + if(!linkedGroups.isEmpty()) { + List ticketsInReservation = ticketRepository.findTicketsInReservation(reservationId); + return Boolean.TRUE.equals(requiresNewTransactionTemplate.execute(status -> + ticketsInReservation + .stream() + .filter(ticket -> linkedGroups.stream().anyMatch(c -> c.getTicketCategoryId() == null || c.getTicketCategoryId().equals(ticket.getCategoryId()))) + .map(t -> groupManager.acquireMemberForTicket(t)) + .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 +706,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 +1020,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 +1096,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..9f55bdb35a --- /dev/null +++ b/src/main/java/alfio/repository/GroupRepository.java @@ -0,0 +1,129 @@ +/** + * 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 group_active 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 group_active where id = :id") + Optional getOptionalById(@Bind("id") int id); + + @Query("select * from group_active 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_active 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 disableLink(@Bind("id") int id); + + @Query("update group_link set active = false where a_group_id_fk = :groupId") + int disableAllLinks(@Bind("groupId") int groupId); + + @Query("select * from group_member_active 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_active 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); + + @Query(type = QueryType.TEMPLATE, value = "update group_member set active = false, value = 'DISABLED-' || :disabledPlaceholder where id = :memberId and a_group_id_fk = :groupId") + String deactivateGroupMember(); + + + @Query("update a_group set active = false where id = :groupId") + int deactivateGroup(@Bind("groupId") int groupId); +} 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..7264adc25a --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/V22_1.15.5.1__CREATE_GROUP.sql @@ -0,0 +1,73 @@ +-- +-- 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, + active boolean default true +); + +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), + active boolean default true +); + +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; + +create view group_member_active as select * from group_member where active = true; + +create view group_active as select * from a_group 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}}