From 61a64521b231663d0f1afa0416f2764a965d94a5 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 30 Dec 2025 14:15:04 -0500 Subject: [PATCH] finished cleaning up all this wild db heavy api calls. --- .../config/SpringDataWebConfig.java | 11 ++ .../controllers/CatalogController.java | 18 ++- .../battlbuilder/repos/ProductRepository.java | 104 +++++++++++++++++- .../repos/projections/CatalogRow.java | 14 +++ .../impl/CatalogQueryServiceImpl.java | 39 ++++++- 5 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 src/main/java/group/goforward/battlbuilder/config/SpringDataWebConfig.java create mode 100644 src/main/java/group/goforward/battlbuilder/repos/projections/CatalogRow.java diff --git a/src/main/java/group/goforward/battlbuilder/config/SpringDataWebConfig.java b/src/main/java/group/goforward/battlbuilder/config/SpringDataWebConfig.java new file mode 100644 index 0000000..58c8d80 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/config/SpringDataWebConfig.java @@ -0,0 +1,11 @@ +package group.goforward.battlbuilder.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.web.config.EnableSpringDataWebSupport; + +import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO; + +@Configuration +@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) +public class SpringDataWebConfig { +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controllers/CatalogController.java b/src/main/java/group/goforward/battlbuilder/controllers/CatalogController.java index dccbcbc..8abdafe 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/CatalogController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/CatalogController.java @@ -32,7 +32,23 @@ public class CatalogController { @RequestParam(required = false) String q, Pageable pageable ) { - return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, pageable); + Pageable safe = sanitizeCatalogPageable(pageable); + return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe); + } + + private Pageable sanitizeCatalogPageable(Pageable pageable) { + int page = Math.max(0, pageable.getPageNumber()); + + // hard cap to keep UI snappy + protect DB + int requested = pageable.getPageSize(); + int size = Math.min(Math.max(requested, 1), 48); // 48 max + + // default sort if none provided + Sort sort = pageable.getSort().isSorted() + ? pageable.getSort() + : Sort.by(Sort.Direction.DESC, "updatedAt"); + + return PageRequest.of(page, size, sort); } @PostMapping("/products/by-ids") diff --git a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java index 456d611..9c93a27 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java @@ -4,14 +4,14 @@ import group.goforward.battlbuilder.model.ImportStatus; import group.goforward.battlbuilder.model.Brand; import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.repos.projections.CatalogRow; 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; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; -import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.EntityGraph; import java.util.Collection; import java.util.List; @@ -21,7 +21,12 @@ public interface ProductRepository extends JpaRepository, JpaSpecificationExecutor { + @EntityGraph(attributePaths = {"brand"}) + List findByIdIn(Collection ids); + @Override + @EntityGraph(attributePaths = {"brand"}) + Page findAll(Specification spec, Pageable pageable); /** * Catalog mapping UI: @@ -533,4 +538,95 @@ ORDER BY productCount DESC limit :limit """, nativeQuery = true) List findProductsMissingCaliberGroup(@Param("limit") int limit); + + + + // ------------------------------------------------- + // Deliver 1 Product and 1 Best Offer + // ------------------------------------------------- + + @Query( + value = """ + select + p.id as id, + p.name as name, + p.platform as platform, + p.part_role as partRole, + coalesce(p.battl_image_url, p.main_image_url) as imageUrl, + b.name as brand, + + o.price as price, + o.buy_url as buyUrl, + o.in_stock as inStock + from products p + join brands b on b.id = p.brand_id + left join lateral ( + select po.price, po.buy_url, po.in_stock + from product_offers po + where po.product_id = p.id + order by + case when po.in_stock then 0 else 1 end, + po.price asc nulls last, + po.last_seen_at desc + limit 1 + ) o on true + where p.deleted_at is null + and p.status = 'ACTIVE' + and p.visibility = 'PUBLIC' + and p.builder_eligible = true + and (:platform is null or p.platform = :platform) + and ( + (:partRole is null and (:partRoles is null or cardinality(:partRoles) = 0)) + or (:partRole is not null and p.part_role = :partRole) + or (:partRoles is not null and p.part_role = any(:partRoles)) + ) + and ( + (:brands is null or cardinality(:brands) = 0) + or b.name = any(:brands) + ) + and ( + :q is null + or lower(p.name) like lower(concat('%', :q, '%')) + or lower(b.name) like lower(concat('%', :q, '%')) + ) + """, + countQuery = """ + select count(*) + from products p + 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 p.platform = :platform) + and ( + (:partRole is null and (:partRoles is null or cardinality(:partRoles) = 0)) + or (:partRole is not null and p.part_role = :partRole) + or (:partRoles is not null and p.part_role = any(:partRoles)) + ) + and ( + (:brands is null or cardinality(:brands) = 0) + or b.name = any(:brands) + ) + and ( + :q is null + or lower(p.name) like lower(concat('%', :q, '%')) + or lower(b.name) like lower(concat('%', :q, '%')) + ) + """, + nativeQuery = true + ) + Page searchCatalog( + @Param("platform") String platform, + @Param("partRole") String partRole, + @Param("partRoles") String[] partRoles, + @Param("brands") String[] brands, + @Param("q") String q, + Pageable pageable + ); + + } + + + diff --git a/src/main/java/group/goforward/battlbuilder/repos/projections/CatalogRow.java b/src/main/java/group/goforward/battlbuilder/repos/projections/CatalogRow.java new file mode 100644 index 0000000..0d91493 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/repos/projections/CatalogRow.java @@ -0,0 +1,14 @@ +package group.goforward.battlbuilder.repos.projections; + +public interface CatalogRow { + Long getId(); + String getName(); + String getPlatform(); + String getPartRole(); + String getImageUrl(); // or mainImageUrl depending on your schema + String getBrand(); + + Double getPrice(); + String getBuyUrl(); + Boolean getInStock(); +} \ 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 index c934cf9..cd945ba 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/CatalogQueryServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/CatalogQueryServiceImpl.java @@ -15,6 +15,7 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import java.math.BigDecimal; +import java.time.OffsetDateTime; import java.util.*; import java.util.stream.Collectors; @@ -93,7 +94,7 @@ public class CatalogQueryServiceImpl implements CatalogQueryService { ids = ids.stream().filter(Objects::nonNull).distinct().toList(); - List products = productRepository.findAllById(ids); + List products = productRepository.findByIdIn(ids); if (products.isEmpty()) return List.of(); List offers = productOfferRepository.findByProduct_IdIn(ids); @@ -119,7 +120,7 @@ public class CatalogQueryServiceImpl implements CatalogQueryService { private Map pickBestOffers(List offers) { Map best = new HashMap<>(); - if (offers == null) return best; + if (offers == null || offers.isEmpty()) return best; for (ProductOffer o : offers) { if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue; @@ -134,9 +135,40 @@ public class CatalogQueryServiceImpl implements CatalogQueryService { continue; } + // ---- ranking rules (in order) ---- + // 1) prefer in-stock + boolean oStock = Boolean.TRUE.equals(o.getInStock()); + boolean cStock = Boolean.TRUE.equals(current.getInStock()); + if (oStock != cStock) { + if (oStock) best.put(pid, o); + continue; + } + + // 2) prefer cheaper price BigDecimal currentPrice = current.getEffectivePrice(); if (currentPrice == null || price.compareTo(currentPrice) < 0) { best.put(pid, o); + continue; + } + if (price.compareTo(currentPrice) > 0) continue; + + // 3) tie-break: most recently seen + OffsetDateTime oSeen = o.getLastSeenAt(); + OffsetDateTime cSeen = current.getLastSeenAt(); + + if (oSeen != null && cSeen != null && oSeen.isAfter(cSeen)) { + best.put(pid, o); + continue; + } + if (oSeen != null && cSeen == null) { + best.put(pid, o); + } + + // 4) tie-break: prefer offer with buyUrl + String oUrl = o.getBuyUrl(); + String cUrl = current.getBuyUrl(); + if ((oUrl != null && !oUrl.isBlank()) && (cUrl == null || cUrl.isBlank())) { + best.put(pid, o); } } @@ -149,7 +181,8 @@ public class CatalogQueryServiceImpl implements CatalogQueryService { } int page = pageable.getPageNumber(); - int size = pageable.getPageSize(); + int requested = pageable.getPageSize(); + int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap // Default sort if none provided if (pageable.getSort() == null || pageable.getSort().isUnsorted()) {