diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java index 301a166..858e87b 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java @@ -40,9 +40,12 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.UriInfo; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Map; @Path("/api/superuser/tickets") @Produces(MediaType.APPLICATION_JSON) @@ -109,6 +112,33 @@ public SupportTicketApiResource.SupportTicketListResponse list( meta.page(), meta.pageSize(), meta.totalItems(), meta.totalPages()); } + @GET + @Path("/search") + @Transactional + public SupportTicketApiResource.SupportTicketListResponse search(@Context UriInfo uriInfo, + @QueryParam("page") Integer page, @QueryParam("pageSize") Integer pageSize, @QueryParam("sort") String sort, + @QueryParam("dir") String dir) { + User user = currentUser.get(); + SuperuserResource.SupportTicketData data = superuserResource.buildTicketDataForUser(user); + List scoped = TicketSearchSupport.combineTickets(data.assignedTickets, data.openTickets, + data.closedTickets); + TicketAdvancedSearchSupport.Criteria criteria = TicketAdvancedSearchSupport.parse(uriInfo, user, + ticketCustomFieldService); + List tickets = TicketAdvancedSearchSupport.filter(scoped, criteria, user, data.messageDates, + ticketCustomFieldService); + Map displayTickets = TicketAdvancedSearchSupport.indexById(scoped); + int totalItems = tickets.size(); + List allSummaries = tickets.stream() + .map(ticket -> toSummary(displayTickets.getOrDefault(ticket.id, ticket), data)).toList(); + List pageItems = PaginationSupport.sortAndPaginate(allSummaries, + sort, dir, SupportTicketApiResource.ticketSortColumns(), page, pageSize); + PaginationSupport.PaginationMeta meta = PaginationSupport.meta(page, pageSize, totalItems); + return new SupportTicketApiResource.SupportTicketListResponse("advanced", "Search results", + data.assignedTickets == null ? 0 : data.assignedTickets.size(), + data.openTickets == null ? 0 : data.openTickets.size(), "/superuser/tickets/new", criteria.q(), + pageItems, meta.page(), meta.pageSize(), meta.totalItems(), meta.totalPages()); + } + @GET @Path("/suggest") @Transactional @@ -132,7 +162,8 @@ public SupportTicketApiResource.TicketSuggestionResponse suggest( @Path("/bootstrap") @Transactional public SupportTicketApiResource.SupportTicketBootstrapResponse bootstrap(@QueryParam("companyId") Long companyId, - @QueryParam("companyEntitlementId") Long companyEntitlementId) { + @QueryParam("companyEntitlementId") Long companyEntitlementId, + @QueryParam("allVersions") @DefaultValue("false") boolean allVersions) { User user = currentUser.get(); SuperuserResource.SupportTicketData data = superuserResource.buildTicketDataForUser(user); List companies = directoryService.allowedCompanies(user); @@ -142,8 +173,11 @@ public SupportTicketApiResource.SupportTicketBootstrapResponse bootstrap(@QueryP Long selectedCompanyEntitlementId = TicketBootstrapService.selectEntitlementId(entitlements, companyEntitlementId); Long versionsEntitlementId = entitlementIdForVersions(selectedCompanyEntitlementId, entitlements); - List versions = versionsEntitlementId == null ? List.of() - : ticketBootstrapService.versionOptionsForEntitlement(versionsEntitlementId); + List versions = allVersions + ? ticketBootstrapService + .versionOptionsForCompanies(companies.stream().map(company -> company.id).toList()) + : versionsEntitlementId == null ? List.of() + : ticketBootstrapService.versionOptionsForEntitlement(versionsEntitlementId); TicketBootstrapService.VersionOption defaultVersion = TicketBootstrapService .selectDefaultVersionOption(versions); Category defaultCategory = Category.findDefault(); @@ -218,7 +252,8 @@ public UserTicketApiResource.RoleTicketDetailResponse detail(@PathParam("id") Lo List eventEntries = rawEvents.stream().map(this::toEventEntry).toList(); return new UserTicketApiResource.RoleTicketDetailResponse(ticket.id, ticket.name, ticket.displayTitle(), - normalizeDisplayStatus(ticket.status), data.assignedTickets == null ? 0 : data.assignedTickets.size(), + TicketSearchSupport.displayStatus(ticket.status, user), + data.assignedTickets == null ? 0 : data.assignedTickets.size(), data.openTickets == null ? 0 : data.openTickets.size(), ticket.company == null ? null : ticket.company.id, ticket.company == null ? null : ticket.company.name, ticket.category == null ? null : ticket.category.id, @@ -260,7 +295,8 @@ private SupportTicketApiResource.SupportTicketSummary toSummary(Ticket ticket, SuperuserResource.SupportTicketData data) { User assignedSupport = data.supportAssignmentUsers.get(ticket.id); return new SupportTicketApiResource.SupportTicketSummary(ticket.id, ticket.name, ticket.displayTitle(), - normalizeDisplayStatus(ticket.status), data.messageDateLabels.get(ticket.id), + TicketSearchSupport.displayStatus(ticket.status, currentUser.get()), + data.messageDateLabels.get(ticket.id), data.messageDates.get(ticket.id) == null ? null : data.messageDates.get(ticket.id).toString(), data.messageDirectionArrows.get(ticket.id), data.slaColors.get(ticket.id), ticket.category == null ? null : ticket.category.name, @@ -317,16 +353,6 @@ private String normalizeView(String view) { return "assigned"; } - private String normalizeDisplayStatus(String status) { - if (status == null || status.isBlank()) { - return "Open"; - } - if ("Waiting for External Feedback".equalsIgnoreCase(status.trim())) { - return "In Progress"; - } - return status; - } - private Company selectCompany(List companies, Long companyId) { if (companies == null || companies.isEmpty()) { return null; diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java index 077d0ee..86dfa06 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java @@ -39,11 +39,14 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.UriInfo; import java.time.LocalDate; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; @Path("/api/support/tickets") @@ -103,6 +106,32 @@ public SupportTicketListResponse list(@QueryParam("view") @DefaultValue("assigne meta.totalPages()); } + @GET + @Path("/search") + @Transactional + public SupportTicketListResponse search(@Context UriInfo uriInfo, @QueryParam("page") Integer page, + @QueryParam("pageSize") Integer pageSize, @QueryParam("sort") String sort, @QueryParam("dir") String dir) { + User user = currentUser.get(); + SupportTicketViewSupport.SupportTicketData data = SupportTicketViewSupport.buildTicketData(user); + SupportTicketViewSupport.SupportTicketCounts counts = SupportTicketViewSupport.loadTicketCounts(user); + List scoped = TicketSearchSupport.combineTickets(data.assignedTickets(), data.openTickets(), + data.closedTickets()); + TicketAdvancedSearchSupport.Criteria criteria = TicketAdvancedSearchSupport.parse(uriInfo, user, + ticketCustomFieldService); + List tickets = TicketAdvancedSearchSupport.filter(scoped, criteria, user, data.messageDates(), + ticketCustomFieldService); + Map displayTickets = TicketAdvancedSearchSupport.indexById(scoped); + int totalItems = tickets.size(); + List allSummaries = tickets.stream() + .map(ticket -> toSummary(displayTickets.getOrDefault(ticket.id, ticket), data)).toList(); + List pageItems = PaginationSupport.sortAndPaginate(allSummaries, sort, dir, + ticketSortColumns(), page, pageSize); + PaginationSupport.PaginationMeta meta = PaginationSupport.meta(page, pageSize, totalItems); + return new SupportTicketListResponse("advanced", "Search results", counts.assignedCount(), counts.openCount(), + "/support/tickets/new", criteria.q(), pageItems, meta.page(), meta.pageSize(), meta.totalItems(), + meta.totalPages()); + } + @GET @Path("/suggest") @Transactional @@ -125,7 +154,8 @@ public TicketSuggestionResponse suggest(@QueryParam("view") @DefaultValue("assig @Path("/bootstrap") @Transactional public SupportTicketBootstrapResponse bootstrap(@QueryParam("companyId") Long companyId, - @QueryParam("companyEntitlementId") Long companyEntitlementId) { + @QueryParam("companyEntitlementId") Long companyEntitlementId, + @QueryParam("allVersions") @DefaultValue("false") boolean allVersions) { User user = currentUser.get(); SupportTicketViewSupport.SupportTicketCounts counts = SupportTicketViewSupport.loadTicketCounts(user); List companies = ticketBootstrapService.allCompanyOptions(); @@ -136,8 +166,11 @@ public SupportTicketBootstrapResponse bootstrap(@QueryParam("companyId") Long co Long selectedCompanyEntitlementId = TicketBootstrapService.selectEntitlementId(entitlements, companyEntitlementId); Long versionsEntitlementId = entitlementIdForVersions(selectedCompanyEntitlementId, entitlements); - List versions = versionsEntitlementId == null ? List.of() - : ticketBootstrapService.versionOptionsForEntitlement(versionsEntitlementId); + List versions = allVersions + ? ticketBootstrapService.versionOptionsForCompanies( + companies.stream().map(TicketBootstrapService.CompanyOption::id).toList()) + : versionsEntitlementId == null ? List.of() + : ticketBootstrapService.versionOptionsForEntitlement(versionsEntitlementId); TicketBootstrapService.VersionOption defaultVersion = TicketBootstrapService .selectDefaultVersionOption(versions); Category defaultCategory = Category.findDefault(); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketAdvancedSearchSupport.java b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketAdvancedSearchSupport.java new file mode 100644 index 0000000..76505b8 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketAdvancedSearchSupport.java @@ -0,0 +1,571 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne_systems.resource; + +import ai.mnemosyne_systems.model.Message; +import ai.mnemosyne_systems.model.Ticket; +import ai.mnemosyne_systems.model.TicketCustomFieldValue; +import ai.mnemosyne_systems.model.User; +import ai.mnemosyne_systems.model.event.Event; +import ai.mnemosyne_systems.model.event.EventConstants; +import ai.mnemosyne_systems.service.TicketCustomFieldService; +import ai.mnemosyne_systems.service.TicketCustomFieldService.CustomFieldDefinition; +import ai.mnemosyne_systems.util.AuthHelper; +import io.quarkus.hibernate.orm.panache.Panache; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +final class TicketAdvancedSearchSupport { + + private TicketAdvancedSearchSupport() { + } + + record Criteria(String q, Set statuses, Long categoryId, Long companyId, Long companyEntitlementId, + Long entitlementId, Long levelId, String entitlementName, String levelName, Long affectsVersionId, + Long resolvedVersionId, Long requesterId, String requester, Long supportUserId, String supportUser, + Long tamUserId, String tamUser, LocalDate activityFrom, LocalDate activityTo, LocalDate createdFrom, + LocalDate createdTo, LocalDate resolvedFrom, LocalDate resolvedTo, List customFields) { + } + + private enum CustomFieldOperation { + TEXT_CONTAINS, SELECT_EXACT, NUMBER_EQUALS, NUMBER_RANGE, DATE_EQUALS, DATE_RANGE + } + + private record CustomFieldFilter(long fieldId, CustomFieldOperation operation, String text, BigDecimal number, + BigDecimal upperNumber, LocalDate date, LocalDate upperDate) { + } + + static Criteria parse(UriInfo uriInfo, User viewer, TicketCustomFieldService customFieldService) { + MultivaluedMap params = uriInfo.getQueryParameters(); + boolean support = AuthHelper.isSupport(viewer); + boolean superuser = AuthHelper.isSuperuser(viewer); + boolean tam = AuthHelper.isTam(viewer); + + boolean canUseCompanyFilters = support || superuser || tam; + boolean canUseTamFilters = support || superuser; + boolean canUseSupportFilter = support || superuser || tam; + + String q = trimToNull(params.getFirst("q")); + Set statuses = parseStatuses(params); + Long categoryId = parseLong(params, "categoryId"); + Long companyId = parseLong(params, "companyId"); + Long companyEntitlementId = parseLong(params, "companyEntitlementId"); + Long entitlementId = parseLong(params, "entitlementId"); + Long levelId = parseLong(params, "levelId"); + String entitlementName = trimToNull(params.getFirst("entitlementName")); + String levelName = trimToNull(params.getFirst("levelName")); + Long affectsVersionId = parseLong(params, "affectsVersionId"); + Long resolvedVersionId = parseLong(params, "resolvedVersionId"); + Long requesterId = parseLong(params, "requesterId"); + String requester = trimToNull(params.getFirst("requester")); + Long supportUserId = parseLong(params, "supportUserId"); + String supportUser = trimToNull(params.getFirst("supportUser")); + Long tamUserId = parseLong(params, "tamUserId"); + String tamUser = trimToNull(params.getFirst("tamUser")); + + LocalDate activityFrom = parseDate(params, "activityFrom"); + LocalDate activityTo = parseDate(params, "activityTo"); + LocalDate createdFrom = parseDate(params, "createdFrom"); + LocalDate createdTo = parseDate(params, "createdTo"); + LocalDate resolvedFrom = parseDate(params, "resolvedFrom"); + LocalDate resolvedTo = parseDate(params, "resolvedTo"); + validateRange(activityFrom, activityTo, "activity"); + validateRange(createdFrom, createdTo, "created"); + validateRange(resolvedFrom, resolvedTo, "resolved"); + + List customFields = parseCustomFields(params, customFieldService); + + if (!canUseCompanyFilters) { + companyId = null; + companyEntitlementId = null; + entitlementId = null; + levelId = null; + entitlementName = null; + levelName = null; + } + if (!canUseSupportFilter) { + supportUserId = null; + supportUser = null; + } + if (!canUseTamFilters) { + tamUserId = null; + tamUser = null; + } + // Requester data is not exposed by any ticket list or detail response today, so it is never applied. + requesterId = null; + requester = null; + + return new Criteria(q, statuses, categoryId, companyId, companyEntitlementId, entitlementId, levelId, + entitlementName, levelName, affectsVersionId, resolvedVersionId, requesterId, requester, supportUserId, + supportUser, tamUserId, tamUser, activityFrom, activityTo, createdFrom, createdTo, resolvedFrom, + resolvedTo, customFields); + } + + static List filter(List scopedTickets, Criteria criteria, User viewer, + Map activityDates, TicketCustomFieldService customFieldService) { + List scoped = scopedTickets == null ? List.of() : scopedTickets; + Set scopedIds = new LinkedHashSet<>(); + Map displayStatuses = new HashMap<>(); + for (Ticket ticket : scoped) { + if (ticket != null && ticket.id != null) { + scopedIds.add(ticket.id); + displayStatuses.putIfAbsent(ticket.id, ticket.status); + } + } + if (scopedIds.isEmpty()) { + return List.of(); + } + + List originals = Ticket.list("id in ?1", List.copyOf(scopedIds)); + if (originals.isEmpty()) { + return List.of(); + } + + List messages = List.of(); + Map earliestVisibleMessageDates = new HashMap<>(); + Set messageMatchIds = new LinkedHashSet<>(); + if (criteria.q() != null || criteria.createdFrom() != null || criteria.createdTo() != null) { + messages = Panache.getEntityManager() + .createQuery("select m from Message m where m.ticket.id in :ticketIds", Message.class) + .setParameter("ticketIds", List.copyOf(scopedIds)).getResultList(); + String normalizedQuery = criteria.q() == null ? null : criteria.q().toLowerCase(Locale.ENGLISH); + for (Message message : messages) { + if (message == null || message.ticket == null || message.ticket.id == null + || !MessageVisibilitySupport.canViewMessage(viewer, message)) { + continue; + } + if (message.date != null) { + earliestVisibleMessageDates.merge(message.ticket.id, message.date, + (left, right) -> left.isBefore(right) ? left : right); + } + if (normalizedQuery != null && containsIgnoreCase(message.body, normalizedQuery)) { + messageMatchIds.add(message.ticket.id); + } + } + } + + Map openedDates = new HashMap<>(); + if (criteria.createdFrom() != null || criteria.createdTo() != null) { + List openedEvents = Panache.getEntityManager() + .createQuery("select e from Event e where e.key in :keys and e.eventType = :eventType", Event.class) + .setParameter("keys", List.copyOf(scopedIds)) + .setParameter("eventType", EventConstants.TICKET_OPENED).getResultList(); + for (Event event : openedEvents) { + if (event == null || event.key == null || event.createdAt == null) { + continue; + } + openedDates.merge(event.key, event.createdAt, (left, right) -> left.isBefore(right) ? left : right); + } + } + + Map> customValues = new HashMap<>(); + if (!criteria.customFields().isEmpty()) { + List values = Panache.getEntityManager().createQuery( + "select v from TicketCustomFieldValue v join fetch v.customField where v.ticket.id in :ticketIds", + TicketCustomFieldValue.class).setParameter("ticketIds", List.copyOf(scopedIds)).getResultList(); + for (TicketCustomFieldValue value : values) { + if (value == null || value.ticket == null || value.ticket.id == null || value.customField == null + || value.customField.id == null) { + continue; + } + customValues.computeIfAbsent(value.ticket.id, ignored -> new HashMap<>()).put(value.customField.id, + value); + } + } + + String normalizedQuery = criteria.q() == null ? null : criteria.q().toLowerCase(Locale.ENGLISH); + List result = new ArrayList<>(); + for (Ticket ticket : originals) { + if (!scopedIds.contains(ticket.id)) { + continue; + } + if (!matchesText(ticket, normalizedQuery, messageMatchIds)) { + continue; + } + if (!criteria.statuses().isEmpty()) { + String displayStatus = TicketSearchSupport + .displayStatus(displayStatuses.getOrDefault(ticket.id, ticket.status), viewer); + if (!criteria.statuses().contains(displayStatus.trim().toLowerCase(Locale.ENGLISH))) { + continue; + } + } + if (!matchesId(criteria.categoryId(), ticket.category == null ? null : ticket.category.id)) { + continue; + } + if (!matchesId(criteria.companyId(), ticket.company == null ? null : ticket.company.id)) { + continue; + } + if (!matchesId(criteria.companyEntitlementId(), + ticket.companyEntitlement == null ? null : ticket.companyEntitlement.id)) { + continue; + } + if (!matchesId(criteria.entitlementId(), + ticket.companyEntitlement == null || ticket.companyEntitlement.entitlement == null ? null + : ticket.companyEntitlement.entitlement.id)) { + continue; + } + if (!matchesId(criteria.levelId(), + ticket.companyEntitlement == null || ticket.companyEntitlement.supportLevel == null ? null + : ticket.companyEntitlement.supportLevel.id)) { + continue; + } + if (!matchesName(criteria.entitlementName(), + ticket.companyEntitlement == null || ticket.companyEntitlement.entitlement == null ? null + : ticket.companyEntitlement.entitlement.name)) { + continue; + } + if (!matchesName(criteria.levelName(), + ticket.companyEntitlement == null || ticket.companyEntitlement.supportLevel == null ? null + : ticket.companyEntitlement.supportLevel.name)) { + continue; + } + if (!matchesId(criteria.affectsVersionId(), + ticket.affectsVersion == null ? null : ticket.affectsVersion.id)) { + continue; + } + if (!matchesId(criteria.resolvedVersionId(), + ticket.resolvedVersion == null ? null : ticket.resolvedVersion.id)) { + continue; + } + if (!matchesUser(ticket.supportUsers, criteria.supportUserId(), criteria.supportUser())) { + continue; + } + if (!matchesUser(ticket.tamUsers, criteria.tamUserId(), criteria.tamUser())) { + continue; + } + if (!matchesDate(activityDates.get(ticket.id), criteria.activityFrom(), criteria.activityTo())) { + continue; + } + LocalDateTime createdDate = openedDates.get(ticket.id); + if (createdDate == null) { + createdDate = earliestVisibleMessageDates.get(ticket.id); + } + if (!matchesDate(createdDate, criteria.createdFrom(), criteria.createdTo())) { + continue; + } + if (!matchesDate(ticket.resolvedAt, criteria.resolvedFrom(), criteria.resolvedTo())) { + continue; + } + if (!matchesCustomFields(ticket.id, criteria.customFields(), customValues)) { + continue; + } + result.add(ticket); + } + return result.stream().filter(ticket -> ticket != null && scopedIds.contains(ticket.id)).toList(); + } + + static Map indexById(List tickets) { + Map indexed = new LinkedHashMap<>(); + if (tickets == null) { + return indexed; + } + for (Ticket ticket : tickets) { + if (ticket != null && ticket.id != null) { + indexed.putIfAbsent(ticket.id, ticket); + } + } + return indexed; + } + + private static boolean matchesText(Ticket ticket, String normalizedQuery, Set messageMatchIds) { + if (normalizedQuery == null) { + return true; + } + return containsIgnoreCase(ticket.name, normalizedQuery) || containsIgnoreCase(ticket.title, normalizedQuery) + || (ticket.id != null && messageMatchIds.contains(ticket.id)); + } + + private static boolean matchesId(Long expected, Long actual) { + return expected == null || (actual != null && expected.equals(actual)); + } + + private static boolean matchesName(String expected, String actual) { + return expected == null || (actual != null && expected.equalsIgnoreCase(actual.trim())); + } + + private static boolean matchesUser(List users, Long expectedId, String expectedText) { + if (expectedId == null && expectedText == null) { + return true; + } + if (users == null || users.isEmpty()) { + return false; + } + String normalizedText = expectedText == null ? null : expectedText.trim().toLowerCase(Locale.ENGLISH); + for (User user : users) { + if (user == null) { + continue; + } + if (expectedId != null && expectedId.equals(user.id)) { + return true; + } + if (normalizedText != null && (containsIgnoreCase(user.getDisplayName(), normalizedText) + || containsIgnoreCase(user.email, normalizedText))) { + return true; + } + } + return false; + } + + private static boolean matchesDate(LocalDateTime value, LocalDate from, LocalDate to) { + if (from == null && to == null) { + return true; + } + if (value == null) { + return false; + } + LocalDate date = value.toLocalDate(); + return (from == null || !date.isBefore(from)) && (to == null || !date.isAfter(to)); + } + + private static boolean matchesCustomFields(long ticketId, List filters, + Map> values) { + if (filters.isEmpty()) { + return true; + } + Map ticketValues = values.get(ticketId); + for (CustomFieldFilter filter : filters) { + TicketCustomFieldValue value = ticketValues == null ? null : ticketValues.get(filter.fieldId()); + if (value == null || !matchesCustomField(filter, value)) { + return false; + } + } + return true; + } + + private static boolean matchesCustomField(CustomFieldFilter filter, TicketCustomFieldValue value) { + return switch (filter.operation()) { + case TEXT_CONTAINS -> containsIgnoreCase(value.textValue, filter.text().toLowerCase(Locale.ENGLISH)); + case SELECT_EXACT -> filter.text().equals(value.textValue); + case NUMBER_EQUALS -> value.numberValue != null && value.numberValue.compareTo(filter.number()) == 0; + case NUMBER_RANGE -> value.numberValue != null + && (filter.number() == null || value.numberValue.compareTo(filter.number()) >= 0) + && (filter.upperNumber() == null || value.numberValue.compareTo(filter.upperNumber()) <= 0); + case DATE_EQUALS -> filter.date().equals(value.dateValue); + case DATE_RANGE -> + value.dateValue != null && (filter.date() == null || !value.dateValue.isBefore(filter.date())) + && (filter.upperDate() == null || !value.dateValue.isAfter(filter.upperDate())); + }; + } + + private static boolean containsIgnoreCase(String value, String normalizedNeedle) { + if (value == null || normalizedNeedle == null) { + return false; + } + return value.toLowerCase(Locale.ENGLISH).contains(normalizedNeedle); + } + + private static Set parseStatuses(MultivaluedMap params) { + List values = params.get("status"); + if (values == null || values.isEmpty()) { + return Set.of(); + } + Set statuses = new LinkedHashSet<>(); + for (String value : values) { + if (value == null) { + continue; + } + Arrays.stream(value.split(",")).map(String::trim).filter(item -> !item.isEmpty()) + .map(item -> item.toLowerCase(Locale.ENGLISH)).forEach(statuses::add); + } + return statuses; + } + + private static Long parseLong(MultivaluedMap params, String name) { + String value = trimToNull(params.getFirst(name)); + if (value == null) { + return null; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException exception) { + throw badRequest(name + " must be a number"); + } + } + + private static LocalDate parseDate(MultivaluedMap params, String name) { + String value = trimToNull(params.getFirst(name)); + if (value == null) { + return null; + } + try { + return LocalDate.parse(value); + } catch (DateTimeParseException exception) { + throw badRequest(name + " must be a valid date (YYYY-MM-DD)"); + } + } + + private static void validateRange(LocalDate from, LocalDate to, String name) { + if (from != null && to != null && from.isAfter(to)) { + throw badRequest(name + "From must not be after " + name + "To"); + } + } + + private static List parseCustomFields(MultivaluedMap params, + TicketCustomFieldService customFieldService) { + Map partsByField = new LinkedHashMap<>(); + for (String key : params.keySet()) { + if (key == null || !key.startsWith("cf.")) { + continue; + } + String suffix = key.substring(3); + String idPart = suffix; + String part = "value"; + if (suffix.endsWith(".from")) { + idPart = suffix.substring(0, suffix.length() - 5); + part = "from"; + } else if (suffix.endsWith(".to")) { + idPart = suffix.substring(0, suffix.length() - 3); + part = "to"; + } + Long fieldId; + try { + fieldId = Long.parseLong(idPart); + } catch (NumberFormatException exception) { + throw badRequest("Invalid custom field parameter " + key); + } + CustomFieldParts parts = partsByField.computeIfAbsent(fieldId, ignored -> new CustomFieldParts()); + String value = trimToNull(params.getFirst(key)); + switch (part) { + case "from" -> parts.from = value; + case "to" -> parts.to = value; + default -> parts.value = value; + } + } + if (partsByField.isEmpty()) { + return List.of(); + } + + Map definitions = new HashMap<>(); + for (CustomFieldDefinition definition : customFieldService.activeDefinitions()) { + definitions.put(definition.id(), definition); + } + + List filters = new ArrayList<>(); + for (Map.Entry entry : partsByField.entrySet()) { + Long fieldId = entry.getKey(); + CustomFieldParts parts = entry.getValue(); + CustomFieldDefinition definition = definitions.get(fieldId); + if (definition == null) { + throw badRequest("Unknown or inactive custom field: " + fieldId); + } + filters.add(toCustomFieldFilter(fieldId, definition, parts)); + } + return filters; + } + + private static CustomFieldFilter toCustomFieldFilter(Long fieldId, CustomFieldDefinition definition, + CustomFieldParts parts) { + boolean hasValue = parts.value != null; + boolean hasRange = parts.from != null || parts.to != null; + if (hasValue && hasRange) { + throw badRequest("Custom field " + fieldId + " cannot combine a value with a range"); + } + if (!hasValue && !hasRange) { + throw badRequest("Custom field " + fieldId + " requires a value or range"); + } + + String type = definition.fieldType(); + return switch (type) { + case "TEXT" -> { + if (hasRange) { + throw badRequest("Custom field " + fieldId + " does not support ranges"); + } + yield new CustomFieldFilter(fieldId, CustomFieldOperation.TEXT_CONTAINS, parts.value, null, null, null, + null); + } + case "SELECT" -> { + if (hasRange) { + throw badRequest("Custom field " + fieldId + " does not support ranges"); + } + if (definition.options() == null || !definition.options().contains(parts.value)) { + throw badRequest("Custom field " + fieldId + " has an invalid option"); + } + yield new CustomFieldFilter(fieldId, CustomFieldOperation.SELECT_EXACT, parts.value, null, null, null, + null); + } + case "NUMBER" -> { + BigDecimal number = parts.value == null ? null : parseNumber(parts.value, fieldId); + BigDecimal from = parts.from == null ? null : parseNumber(parts.from, fieldId); + BigDecimal to = parts.to == null ? null : parseNumber(parts.to, fieldId); + if (from != null && to != null && from.compareTo(to) > 0) { + throw badRequest("Custom field " + fieldId + " from must not be after to"); + } + if (hasValue) { + yield new CustomFieldFilter(fieldId, CustomFieldOperation.NUMBER_EQUALS, null, number, null, null, + null); + } + yield new CustomFieldFilter(fieldId, CustomFieldOperation.NUMBER_RANGE, null, from, to, null, null); + } + case "DATE" -> { + LocalDate date = parts.value == null ? null : parseDateValue(parts.value, fieldId); + LocalDate from = parts.from == null ? null : parseDateValue(parts.from, fieldId); + LocalDate to = parts.to == null ? null : parseDateValue(parts.to, fieldId); + if (from != null && to != null && from.isAfter(to)) { + throw badRequest("Custom field " + fieldId + " from must not be after to"); + } + if (hasValue) { + yield new CustomFieldFilter(fieldId, CustomFieldOperation.DATE_EQUALS, null, null, null, date, + null); + } + yield new CustomFieldFilter(fieldId, CustomFieldOperation.DATE_RANGE, null, null, null, from, to); + } + default -> throw badRequest("Unsupported custom field type for " + fieldId); + }; + } + + private static BigDecimal parseNumber(String value, Long fieldId) { + try { + return new BigDecimal(value); + } catch (NumberFormatException exception) { + throw badRequest("Custom field " + fieldId + " must be a number"); + } + } + + private static LocalDate parseDateValue(String value, Long fieldId) { + try { + return LocalDate.parse(value); + } catch (DateTimeParseException exception) { + throw badRequest("Custom field " + fieldId + " must be a valid date (YYYY-MM-DD)"); + } + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static BadRequestException badRequest(String message) { + return new BadRequestException(Response.status(Response.Status.BAD_REQUEST).entity(message) + .type(jakarta.ws.rs.core.MediaType.TEXT_PLAIN_TYPE).build()); + } + + private static final class CustomFieldParts { + private String value; + private String from; + private String to; + } +} diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketSearchSupport.java b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketSearchSupport.java index 9ad8021..88749a2 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketSearchSupport.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketSearchSupport.java @@ -11,6 +11,7 @@ import ai.mnemosyne_systems.model.Message; import ai.mnemosyne_systems.model.Ticket; import ai.mnemosyne_systems.model.User; +import ai.mnemosyne_systems.util.AuthHelper; import io.quarkus.hibernate.orm.panache.Panache; import java.util.ArrayList; import java.util.LinkedHashSet; @@ -31,6 +32,20 @@ static String normalizeSearchTerm(String searchTerm) { return normalized.isEmpty() ? null : normalized; } + /** + * Status label shown by the ticket lists. Assigned tickets are already normalized by the list data builders; this + * additionally mirrors the summary normalization for statuses that users and superusers never see verbatim. + */ + static String displayStatus(String status, User viewer) { + if (status == null || status.isBlank()) { + return "Open"; + } + if (!AuthHelper.isSupport(viewer) && "Waiting for External Feedback".equalsIgnoreCase(status.trim())) { + return "In Progress"; + } + return status; + } + static List filterTicketsBySearch(List tickets, String searchTerm, User viewer) { List scopedTickets = tickets == null ? List.of() : tickets; String normalizedSearchTerm = normalizeSearchTerm(searchTerm); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java index e3796a5..e32c262 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java @@ -38,10 +38,13 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.UriInfo; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; @Path("/api/user/tickets") @@ -109,6 +112,33 @@ public SupportTicketApiResource.SupportTicketListResponse list( meta.page(), meta.pageSize(), meta.totalItems(), meta.totalPages()); } + @GET + @Path("/search") + @Transactional + public SupportTicketApiResource.SupportTicketListResponse search(@Context UriInfo uriInfo, + @QueryParam("page") Integer page, @QueryParam("pageSize") Integer pageSize, @QueryParam("sort") String sort, + @QueryParam("dir") String dir) { + User user = currentUser.get(); + UserResource.SupportTicketData data = userResource.buildTicketDataForUser(user); + List scoped = TicketSearchSupport.combineTickets(data.assignedTickets, data.openTickets, + data.closedTickets); + TicketAdvancedSearchSupport.Criteria criteria = TicketAdvancedSearchSupport.parse(uriInfo, user, + ticketCustomFieldService); + List tickets = TicketAdvancedSearchSupport.filter(scoped, criteria, user, data.messageDates, + ticketCustomFieldService); + Map displayTickets = TicketAdvancedSearchSupport.indexById(scoped); + int totalItems = tickets.size(); + List allSummaries = tickets.stream() + .map(ticket -> toSummary(displayTickets.getOrDefault(ticket.id, ticket), data, user)).toList(); + List pageItems = PaginationSupport.sortAndPaginate(allSummaries, + sort, dir, SupportTicketApiResource.ticketSortColumns(), page, pageSize); + PaginationSupport.PaginationMeta meta = PaginationSupport.meta(page, pageSize, totalItems); + return new SupportTicketApiResource.SupportTicketListResponse("advanced", "Search results", + data.assignedTickets == null ? 0 : data.assignedTickets.size(), + data.openTickets == null ? 0 : data.openTickets.size(), "/user/tickets/new", criteria.q(), pageItems, + meta.page(), meta.pageSize(), meta.totalItems(), meta.totalPages()); + } + @GET @Path("/suggest") @Transactional @@ -132,7 +162,8 @@ public SupportTicketApiResource.TicketSuggestionResponse suggest( @Path("/bootstrap") @Transactional public SupportTicketApiResource.SupportTicketBootstrapResponse bootstrap(@QueryParam("companyId") Long companyId, - @QueryParam("companyEntitlementId") Long companyEntitlementId) { + @QueryParam("companyEntitlementId") Long companyEntitlementId, + @QueryParam("allVersions") @DefaultValue("false") boolean allVersions) { User user = currentUser.get(); UserResource.SupportTicketData data = userResource.buildTicketDataForUser(user); List companies = directoryService.allowedCompanies(user); @@ -142,8 +173,11 @@ public SupportTicketApiResource.SupportTicketBootstrapResponse bootstrap(@QueryP Long selectedCompanyEntitlementId = TicketBootstrapService.selectEntitlementId(entitlements, companyEntitlementId); Long versionsEntitlementId = entitlementIdForVersions(selectedCompanyEntitlementId, entitlements); - List versions = versionsEntitlementId == null ? List.of() - : ticketBootstrapService.versionOptionsForEntitlement(versionsEntitlementId); + List versions = allVersions + ? ticketBootstrapService + .versionOptionsForCompanies(companies.stream().map(company -> company.id).toList()) + : versionsEntitlementId == null ? List.of() + : ticketBootstrapService.versionOptionsForEntitlement(versionsEntitlementId); TicketBootstrapService.VersionOption defaultVersion = TicketBootstrapService .selectDefaultVersionOption(versions); Category defaultCategory = Category.findDefault(); @@ -221,7 +255,8 @@ public RoleTicketDetailResponse detail(@PathParam("id") Long id) { List eventEntries = rawEvents.stream().map(this::toEventEntry).toList(); List availabilityWarnings = userAvailabilityService.getWarningsForTicket(ticket); return new RoleTicketDetailResponse(ticket.id, ticket.name, ticket.displayTitle(), - normalizeDisplayStatus(ticket.status), data.assignedTickets == null ? 0 : data.assignedTickets.size(), + TicketSearchSupport.displayStatus(ticket.status, user), + data.assignedTickets == null ? 0 : data.assignedTickets.size(), data.openTickets == null ? 0 : data.openTickets.size(), ticket.company == null ? null : ticket.company.id, ticket.company == null ? null : ticket.company.name, ticket.category == null ? null : ticket.category.id, @@ -268,7 +303,7 @@ private SupportTicketApiResource.SupportTicketSummary toSummary(Ticket ticket, U User currentUser) { User assignedSupport = data.supportAssignmentUsers.get(ticket.id); return new SupportTicketApiResource.SupportTicketSummary(ticket.id, ticket.name, ticket.displayTitle(), - normalizeDisplayStatus(ticket.status), data.messageDateLabels.get(ticket.id), + TicketSearchSupport.displayStatus(ticket.status, currentUser), data.messageDateLabels.get(ticket.id), data.messageDates.get(ticket.id) == null ? null : data.messageDates.get(ticket.id).toString(), data.messageDirectionArrows.get(ticket.id), data.slaColors.get(ticket.id), ticket.category == null ? null : ticket.category.name, @@ -333,16 +368,6 @@ private String normalizeView(String view) { return "assigned"; } - private String normalizeDisplayStatus(String status) { - if (status == null || status.isBlank()) { - return "Open"; - } - if ("Waiting for External Feedback".equalsIgnoreCase(status.trim())) { - return "In Progress"; - } - return status; - } - private Company selectCompany(List companies, Long companyId) { if (companies == null || companies.isEmpty()) { return null; diff --git a/src/backend/main/java/ai/mnemosyne_systems/service/TicketBootstrapService.java b/src/backend/main/java/ai/mnemosyne_systems/service/TicketBootstrapService.java index 67cedac..4d9eeac 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/service/TicketBootstrapService.java +++ b/src/backend/main/java/ai/mnemosyne_systems/service/TicketBootstrapService.java @@ -86,6 +86,22 @@ public List versionOptionsForEntitlement(@CacheKey Long entitleme .map(version -> new VersionOption(version.id, version.name, version.date)).toList(); } + /** + * Versions for advanced search: every version attached to an entitlement of the given companies, rather than only + * the entitlement currently selected in the ticket form. Search spans all companies a role can see, so limiting the + * version lookup to one entitlement hides valid filter values. + */ + public List versionOptionsForCompanies(List companyIds) { + if (companyIds == null || companyIds.isEmpty()) { + return List.of(); + } + return Version + . find("select distinct v from Version v, CompanyEntitlement ce" + + " where ce.entitlement = v.entitlement and ce.company.id in ?1" + + " order by v.name asc, v.date asc, v.id asc", companyIds) + .list().stream().map(version -> new VersionOption(version.id, version.name, version.date)).toList(); + } + /** * Selects the requested company id from already-loaded options, falling back to the first option — mirroring the * per-resource {@code selectCompany} helpers. Returns {@code null} only when there are no options at all. diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/AdvancedSearchApiTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/AdvancedSearchApiTest.java new file mode 100644 index 0000000..63b8eae --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/AdvancedSearchApiTest.java @@ -0,0 +1,1012 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne_systems.resource; + +import ai.mnemosyne_systems.model.Category; +import ai.mnemosyne_systems.model.Company; +import ai.mnemosyne_systems.model.CompanyEntitlement; +import ai.mnemosyne_systems.model.CustomField; +import ai.mnemosyne_systems.model.CustomFieldOption; +import ai.mnemosyne_systems.model.Entitlement; +import ai.mnemosyne_systems.model.Level; +import ai.mnemosyne_systems.model.Message; +import ai.mnemosyne_systems.model.Ticket; +import ai.mnemosyne_systems.model.TicketCustomFieldValue; +import ai.mnemosyne_systems.model.User; +import ai.mnemosyne_systems.model.Version; +import ai.mnemosyne_systems.model.event.EventConstants; +import ai.mnemosyne_systems.service.TicketCustomFieldService; +import io.quarkus.hibernate.orm.panache.Panache; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.jwt.Claim; +import io.quarkus.test.security.jwt.JwtSecurity; +import io.restassured.RestAssured; +import io.restassured.response.Response; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class AdvancedSearchApiTest extends AccessTestSupport { + + private static final String SUPPORT_EMAIL = "adv-support@mnemosyne-systems.ai"; + private static final String SUPPORT2_EMAIL = "adv-support2@mnemosyne-systems.ai"; + private static final String TAM_EMAIL = "adv-tam-member@mnemosyne-systems.ai"; + private static final String USER_EMAIL = "adv-user@mnemosyne-systems.ai"; + private static final String SUPERUSER_EMAIL = "adv-superuser@mnemosyne-systems.ai"; + private static final String TAM_ROLE_EMAIL = "adv-tam@mnemosyne-systems.ai"; + private static final String TAM2_ROLE_EMAIL = "adv-tam2@mnemosyne-systems.ai"; + private static final String USER2_EMAIL = "adv-user2@mnemosyne-systems.ai"; + private static final String ADMIN_EMAIL = "adv-admin@mnemosyne-systems.ai"; + + private static final String SUPPORT_PATH = "/api/support/tickets/search"; + private static final String SUPERUSER_PATH = "/api/superuser/tickets/search"; + private static final String USER_PATH = "/api/user/tickets/search"; + + @Inject + TicketCustomFieldService ticketCustomFieldService; + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void supportEmptySearchReturnsScopedTickets() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + Long companyId = seedCompany("ADV Empty Support Co"); + EntitlementData entry = seedEntitlement(companyId, "ADV Empty Support Entitlement", "Normal"); + ensureUser("adv-support2", SUPPORT2_EMAIL, User.TYPE_SUPPORT); + Ticket assigned = seedTicket(companyId, entry.id, "ADV Empty Support Assigned", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + Ticket otherSupport = seedTicket(companyId, entry.id, "ADV Empty Support Other", "Assigned", USER_EMAIL, null, + null, null, entry.version100, null, null); + assignSupport(otherSupport.id, SUPPORT2_EMAIL); + + Set expected = unionOfListViews("/api/support/tickets"); + Set actual = searchAllIds(SUPPORT_PATH, Map.of()); + + Assertions.assertEquals(expected, actual); + Assertions.assertTrue(actual.contains(assigned.id)); + Assertions.assertFalse(actual.contains(otherSupport.id)); + + RestAssured.given().get(SUPPORT_PATH).then().statusCode(200).body("view", Matchers.equalTo("advanced")) + .body("title", Matchers.equalTo("Search results")); + } + + @Test + @TestSecurity(user = SUPERUSER_EMAIL, roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPERUSER_EMAIL), + @Claim(key = "sub", value = "adv-superuser") }) + void superuserEmptySearchReturnsScopedTickets() { + ensureUser("adv-superuser", SUPERUSER_EMAIL, User.TYPE_SUPERUSER); + Long companyId = seedCompany("ADV Empty Superuser Co", SUPERUSER_EMAIL); + EntitlementData entry = seedEntitlement(companyId, "ADV Empty Superuser Entitlement", "Normal"); + Ticket mine = seedTicket(companyId, entry.id, "ADV Empty Superuser Mine", "Assigned", USER_EMAIL, SUPPORT_EMAIL, + null, null, entry.version100, null, null); + Long otherCompanyId = seedCompany("ADV Empty Superuser Other Co"); + EntitlementData otherEntry = seedEntitlement(otherCompanyId, "ADV Empty Superuser Other Entitlement", "Normal"); + Ticket other = seedTicket(otherCompanyId, otherEntry.id, "ADV Empty Superuser Other", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, otherEntry.version100, null, null); + + Set expected = unionOfListViews("/api/superuser/tickets"); + Set actual = searchAllIds(SUPERUSER_PATH, Map.of()); + + Assertions.assertEquals(expected, actual); + Assertions.assertTrue(actual.contains(mine.id)); + Assertions.assertFalse(actual.contains(other.id)); + } + + @Test + @TestSecurity(user = TAM_ROLE_EMAIL, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM_ROLE_EMAIL), @Claim(key = "sub", value = "adv-tam") }) + void tamEmptySearchReturnsScopedTickets() { + ensureUser("adv-tam", TAM_ROLE_EMAIL, User.TYPE_TAM); + Long companyId = seedCompany("ADV Empty Tam Co", TAM_ROLE_EMAIL); + EntitlementData entry = seedEntitlement(companyId, "ADV Empty Tam Entitlement", "Normal"); + Ticket mine = seedTicket(companyId, entry.id, "ADV Empty Tam Mine", "Assigned", USER_EMAIL, SUPPORT_EMAIL, null, + null, entry.version100, null, null); + Long otherCompanyId = seedCompany("ADV Empty Tam Other Co"); + EntitlementData otherEntry = seedEntitlement(otherCompanyId, "ADV Empty Tam Other Entitlement", "Normal"); + Ticket other = seedTicket(otherCompanyId, otherEntry.id, "ADV Empty Tam Other", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, otherEntry.version100, null, null); + + Set expected = unionOfListViews("/api/user/tickets"); + Set actual = searchAllIds(USER_PATH, Map.of()); + + Assertions.assertEquals(expected, actual); + Assertions.assertTrue(actual.contains(mine.id)); + Assertions.assertFalse(actual.contains(other.id)); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "adv-user") }) + void userEmptySearchReturnsScopedTickets() { + ensureUser("adv-user", USER_EMAIL, User.TYPE_USER); + ensureUser("adv-user2", USER2_EMAIL, User.TYPE_USER); + Long companyId = seedCompany("ADV Empty User Co", USER_EMAIL); + EntitlementData entry = seedEntitlement(companyId, "ADV Empty User Entitlement", "Normal"); + Ticket mine = seedTicket(companyId, entry.id, "ADV Empty User Mine", "Assigned", USER_EMAIL, SUPPORT_EMAIL, + null, null, entry.version100, null, null); + Ticket other = seedTicket(companyId, entry.id, "ADV Empty User Other", "Assigned", USER2_EMAIL, SUPPORT_EMAIL, + null, null, entry.version100, null, null); + + Set expected = unionOfListViews("/api/user/tickets"); + Set actual = searchAllIds(USER_PATH, Map.of()); + + Assertions.assertEquals(expected, actual); + Assertions.assertTrue(actual.contains(mine.id)); + Assertions.assertFalse(actual.contains(other.id)); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void searchBootstrapExposesVersionsBeyondTheTicketFormEntitlement() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + + Response plain = RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200).extract() + .response(); + List companyIds = plain.jsonPath().getList("companies.id", Integer.class); + Assertions.assertFalse(companyIds.isEmpty()); + Long companyId = companyIds.get(0).longValue(); + List formVersionIds = plain.jsonPath().getList("versions.id", Integer.class); + + // A second entitlement for the same company owns versions the ticket form's default entitlement does not. + EntitlementData entry = seedEntitlement(companyId, "ZZZ Advanced Search Versions", "Normal"); + + Response searchBootstrap = RestAssured.given().queryParam("allVersions", "true") + .get("/api/support/tickets/bootstrap").then().statusCode(200).extract().response(); + List searchVersionIds = searchBootstrap.jsonPath().getList("versions.id", Integer.class); + + Assertions.assertTrue(searchVersionIds.contains(entry.version100.intValue())); + Assertions.assertTrue(searchVersionIds.containsAll(formVersionIds)); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void supportFiltersNeverReachOutOfScopeTickets() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + ensureUser("adv-support2", SUPPORT2_EMAIL, User.TYPE_SUPPORT); + Long inCompanyId = seedCompany("ADV Invariant Support In Co"); + EntitlementData inEntry = seedEntitlement(inCompanyId, "ADV Invariant Support In Entitlement", "Normal"); + Ticket inScope = seedTicket(inCompanyId, inEntry.id, "ADV Invariant Support In Scope", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, inEntry.version100, null, null); + Long outCompanyId = seedCompany("ADV Invariant Support Out Co"); + EntitlementData outEntry = seedEntitlement(outCompanyId, "ADV Invariant Support Out Entitlement", "Normal"); + Ticket outOfScope = seedTicket(outCompanyId, outEntry.id, "ADV Invariant Support Out Of Scope", "Assigned", + USER_EMAIL, null, null, null, outEntry.version100, null, null); + assignSupport(outOfScope.id, SUPPORT2_EMAIL); + Long fieldId = seedCustomField("ADV Invariant Support Field", CustomField.TYPE_TEXT, List.of()); + setCustomValue(outOfScope.id, fieldId, "secret-probe", null, null); + + Assertions.assertTrue(searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Invariant Support Out Of Scope")).isEmpty()); + Assertions.assertTrue(searchAllIds(SUPPORT_PATH, Map.of("companyId", outCompanyId)).isEmpty()); + Assertions.assertTrue(searchAllIds(SUPPORT_PATH, Map.of("companyEntitlementId", outEntry.id)).isEmpty()); + Assertions.assertTrue(searchAllIds(SUPPORT_PATH, Map.of("cf." + fieldId, "secret-probe")).isEmpty()); + + Set combined = searchAllIds(SUPPORT_PATH, + Map.of("q", "ADV Invariant Support", "companyId", outCompanyId)); + Assertions.assertTrue(combined.isEmpty()); + Set matchingInScope = searchAllIds(SUPPORT_PATH, Map.of("companyId", inCompanyId)); + Assertions.assertEquals(Set.of(inScope.id), matchingInScope); + } + + @Test + @TestSecurity(user = SUPERUSER_EMAIL, roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPERUSER_EMAIL), + @Claim(key = "sub", value = "adv-superuser") }) + void superuserFiltersNeverReachOutOfScopeTickets() { + ensureUser("adv-superuser", SUPERUSER_EMAIL, User.TYPE_SUPERUSER); + Long inCompanyId = seedCompany("ADV Invariant Superuser In Co", SUPERUSER_EMAIL); + EntitlementData inEntry = seedEntitlement(inCompanyId, "ADV Invariant Superuser In Entitlement", "Normal"); + Ticket inScope = seedTicket(inCompanyId, inEntry.id, "ADV Invariant Superuser In Scope", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, inEntry.version100, null, null); + Long outCompanyId = seedCompany("ADV Invariant Superuser Out Co"); + EntitlementData outEntry = seedEntitlement(outCompanyId, "ADV Invariant Superuser Out Entitlement", "Normal"); + Ticket outOfScope = seedTicket(outCompanyId, outEntry.id, "ADV Invariant Superuser Out Of Scope", "Assigned", + USER_EMAIL, SUPPORT_EMAIL, null, null, outEntry.version100, null, null); + + Assertions.assertTrue( + searchAllIds(SUPERUSER_PATH, Map.of("q", "ADV Invariant Superuser Out Of Scope")).isEmpty()); + Assertions.assertTrue(searchAllIds(SUPERUSER_PATH, Map.of("companyId", outCompanyId)).isEmpty()); + Assertions.assertTrue(searchAllIds(SUPERUSER_PATH, Map.of("companyEntitlementId", outEntry.id)).isEmpty()); + Assertions.assertEquals(Set.of(inScope.id), searchAllIds(SUPERUSER_PATH, Map.of("companyId", inCompanyId))); + Assertions.assertFalse(searchAllIds(SUPERUSER_PATH, Map.of()).contains(outOfScope.id)); + } + + @Test + @TestSecurity(user = TAM_ROLE_EMAIL, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM_ROLE_EMAIL), @Claim(key = "sub", value = "adv-tam") }) + void tamFiltersNeverReachOutOfScopeTickets() { + ensureUser("adv-tam", TAM_ROLE_EMAIL, User.TYPE_TAM); + Long inCompanyId = seedCompany("ADV Invariant Tam In Co", TAM_ROLE_EMAIL); + EntitlementData inEntry = seedEntitlement(inCompanyId, "ADV Invariant Tam In Entitlement", "Normal"); + Ticket inScope = seedTicket(inCompanyId, inEntry.id, "ADV Invariant Tam In Scope", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, inEntry.version100, null, null); + Long outCompanyId = seedCompany("ADV Invariant Tam Out Co"); + EntitlementData outEntry = seedEntitlement(outCompanyId, "ADV Invariant Tam Out Entitlement", "Normal"); + Ticket outOfScope = seedTicket(outCompanyId, outEntry.id, "ADV Invariant Tam Out Of Scope", "Assigned", + USER_EMAIL, SUPPORT_EMAIL, null, null, outEntry.version100, null, null); + + Assertions.assertTrue(searchAllIds(USER_PATH, Map.of("q", "ADV Invariant Tam Out Of Scope")).isEmpty()); + Assertions.assertTrue(searchAllIds(USER_PATH, Map.of("companyId", outCompanyId)).isEmpty()); + Assertions.assertTrue(searchAllIds(USER_PATH, Map.of("companyEntitlementId", outEntry.id)).isEmpty()); + Assertions.assertEquals(Set.of(inScope.id), searchAllIds(USER_PATH, Map.of("companyId", inCompanyId))); + Assertions.assertFalse(searchAllIds(USER_PATH, Map.of()).contains(outOfScope.id)); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "adv-user") }) + void userFiltersNeverReachOutOfScopeTickets() { + ensureUser("adv-user", USER_EMAIL, User.TYPE_USER); + ensureUser("adv-user2", USER2_EMAIL, User.TYPE_USER); + Long companyId = seedCompany("ADV Invariant User Co", USER_EMAIL, USER2_EMAIL); + EntitlementData entry = seedEntitlement(companyId, "ADV Invariant User Entitlement", "Normal"); + Ticket inScope = seedTicket(companyId, entry.id, "ADV Invariant User In Scope", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + Ticket outOfScope = seedTicket(companyId, entry.id, "ADV Invariant User Out Of Scope", "Assigned", USER2_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + Long fieldId = seedCustomField("ADV Invariant User Field", CustomField.TYPE_TEXT, List.of()); + setCustomValue(outOfScope.id, fieldId, "user-secret-probe", null, null); + + Assertions.assertTrue(searchAllIds(USER_PATH, Map.of("q", "ADV Invariant User Out Of Scope")).isEmpty()); + Assertions.assertTrue(searchAllIds(USER_PATH, Map.of("cf." + fieldId, "user-secret-probe")).isEmpty()); + Assertions.assertEquals(Set.of(inScope.id), + searchAllIds(USER_PATH, Map.of("q", "ADV Invariant User In Scope"))); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void supportStatusFilterUsesDisplayedStatus() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + Long companyId = seedCompany("ADV Status Support Co"); + EntitlementData entry = seedEntitlement(companyId, "ADV Status Support Entitlement", "Normal"); + Ticket assignedOpen = seedTicket(companyId, entry.id, "ADV Status Support Assigned Open", "Open", USER_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + Ticket unassignedOpen = seedTicket(companyId, entry.id, "ADV Status Support Unassigned Open", "Open", + USER_EMAIL, null, null, null, entry.version100, null, null); + Ticket assignedWaiting = seedTicket(companyId, entry.id, "ADV Status Support Assigned Waiting", + "Waiting for External Feedback", USER_EMAIL, SUPPORT_EMAIL, null, null, entry.version100, null, null); + + Map> listStatuses = listStatusIds("/api/support/tickets"); + Assertions.assertTrue(listStatuses.getOrDefault("Assigned", Set.of()).contains(assignedOpen.id)); + Assertions.assertFalse(listStatuses.getOrDefault("Open", Set.of()).contains(assignedOpen.id)); + Assertions.assertTrue(listStatuses.getOrDefault("Open", Set.of()).contains(unassignedOpen.id)); + Assertions.assertTrue( + listStatuses.getOrDefault("Waiting for External Feedback", Set.of()).contains(assignedWaiting.id)); + assertStatusFiltersMatchList(SUPPORT_PATH, "/api/support/tickets"); + } + + @Test + @TestSecurity(user = SUPERUSER_EMAIL, roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPERUSER_EMAIL), + @Claim(key = "sub", value = "adv-superuser") }) + void superuserStatusFilterUsesDisplayedStatus() { + ensureUser("adv-superuser", SUPERUSER_EMAIL, User.TYPE_SUPERUSER); + Long companyId = seedCompany("ADV Status Superuser Co", SUPERUSER_EMAIL); + EntitlementData entry = seedEntitlement(companyId, "ADV Status Superuser Entitlement", "Normal"); + Ticket assignedOpen = seedTicket(companyId, entry.id, "ADV Status Superuser Assigned Open", "Open", USER_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + Ticket unassignedOpen = seedTicket(companyId, entry.id, "ADV Status Superuser Unassigned Open", "Open", + USER_EMAIL, null, null, null, entry.version100, null, null); + Ticket assignedWaiting = seedTicket(companyId, entry.id, "ADV Status Superuser Assigned Waiting", + "Waiting for External Feedback", USER_EMAIL, SUPPORT_EMAIL, null, null, entry.version100, null, null); + + Map> listStatuses = listStatusIds("/api/superuser/tickets"); + Assertions.assertTrue(listStatuses.getOrDefault("Assigned", Set.of()).contains(assignedOpen.id)); + Assertions.assertFalse(listStatuses.getOrDefault("Open", Set.of()).contains(assignedOpen.id)); + Assertions.assertTrue(listStatuses.getOrDefault("Open", Set.of()).contains(unassignedOpen.id)); + Assertions.assertTrue(listStatuses.getOrDefault("In Progress", Set.of()).contains(assignedWaiting.id)); + Assertions.assertFalse( + listStatuses.getOrDefault("Waiting for External Feedback", Set.of()).contains(assignedWaiting.id)); + assertStatusFiltersMatchList(SUPERUSER_PATH, "/api/superuser/tickets"); + } + + @Test + @TestSecurity(user = TAM_ROLE_EMAIL, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM_ROLE_EMAIL), @Claim(key = "sub", value = "adv-tam") }) + void tamStatusFilterUsesDisplayedStatus() { + ensureUser("adv-tam", TAM_ROLE_EMAIL, User.TYPE_TAM); + Long companyId = seedCompany("ADV Status Tam Co", TAM_ROLE_EMAIL); + EntitlementData entry = seedEntitlement(companyId, "ADV Status Tam Entitlement", "Normal"); + Ticket assignedOpen = seedTicket(companyId, entry.id, "ADV Status Tam Assigned Open", "Open", USER_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + Ticket unassignedOpen = seedTicket(companyId, entry.id, "ADV Status Tam Unassigned Open", "Open", USER_EMAIL, + null, null, null, entry.version100, null, null); + Ticket assignedWaiting = seedTicket(companyId, entry.id, "ADV Status Tam Assigned Waiting", + "Waiting for External Feedback", USER_EMAIL, SUPPORT_EMAIL, null, null, entry.version100, null, null); + + Map> listStatuses = listStatusIds("/api/user/tickets"); + Assertions.assertTrue(listStatuses.getOrDefault("Assigned", Set.of()).contains(assignedOpen.id)); + Assertions.assertFalse(listStatuses.getOrDefault("Open", Set.of()).contains(assignedOpen.id)); + Assertions.assertTrue(listStatuses.getOrDefault("Open", Set.of()).contains(unassignedOpen.id)); + Assertions.assertTrue(listStatuses.getOrDefault("In Progress", Set.of()).contains(assignedWaiting.id)); + Assertions.assertFalse( + listStatuses.getOrDefault("Waiting for External Feedback", Set.of()).contains(assignedWaiting.id)); + assertStatusFiltersMatchList(USER_PATH, "/api/user/tickets"); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "adv-user") }) + void userStatusFilterUsesDisplayedStatus() { + ensureUser("adv-user", USER_EMAIL, User.TYPE_USER); + Long companyId = seedCompany("ADV Status User Co", USER_EMAIL); + EntitlementData entry = seedEntitlement(companyId, "ADV Status User Entitlement", "Normal"); + Ticket assignedOpen = seedTicket(companyId, entry.id, "ADV Status User Assigned Open", "Open", USER_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + Ticket unassignedOpen = seedTicket(companyId, entry.id, "ADV Status User Unassigned Open", "Open", USER_EMAIL, + null, null, null, entry.version100, null, null); + Ticket assignedWaiting = seedTicket(companyId, entry.id, "ADV Status User Assigned Waiting", + "Waiting for External Feedback", USER_EMAIL, SUPPORT_EMAIL, null, null, entry.version100, null, null); + + Map> listStatuses = listStatusIds("/api/user/tickets"); + Assertions.assertTrue(listStatuses.getOrDefault("Assigned", Set.of()).contains(assignedOpen.id)); + Assertions.assertFalse(listStatuses.getOrDefault("Open", Set.of()).contains(assignedOpen.id)); + Assertions.assertTrue(listStatuses.getOrDefault("Open", Set.of()).contains(unassignedOpen.id)); + Assertions.assertTrue(listStatuses.getOrDefault("In Progress", Set.of()).contains(assignedWaiting.id)); + Assertions.assertFalse( + listStatuses.getOrDefault("Waiting for External Feedback", Set.of()).contains(assignedWaiting.id)); + assertStatusFiltersMatchList(USER_PATH, "/api/user/tickets"); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "adv-user") }) + void userIgnoredFiltersDoNotChangeResults() { + ensureUser("adv-user", USER_EMAIL, User.TYPE_USER); + ensureUser("adv-user2", USER2_EMAIL, User.TYPE_USER); + ensureUser("adv-support2", SUPPORT2_EMAIL, User.TYPE_SUPPORT); + ensureUser("adv-tam-member", TAM_EMAIL, User.TYPE_TAM); + Long companyId = seedCompany("ADV Ignored User Co", USER_EMAIL); + EntitlementData entry = seedEntitlement(companyId, "ADV Ignored User Entitlement", "Normal"); + Ticket mine = seedTicket(companyId, entry.id, "ADV Ignored User Ticket", "Assigned", USER_EMAIL, SUPPORT_EMAIL, + TAM_EMAIL, null, entry.version100, null, null); + Long otherCompanyId = seedCompany("ADV Ignored User Other Co"); + EntitlementData otherEntry = seedEntitlement(otherCompanyId, "ADV Ignored User Other Entitlement", "Normal"); + Ticket other = seedTicket(otherCompanyId, otherEntry.id, "ADV Ignored User Other Ticket", "Assigned", + USER2_EMAIL, null, null, null, otherEntry.version100, null, null); + + Set baseline = searchAllIds(USER_PATH, Map.of()); + Assertions.assertTrue(baseline.contains(mine.id)); + + Map ignored = new LinkedHashMap<>(); + ignored.put("companyId", otherCompanyId); + ignored.put("companyEntitlementId", otherEntry.id); + ignored.put("entitlementId", otherEntry.entitlementId); + ignored.put("levelId", otherEntry.levelId); + ignored.put("supportUserId", userId(SUPPORT2_EMAIL)); + ignored.put("tamUserId", userId(TAM_EMAIL)); + ignored.put("requesterId", userId(USER2_EMAIL)); + ignored.put("supportUser", "support2"); + ignored.put("tamUser", "tam1"); + ignored.put("entitlementName", "ADV Ignored User Other Entitlement"); + ignored.put("levelName", otherEntry.levelName); + ignored.put("requester", "adv-user2"); + for (Map.Entry parameter : ignored.entrySet()) { + Assertions.assertEquals(baseline, searchAllIds(USER_PATH, Map.of(parameter.getKey(), parameter.getValue())), + "Ignored user filter must not change results: " + parameter.getKey()); + } + Assertions.assertFalse(searchAllIds(USER_PATH, Map.of()).contains(other.id)); + } + + @Test + @TestSecurity(user = TAM_ROLE_EMAIL, roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = TAM_ROLE_EMAIL), @Claim(key = "sub", value = "adv-tam") }) + void tamIgnoredFiltersDoNotChangeResults() { + ensureUser("adv-tam", TAM_ROLE_EMAIL, User.TYPE_TAM); + ensureUser("adv-tam2", TAM2_ROLE_EMAIL, User.TYPE_TAM); + ensureUser("adv-tam-member", TAM_EMAIL, User.TYPE_TAM); + ensureUser("adv-user2", USER2_EMAIL, User.TYPE_USER); + Long companyId = seedCompany("ADV Ignored Tam Co", TAM_ROLE_EMAIL); + EntitlementData entry = seedEntitlement(companyId, "ADV Ignored Tam Entitlement", "Normal"); + Ticket mine = seedTicket(companyId, entry.id, "ADV Ignored Tam Ticket", "Assigned", USER_EMAIL, SUPPORT_EMAIL, + TAM_EMAIL, null, entry.version100, null, null); + + Set baseline = searchAllIds(USER_PATH, Map.of()); + Assertions.assertTrue(baseline.contains(mine.id)); + + Assertions.assertEquals(baseline, searchAllIds(USER_PATH, Map.of("tamUserId", userId(TAM2_ROLE_EMAIL)))); + Assertions.assertEquals(baseline, searchAllIds(USER_PATH, Map.of("tamUser", "adv-tam2"))); + Assertions.assertEquals(baseline, searchAllIds(USER_PATH, Map.of("requesterId", userId(USER2_EMAIL)))); + Assertions.assertEquals(baseline, searchAllIds(USER_PATH, Map.of("requester", "adv-user2"))); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void supportRequesterFilterIsIgnored() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + ensureUser("adv-user2", USER2_EMAIL, User.TYPE_USER); + Long companyId = seedCompany("ADV Ignored Support Co"); + EntitlementData entry = seedEntitlement(companyId, "ADV Ignored Support Entitlement", "Normal"); + Ticket mine = seedTicket(companyId, entry.id, "ADV Ignored Support Ticket", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + + Set baseline = searchAllIds(SUPPORT_PATH, Map.of()); + Assertions.assertTrue(baseline.contains(mine.id)); + Assertions.assertEquals(baseline, searchAllIds(SUPPORT_PATH, Map.of("requesterId", userId(USER2_EMAIL)))); + Assertions.assertEquals(baseline, searchAllIds(SUPPORT_PATH, Map.of("requester", "adv-user2"))); + } + + @Test + @TestSecurity(user = SUPERUSER_EMAIL, roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPERUSER_EMAIL), + @Claim(key = "sub", value = "adv-superuser") }) + void superuserRequesterFilterIsIgnored() { + ensureUser("adv-superuser", SUPERUSER_EMAIL, User.TYPE_SUPERUSER); + ensureUser("adv-user2", USER2_EMAIL, User.TYPE_USER); + Long companyId = seedCompany("ADV Ignored Superuser Co", SUPERUSER_EMAIL); + EntitlementData entry = seedEntitlement(companyId, "ADV Ignored Superuser Entitlement", "Normal"); + Ticket mine = seedTicket(companyId, entry.id, "ADV Ignored Superuser Ticket", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + + Set baseline = searchAllIds(SUPERUSER_PATH, Map.of()); + Assertions.assertTrue(baseline.contains(mine.id)); + Assertions.assertEquals(baseline, searchAllIds(SUPERUSER_PATH, Map.of("requesterId", userId(USER2_EMAIL)))); + Assertions.assertEquals(baseline, searchAllIds(SUPERUSER_PATH, Map.of("requester", "adv-user2"))); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void supportIndividualAndCombinedFilters() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + ensureUser("adv-tam", TAM_ROLE_EMAIL, User.TYPE_TAM); + ensureUser("adv-tam2", TAM2_ROLE_EMAIL, User.TYPE_TAM); + ensureUser("adv-tam-member", TAM_EMAIL, User.TYPE_TAM); + FilterData data = seedFilterData(); + + Set prefix = searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Filter")); + Assertions.assertEquals(Set.of(data.alpha, data.beta, data.gamma, data.delta), prefix); + + Assertions.assertEquals(Set.of(data.alpha, data.beta, data.delta), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Filter", "status", "assigned"))); + Assertions.assertEquals(Set.of(data.alpha, data.beta, data.delta), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Filter", "status", "open,assigned"))); + Assertions.assertEquals(Set.of(data.gamma), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Filter", "status", "Closed"))); + Assertions.assertEquals(Set.of(data.alpha, data.gamma), + searchAllIds(SUPPORT_PATH, Map.of("categoryId", data.categoryAlpha))); + Assertions.assertEquals(Set.of(data.alpha, data.beta), + searchAllIds(SUPPORT_PATH, Map.of("affectsVersionId", data.version100))); + Assertions.assertEquals(Set.of(data.alpha), + searchAllIds(SUPPORT_PATH, Map.of("resolvedVersionId", data.version101))); + Assertions.assertEquals(Set.of(data.alpha, data.beta, data.gamma), + searchAllIds(SUPPORT_PATH, Map.of("companyId", data.companyA))); + Assertions.assertEquals(Set.of(data.alpha, data.beta), + searchAllIds(SUPPORT_PATH, Map.of("companyEntitlementId", data.entitlementAlpha))); + Assertions.assertEquals(Set.of(data.alpha, data.beta), + searchAllIds(SUPPORT_PATH, Map.of("entitlementId", data.entitlementAId))); + Assertions.assertEquals(Set.of(data.alpha, data.beta), + searchAllIds(SUPPORT_PATH, Map.of("levelId", data.levelAId))); + Assertions.assertEquals(Set.of(data.alpha, data.beta), searchAllIds(SUPPORT_PATH, + Map.of("companyId", data.companyA, "entitlementName", "ADV Filter Entitlement A"))); + Assertions.assertEquals(Set.of(data.alpha, data.beta), + searchAllIds(SUPPORT_PATH, Map.of("companyId", data.companyA, "levelName", "ADV Filter Level A"))); + + Assertions.assertEquals(Set.of(data.alpha, data.beta, data.gamma, data.delta), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Filter", "supportUserId", userId(SUPPORT_EMAIL)))); + Assertions.assertTrue( + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Filter", "supportUser", "adv-support2")).isEmpty()); + Assertions.assertEquals(Set.of(data.alpha, data.beta, data.gamma, data.delta), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Filter", "supportUser", "adv-support"))); + Assertions.assertEquals(Set.of(data.alpha), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Filter", "tamUserId", userId(TAM_EMAIL)))); + Assertions.assertEquals(Set.of(data.beta), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Filter", "tamUser", "adv-tam2"))); + + Assertions.assertEquals(Set.of(data.alpha), searchAllIds(SUPPORT_PATH, Map.of("q", "Alpha", "status", + "Assigned", "companyId", data.companyA, "categoryId", data.categoryAlpha, "levelId", data.levelAId))); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void supportDateFiltersUseInclusiveRanges() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + Long companyId = seedCompany("ADV Date Co"); + EntitlementData entry = seedEntitlement(companyId, "ADV Date Entitlement", "Normal"); + Ticket old = seedTicket(companyId, entry.id, "ADV Date Old", "Assigned", USER_EMAIL, SUPPORT_EMAIL, null, null, + entry.version100, null, LocalDateTime.of(2026, 3, 1, 9, 0)); + seedMessage(old.id, "ADV Date Old public", SUPPORT_EMAIL, LocalDateTime.of(2026, 2, 1, 10, 0), true); + insertOpenedEvent(old.id, companyId, LocalDateTime.of(2026, 1, 5, 12, 0)); + Ticket recent = seedTicket(companyId, entry.id, "ADV Date Recent", "Assigned", USER_EMAIL, SUPPORT_EMAIL, null, + null, entry.version100, null, LocalDateTime.of(2026, 3, 10, 9, 0)); + seedMessage(recent.id, "ADV Date Recent public", SUPPORT_EMAIL, LocalDateTime.of(2026, 2, 10, 23, 59, 59), + true); + insertOpenedEvent(recent.id, companyId, LocalDateTime.of(2026, 1, 20, 0, 0)); + Ticket withoutDates = seedTicket(companyId, entry.id, "ADV Date Without Dates", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + + Assertions.assertEquals(Set.of(old.id), searchAllIds(SUPPORT_PATH, + Map.of("q", "ADV Date", "activityFrom", "2026-02-01", "activityTo", "2026-02-01"))); + Assertions.assertEquals(Set.of(recent.id), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Date", "activityFrom", "2026-02-10"))); + Assertions.assertEquals(Set.of(old.id), searchAllIds(SUPPORT_PATH, + Map.of("q", "ADV Date", "createdFrom", "2026-01-05", "createdTo", "2026-01-05"))); + Assertions.assertEquals(Set.of(recent.id), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Date", "createdFrom", "2026-01-20"))); + Assertions.assertEquals(Set.of(old.id), searchAllIds(SUPPORT_PATH, + Map.of("q", "ADV Date", "resolvedFrom", "2026-03-01", "resolvedTo", "2026-03-01"))); + Assertions.assertEquals(Set.of(recent.id), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Date", "resolvedFrom", "2026-03-09"))); + Assertions.assertTrue( + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Date", "activityFrom", "2030-01-01")).isEmpty()); + Assertions.assertFalse(searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Date", "activityFrom", "2026-02-01")) + .contains(withoutDates.id)); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void supportCustomFieldFilters() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + Long companyId = seedCompany("ADV Custom Field Co"); + EntitlementData entry = seedEntitlement(companyId, "ADV Custom Field Entitlement", "Normal"); + Ticket first = seedTicket(companyId, entry.id, "ADV Custom Field First", "Assigned", USER_EMAIL, SUPPORT_EMAIL, + null, null, entry.version100, null, null); + Ticket second = seedTicket(companyId, entry.id, "ADV Custom Field Second", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + Long textField = seedCustomField("ADV CF Text", CustomField.TYPE_TEXT, List.of()); + Long numberField = seedCustomField("ADV CF Number", CustomField.TYPE_NUMBER, List.of()); + Long dateField = seedCustomField("ADV CF Date", CustomField.TYPE_DATE, List.of()); + Long selectField = seedCustomField("ADV CF Select", CustomField.TYPE_SELECT, List.of("One", "Two")); + setCustomValue(first.id, textField, "Hello World", null, null); + setCustomValue(first.id, numberField, null, new BigDecimal("10"), null); + setCustomValue(first.id, dateField, null, null, LocalDate.of(2026, 4, 1)); + setCustomValue(first.id, selectField, "One", null, null); + setCustomValue(second.id, textField, "Something Else", null, null); + setCustomValue(second.id, numberField, null, new BigDecimal("20"), null); + setCustomValue(second.id, dateField, null, null, LocalDate.of(2026, 4, 15)); + setCustomValue(second.id, selectField, "Two", null, null); + + Assertions.assertEquals(Set.of(first.id), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Custom Field", "cf." + textField, "hello"))); + Assertions.assertEquals(Set.of(first.id), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Custom Field", "cf." + textField, "Hello Wor"))); + Assertions.assertTrue( + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Custom Field", "cf." + textField, "%")).isEmpty()); + Assertions.assertTrue( + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Custom Field", "cf." + textField, "_")).isEmpty()); + Assertions.assertEquals(Set.of(first.id), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Custom Field", "cf." + selectField, "One"))); + Assertions.assertEquals(Set.of(first.id), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Custom Field", "cf." + numberField, "10"))); + Assertions.assertEquals(Set.of(second.id), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Custom Field", "cf." + numberField + ".from", "11"))); + Assertions.assertEquals(Set.of(first.id), searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Custom Field", + "cf." + numberField + ".from", "5", "cf." + numberField + ".to", "15"))); + Assertions.assertEquals(Set.of(first.id), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Custom Field", "cf." + dateField, "2026-04-01"))); + Assertions.assertEquals(Set.of(second.id), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV Custom Field", "cf." + dateField + ".from", "2026-04-02"))); + Assertions.assertEquals(Set.of(first.id), searchAllIds(SUPPORT_PATH, + Map.of("q", "ADV Custom Field", "cf." + textField, "hello", "cf." + selectField, "One"))); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void malformedSearchValuesReturnBadRequest() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + Long inactiveField = seedCustomField("ADV CF Inactive", CustomField.TYPE_TEXT, List.of()); + deactivateDefinitionDirect(inactiveField); + Long numberField = seedCustomField("ADV CF Invalid Number", CustomField.TYPE_NUMBER, List.of()); + Long dateField = seedCustomField("ADV CF Invalid Date", CustomField.TYPE_DATE, List.of()); + Long selectField = seedCustomField("ADV CF Invalid Select", CustomField.TYPE_SELECT, List.of("One", "Two")); + Long textField = seedCustomField("ADV CF Invalid Text", CustomField.TYPE_TEXT, List.of()); + + badRequest(Map.of("activityFrom", "not-a-date")); + badRequest(Map.of("activityFrom", "2026-02-02", "activityTo", "2026-02-01")); + badRequest(Map.of("createdFrom", "2026-02-02", "createdTo", "2026-02-01")); + badRequest(Map.of("resolvedFrom", "2026-02-02", "resolvedTo", "2026-02-01")); + badRequest(Map.of("categoryId", "abc")); + badRequest(Map.of("companyId", "abc")); + badRequest(Map.of("requesterId", "abc")); + badRequest(Map.of("supportUserId", "abc")); + badRequest(Map.of("tamUserId", "abc")); + badRequest(Map.of("cf.abc", "value")); + badRequest(Map.of("cf.999999999", "value")); + badRequest(Map.of("cf." + inactiveField, "value")); + badRequest(Map.of("cf." + numberField, "not-a-number")); + badRequest(Map.of("cf." + numberField + ".from", "20", "cf." + numberField + ".to", "10")); + badRequest(Map.of("cf." + dateField, "2026-13-40")); + badRequest(Map.of("cf." + dateField + ".from", "2026-05-02", "cf." + dateField + ".to", "2026-05-01")); + badRequest(Map.of("cf." + selectField, "Three")); + badRequest(Map.of("cf." + textField + ".from", "a")); + badRequest(Map.of("cf." + textField, "a", "cf." + textField + ".to", "b")); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void supportPaginationAndSorting() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + Long companyId = seedCompany("ADV Page Co"); + EntitlementData entry = seedEntitlement(companyId, "ADV Page Entitlement", "Normal"); + Ticket a = seedTicket(companyId, entry.id, "ADV Page A", "Assigned", USER_EMAIL, SUPPORT_EMAIL, null, null, + entry.version100, null, null); + Ticket b = seedTicket(companyId, entry.id, "ADV Page B", "Assigned", USER_EMAIL, SUPPORT_EMAIL, null, null, + entry.version100, null, null); + Ticket c = seedTicket(companyId, entry.id, "ADV Page C", "Assigned", USER_EMAIL, SUPPORT_EMAIL, null, null, + entry.version100, null, null); + + Response firstPage = RestAssured.given().queryParam("q", "ADV Page").queryParam("sort", "title") + .queryParam("dir", "asc").queryParam("page", 1).queryParam("pageSize", 2).get(SUPPORT_PATH).then() + .statusCode(200).body("totalItems", Matchers.equalTo(3)).body("totalPages", Matchers.equalTo(2)) + .extract().response(); + Assertions.assertEquals(List.of(a.id.intValue(), b.id.intValue()), firstPage.jsonPath().getList("items.id")); + + Response secondPage = RestAssured.given().queryParam("q", "ADV Page").queryParam("sort", "title") + .queryParam("dir", "asc").queryParam("page", 2).queryParam("pageSize", 2).get(SUPPORT_PATH).then() + .statusCode(200).extract().response(); + Assertions.assertEquals(List.of(c.id.intValue()), secondPage.jsonPath().getList("items.id")); + + Response descending = RestAssured.given().queryParam("q", "ADV Page").queryParam("sort", "title") + .queryParam("dir", "desc").queryParam("page", 1).queryParam("pageSize", 2).get(SUPPORT_PATH).then() + .statusCode(200).extract().response(); + Assertions.assertEquals(List.of(c.id.intValue(), b.id.intValue()), descending.jsonPath().getList("items.id")); + } + + @Test + @TestSecurity(user = USER_EMAIL, roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = USER_EMAIL), @Claim(key = "sub", value = "adv-user") }) + void userCannotMatchInternalMessageBodies() { + ensureUser("adv-user", USER_EMAIL, User.TYPE_USER); + Long companyId = seedCompany("ADV Message Co", USER_EMAIL); + EntitlementData entry = seedEntitlement(companyId, "ADV Message Entitlement", "Normal"); + Ticket ticket = seedTicket(companyId, entry.id, "ADV Message Ticket", "Assigned", USER_EMAIL, SUPPORT_EMAIL, + null, null, entry.version100, null, null); + seedMessage(ticket.id, "ADV PUBLIC BODY", SUPPORT_EMAIL, LocalDateTime.now(), true); + seedMessage(ticket.id, "ADV INTERNAL BODY", SUPPORT_EMAIL, LocalDateTime.now().plusSeconds(1), false); + + Assertions.assertEquals(Set.of(ticket.id), searchAllIds(USER_PATH, Map.of("q", "ADV PUBLIC BODY"))); + Assertions.assertTrue(searchAllIds(USER_PATH, Map.of("q", "ADV INTERNAL BODY")).isEmpty()); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void supportCanMatchInternalMessageBodies() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + Long companyId = seedCompany("ADV Message Support Co"); + EntitlementData entry = seedEntitlement(companyId, "ADV Message Support Entitlement", "Normal"); + Ticket ticket = seedTicket(companyId, entry.id, "ADV Message Support Ticket", "Assigned", USER_EMAIL, + SUPPORT_EMAIL, null, null, entry.version100, null, null); + seedMessage(ticket.id, "ADV SUPPORT INTERNAL BODY", SUPPORT_EMAIL, LocalDateTime.now(), false); + + Assertions.assertEquals(Set.of(ticket.id), + searchAllIds(SUPPORT_PATH, Map.of("q", "ADV SUPPORT INTERNAL BODY"))); + } + + @Test + @TestSecurity(user = ADMIN_EMAIL, roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = ADMIN_EMAIL), @Claim(key = "sub", value = "adv-admin") }) + void adminCannotUseTicketSearch() { + ensureUser("adv-admin", ADMIN_EMAIL, User.TYPE_ADMIN); + RestAssured.given().get(SUPPORT_PATH).then().statusCode(403); + RestAssured.given().get(SUPERUSER_PATH).then().statusCode(403); + RestAssured.given().get(USER_PATH).then().statusCode(403); + } + + @Test + @TestSecurity(user = SUPPORT_EMAIL, roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = SUPPORT_EMAIL), @Claim(key = "sub", value = "adv-support") }) + void simpleListAndSearchRemainUnchanged() { + ensureUser("adv-support", SUPPORT_EMAIL, User.TYPE_SUPPORT); + Long companyId = seedCompany("ADV Regression Co"); + EntitlementData entry = seedEntitlement(companyId, "ADV Regression Entitlement", "Normal"); + Ticket ticket = seedTicket(companyId, entry.id, "ADV Regression Ticket", "Assigned", USER_EMAIL, SUPPORT_EMAIL, + null, null, entry.version100, null, null); + + RestAssured.given().queryParam("q", ticket.name).get("/api/support/tickets").then().statusCode(200) + .body("searchTerm", Matchers.equalTo(ticket.name)) + .body("items.id", Matchers.hasItem(ticket.id.intValue())); + RestAssured.given().queryParam("q", ticket.name).get("/api/support/tickets/suggest").then().statusCode(200) + .body("items.id", Matchers.hasItem(ticket.id.intValue())); + } + + private void badRequest(Map parameters) { + RestAssured.given().queryParams(parameters).get(SUPPORT_PATH).then().statusCode(400); + } + + private Set searchAllIds(String path, Map parameters) { + Set ids = new LinkedHashSet<>(); + int page = 1; + int totalPages; + do { + Response response = RestAssured.given().queryParams(parameters).queryParam("page", page) + .queryParam("pageSize", 100).get(path).then().statusCode(200).extract().response(); + for (Integer id : response.jsonPath().getList("items.id", Integer.class)) { + ids.add(id.longValue()); + } + totalPages = response.jsonPath().getInt("totalPages"); + page++; + } while (page <= totalPages); + return ids; + } + + private Set unionOfListViews(String path) { + Set ids = new LinkedHashSet<>(); + for (String view : List.of("assigned", "open", "closed")) { + int page = 1; + int totalPages; + do { + Response response = RestAssured.given().queryParam("view", view).queryParam("page", page) + .queryParam("pageSize", 100).get(path).then().statusCode(200).extract().response(); + for (Integer id : response.jsonPath().getList("items.id", Integer.class)) { + ids.add(id.longValue()); + } + totalPages = response.jsonPath().getInt("totalPages"); + page++; + } while (page <= totalPages); + } + return ids; + } + + private Map> listStatusIds(String path) { + Map> statuses = new LinkedHashMap<>(); + for (String view : List.of("assigned", "open", "closed")) { + int page = 1; + int totalPages; + do { + Response response = RestAssured.given().queryParam("view", view).queryParam("page", page) + .queryParam("pageSize", 100).get(path).then().statusCode(200).extract().response(); + List> items = response.jsonPath().getList("items"); + for (Map item : items) { + Object id = item.get("id"); + Object status = item.get("status"); + if (id instanceof Number number && status != null) { + statuses.computeIfAbsent(status.toString(), ignored -> new LinkedHashSet<>()) + .add(number.longValue()); + } + } + totalPages = response.jsonPath().getInt("totalPages"); + page++; + } while (page <= totalPages); + } + return statuses; + } + + private void assertStatusFiltersMatchList(String searchPath, String listPath) { + for (Map.Entry> entry : listStatusIds(listPath).entrySet()) { + Assertions.assertEquals(entry.getValue(), searchAllIds(searchPath, Map.of("status", entry.getKey())), + "Status filter must match the list for " + entry.getKey()); + } + } + + private Long userId(String email) { + User user = User.find("email", email).firstResult(); + return user == null ? null : user.id; + } + + @Transactional + Long seedCompany(String name, String... memberEmails) { + Company company = Company.find("name", name).firstResult(); + if (company == null) { + company = new Company(); + company.name = name; + company.persist(); + } + for (String email : memberEmails) { + User user = User.find("email", email).firstResult(); + if (user != null && company.users.stream().noneMatch(existing -> existing.id.equals(user.id))) { + company.users.add(user); + } + } + return company.id; + } + + @Transactional + EntitlementData seedEntitlement(Long companyId, String entitlementName, String levelName) { + Company company = Company.findById(companyId); + Entitlement entitlement = ensureEntitlement(entitlementName, "Advanced search test"); + Level level = ensureLevel(levelName, levelName, 1, "#123456"); + CompanyEntitlement entry = ensureCompanyEntitlement(company, entitlement, level); + Version version100 = ensureVersion(entitlement, "1.0.0"); + Version version101 = ensureVersion(entitlement, "1.0.1"); + return new EntitlementData(entry.id, entitlement.id, level.id, version100.id, version101.id, level.name); + } + + @Transactional + Ticket seedTicket(Long companyId, Long companyEntitlementId, String title, String status, String requesterEmail, + String supportEmail, String tamEmail, Long categoryId, Long affectsVersionId, Long resolvedVersionId, + LocalDateTime resolvedAt) { + Company company = Company.findById(companyId); + Ticket ticket = new Ticket(); + ticket.name = Ticket.nextName(company); + ticket.title = title; + ticket.status = status; + ticket.company = company; + ticket.requester = requesterEmail == null ? null : User.find("email", requesterEmail).firstResult(); + ticket.companyEntitlement = companyEntitlementId == null ? null + : CompanyEntitlement.findById(companyEntitlementId); + ticket.category = categoryId == null ? null : Category.findById(categoryId); + ticket.affectsVersion = affectsVersionId == null ? null : Version.findById(affectsVersionId); + ticket.resolvedVersion = resolvedVersionId == null ? null : Version.findById(resolvedVersionId); + ticket.resolvedAt = resolvedAt; + if (supportEmail != null) { + User support = User.find("email", supportEmail).firstResult(); + if (support != null) { + ticket.supportUsers.add(support); + } + } + if (tamEmail != null) { + User tam = User.find("email", tamEmail).firstResult(); + if (tam != null) { + ticket.tamUsers.add(tam); + } + } + ticket.persist(); + return ticket; + } + + @Transactional + void assignSupport(Long ticketId, String supportEmail) { + Ticket ticket = Ticket.findById(ticketId); + User support = User.find("email", supportEmail).firstResult(); + if (support != null && ticket.supportUsers.stream().noneMatch(existing -> existing.id.equals(support.id))) { + ticket.supportUsers.add(support); + } + } + + @Transactional + void seedMessage(Long ticketId, String body, String authorEmail, LocalDateTime date, boolean isPublic) { + Message message = new Message(); + message.ticket = Ticket.findById(ticketId); + message.body = body; + message.author = User.find("email", authorEmail).firstResult(); + message.date = date; + message.isPublic = isPublic; + message.persist(); + } + + @Transactional + void insertOpenedEvent(Long ticketId, Long companyId, LocalDateTime createdAt) { + Panache.getEntityManager() + .createNativeQuery( + "insert into events (event_type, created_at, event_key, company_id) values (?1, ?2, ?3, ?4)") + .setParameter(1, EventConstants.TICKET_OPENED).setParameter(2, createdAt).setParameter(3, ticketId) + .setParameter(4, companyId).executeUpdate(); + } + + @Transactional + Long seedCustomField(String name, String type, List options) { + CustomField field = new CustomField(); + field.name = name; + field.nameKey = TicketCustomFieldService.normalizeNameKey(name); + field.fieldType = type; + field.required = false; + field.active = true; + field.sortOrder = 0; + field.persist(); + int index = 0; + for (String label : options) { + CustomFieldOption option = new CustomFieldOption(); + option.customField = field; + option.label = label; + option.sortOrder = index++; + option.persist(); + field.options.add(option); + } + ticketCustomFieldService.invalidateDefinitions(); + return field.id; + } + + @Transactional + void setCustomValue(Long ticketId, Long fieldId, String text, BigDecimal number, LocalDate date) { + Ticket ticket = Ticket.findById(ticketId); + CustomField field = CustomField.findById(fieldId); + TicketCustomFieldValue value = new TicketCustomFieldValue(); + value.ticket = ticket; + value.customField = field; + value.textValue = text; + value.numberValue = number; + value.dateValue = date; + value.persist(); + ticket.customFieldValues.add(value); + } + + @Transactional + void deactivateDefinitionDirect(Long fieldId) { + CustomField field = CustomField.findById(fieldId); + if (field != null) { + field.active = false; + } + ticketCustomFieldService.invalidateDefinitions(); + } + + private FilterData seedFilterData() { + Long companyA = seedCompany("ADV Filter Co A"); + Long companyB = seedCompany("ADV Filter Co B"); + EntitlementData entitlementA = seedEntitlement(companyA, "ADV Filter Entitlement A", "ADV Filter Level A"); + EntitlementData entitlementB = seedEntitlement(companyB, "ADV Filter Entitlement B", "ADV Filter Level B"); + Category categoryAlpha = ensureCategory("ADV Filter Category Alpha", "Advanced search", false); + Category categoryBeta = ensureCategory("ADV Filter Category Beta", "Advanced search", false); + + Ticket alpha = seedTicket(companyA, entitlementA.id, "ADV Filter Alpha", "Assigned", USER_EMAIL, SUPPORT_EMAIL, + TAM_EMAIL, categoryAlpha.id, entitlementA.version100, entitlementA.version101, null); + Ticket beta = seedTicket(companyA, entitlementA.id, "ADV Filter Beta", "Open", USER_EMAIL, SUPPORT_EMAIL, + TAM2_ROLE_EMAIL, categoryBeta.id, entitlementA.version100, null, null); + Ticket gamma = seedTicket(companyA, entitlementB.id, "ADV Filter Gamma", "Closed", USER_EMAIL, SUPPORT_EMAIL, + null, categoryAlpha.id, entitlementB.version101, null, LocalDateTime.now()); + Ticket delta = seedTicket(companyB, entitlementB.id, "ADV Filter Delta", "Assigned", USER_EMAIL, SUPPORT_EMAIL, + null, null, entitlementB.version100, null, null); + return new FilterData(alpha.id, beta.id, gamma.id, delta.id, companyA, entitlementA.id, entitlementB.id, + entitlementA.entitlementId, entitlementA.levelId, entitlementB.levelId, entitlementA.version100, + entitlementA.version101, categoryAlpha.id); + } + + private static final class EntitlementData { + private final Long id; + private final Long entitlementId; + private final Long levelId; + private final Long version100; + private final Long version101; + private final String levelName; + + private EntitlementData(Long id, Long entitlementId, Long levelId, Long version100, Long version101, + String levelName) { + this.id = id; + this.entitlementId = entitlementId; + this.levelId = levelId; + this.version100 = version100; + this.version101 = version101; + this.levelName = levelName; + } + } + + private static final class FilterData { + private final Long alpha; + private final Long beta; + private final Long gamma; + private final Long delta; + private final Long companyA; + private final Long entitlementAlpha; + private final Long entitlementBeta; + private final Long entitlementAId; + private final Long levelAId; + private final Long levelBId; + private final Long version100; + private final Long version101; + private final Long categoryAlpha; + + private FilterData(Long alpha, Long beta, Long gamma, Long delta, Long companyA, Long entitlementAlpha, + Long entitlementBeta, Long entitlementAId, Long levelAId, Long levelBId, Long version100, + Long version101, Long categoryAlpha) { + this.alpha = alpha; + this.beta = beta; + this.gamma = gamma; + this.delta = delta; + this.companyA = companyA; + this.entitlementAlpha = entitlementAlpha; + this.entitlementBeta = entitlementBeta; + this.entitlementAId = entitlementAId; + this.levelAId = levelAId; + this.levelBId = levelBId; + this.version100 = version100; + this.version101 = version101; + this.categoryAlpha = categoryAlpha; + } + } +} diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index dbb3913..d7d4106 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -80,6 +80,9 @@ "typescript": "^6.0.2", "typescript-eslint": "^8.58.0", "vite": "^7.1.3" + }, + "engines": { + "node": ">=26" } }, "node_modules/@babel/code-frame": { diff --git a/src/frontend/src/components/layout/AuthenticatedHeader.tsx b/src/frontend/src/components/layout/AuthenticatedHeader.tsx index 8f57391..5501de7 100644 --- a/src/frontend/src/components/layout/AuthenticatedHeader.tsx +++ b/src/frontend/src/components/layout/AuthenticatedHeader.tsx @@ -25,6 +25,7 @@ import { headerNavigation, rssPath, ticketShortcutPaths, + advancedSearchPath, } from "../../utils/navigation"; import type { Session } from "../../types/app"; import { @@ -35,7 +36,19 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { Button } from "../ui/button"; -import { BookOpenIcon, MoonIcon, SearchIcon, SunIcon } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import { + BookOpenIcon, + MoonIcon, + SearchIcon, + SlidersHorizontalIcon, + SunIcon, +} from "lucide-react"; interface TicketCounts { assignedCount?: number; @@ -105,6 +118,7 @@ export default function AuthenticatedHeader({ .toLowerCase() === "true"; const isTicketRoute = isRoleTicketRoute(role, location.pathname); const rssHref = rssPath(role); + const advancedSearchHref = advancedSearchPath(role); // --- Universal search --- const ticketSuggestApiBase = ticketCountsApiPath(role); @@ -195,6 +209,32 @@ export default function AuthenticatedHeader({ }; }, [session?.authenticated]); + useEffect(() => { + if (!session?.authenticated || !advancedSearchHref) { + return undefined; + } + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + !(event.ctrlKey || event.metaKey) || + !event.shiftKey || + event.altKey || + isEditableTarget(event.target) + ) { + return; + } + if (event.key.toLowerCase() !== "f") { + return; + } + event.preventDefault(); + navigate(advancedSearchHref); + }; + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [advancedSearchHref, navigate, session?.authenticated]); + // Close dropdown when clicking outside useEffect(() => { if (!searchFocused) { @@ -387,7 +427,10 @@ export default function AuthenticatedHeader({ {session?.authenticated && ( <> -
+
-
- {showDropdown && ( -
- {!hasResults && ( -
- No matches. -
- )} - {allSuggestions.map((suggestion, index) => ( - - ))} -
+ )} + {allSuggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+ {advancedSearchHref && ( + + + + + + + Advanced search (Ctrl/Cmd+Shift+F) + + + )} + + + + {chips.length > 0 && ( +
+ + Active filters: + + {chips.map((chip) => ( + + {chip.label} + + + ))} +
+ )} + + + ); +} + +interface DateRangeFieldProps { + idPrefix: string; + label: string; + from: string; + to: string; + error?: string; + onChange: (from: string, to: string) => void; +} + +function DateRangeField({ + idPrefix, + label, + from, + to, + error, + onChange, +}: DateRangeFieldProps) { + return ( + + {label} +
+
+ + onChange(event.target.value, to)} + aria-invalid={Boolean(error) || undefined} + /> +
+
+ + onChange(from, event.target.value)} + aria-invalid={Boolean(error) || undefined} + /> +
+
+ {error} +
+ ); +} diff --git a/src/frontend/src/hooks/useJson.ts b/src/frontend/src/hooks/useJson.ts index eb8e904..36cf391 100644 --- a/src/frontend/src/hooks/useJson.ts +++ b/src/frontend/src/hooks/useJson.ts @@ -15,6 +15,10 @@ interface JsonSnapshot extends AsyncState { url: string | null; } +interface UseJsonOptions { + keepPreviousData?: boolean; +} + function emptyState(): AsyncState { return { loading: false, @@ -37,7 +41,10 @@ function loadingState(): AsyncState { }; } -export default function useJson(url: string | null): AsyncState { +export default function useJson( + url: string | null, + options: UseJsonOptions = {}, +): AsyncState { const [state, setState] = useState>({ url: null, ...emptyState(), @@ -61,6 +68,13 @@ export default function useJson(url: string | null): AsyncState { return { forbidden: true } as JsonResult; } if (!response.ok) { + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("text/plain")) { + const body = (await response.text()).trim(); + if (body) { + throw new Error(body); + } + } throw new Error(`Unable to load ${url}`); } const data = (await response.json()) as T; @@ -132,6 +146,9 @@ export default function useJson(url: string | null): AsyncState { } if (state.url !== url) { + if (options.keepPreviousData && state.data) { + return { ...loadingState(), data: state.data }; + } return loadingState(); } diff --git a/src/frontend/src/pages/SupportTicketsPage.tsx b/src/frontend/src/pages/SupportTicketsPage.tsx index 7d084d8..1555b38 100644 --- a/src/frontend/src/pages/SupportTicketsPage.tsx +++ b/src/frontend/src/pages/SupportTicketsPage.tsx @@ -6,19 +6,32 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import DataState from "../components/common/DataState"; import PaginationControls from "../components/common/PaginationControls"; import SortableTableHead from "../components/common/SortableTableHead"; import PageHeader from "../components/layout/PageHeader"; +import AdvancedSearchFilters from "../components/tickets/AdvancedSearchFilters"; +import useJson from "../hooks/useJson"; import usePaginatedList from "../hooks/usePaginatedList"; import { shouldUseLightTextOnColor } from "../utils/formatting"; import useNumberShortcuts from "../hooks/useNumberShortcuts"; import { SmartLink } from "../utils/routing"; +import { + advancedSearchChips, + advancedSearchFilterSignature, + emptyAdvancedSearchDraft, + parseAdvancedSearch, + removeAdvancedSearchChip, + serializeAdvancedSearchParams, + validateAdvancedSearch, + type AdvancedSearchBootstrap, + type AdvancedSearchDraft, +} from "../utils/advancedSearch"; import type { SessionPageProps } from "../types/app"; import type { CollectionResponse, TicketListItem } from "../types/domain"; import { Button } from "../components/ui/button"; -import { useLocation } from "react-router-dom"; +import { useLocation, useSearchParams } from "react-router-dom"; import { Table, TableBody, @@ -33,6 +46,7 @@ interface SupportTicketsPageProps extends SessionPageProps { apiBase?: string; createFallbackPath?: string; title?: string; + advancedSearch?: boolean; } interface TicketListResponse extends CollectionResponse { @@ -58,19 +72,166 @@ export default function SupportTicketsPage({ sessionState, apiBase = "/api/support/tickets", createFallbackPath = "/support/tickets/new", + advancedSearch = false, }: SupportTicketsPageProps) { const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); const locationSearchTerm = new URLSearchParams(location.search).get("q") || ""; + const role = sessionState.data?.role; + + const [bootstrapRefresh, setBootstrapRefresh] = useState(0); + const bootstrapLoadedRef = useRef(false); + const bootstrapState = useJson( + advancedSearch + ? `${apiBase}/bootstrap?allVersions=true&refresh=${bootstrapRefresh}` + : null, + { keepPreviousData: true }, + ); + const bootstrap = bootstrapState.data; + + useEffect(() => { + if (bootstrapState.data) { + bootstrapLoadedRef.current = true; + } + }, [bootstrapState.data]); + + // Custom fields can be added while this page is open in another tab. Refresh + // the lookups when the tab becomes visible again so the new fields appear. + useEffect(() => { + if (!advancedSearch) { + return undefined; + } + const refresh = () => { + if (bootstrapLoadedRef.current) { + setBootstrapRefresh((value) => value + 1); + } + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + refresh(); + } + }; + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [advancedSearch]); + + const customFields = useMemo( + () => + (bootstrap?.customFields || []).filter((field) => field.active !== false), + [bootstrap], + ); + + const [draft, setDraft] = useState(() => + parseAdvancedSearch(searchParams, role), + ); + const [validationErrors, setValidationErrors] = useState< + Record + >({}); + const filterSignature = useMemo( + () => advancedSearchFilterSignature(searchParams, role), + [searchParams, role], + ); + const [draftSignature, setDraftSignature] = useState(filterSignature); + + if (draftSignature !== filterSignature) { + setDraftSignature(filterSignature); + setDraft(parseAdvancedSearch(searchParams, role)); + setValidationErrors({}); + } + + const appliedDraft = useMemo( + () => parseAdvancedSearch(searchParams, role), + [searchParams, role], + ); + const appliedParams = useMemo( + () => + serializeAdvancedSearchParams( + appliedDraft, + role, + bootstrap ? customFields : undefined, + ), + [appliedDraft, role, bootstrap, customFields], + ); const extraParams = useMemo( - () => ({ - view: view !== "assigned" ? view : undefined, - q: locationSearchTerm || undefined, - }), - [view, locationSearchTerm], + () => + advancedSearch + ? appliedParams + : { + view: view !== "assigned" ? view : undefined, + q: locationSearchTerm || undefined, + }, + [advancedSearch, appliedParams, view, locationSearchTerm], + ); + + const chips = useMemo( + () => + advancedSearch + ? advancedSearchChips(draft, { + companies: bootstrap?.companies, + categories: bootstrap?.categories, + versions: bootstrap?.versions, + customFields, + }) + : [], + [advancedSearch, draft, bootstrap, customFields], ); + function applyDraft(nextDraft: AdvancedSearchDraft) { + const next = new URLSearchParams(searchParams); + const preserved = new Set(["page", "pageSize", "sort", "dir"]); + for (const key of Array.from(next.keys())) { + if (!preserved.has(key)) { + next.delete(key); + } + } + const serialized = serializeAdvancedSearchParams( + nextDraft, + role, + customFields, + ); + for (const [key, value] of Object.entries(serialized)) { + next.set(key, value); + } + next.set("page", "1"); + setSearchParams(next); + } + + function handleDraftChange(nextDraft: AdvancedSearchDraft) { + setDraft(nextDraft); + if (Object.keys(validationErrors).length > 0) { + setValidationErrors({}); + } + } + + function handleApply() { + const errors = validateAdvancedSearch(draft, customFields); + setValidationErrors(errors); + if (Object.keys(errors).length > 0) { + return; + } + applyDraft(draft); + } + + function handleReset() { + const empty = emptyAdvancedSearchDraft(); + setDraft(empty); + setValidationErrors({}); + applyDraft(empty); + } + + function handleRemoveChip(chipId: string) { + const nextDraft = removeAdvancedSearchChip(draft, chipId); + setDraft(nextDraft); + setValidationErrors({}); + applyDraft(nextDraft); + } + const defaultPageSize = sessionState.data?.defaultPageSize ?? undefined; const { @@ -86,7 +247,7 @@ export default function SupportTicketsPage({ setSort, pageSizeOptions, } = usePaginatedList({ - apiUrl: apiBase, + apiUrl: advancedSearch ? `${apiBase}/search` : apiBase, extraParams, defaultPageSize, defaultSort: "name", @@ -96,10 +257,11 @@ export default function SupportTicketsPage({ const currentView = ticketsState.data?.view || view || "assigned"; const activeSearch = ticketsState.data?.searchTerm || locationSearchTerm; + const hasActiveAdvancedFilters = Object.keys(appliedParams).length > 0; const showLevelColumn = apiBase !== "/api/user/tickets"; - const showCreateButton = !( - apiBase === "/api/user/tickets" && currentView === "closed" - ); + const showCreateButton = + !advancedSearch && + !(apiBase === "/api/user/tickets" && currentView === "closed"); const pageTitle = title || (currentView === "open" @@ -107,9 +269,13 @@ export default function SupportTicketsPage({ : currentView === "closed" ? "Closed tickets" : "Active tickets"); - const emptyMessage = activeSearch - ? `No tickets matched "${activeSearch}".` - : "No tickets"; + const emptyMessage = advancedSearch + ? hasActiveAdvancedFilters + ? "No tickets matched your search." + : "No tickets" + : activeSearch + ? `No tickets matched "${activeSearch}".` + : "No tickets"; const shortcutTickets = (ticketsState.data?.items || []).slice(0, 10); @@ -123,8 +289,8 @@ export default function SupportTicketsPage({ - {showCreateButton ? ( + showCreateButton ? ( +
- ) : null} -
+ + ) : undefined } />
+ {advancedSearch ? ( + + ) : null}
diff --git a/src/frontend/src/routes/TicketRoutes.tsx b/src/frontend/src/routes/TicketRoutes.tsx index 0c51583..d41ee50 100644 --- a/src/frontend/src/routes/TicketRoutes.tsx +++ b/src/frontend/src/routes/TicketRoutes.tsx @@ -73,7 +73,8 @@ export function getTicketRoutes(sessionState: SessionState): AppRoute[] { ), requiresAuth: true, @@ -149,7 +150,8 @@ export function getTicketRoutes(sessionState: SessionState): AppRoute[] { view="assigned" apiBase="/api/user/tickets" createFallbackPath={PATHS.userTicketsNew} - title="Search" + title="Advanced search" + advancedSearch /> ), requiresAuth: true, @@ -236,7 +238,8 @@ export function getTicketRoutes(sessionState: SessionState): AppRoute[] { view="assigned" apiBase="/api/superuser/tickets" createFallbackPath={PATHS.superuserTicketsNew} - title="Search" + title="Advanced search" + advancedSearch /> ), requiresAuth: true, diff --git a/src/frontend/src/utils/advancedSearch.ts b/src/frontend/src/utils/advancedSearch.ts new file mode 100644 index 0000000..c6758ce --- /dev/null +++ b/src/frontend/src/utils/advancedSearch.ts @@ -0,0 +1,553 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import type { Role } from "../types/app"; +import type { NamedEntity, VersionInfo } from "../types/domain/common"; +import type { CustomFieldView } from "../types/domain/customFields"; + +export interface AdvancedSearchBootstrap { + companies?: NamedEntity[]; + categories?: NamedEntity[]; + versions?: VersionInfo[]; + customFields?: CustomFieldView[]; + [key: string]: unknown; +} + +export const ADVANCED_SEARCH_ANY = "__any__"; + +export const ADVANCED_SEARCH_STATUSES = [ + "Open", + "Assigned", + "In Progress", + "Waiting for External Feedback", + "Resolved", + "Closed", +] as const; + +/** Status labels the role's ticket list can actually display. */ +export function advancedSearchStatuses(role?: Role): readonly string[] { + if (role === "support") { + return ADVANCED_SEARCH_STATUSES; + } + return ADVANCED_SEARCH_STATUSES.filter( + (status) => status !== "Waiting for External Feedback", + ); +} + +export interface AdvancedSearchCustomFieldDraft { + value: string; + from: string; + to: string; +} + +export interface AdvancedSearchDraft { + q: string; + statuses: string[]; + categoryId: string; + companyId: string; + entitlementName: string; + levelName: string; + supportUser: string; + tamUser: string; + affectsVersionId: string; + resolvedVersionId: string; + activityFrom: string; + activityTo: string; + createdFrom: string; + createdTo: string; + resolvedFrom: string; + resolvedTo: string; + customFields: Record; +} + +export interface AdvancedSearchCapabilities { + companyFilters: boolean; + supportFilter: boolean; + tamFilter: boolean; +} + +export interface AdvancedSearchChip { + id: string; + label: string; +} + +export interface AdvancedSearchLookups { + companies?: NamedEntity[]; + categories?: NamedEntity[]; + versions?: VersionInfo[]; + customFields?: CustomFieldView[]; +} + +const TOP_LEVEL_KEYS = [ + "q", + "categoryId", + "companyId", + "entitlementName", + "levelName", + "supportUser", + "tamUser", + "affectsVersionId", + "resolvedVersionId", + "activityFrom", + "activityTo", + "createdFrom", + "createdTo", + "resolvedFrom", + "resolvedTo", +] as const; + +const RANGE_KEYS = [ + ["activity", "activityFrom", "activityTo"], + ["created", "createdFrom", "createdTo"], + ["resolved", "resolvedFrom", "resolvedTo"], +] as const; + +export function advancedSearchCapabilities( + role?: Role, +): AdvancedSearchCapabilities { + const companyFilters = + role === "support" || role === "superuser" || role === "tam"; + return { + companyFilters, + supportFilter: companyFilters, + tamFilter: role === "support" || role === "superuser", + }; +} + +export function emptyAdvancedSearchDraft(): AdvancedSearchDraft { + return { + q: "", + statuses: [], + categoryId: "", + companyId: "", + entitlementName: "", + levelName: "", + supportUser: "", + tamUser: "", + affectsVersionId: "", + resolvedVersionId: "", + activityFrom: "", + activityTo: "", + createdFrom: "", + createdTo: "", + resolvedFrom: "", + resolvedTo: "", + customFields: {}, + }; +} + +export function parseAdvancedSearch( + source: URLSearchParams | string, + role?: Role, +): AdvancedSearchDraft { + const params = toSearchParams(source); + const capabilities = advancedSearchCapabilities(role); + return { + q: readValue(params, "q"), + statuses: parseStatuses(params), + categoryId: readValue(params, "categoryId"), + companyId: capabilities.companyFilters + ? readValue(params, "companyId") + : "", + entitlementName: capabilities.companyFilters + ? readValue(params, "entitlementName") + : "", + levelName: capabilities.companyFilters + ? readValue(params, "levelName") + : "", + supportUser: capabilities.supportFilter + ? readValue(params, "supportUser") + : "", + tamUser: capabilities.tamFilter ? readValue(params, "tamUser") : "", + affectsVersionId: readValue(params, "affectsVersionId"), + resolvedVersionId: readValue(params, "resolvedVersionId"), + activityFrom: readValue(params, "activityFrom"), + activityTo: readValue(params, "activityTo"), + createdFrom: readValue(params, "createdFrom"), + createdTo: readValue(params, "createdTo"), + resolvedFrom: readValue(params, "resolvedFrom"), + resolvedTo: readValue(params, "resolvedTo"), + customFields: parseCustomFields(params), + }; +} + +export function serializeAdvancedSearchParams( + draft: AdvancedSearchDraft, + role?: Role, + fields?: CustomFieldView[], +): Record { + const capabilities = advancedSearchCapabilities(role); + const params: Record = {}; + + addValue(params, "q", draft.q); + if (draft.statuses.length > 0) { + params.status = draft.statuses.join(","); + } + addValue(params, "categoryId", draft.categoryId); + if (capabilities.companyFilters) { + addValue(params, "companyId", draft.companyId); + addValue(params, "entitlementName", draft.entitlementName); + addValue(params, "levelName", draft.levelName); + } + if (capabilities.supportFilter) { + addValue(params, "supportUser", draft.supportUser); + } + if (capabilities.tamFilter) { + addValue(params, "tamUser", draft.tamUser); + } + addValue(params, "affectsVersionId", draft.affectsVersionId); + addValue(params, "resolvedVersionId", draft.resolvedVersionId); + addValue(params, "activityFrom", draft.activityFrom); + addValue(params, "activityTo", draft.activityTo); + addValue(params, "createdFrom", draft.createdFrom); + addValue(params, "createdTo", draft.createdTo); + addValue(params, "resolvedFrom", draft.resolvedFrom); + addValue(params, "resolvedTo", draft.resolvedTo); + + const definitions = + fields === undefined + ? null + : new Map( + fields + .filter((field) => field.active !== false) + .map((field) => [String(field.id), field]), + ); + + for (const [fieldId, value] of Object.entries(draft.customFields)) { + const field = definitions ? definitions.get(fieldId) : undefined; + if (definitions && !field) { + continue; + } + const type = field ? String(field.fieldType).toUpperCase() : null; + const text = value.value.trim(); + const from = value.from.trim(); + const to = value.to.trim(); + + if (type === "DATE") { + addValue(params, `cf.${fieldId}.from`, from); + addValue(params, `cf.${fieldId}.to`, to); + } else if (type === "NUMBER") { + if (text) { + addValue(params, `cf.${fieldId}`, text); + } else { + addValue(params, `cf.${fieldId}.from`, from); + addValue(params, `cf.${fieldId}.to`, to); + } + } else if (type === "TEXT" || type === "SELECT") { + addValue(params, `cf.${fieldId}`, text); + } else { + addValue(params, `cf.${fieldId}`, text); + addValue(params, `cf.${fieldId}.from`, from); + addValue(params, `cf.${fieldId}.to`, to); + } + } + + return params; +} + +export function validateAdvancedSearch( + draft: AdvancedSearchDraft, + fields: CustomFieldView[], +): Record { + const errors: Record = {}; + + for (const [key, fromKey, toKey] of RANGE_KEYS) { + const from = draft[fromKey]; + const to = draft[toKey]; + if (from && to && from > to) { + errors[key] = "The From date must not be after the To date."; + } + } + + for (const field of fields) { + if (field.active === false) { + continue; + } + const fieldId = String(field.id); + const value = draft.customFields[fieldId]; + if (!value) { + continue; + } + const type = String(field.fieldType).toUpperCase(); + const text = value.value.trim(); + const from = value.from.trim(); + const to = value.to.trim(); + + if (type === "NUMBER") { + if (text && (from || to)) { + errors[`cf.${fieldId}`] = + "Use either an exact value or a range, not both."; + continue; + } + if (text && !isNumber(text)) { + errors[`cf.${fieldId}`] = "Enter a valid number."; + continue; + } + if ((from && !isNumber(from)) || (to && !isNumber(to))) { + errors[`cf.${fieldId}`] = "Enter a valid number range."; + continue; + } + if (from && to && Number(from) > Number(to)) { + errors[`cf.${fieldId}`] = "From must not be greater than To."; + } + } else if (type === "DATE" && from && to && from > to) { + errors[`cf.${fieldId}`] = "From must not be after To."; + } + } + + return errors; +} + +export function advancedSearchChips( + draft: AdvancedSearchDraft, + lookups: AdvancedSearchLookups = {}, +): AdvancedSearchChip[] { + const chips: AdvancedSearchChip[] = []; + const query = draft.q.trim(); + if (query) { + chips.push({ id: "q", label: `Text: ${query}` }); + } + for (const status of draft.statuses) { + chips.push({ id: `status:${status}`, label: `Status: ${status}` }); + } + if (draft.categoryId) { + chips.push({ + id: "categoryId", + label: `Category: ${nameFor(lookups.categories, draft.categoryId)}`, + }); + } + if (draft.companyId) { + chips.push({ + id: "companyId", + label: `Company: ${nameFor(lookups.companies, draft.companyId)}`, + }); + } + if (draft.entitlementName) { + chips.push({ + id: "entitlementName", + label: `Entitlement: ${draft.entitlementName}`, + }); + } + if (draft.levelName) { + chips.push({ id: "levelName", label: `Level: ${draft.levelName}` }); + } + if (draft.supportUser) { + chips.push({ id: "supportUser", label: `Support: ${draft.supportUser}` }); + } + if (draft.tamUser) { + chips.push({ id: "tamUser", label: `TAM: ${draft.tamUser}` }); + } + if (draft.affectsVersionId) { + chips.push({ + id: "affectsVersionId", + label: `Affects: ${nameFor(lookups.versions, draft.affectsVersionId)}`, + }); + } + if (draft.resolvedVersionId) { + chips.push({ + id: "resolvedVersionId", + label: `Resolved: ${nameFor(lookups.versions, draft.resolvedVersionId)}`, + }); + } + for (const [key, fromKey, toKey] of RANGE_KEYS) { + const from = draft[fromKey]; + const to = draft[toKey]; + if (from || to) { + const label = key.charAt(0).toUpperCase() + key.slice(1); + chips.push({ + id: key, + label: `${label}: ${from || "…"} – ${to || "…"}`, + }); + } + } + for (const field of lookups.customFields || []) { + if (field.active === false) { + continue; + } + const fieldId = String(field.id); + const value = draft.customFields[fieldId]; + if (!value) { + continue; + } + const label = customFieldChipLabel(field, value); + if (label) { + chips.push({ id: `cf:${fieldId}`, label }); + } + } + return chips; +} + +export function removeAdvancedSearchChip( + draft: AdvancedSearchDraft, + chipId: string, +): AdvancedSearchDraft { + if (chipId === "q") { + return { ...draft, q: "" }; + } + if (chipId.startsWith("status:")) { + const status = chipId.slice("status:".length); + return { + ...draft, + statuses: draft.statuses.filter((value) => value !== status), + }; + } + if (chipId === "activity") { + return { ...draft, activityFrom: "", activityTo: "" }; + } + if (chipId === "created") { + return { ...draft, createdFrom: "", createdTo: "" }; + } + if (chipId === "resolved") { + return { ...draft, resolvedFrom: "", resolvedTo: "" }; + } + if (chipId.startsWith("cf:")) { + const customFields = { ...draft.customFields }; + delete customFields[chipId.slice("cf:".length)]; + return { ...draft, customFields }; + } + if (TOP_LEVEL_KEYS.includes(chipId as (typeof TOP_LEVEL_KEYS)[number])) { + return { ...draft, [chipId]: "" }; + } + return draft; +} + +export function advancedSearchFilterSignature( + source: URLSearchParams | string, + role?: Role, +): string { + const params = toSearchParams(source); + const capabilities = advancedSearchCapabilities(role); + const keys: string[] = [ + "q", + "status", + "categoryId", + "affectsVersionId", + "resolvedVersionId", + "activityFrom", + "activityTo", + "createdFrom", + "createdTo", + "resolvedFrom", + "resolvedTo", + ]; + if (capabilities.companyFilters) { + keys.push("companyId", "entitlementName", "levelName"); + } + if (capabilities.supportFilter) { + keys.push("supportUser"); + } + if (capabilities.tamFilter) { + keys.push("tamUser"); + } + + const entries: string[] = []; + for (const key of keys) { + for (const value of params.getAll(key)) { + if (value) { + entries.push(`${key}=${value}`); + } + } + } + for (const [key, value] of params.entries()) { + if (key.startsWith("cf.") && value) { + entries.push(`${key}=${value}`); + } + } + entries.sort(); + return entries.join("&"); +} + +function toSearchParams(source: URLSearchParams | string): URLSearchParams { + return typeof source === "string" ? new URLSearchParams(source) : source; +} + +function readValue(params: URLSearchParams, key: string): string { + return (params.get(key) || "").trim(); +} + +function parseStatuses(params: URLSearchParams): string[] { + const statuses: string[] = []; + for (const raw of params.getAll("status")) { + for (const value of raw.split(",")) { + const status = value.trim(); + if (status && !statuses.includes(status)) { + statuses.push(status); + } + } + } + return statuses; +} + +function parseCustomFields( + params: URLSearchParams, +): Record { + const result: Record = {}; + for (const [key, value] of params.entries()) { + if (!key.startsWith("cf.")) { + continue; + } + let fieldId = key.slice("cf.".length); + let part: keyof AdvancedSearchCustomFieldDraft = "value"; + if (fieldId.endsWith(".from")) { + fieldId = fieldId.slice(0, -".from".length); + part = "from"; + } else if (fieldId.endsWith(".to")) { + fieldId = fieldId.slice(0, -".to".length); + part = "to"; + } + if (!/^\d+$/.test(fieldId)) { + continue; + } + const current = result[fieldId] || { value: "", from: "", to: "" }; + current[part] = value; + result[fieldId] = current; + } + return result; +} + +function addValue( + params: Record, + key: string, + value: string, +): void { + const trimmed = value.trim(); + if (trimmed) { + params[key] = trimmed; + } +} + +function nameFor(items: NamedEntity[] | undefined, id: string): string { + const match = (items || []).find((item) => String(item.id) === id); + return match?.name ? String(match.name) : id; +} + +function isNumber(value: string): boolean { + return Number.isFinite(Number(value)); +} + +function customFieldChipLabel( + field: CustomFieldView, + value: AdvancedSearchCustomFieldDraft, +): string { + const name = field.name || `Custom field ${field.id}`; + const type = String(field.fieldType).toUpperCase(); + const text = value.value.trim(); + const from = value.from.trim(); + const to = value.to.trim(); + + if (type === "DATE") { + return from || to ? `${name}: ${from || "…"} – ${to || "…"}` : ""; + } + if (type === "NUMBER") { + if (text) { + return `${name}: ${text}`; + } + return from || to ? `${name}: ${from || "…"} – ${to || "…"}` : ""; + } + return text ? `${name}: ${text}` : ""; +} diff --git a/src/frontend/src/utils/navigation.ts b/src/frontend/src/utils/navigation.ts index 4642f2b..8c79447 100644 --- a/src/frontend/src/utils/navigation.ts +++ b/src/frontend/src/utils/navigation.ts @@ -149,6 +149,19 @@ export function rssPath(role?: Role): string { return ""; } +export function advancedSearchPath(role?: Role): string | null { + if (role === "support") { + return "/support/tickets/search"; + } + if (role === "superuser") { + return "/superuser/tickets/search"; + } + if (role === "tam" || role === "user") { + return "/user/tickets/search"; + } + return null; +} + export function ticketShortcutPaths(role?: Role): TicketShortcutPaths | null { if (role === "support") { return {