cleaned up api stuff. no longer pulling 2000 products each call.

This commit is contained in:
2025-12-30 13:53:37 -05:00
parent fdaf25927d
commit ec44e0d0a1
10 changed files with 450 additions and 7 deletions

View File

@@ -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<ProductSummaryDto> getOptions(
@RequestParam(required = false) String platform,
@RequestParam(required = false) String partRole,
@RequestParam(required = false) List<String> partRoles,
@RequestParam(required = false, name = "brand") List<String> brands,
@RequestParam(required = false) String q,
Pageable pageable
) {
return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, pageable);
}
@PostMapping("/products/by-ids")
public List<ProductSummaryDto> getProductsByIds(@RequestBody CatalogProductIdsRequest request) {
return catalogQueryService.getProductsByIds(request);
}
}

View File

@@ -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<CatalogOptionRow> searchCatalogOptions(
@Param("platform") String platform,
@Param("partRoles") String[] partRoles,
@Param("q") String q,
@Param("brands") String[] brands,
Pageable pageable
);
// -------------------------------------------------
// Used by MerchantFeedImportServiceImpl
// -------------------------------------------------

View File

@@ -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<Product> 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<Product> platformEquals(String platform) {
return (root, query, cb) -> cb.equal(root.get("platform"), platform);
}
public static Specification<Product> partRoleIn(List<String> roles) {
return (root, query, cb) -> root.get("partRole").in(roles);
}
public static Specification<Product> brandNameIn(List<String> 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<Product> 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)
);
};
}
}

View File

@@ -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<ProductSummaryDto> getOptions(
String platform,
String partRole,
List<String> partRoles,
List<String> brands,
String q,
Pageable pageable
);
List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request);
}

View File

@@ -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<ProductSummaryDto> getOptions(
String platform,
String partRole,
List<String> partRoles,
List<String> brands,
String q,
Pageable pageable
) {
pageable = sanitizeCatalogPageable(pageable);
// Normalize roles: accept partRole OR partRoles
List<String> 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<Product> 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<Product> page = productRepository.findAll(spec, pageable);
if (page.isEmpty()) {
return new PageImpl<>(List.of(), pageable, 0);
}
// Bulk offers for this page (no N+1)
List<Integer> productIds = page.getContent().stream().map(Product::getId).toList();
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(productIds);
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
List<ProductSummaryDto> 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<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request) {
List<Integer> ids = request != null ? request.getIds() : null;
if (ids == null || ids.isEmpty()) return List.of();
ids = ids.stream().filter(Objects::nonNull).distinct().toList();
List<Product> products = productRepository.findAllById(ids);
if (products.isEmpty()) return List.of();
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(ids);
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
Map<Integer, Product> productById = products.stream()
.collect(Collectors.toMap(Product::getId, p -> p));
List<ProductSummaryDto> 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<Integer, ProductOffer> pickBestOffers(List<ProductOffer> offers) {
Map<Integer, ProductOffer> 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"));
};
}
}

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
package group.goforward.battlbuilder.web.dto.catalog;
import java.util.List;
public class CatalogProductIdsRequest {
private List<Integer> ids;
public List<Integer> getIds() { return ids; }
public void setIds(List<Integer> ids) { this.ids = ids; }
}

View File

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

View File

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