added server side price sorting endpoints

This commit is contained in:
2026-01-02 09:50:33 -05:00
parent babba2757b
commit d52dee105c
6 changed files with 110 additions and 16 deletions

View File

@@ -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
}

View File

@@ -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<ProductSummaryDto> getProducts(
@RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List<String> 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")

View File

@@ -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<ProductWithBestPrice> findProductsWithBestPriceInStock(
@Param("platform") String platform,
Pageable pageable
);
}

View File

@@ -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<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
@@ -16,5 +16,10 @@ public interface ProductQueryService {
ProductSummaryDto getProductById(Integer productId);
Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable);
Page<ProductSummaryDto> getProductsPage(
String platform,
List<String> partRoles,
Pageable pageable,
ProductSort sort
);
}

View File

@@ -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<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) {
public Page<ProductSummaryDto> getProductsPage(
String platform,
List<String> 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<Product> 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<Product> products = productPage.getContent();
if (products.isEmpty()) {
return Page.empty(pageable);
return Page.empty(safePageable);
}
List<Integer> 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<ProductSummaryDto> dtos = products.stream()
.map(p -> {
List<ProductOffer> 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<ProductSummaryDto> 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());
}
//

View File

@@ -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;
};
}
}