diff --git a/src/main/java/group/goforward/battlbuilder/catalog/query/ProductWithBestPrice.java b/src/main/java/group/goforward/battlbuilder/catalog/query/ProductWithBestPrice.java new file mode 100644 index 0000000..21e1542 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/catalog/query/ProductWithBestPrice.java @@ -0,0 +1,15 @@ +package group.goforward.battlbuilder.catalog.query; + +import java.math.BigDecimal; +import java.util.UUID; + +public interface ProductWithBestPrice { + Long getId(); + UUID getUuid(); + String getName(); + String getSlug(); + String getPlatform(); + String getPartRole(); + + BigDecimal getBestPrice(); // derived +} diff --git a/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java b/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java index 7d4fa23..5799275 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java @@ -3,12 +3,13 @@ package group.goforward.battlbuilder.controllers; import group.goforward.battlbuilder.services.ProductQueryService; import group.goforward.battlbuilder.web.dto.ProductOfferDto; import group.goforward.battlbuilder.web.dto.ProductSummaryDto; +import group.goforward.battlbuilder.web.dto.catalog.ProductSort; import org.springframework.cache.annotation.Cacheable; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; import java.util.List; @@ -23,17 +24,26 @@ public class ProductV1Controller { this.productQueryService = productQueryService; } + /** + * Product list endpoint + * Example: + * /api/v1/products?platform=AR-15&partRoles=upper-receiver&priceSort=price_asc&page=0&size=50 + * + * NOTE: do NOT use `sort=` here — Spring reserves it for Pageable sorting. + */ @GetMapping @Cacheable( value = "gunbuilderProductsV1", - key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize" + key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles) + '::' + #priceSort + '::' + #pageable.pageNumber + '::' + #pageable.pageSize" ) public Page getProducts( @RequestParam(defaultValue = "AR-15") String platform, @RequestParam(required = false, name = "partRoles") List partRoles, + @RequestParam(name = "priceSort", defaultValue = "price_asc") String priceSort, @PageableDefault(size = 50) Pageable pageable ) { - return productQueryService.getProductsPage(platform, partRoles, pageable); + ProductSort sortEnum = ProductSort.from(priceSort); + return productQueryService.getProductsPage(platform, partRoles, pageable, sortEnum); } @GetMapping("/{id}/offers") diff --git a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java index 9c93a27..9b11fb9 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java @@ -3,6 +3,7 @@ package group.goforward.battlbuilder.repos; import group.goforward.battlbuilder.model.ImportStatus; import group.goforward.battlbuilder.model.Brand; import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.catalog.query.ProductWithBestPrice; import group.goforward.battlbuilder.repos.projections.CatalogRow; import group.goforward.battlbuilder.web.dto.catalog.CatalogOptionRow; @@ -625,6 +626,28 @@ ORDER BY productCount DESC Pageable pageable ); + // ------------------------------------------------- + // Best Price + // ------------------------------------------------- + @Query(""" + select + p.id as id, + p.uuid as uuid, + p.name as name, + p.slug as slug, + p.platform as platform, + p.partRole as partRole, + min(o.price) as bestPrice + from Product p + join ProductOffer o on o.product.id = p.id + where (:platform is null or p.platform = :platform) + and o.inStock = true + group by p.id, p.uuid, p.name, p.slug, p.platform, p.partRole + """) + Page findProductsWithBestPriceInStock( + @Param("platform") String platform, + Pageable pageable + ); } diff --git a/src/main/java/group/goforward/battlbuilder/services/ProductQueryService.java b/src/main/java/group/goforward/battlbuilder/services/ProductQueryService.java index 0ca20d1..f6f9c5a 100644 --- a/src/main/java/group/goforward/battlbuilder/services/ProductQueryService.java +++ b/src/main/java/group/goforward/battlbuilder/services/ProductQueryService.java @@ -2,12 +2,12 @@ package group.goforward.battlbuilder.services; import group.goforward.battlbuilder.web.dto.ProductOfferDto; import group.goforward.battlbuilder.web.dto.ProductSummaryDto; - -import java.util.List; - +import group.goforward.battlbuilder.web.dto.catalog.ProductSort; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; + public interface ProductQueryService { List getProducts(String platform, List partRoles); @@ -16,5 +16,10 @@ public interface ProductQueryService { ProductSummaryDto getProductById(Integer productId); - Page getProductsPage(String platform, List partRoles, Pageable pageable); + Page getProductsPage( + String platform, + List partRoles, + Pageable pageable, + ProductSort sort + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/ProductQueryServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/ProductQueryServiceImpl.java index 49f75ca..c4ac5bd 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/ProductQueryServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/ProductQueryServiceImpl.java @@ -8,7 +8,7 @@ import group.goforward.battlbuilder.services.ProductQueryService; import group.goforward.battlbuilder.web.dto.ProductOfferDto; import group.goforward.battlbuilder.web.dto.ProductSummaryDto; import group.goforward.battlbuilder.web.mapper.ProductMapper; -import org.springframework.stereotype.Service; +import group.goforward.battlbuilder.web.dto.catalog.ProductSort; import java.math.BigDecimal; import java.util.*; @@ -16,6 +16,8 @@ import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + import java.util.stream.Collectors; import java.util.Collections; @@ -79,24 +81,37 @@ public class ProductQueryServiceImpl implements ProductQueryService { } @Override - public Page getProductsPage(String platform, List partRoles, Pageable pageable) { + public Page getProductsPage( + String platform, + List partRoles, + Pageable pageable, + ProductSort sort + ) { final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL"); + // IMPORTANT: ignore Pageable sorting (because we are doing our own "best price" logic) + // If the client accidentally passes ?sort=..., Spring Data will try ordering by a Product field. + // We'll strip it to be safe. + Pageable safePageable = pageable; + if (pageable != null && pageable.getSort() != null && pageable.getSort().isSorted()) { + safePageable = Pageable.ofSize(pageable.getPageSize()).withPage(pageable.getPageNumber()); + } + Page productPage; if (partRoles == null || partRoles.isEmpty()) { productPage = allPlatforms - ? productRepository.findAllWithBrand(pageable) - : productRepository.findByPlatformWithBrand(platform, pageable); + ? productRepository.findAllWithBrand(safePageable) + : productRepository.findByPlatformWithBrand(platform, safePageable); } else { productPage = allPlatforms - ? productRepository.findByPartRoleInWithBrand(partRoles, pageable) - : productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable); + ? productRepository.findByPartRoleInWithBrand(partRoles, safePageable) + : productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, safePageable); } List products = productPage.getContent(); if (products.isEmpty()) { - return Page.empty(pageable); + return Page.empty(safePageable); } List productIds = products.stream().map(Product::getId).toList(); @@ -108,6 +123,7 @@ public class ProductQueryServiceImpl implements ProductQueryService { .filter(o -> o.getProduct() != null && o.getProduct().getId() != null) .collect(Collectors.groupingBy(o -> o.getProduct().getId())); + // Build DTOs (same as before) List dtos = products.stream() .map(p -> { List offersForProduct = @@ -122,7 +138,17 @@ public class ProductQueryServiceImpl implements ProductQueryService { }) .toList(); - return new PageImpl<>(dtos, pageable, productPage.getTotalElements()); + // Phase 3 "server-side sort by price" (within the page for now) + Comparator byPriceNullLast = + Comparator.comparing(ProductSummaryDto::getPrice, Comparator.nullsLast(Comparator.naturalOrder())); + + if (sort == ProductSort.PRICE_DESC) { + dtos = dtos.stream().sorted(byPriceNullLast.reversed()).toList(); + } else { + dtos = dtos.stream().sorted(byPriceNullLast).toList(); + } + + return new PageImpl<>(dtos, safePageable, productPage.getTotalElements()); } // diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/catalog/ProductSort.java b/src/main/java/group/goforward/battlbuilder/web/dto/catalog/ProductSort.java new file mode 100644 index 0000000..d43d532 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/catalog/ProductSort.java @@ -0,0 +1,15 @@ +package group.goforward.battlbuilder.web.dto.catalog; + +public enum ProductSort { + PRICE_ASC, + PRICE_DESC; + + public static ProductSort from(String raw) { + if (raw == null) return PRICE_ASC; + String s = raw.trim().toLowerCase(); + return switch (s) { + case "price_desc", "price-desc", "desc" -> PRICE_DESC; + default -> PRICE_ASC; + }; + } +} \ No newline at end of file