finished cleaning up all this wild db heavy api calls.

This commit is contained in:
2025-12-30 14:15:04 -05:00
parent ec44e0d0a1
commit 61a64521b2
5 changed files with 178 additions and 8 deletions

View File

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

View File

@@ -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")

View File

@@ -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<Product, Integer>,
JpaSpecificationExecutor<Product> {
@EntityGraph(attributePaths = {"brand"})
List<Product> findByIdIn(Collection<Integer> ids);
@Override
@EntityGraph(attributePaths = {"brand"})
Page<Product> findAll(Specification<Product> spec, Pageable pageable);
/**
* Catalog mapping UI:
@@ -533,4 +538,95 @@ ORDER BY productCount DESC
limit :limit
""", nativeQuery = true)
List<Product> 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<CatalogRow> searchCatalog(
@Param("platform") String platform,
@Param("partRole") String partRole,
@Param("partRoles") String[] partRoles,
@Param("brands") String[] brands,
@Param("q") String q,
Pageable pageable
);
}

View File

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

View File

@@ -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<Product> products = productRepository.findAllById(ids);
List<Product> products = productRepository.findByIdIn(ids);
if (products.isEmpty()) return List.of();
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(ids);
@@ -119,7 +120,7 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
private Map<Integer, ProductOffer> pickBestOffers(List<ProductOffer> offers) {
Map<Integer, ProductOffer> 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()) {