From ec44e0d0a1b31843c2c1d4bafabb176320049e26 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 30 Dec 2025 13:53:37 -0500 Subject: [PATCH] cleaned up api stuff. no longer pulling 2000 products each call. --- .../controllers/CatalogController.java | 44 +++++ .../battlbuilder/repos/ProductRepository.java | 77 +++++++- .../spec/CatalogProductSpecifications.java | 57 ++++++ .../services/CatalogQueryService.java | 23 +++ .../impl/CatalogQueryServiceImpl.java | 176 ++++++++++++++++++ .../web/dto/catalog/CatalogOptionDto.java | 16 ++ .../web/dto/catalog/CatalogOptionRow.java | 17 ++ .../dto/catalog/CatalogProductIdsRequest.java | 11 ++ .../web/mapper/CatalogOptionMapper.java | 22 +++ src/main/resources/application.properties | 14 +- 10 files changed, 450 insertions(+), 7 deletions(-) create mode 100644 src/main/java/group/goforward/battlbuilder/controllers/CatalogController.java create mode 100644 src/main/java/group/goforward/battlbuilder/repos/catalog/spec/CatalogProductSpecifications.java create mode 100644 src/main/java/group/goforward/battlbuilder/services/CatalogQueryService.java create mode 100644 src/main/java/group/goforward/battlbuilder/services/impl/CatalogQueryServiceImpl.java create mode 100644 src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogOptionDto.java create mode 100644 src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogOptionRow.java create mode 100644 src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogProductIdsRequest.java create mode 100644 src/main/java/group/goforward/battlbuilder/web/mapper/CatalogOptionMapper.java diff --git a/src/main/java/group/goforward/battlbuilder/controllers/CatalogController.java b/src/main/java/group/goforward/battlbuilder/controllers/CatalogController.java new file mode 100644 index 0000000..dccbcbc --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/controllers/CatalogController.java @@ -0,0 +1,44 @@ +package group.goforward.battlbuilder.controllers; + +import group.goforward.battlbuilder.services.CatalogQueryService; +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; +import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/catalog") +@CrossOrigin // tighten later +public class CatalogController { + + private final CatalogQueryService catalogQueryService; + + public CatalogController(CatalogQueryService catalogQueryService) { + this.catalogQueryService = catalogQueryService; + } + + @GetMapping("/options") + public Page getOptions( + @RequestParam(required = false) String platform, + @RequestParam(required = false) String partRole, + @RequestParam(required = false) List partRoles, + @RequestParam(required = false, name = "brand") List brands, + @RequestParam(required = false) String q, + Pageable pageable + ) { + return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, pageable); + } + + @PostMapping("/products/by-ids") + public List getProductsByIds(@RequestBody CatalogProductIdsRequest request) { + return catalogQueryService.getProductsByIds(request); + } + + +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java index dfa53e7..456d611 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java @@ -3,9 +3,10 @@ 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.web.dto.catalog.CatalogOptionRow; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; - import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -138,6 +139,80 @@ where po.product_id = p.id @Param("canonicalCategoryId") Integer canonicalCategoryId ); + @Query( + value = """ + SELECT + p.id AS id, + p.name AS name, + b.name AS brand, + p.platform AS platform, + p.part_role AS partRole, + p.raw_category_key AS categoryKey, + p.main_image_url AS imageUrl, + bo.price AS price, + bo.buy_url AS buyUrl, + bo.in_stock AS inStock + FROM products p + LEFT JOIN brands b ON b.id = p.brand_id + LEFT JOIN ( + SELECT x.product_id, x.price, x.buy_url, x.in_stock + FROM ( + SELECT + po.product_id, + COALESCE( + CASE WHEN po.original_price IS NOT NULL AND po.price < po.original_price THEN po.price ELSE po.price END, + po.original_price + ) AS price, + po.buy_url, + po.in_stock, + ROW_NUMBER() OVER ( + PARTITION BY po.product_id + ORDER BY + po.in_stock DESC, + COALESCE( + CASE WHEN po.original_price IS NOT NULL AND po.price < po.original_price THEN po.price ELSE po.price END, + po.original_price + ) ASC NULLS LAST, + po.last_seen_at DESC + ) AS rn + FROM product_offers po + ) x + WHERE x.rn = 1 + ) bo ON bo.product_id = p.id + WHERE + p.deleted_at IS NULL + AND p.status = 'ACTIVE' + AND p.visibility = 'PUBLIC' + AND p.builder_eligible = true + AND (:platform IS NULL OR :platform = '' OR p.platform = :platform) + AND (COALESCE(:partRoles) IS NULL OR p.part_role = ANY(:partRoles)) + AND (:q IS NULL OR :q = '' OR LOWER(p.name) LIKE LOWER(CONCAT('%', :q, '%'))) + AND (COALESCE(:brands) IS NULL OR b.name = ANY(:brands)) + """, + countQuery = """ + SELECT COUNT(*) + FROM products p + LEFT JOIN brands b ON b.id = p.brand_id + WHERE + p.deleted_at IS NULL + AND p.status = 'ACTIVE' + AND p.visibility = 'PUBLIC' + AND p.builder_eligible = true + AND (:platform IS NULL OR :platform = '' OR p.platform = :platform) + AND (COALESCE(:partRoles) IS NULL OR p.part_role = ANY(:partRoles)) + AND (:q IS NULL OR :q = '' OR LOWER(p.name) LIKE LOWER(CONCAT('%', :q, '%'))) + AND (COALESCE(:brands) IS NULL OR b.name = ANY(:brands)) + """, + nativeQuery = true + ) + Page searchCatalogOptions( + @Param("platform") String platform, + @Param("partRoles") String[] partRoles, + @Param("q") String q, + @Param("brands") String[] brands, + Pageable pageable + ); + // ------------------------------------------------- // Used by MerchantFeedImportServiceImpl // ------------------------------------------------- diff --git a/src/main/java/group/goforward/battlbuilder/repos/catalog/spec/CatalogProductSpecifications.java b/src/main/java/group/goforward/battlbuilder/repos/catalog/spec/CatalogProductSpecifications.java new file mode 100644 index 0000000..8f92cf0 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/repos/catalog/spec/CatalogProductSpecifications.java @@ -0,0 +1,57 @@ +package group.goforward.battlbuilder.repos.spec; + +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.model.ProductStatus; +import group.goforward.battlbuilder.model.ProductVisibility; + +import org.springframework.data.jpa.domain.Specification; + +import jakarta.persistence.criteria.JoinType; +import java.util.List; + +public class CatalogProductSpecifications { + + private CatalogProductSpecifications() {} + + // Default public catalog rules + public static Specification isCatalogVisible() { + return (root, query, cb) -> cb.and( + cb.isNull(root.get("deletedAt")), + cb.equal(root.get("status"), ProductStatus.ACTIVE), + cb.equal(root.get("visibility"), ProductVisibility.PUBLIC), + cb.isTrue(root.get("builderEligible")) + ); + } + + public static Specification platformEquals(String platform) { + return (root, query, cb) -> cb.equal(root.get("platform"), platform); + } + + public static Specification partRoleIn(List roles) { + return (root, query, cb) -> root.get("partRole").in(roles); + } + + public static Specification brandNameIn(List brandNames) { + return (root, query, cb) -> { + root.fetch("brand", JoinType.LEFT); + query.distinct(true); + return root.join("brand", JoinType.LEFT).get("name").in(brandNames); + }; + } + + public static Specification queryLike(String q) { + final String like = "%" + q.toLowerCase().trim() + "%"; + return (root, query, cb) -> { + root.fetch("brand", JoinType.LEFT); + query.distinct(true); + + var brandJoin = root.join("brand", JoinType.LEFT); + return cb.or( + cb.like(cb.lower(root.get("name")), like), + cb.like(cb.lower(brandJoin.get("name")), like), + cb.like(cb.lower(root.get("mpn")), like), + cb.like(cb.lower(root.get("upc")), like) + ); + }; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/CatalogQueryService.java b/src/main/java/group/goforward/battlbuilder/services/CatalogQueryService.java new file mode 100644 index 0000000..c405278 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/services/CatalogQueryService.java @@ -0,0 +1,23 @@ +package group.goforward.battlbuilder.services; + +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; +import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface CatalogQueryService { + + Page getOptions( + String platform, + String partRole, + List partRoles, + List brands, + String q, + Pageable pageable + ); + + List getProductsByIds(CatalogProductIdsRequest request); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/CatalogQueryServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/CatalogQueryServiceImpl.java new file mode 100644 index 0000000..c934cf9 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/services/impl/CatalogQueryServiceImpl.java @@ -0,0 +1,176 @@ +package group.goforward.battlbuilder.services.impl; + +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.model.ProductOffer; +import group.goforward.battlbuilder.repos.ProductOfferRepository; +import group.goforward.battlbuilder.repos.ProductRepository; +import group.goforward.battlbuilder.repos.spec.CatalogProductSpecifications; +import group.goforward.battlbuilder.services.CatalogQueryService; +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; +import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; +import group.goforward.battlbuilder.web.mapper.ProductMapper; + +import org.springframework.data.domain.*; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class CatalogQueryServiceImpl implements CatalogQueryService { + + private final ProductRepository productRepository; + private final ProductOfferRepository productOfferRepository; + + public CatalogQueryServiceImpl(ProductRepository productRepository, + ProductOfferRepository productOfferRepository) { + this.productRepository = productRepository; + this.productOfferRepository = productOfferRepository; + } + + @Override + public Page getOptions( + String platform, + String partRole, + List partRoles, + List brands, + String q, + Pageable pageable + ) { + pageable = sanitizeCatalogPageable(pageable); + + // Normalize roles: accept partRole OR partRoles + List roleList = new ArrayList<>(); + if (partRole != null && !partRole.isBlank()) roleList.add(partRole); + if (partRoles != null && !partRoles.isEmpty()) roleList.addAll(partRoles); + roleList = roleList.stream().filter(s -> s != null && !s.isBlank()).distinct().toList(); + + Specification spec = Specification.where(CatalogProductSpecifications.isCatalogVisible()); + + // platform optional: omit/blank/ALL => universal + if (platform != null && !platform.isBlank() && !"ALL".equalsIgnoreCase(platform)) { + spec = spec.and(CatalogProductSpecifications.platformEquals(platform)); + } + + if (!roleList.isEmpty()) { + spec = spec.and(CatalogProductSpecifications.partRoleIn(roleList)); + } + + if (brands != null && !brands.isEmpty()) { + spec = spec.and(CatalogProductSpecifications.brandNameIn(brands)); + } + + if (q != null && !q.isBlank()) { + spec = spec.and(CatalogProductSpecifications.queryLike(q)); + } + + Page page = productRepository.findAll(spec, pageable); + if (page.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0); + } + + // Bulk offers for this page (no N+1) + List productIds = page.getContent().stream().map(Product::getId).toList(); + List offers = productOfferRepository.findByProduct_IdIn(productIds); + Map bestOfferByProductId = pickBestOffers(offers); + + List dtos = page.getContent().stream().map(p -> { + ProductOffer best = bestOfferByProductId.get(p.getId()); + BigDecimal price = best != null ? best.getEffectivePrice() : null; + String buyUrl = best != null ? best.getBuyUrl() : null; + return ProductMapper.toSummary(p, price, buyUrl); + }).toList(); + + return new PageImpl<>(dtos, pageable, page.getTotalElements()); + } + + @Override + public List getProductsByIds(CatalogProductIdsRequest request) { + List ids = request != null ? request.getIds() : null; + if (ids == null || ids.isEmpty()) return List.of(); + + ids = ids.stream().filter(Objects::nonNull).distinct().toList(); + + List products = productRepository.findAllById(ids); + if (products.isEmpty()) return List.of(); + + List offers = productOfferRepository.findByProduct_IdIn(ids); + Map bestOfferByProductId = pickBestOffers(offers); + + Map productById = products.stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + List out = new ArrayList<>(); + for (Integer id : ids) { + Product p = productById.get(id); + if (p == null) continue; + + ProductOffer best = bestOfferByProductId.get(id); + BigDecimal price = best != null ? best.getEffectivePrice() : null; + String buyUrl = best != null ? best.getBuyUrl() : null; + + out.add(ProductMapper.toSummary(p, price, buyUrl)); + } + + return out; + } + + private Map pickBestOffers(List offers) { + Map best = new HashMap<>(); + if (offers == null) return best; + + for (ProductOffer o : offers) { + if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue; + + Integer pid = o.getProduct().getId(); + BigDecimal price = o.getEffectivePrice(); + if (price == null) continue; + + ProductOffer current = best.get(pid); + if (current == null) { + best.put(pid, o); + continue; + } + + BigDecimal currentPrice = current.getEffectivePrice(); + if (currentPrice == null || price.compareTo(currentPrice) < 0) { + best.put(pid, o); + } + } + + return best; + } + + private Pageable sanitizeCatalogPageable(Pageable pageable) { + if (pageable == null) { + return PageRequest.of(0, 24, Sort.by(Sort.Direction.DESC, "updatedAt")); + } + + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + + // Default sort if none provided + if (pageable.getSort() == null || pageable.getSort().isUnsorted()) { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); + } + + // Only allow safe sorts (for now) + Sort.Order first = pageable.getSort().stream().findFirst().orElse(null); + if (first == null) { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); + } + + String prop = first.getProperty(); + Sort.Direction dir = first.getDirection(); + + // IMPORTANT: + // If you're still using JPA Specifications (Product entity), you can only sort by Product fields. + // Once you switch to the native "best offer" query, you can allow "price" and "brand" sorts. + return switch (prop) { + case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop)); + default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); + }; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogOptionDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogOptionDto.java new file mode 100644 index 0000000..48fafce --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogOptionDto.java @@ -0,0 +1,16 @@ +package group.goforward.battlbuilder.web.dto.catalog; + +import java.math.BigDecimal; + +public class CatalogOptionDto { + public String id; + public String name; + public String brand; + public String platform; + public String partRole; + public String categoryKey; + public String imageUrl; + public BigDecimal price; + public String buyUrl; + public Boolean inStock; +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogOptionRow.java b/src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogOptionRow.java new file mode 100644 index 0000000..83957e2 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogOptionRow.java @@ -0,0 +1,17 @@ +package group.goforward.battlbuilder.web.dto.catalog; + +import java.math.BigDecimal; + +public interface CatalogOptionRow { + Integer getId(); + String getName(); + String getBrand(); + String getPlatform(); + String getPartRole(); + String getCategoryKey(); + String getImageUrl(); + + BigDecimal getPrice(); // best offer effective price + String getBuyUrl(); // best offer affiliate url + Boolean getInStock(); // best offer inStock +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogProductIdsRequest.java b/src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogProductIdsRequest.java new file mode 100644 index 0000000..d4a8a51 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/catalog/CatalogProductIdsRequest.java @@ -0,0 +1,11 @@ +package group.goforward.battlbuilder.web.dto.catalog; + +import java.util.List; + +public class CatalogProductIdsRequest { + + private List ids; + + public List getIds() { return ids; } + public void setIds(List ids) { this.ids = ids; } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/mapper/CatalogOptionMapper.java b/src/main/java/group/goforward/battlbuilder/web/mapper/CatalogOptionMapper.java new file mode 100644 index 0000000..5d50c3e --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/mapper/CatalogOptionMapper.java @@ -0,0 +1,22 @@ +package group.goforward.battlbuilder.web.mapper; + +import group.goforward.battlbuilder.web.dto.catalog.CatalogOptionDto; +import group.goforward.battlbuilder.web.dto.catalog.CatalogOptionRow; + +public class CatalogOptionMapper { + + public static CatalogOptionDto fromRow(CatalogOptionRow r) { + CatalogOptionDto dto = new CatalogOptionDto(); + dto.id = String.valueOf(r.getId()); + dto.name = r.getName(); + dto.brand = r.getBrand(); + dto.platform = r.getPlatform(); + dto.partRole = r.getPartRole(); + dto.categoryKey = r.getCategoryKey(); + dto.imageUrl = r.getImageUrl(); + dto.price = r.getPrice(); + dto.buyUrl = r.getBuyUrl(); + dto.inStock = r.getInStock(); + return dto; + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 84df03b..86e261e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,18 +5,20 @@ spring.datasource.username=postgres spring.datasource.password=cul8rman spring.datasource.driver-class-name=org.postgresql.Driver -# Hibernate properties -#spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true -#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect security.jwt.secret=ballistic-test-secret-key-1234567890-ABCDEFGHIJKLNMOPQRST security.jwt.access-token-minutes=2880 -# Logging -logging.level.org.hibernate.SQL=INFO +# Hibernate properties + +#spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +logging.level.org.hibernate.SQL=off logging.level.org.hibernate.type.descriptor.sql.BasicBinder=warn +logging.level.org.hibernate.orm.jdbc.bind=OFF # JSP Configuration spring.mvc.view.prefix=/WEB-INF/views/