diff --git a/src/main/java/alfio/controller/api/admin/EventApiController.java b/src/main/java/alfio/controller/api/admin/EventApiController.java index 7bbda9e34..30f71c9e9 100644 --- a/src/main/java/alfio/controller/api/admin/EventApiController.java +++ b/src/main/java/alfio/controller/api/admin/EventApiController.java @@ -434,6 +434,7 @@ public void downloadSponsorScanExport(@PathVariable("eventName") String eventNam header.addAll(fields.stream().map(TicketFieldConfiguration::getName).collect(toList())); header.add("Sponsor notes"); header.add("Lead Status"); + header.add("Operator"); Stream sponsorScans = userManager.findAllEnabledUsers(principal.getName()).stream() .map(u -> Pair.of(u, userManager.getUserRole(u))) @@ -460,6 +461,7 @@ public void downloadSponsorScanExport(@PathVariable("eventName") String eventNam line.add(sponsorScan.getNotes()); line.add(sponsorScan.getLeadStatus().name()); + line.add(sponsorScan.getOperator()); return line.toArray(new String[0]); }); diff --git a/src/main/java/alfio/controller/api/v1/AttendeeApiController.java b/src/main/java/alfio/controller/api/v1/AttendeeApiController.java index 50db29d3a..204e0a3eb 100644 --- a/src/main/java/alfio/controller/api/v1/AttendeeApiController.java +++ b/src/main/java/alfio/controller/api/v1/AttendeeApiController.java @@ -51,6 +51,7 @@ @Log4j2 public class AttendeeApiController { + public static final String ALFIO_OPERATOR_HEADER = "Alfio-Operator"; private final AttendeeManager attendeeManager; @Autowired @@ -72,15 +73,19 @@ public ResponseEntity handleGenericException(RuntimeException e) { @PostMapping("/sponsor-scan") - public ResponseEntity scanBadge(@RequestBody SponsorScanRequest request, Principal principal) { - return ResponseEntity.ok(attendeeManager.registerSponsorScan(request.eventName, request.ticketIdentifier, request.notes, request.leadStatus, principal.getName())); + public ResponseEntity scanBadge(@RequestBody SponsorScanRequest request, + Principal principal, + @RequestHeader(name = ALFIO_OPERATOR_HEADER, required = false) String operator) { + return ResponseEntity.ok(attendeeManager.registerSponsorScan(request.eventName, request.ticketIdentifier, request.notes, request.leadStatus, principal.getName(), operator)); } @PostMapping("/sponsor-scan/bulk") - public ResponseEntity> scanBadges(@RequestBody List requests, Principal principal) { + public ResponseEntity> scanBadges(@RequestBody List requests, + Principal principal, + @RequestHeader(name = ALFIO_OPERATOR_HEADER, required = false) String operator) { String username = principal.getName(); return ResponseEntity.ok(requests.stream() - .map(request -> attendeeManager.registerSponsorScan(request.eventName, request.ticketIdentifier, request.notes, request.leadStatus, username)) + .map(request -> attendeeManager.registerSponsorScan(request.eventName, request.ticketIdentifier, request.notes, request.leadStatus, username, operator)) .collect(Collectors.toList())); } diff --git a/src/main/java/alfio/manager/AttendeeManager.java b/src/main/java/alfio/manager/AttendeeManager.java index 6cdd14f71..adcad34ca 100644 --- a/src/main/java/alfio/manager/AttendeeManager.java +++ b/src/main/java/alfio/manager/AttendeeManager.java @@ -35,6 +35,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; @@ -42,6 +43,7 @@ @AllArgsConstructor public class AttendeeManager { + public static final String DEFAULT_OPERATOR_ID = "__DEFAULT__"; private final SponsorScanRepository sponsorScanRepository; private final EventRepository eventRepository; private final TicketRepository ticketRepository; @@ -51,7 +53,12 @@ public class AttendeeManager { private final AdditionalServiceItemRepository additionalServiceItemRepository; private final ClockProvider clockProvider; - public TicketAndCheckInResult registerSponsorScan(String eventShortName, String ticketUid, String notes, SponsorScan.LeadStatus leadStatus, String username) { + public TicketAndCheckInResult registerSponsorScan(String eventShortName, + String ticketUid, + String notes, + SponsorScan.LeadStatus leadStatus, + String username, + String operatorId) { int userId = userRepository.getByUsername(username).getId(); Optional maybeEvent = eventRepository.findOptionalEventAndOrganizationIdByShortName(eventShortName); if(maybeEvent.isEmpty()) { @@ -66,12 +73,13 @@ public TicketAndCheckInResult registerSponsorScan(String eventShortName, String if(ticket.getStatus() != Ticket.TicketStatus.CHECKED_IN) { return new TicketAndCheckInResult(new TicketWithCategory(ticket, null), new DefaultCheckInResult(CheckInStatus.INVALID_TICKET_STATE, "not checked-in")); } - Optional existingRegistration = sponsorScanRepository.getRegistrationTimestamp(userId, event.getId(), ticket.getId()); + var operator = Objects.requireNonNullElse(operatorId, DEFAULT_OPERATOR_ID); + Optional existingRegistration = sponsorScanRepository.getRegistrationTimestamp(userId, event.getId(), ticket.getId(), operator); if(existingRegistration.isEmpty()) { ZoneId eventZoneId = eventRepository.getZoneIdByEventId(event.getId()); - sponsorScanRepository.insert(userId, ZonedDateTime.now(clockProvider.withZone(eventZoneId)), event.getId(), ticket.getId(), notes, leadStatus); + sponsorScanRepository.insert(userId, ZonedDateTime.now(clockProvider.withZone(eventZoneId)), event.getId(), ticket.getId(), notes, leadStatus, operator); } else { - sponsorScanRepository.updateNotesAndLeadStatus(userId, event.getId(), ticket.getId(), notes, leadStatus); + sponsorScanRepository.updateNotesAndLeadStatus(userId, event.getId(), ticket.getId(), notes, leadStatus, operator); } return new TicketAndCheckInResult(new TicketWithCategory(ticket, null), new DefaultCheckInResult(CheckInStatus.SUCCESS, "success")); } @@ -107,7 +115,9 @@ private List loadAttendeesData(EventAndOrganizationId event .map(scan -> { Ticket ticket = scan.getTicket(); return new SponsorAttendeeData(ticket.getUuid(), scan.getSponsorScan().getTimestamp().format(EventUtil.JSON_DATETIME_FORMATTER), ticket.getFullName(), ticket.getEmail()); - }).collect(Collectors.toList()); + }) + .distinct() + .collect(Collectors.toList()); } } diff --git a/src/main/java/alfio/model/DetailedScanData.java b/src/main/java/alfio/model/DetailedScanData.java index 339134ed1..0653d741f 100644 --- a/src/main/java/alfio/model/DetailedScanData.java +++ b/src/main/java/alfio/model/DetailedScanData.java @@ -60,12 +60,13 @@ public DetailedScanData(@Column("t_id") int ticketId, @Column("s_event_id") int scanEventId, @Column("s_ticket_id") int scanTicketId, @Column("s_notes") String notes, - @Column("s_lead_status") SponsorScan.LeadStatus leadStatus) { + @Column("s_lead_status") SponsorScan.LeadStatus leadStatus, + @Column("s_operator") String operator) { this.ticket = new Ticket(ticketId, ticketUuid, ticketCreation, ticketCategoryId, ticketStatus, ticketEventId, ticketsReservationId, ticketFullName, ticketFirstName, ticketLastName, ticketEmail, ticketLockedAssignment, ticketUserLanguage, ticketSrcPriceCts, ticketFinalPriceCts, ticketVatCts, ticketDiscountCts, extReference, currencyCode, ticketTags, ticketSubscriptionId, ticketVatStatus); - this.sponsorScan = new SponsorScan(scanUserId, scanTimestamp, scanEventId, scanTicketId, notes, leadStatus); + this.sponsorScan = new SponsorScan(scanUserId, scanTimestamp, scanEventId, scanTicketId, notes, leadStatus, operator); } } diff --git a/src/main/java/alfio/model/SponsorScan.java b/src/main/java/alfio/model/SponsorScan.java index 38426a3e4..1e069fd33 100644 --- a/src/main/java/alfio/model/SponsorScan.java +++ b/src/main/java/alfio/model/SponsorScan.java @@ -34,6 +34,7 @@ public enum LeadStatus { private final int ticketId; private final String notes; private final LeadStatus leadStatus; + private final String operator; public SponsorScan(@Column("user_id") int userId, @@ -41,12 +42,14 @@ public SponsorScan(@Column("user_id") int userId, @Column("event_id") int eventId, @Column("ticket_id") int ticketId, @Column("notes") String notes, - @Column("lead_status") LeadStatus leadStatus) { + @Column("lead_status") LeadStatus leadStatus, + @Column("operator") String operator) { this.userId = userId; this.timestamp = timestamp; this.eventId = eventId; this.ticketId = ticketId; this.notes = notes; this.leadStatus = leadStatus; + this.operator = operator; } } diff --git a/src/main/java/alfio/repository/SponsorScanRepository.java b/src/main/java/alfio/repository/SponsorScanRepository.java index 026ef1ac4..ceba579cc 100644 --- a/src/main/java/alfio/repository/SponsorScanRepository.java +++ b/src/main/java/alfio/repository/SponsorScanRepository.java @@ -33,29 +33,31 @@ public interface SponsorScanRepository { ZonedDateTime DEFAULT_TIMESTAMP = ZonedDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC); - @Query("select creation from sponsor_scan where user_id = :userId and event_id = :eventId and ticket_id = :ticketId") - Optional getRegistrationTimestamp(@Bind("userId") int userId, @Bind("eventId") int eventId, @Bind("ticketId") int ticketId); + @Query("select creation from sponsor_scan where user_id = :userId and event_id = :eventId and ticket_id = :ticketId and operator = :operator") + Optional getRegistrationTimestamp(@Bind("userId") int userId, @Bind("eventId") int eventId, @Bind("ticketId") int ticketId, @Bind("operator") String operator); - @Query("insert into sponsor_scan (user_id, creation, event_id, ticket_id, notes, lead_status) values(:userId, :creation, :eventId, :ticketId, :notes, :leadStatus)") + @Query("insert into sponsor_scan (user_id, creation, event_id, ticket_id, notes, lead_status, operator) values(:userId, :creation, :eventId, :ticketId, :notes, :leadStatus, :operator)") int insert(@Bind("userId") int userId, @Bind("creation") ZonedDateTime creation, @Bind("eventId") int eventId, @Bind("ticketId") int ticketId, @Bind("notes") String notes, - @Bind("leadStatus") SponsorScan.LeadStatus leadStatus); + @Bind("leadStatus") SponsorScan.LeadStatus leadStatus, + @Bind("operator") String operator); - @Query("update sponsor_scan set notes = :notes, lead_status = :leadStatus where user_id = :userId and event_id = :eventId and ticket_id = :ticketId") + @Query("update sponsor_scan set notes = :notes, lead_status = :leadStatus where user_id = :userId and event_id = :eventId and ticket_id = :ticketId and operator = :operator") int updateNotesAndLeadStatus(@Bind("userId") int userId, @Bind("eventId") int eventId, @Bind("ticketId") int ticketId, @Bind("notes") String notes, - @Bind("leadStatus") SponsorScan.LeadStatus leadStatus); + @Bind("leadStatus") SponsorScan.LeadStatus leadStatus, + @Bind("operator") String operator); @Query("select t.id t_id, t.uuid t_uuid, t.creation t_creation, t.category_id t_category_id, t.status t_status, t.event_id t_event_id," + " t.src_price_cts t_src_price_cts, t.final_price_cts t_final_price_cts, t.vat_cts t_vat_cts, t.discount_cts t_discount_cts, t.tickets_reservation_id t_tickets_reservation_id," + " t.full_name t_full_name, t.first_name t_first_name, t.last_name t_last_name, t.email_address t_email_address, t.locked_assignment t_locked_assignment," + " t.user_language t_user_language, t.ext_reference t_ext_reference, t.currency_code t_currency_code, t.tags t_tags, t.subscription_id_fk t_subscription_id, t.vat_status t_vat_status," + - " s.user_id s_user_id, s.creation s_creation, s.event_id s_event_id, s.ticket_id s_ticket_id, s.notes s_notes, s.lead_status s_lead_status, " + + " s.user_id s_user_id, s.creation s_creation, s.event_id s_event_id, s.ticket_id s_ticket_id, s.notes s_notes, s.lead_status s_lead_status, s.operator s_operator, " + " (case when s.lead_status = 'HOT' then 2 when s.lead_status = 'WARM' then 1 else 0 end) as priority"+ " from sponsor_scan s, ticket t where s.event_id = :eventId and s.user_id = :userId and s.creation > :start and s.ticket_id = t.id order by priority desc, s.creation") List loadSponsorData(@Bind("eventId") int eventId, diff --git a/src/main/resources/alfio/db/PGSQL/V204_2.0.0.49.5__SPONSOR_SCAN_OPERATOR.sql b/src/main/resources/alfio/db/PGSQL/V204_2.0.0.49.5__SPONSOR_SCAN_OPERATOR.sql new file mode 100644 index 000000000..61557d91a --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/V204_2.0.0.49.5__SPONSOR_SCAN_OPERATOR.sql @@ -0,0 +1,21 @@ +-- +-- 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 . +-- + +alter table sponsor_scan add column operator text not null default '__DEFAULT__'; +alter table sponsor_scan drop constraint "spsc_unique_ticket"; +alter table sponsor_scan add constraint "spsc_unique_ticket" unique(event_id, ticket_id, user_id, operator); + diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/BaseReservationFlowTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/BaseReservationFlowTest.java index 2e8866388..ab4ddaaf6 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/BaseReservationFlowTest.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/BaseReservationFlowTest.java @@ -39,6 +39,7 @@ import alfio.manager.*; import alfio.manager.support.CheckInStatus; import alfio.manager.support.IncompatibleStateException; +import alfio.manager.support.SponsorAttendeeData; import alfio.manager.support.TicketAndCheckInResult; import alfio.manager.support.extension.ExtensionEvent; import alfio.model.*; @@ -1059,9 +1060,9 @@ protected void testBasicFlow(Supplier contextSupplier) t Mockito.when(sponsorPrincipal.getName()).thenReturn(sponsorUser.getUsername()); // check failures - assertEquals(CheckInStatus.EVENT_NOT_FOUND, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest("not-existing-event", "not-existing-ticket", null, null), sponsorPrincipal).getBody().getResult().getStatus()); - assertEquals(CheckInStatus.TICKET_NOT_FOUND, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest(eventName, "not-existing-ticket", null, null), sponsorPrincipal).getBody().getResult().getStatus()); - assertEquals(CheckInStatus.INVALID_TICKET_STATE, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest(eventName, ticketIdentifier, null, null), sponsorPrincipal).getBody().getResult().getStatus()); + assertEquals(CheckInStatus.EVENT_NOT_FOUND, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest("not-existing-event", "not-existing-ticket", null, null), sponsorPrincipal, null).getBody().getResult().getStatus()); + assertEquals(CheckInStatus.TICKET_NOT_FOUND, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest(eventName, "not-existing-ticket", null, null), sponsorPrincipal, null).getBody().getResult().getStatus()); + assertEquals(CheckInStatus.INVALID_TICKET_STATE, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest(eventName, ticketIdentifier, null, null), sponsorPrincipal, null).getBody().getResult().getStatus()); // @@ -1118,7 +1119,9 @@ protected void testBasicFlow(Supplier contextSupplier) t // check register sponsor scan success flow assertTrue(attendeeApiController.getScannedBadges(context.event.getShortName(), EventUtil.JSON_DATETIME_FORMATTER.format(LocalDateTime.of(1970, 1, 1, 0, 0)), sponsorPrincipal).getBody().isEmpty()); - assertEquals(CheckInStatus.SUCCESS, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest(eventName, ticketwc.getUuid(), null, null), sponsorPrincipal).getBody().getResult().getStatus()); + assertEquals(CheckInStatus.SUCCESS, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest(eventName, ticketwc.getUuid(), null, null), sponsorPrincipal, null).getBody().getResult().getStatus()); + assertEquals(CheckInStatus.SUCCESS, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest(eventName, ticketwc.getUuid(), null, null), sponsorPrincipal, null).getBody().getResult().getStatus()); + // scanned badges returns only unique values for a limited subset of columns assertEquals(1, attendeeApiController.getScannedBadges(context.event.getShortName(), EventUtil.JSON_DATETIME_FORMATTER.format(LocalDateTime.of(1970, 1, 1, 0, 0)), sponsorPrincipal).getBody().size()); // check export @@ -1133,14 +1136,18 @@ protected void testBasicFlow(Supplier contextSupplier) t assertEquals("testmctest@test.com", csvSponsorScan.get(1)[4]); assertEquals("", csvSponsorScan.get(1)[8]); assertEquals(SponsorScan.LeadStatus.WARM.name(), csvSponsorScan.get(1)[9]); + assertEquals(AttendeeManager.DEFAULT_OPERATOR_ID, csvSponsorScan.get(1)[10]); // // check update notes - assertEquals(CheckInStatus.SUCCESS, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest(eventName, ticket.getUuid(), "this is a very good lead!", "HOT"), sponsorPrincipal).getBody().getResult().getStatus()); - assertEquals(1, attendeeApiController.getScannedBadges(context.event.getShortName(), EventUtil.JSON_DATETIME_FORMATTER.format(LocalDateTime.of(1970, 1, 1, 0, 0)), sponsorPrincipal).getBody().size()); + assertEquals(CheckInStatus.SUCCESS, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest(eventName, ticket.getUuid(), "this is a very good lead!", "HOT"), sponsorPrincipal, null).getBody().getResult().getStatus()); + var scannedBadges = attendeeApiController.getScannedBadges(context.event.getShortName(), EventUtil.JSON_DATETIME_FORMATTER.format(LocalDateTime.of(1970, 1, 1, 0, 0)), sponsorPrincipal).getBody(); + assertEquals(1, requireNonNull(scannedBadges).size()); + assertEquals(CheckInStatus.SUCCESS, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest(eventName, ticket.getUuid(), "this is a very good lead!", "HOT"), sponsorPrincipal, null).getBody().getResult().getStatus()); + scannedBadges = attendeeApiController.getScannedBadges(context.event.getShortName(), EventUtil.JSON_DATETIME_FORMATTER.format(LocalDateTime.of(1970, 1, 1, 0, 0)), sponsorPrincipal).getBody(); + assertEquals(1, requireNonNull(scannedBadges).size()); response = new MockHttpServletResponse(); eventApiController.downloadSponsorScanExport(context.event.getShortName(), "csv", response, principal); - response.getContentAsString(); csvReader = new CSVReader(new StringReader(response.getContentAsString())); csvSponsorScan = csvReader.readAll(); assertEquals(2, csvSponsorScan.size()); @@ -1149,6 +1156,27 @@ protected void testBasicFlow(Supplier contextSupplier) t assertEquals("testmctest@test.com", csvSponsorScan.get(1)[4]); assertEquals("this is a very good lead!", csvSponsorScan.get(1)[8]); assertEquals(SponsorScan.LeadStatus.HOT.name(), csvSponsorScan.get(1)[9]); + assertEquals(AttendeeManager.DEFAULT_OPERATOR_ID, csvSponsorScan.get(1)[10]); + + // scan from a different operator + response = new MockHttpServletResponse(); + assertEquals(CheckInStatus.SUCCESS, attendeeApiController.scanBadge(new AttendeeApiController.SponsorScanRequest(eventName, ticketwc.getUuid(), null, null), sponsorPrincipal, "OP2").getBody().getResult().getStatus()); + eventApiController.downloadSponsorScanExport(context.event.getShortName(), "csv", response, principal); + csvReader = new CSVReader(new StringReader(response.getContentAsString())); + csvSponsorScan = csvReader.readAll(); + assertEquals(3, csvSponsorScan.size()); + assertEquals("sponsor", csvSponsorScan.get(1)[0]); + assertEquals("Test Testson", csvSponsorScan.get(1)[3]); + assertEquals("testmctest@test.com", csvSponsorScan.get(1)[4]); + assertEquals("this is a very good lead!", csvSponsorScan.get(1)[8]); + assertEquals(SponsorScan.LeadStatus.HOT.name(), csvSponsorScan.get(1)[9]); + assertEquals(AttendeeManager.DEFAULT_OPERATOR_ID, csvSponsorScan.get(1)[10]); + + assertEquals("sponsor", csvSponsorScan.get(2)[0]); + assertEquals("Test Testson", csvSponsorScan.get(2)[3]); + assertEquals("testmctest@test.com", csvSponsorScan.get(2)[4]); + assertEquals("", csvSponsorScan.get(2)[8]); + assertEquals("OP2", csvSponsorScan.get(2)[10]); // #742 - test multiple check-ins