mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
cleaned up api stuff. no longer pulling 2000 products each call.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
// -------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"));
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
Reference in New Issue
Block a user