diff --git a/src/main/java/group/goforward/ballistic/configuration/CorsConfig.java b/src/main/java/group/goforward/ballistic/configuration/CorsConfig.java index 88b01b8..834f883 100644 --- a/src/main/java/group/goforward/ballistic/configuration/CorsConfig.java +++ b/src/main/java/group/goforward/ballistic/configuration/CorsConfig.java @@ -26,9 +26,12 @@ public class CorsConfig { "http://localhost:4201", "http://localhost:8070", "https://localhost:8070", + "http://localhost:8080", + "https://localhost:8080", + "http://localhost:3000", + "https://localhost:3000", "http://192.168.11.210:8070", "https://192.168.11.210:8070", - "http://localhost:4200", "http://citysites.gofwd.group", "https://citysites.gofwd.group", "http://citysites.gofwd.group:8070", diff --git a/src/main/java/group/goforward/ballistic/controllers/ProductController.java b/src/main/java/group/goforward/ballistic/controllers/ProductController.java new file mode 100644 index 0000000..d8ba863 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/ProductController.java @@ -0,0 +1,86 @@ +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.model.ProductOffer; +import group.goforward.ballistic.repos.ProductOfferRepository; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.web.dto.ProductSummaryDto; +import group.goforward.ballistic.web.mapper.ProductMapper; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/products") +@CrossOrigin +public class ProductController { + + private final ProductRepository productRepository; + private final ProductOfferRepository productOfferRepository; + + public ProductController( + ProductRepository productRepository, + ProductOfferRepository productOfferRepository + ) { + this.productRepository = productRepository; + this.productOfferRepository = productOfferRepository; + } + + @GetMapping("/gunbuilder") + public List getGunbuilderProducts( + @RequestParam(defaultValue = "AR-15") String platform, + @RequestParam(required = false, name = "partRoles") List partRoles + ) { + // 1) Load products + List products; + if (partRoles == null || partRoles.isEmpty()) { + products = productRepository.findByPlatform(platform); + } else { + products = productRepository.findByPlatformAndPartRoleIn(platform, partRoles); + } + + if (products.isEmpty()) { + return List.of(); + } + + // 2) Load offers for these product IDs (Integer IDs) + List productIds = products.stream() + .map(Product::getId) + .toList(); + + List allOffers = + productOfferRepository.findByProductIdIn(productIds); + + Map> offersByProductId = allOffers.stream() + .collect(Collectors.groupingBy(o -> o.getProduct().getId())); + + // 3) Map to DTOs with price and buyUrl + return products.stream() + .map(p -> { + List offersForProduct = + offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); + + ProductOffer bestOffer = pickBestOffer(offersForProduct); + + BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; + String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; + + return ProductMapper.toSummary(p, price, buyUrl); + }) + .toList(); + } + + private ProductOffer pickBestOffer(List offers) { + if (offers == null || offers.isEmpty()) { + return null; + } + + // Right now: lowest price wins, regardless of stock (we set inStock=true on import anyway) + return offers.stream() + .filter(o -> o.getEffectivePrice() != null) + .min(Comparator.comparing(ProductOffer::getEffectivePrice)) + .orElse(null); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java index 929e05d..c40033f 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java @@ -23,6 +23,10 @@ import group.goforward.ballistic.repos.MerchantCategoryMapRepository; import group.goforward.ballistic.model.MerchantCategoryMap; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import group.goforward.ballistic.repos.ProductOfferRepository; +import group.goforward.ballistic.model.ProductOffer; + +import java.time.OffsetDateTime; @Service @Transactional @@ -32,15 +36,18 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService private final BrandRepository brandRepository; private final ProductRepository productRepository; private final MerchantCategoryMapRepository merchantCategoryMapRepository; + private final ProductOfferRepository productOfferRepository; public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, BrandRepository brandRepository, ProductRepository productRepository, - MerchantCategoryMapRepository merchantCategoryMapRepository) { + MerchantCategoryMapRepository merchantCategoryMapRepository, + ProductOfferRepository productOfferRepository) { this.merchantRepository = merchantRepository; this.brandRepository = brandRepository; this.productRepository = productRepository; this.merchantCategoryMapRepository = merchantCategoryMapRepository; + this.productOfferRepository = productOfferRepository; } @Override @@ -105,7 +112,14 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } updateProductFromRow(p, merchant, row, isNew); - return productRepository.save(p); + + // Save the product first + Product saved = productRepository.save(p); + + // Then upsert the offer for this row + upsertOfferFromRow(saved, merchant, row); + + return saved; } private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) { @@ -180,6 +194,62 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } p.setPartRole(partRole); } + private void upsertOfferFromRow(Product product, Merchant merchant, MerchantFeedRow row) { + // For now, we’ll use SKU as the "avantlinkProductId" placeholder. + // If/when you have a real AvantLink product_id in the feed, switch to that. + String avantlinkProductId = trimOrNull(row.sku()); + if (avantlinkProductId == null) { + // If there's truly no SKU, bail out – we can't match this offer reliably. + System.out.println("IMPORT !!! skipping offer: no SKU for product id=" + product.getId()); + return; + } + + // Simple approach: always create a new offer row. + // (If you want idempotent imports later, we can add a repository finder + // like findByProductAndMerchantAndAvantlinkProductId(...) and reuse the row.) + ProductOffer offer = new ProductOffer(); + offer.setProduct(product); + offer.setMerchant(merchant); + offer.setAvantlinkProductId(avantlinkProductId); + + // Identifiers + offer.setSku(trimOrNull(row.sku())); + // No real UPC in this feed yet – leave null for now + offer.setUpc(null); + + // Buy URL + offer.setBuyUrl(trimOrNull(row.buyLink())); + + // Prices from feed + BigDecimal retail = row.retailPrice(); // parsed as BigDecimal in readFeedRowsForMerchant + BigDecimal sale = row.salePrice(); + + BigDecimal effectivePrice; + BigDecimal originalPrice; + + if (sale != null) { + effectivePrice = sale; + originalPrice = (retail != null ? retail : sale); + } else { + effectivePrice = retail; + originalPrice = retail; + } + + offer.setPrice(effectivePrice); + offer.setOriginalPrice(originalPrice); + + // Currency + stock + offer.setCurrency("USD"); + // We don't have a real stock flag in this CSV, so assume in-stock for now + offer.setInStock(Boolean.TRUE); + + // Timestamps + OffsetDateTime now = OffsetDateTime.now(); + offer.setLastSeenAt(now); + offer.setFirstSeenAt(now); // first import: treat now as first seen + + productOfferRepository.save(offer); + } private String resolvePartRole(Merchant merchant, MerchantFeedRow row) { // Build a merchant-specific raw category key like "Department > Category > SubCategory" diff --git a/src/main/java/group/goforward/ballistic/model/ProductOffer.java b/src/main/java/group/goforward/ballistic/model/ProductOffer.java index 64ec40b..d91f32b 100644 --- a/src/main/java/group/goforward/ballistic/model/ProductOffer.java +++ b/src/main/java/group/goforward/ballistic/model/ProductOffer.java @@ -13,9 +13,9 @@ import java.util.UUID; @Table(name = "product_offers") public class ProductOffer { @Id - @GeneratedValue(strategy = GenerationType.AUTO) + @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) - private UUID id; + private Integer id; @ManyToOne(fetch = FetchType.LAZY, optional = false) @OnDelete(action = OnDeleteAction.CASCADE) @@ -60,11 +60,11 @@ public class ProductOffer { @Column(name = "first_seen_at", nullable = false) private OffsetDateTime firstSeenAt; - public UUID getId() { + public Integer getId() { return id; } - public void setId(UUID id) { + public void setId(Integer id) { this.id = id; } @@ -164,4 +164,14 @@ public class ProductOffer { this.firstSeenAt = firstSeenAt; } + public BigDecimal getEffectivePrice() { + // Prefer a true sale price when it's lower than the original + if (price != null && originalPrice != null && price.compareTo(originalPrice) < 0) { + return price; + } + + // Otherwise, use whatever is available + return price != null ? price : originalPrice; + } + } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java index a841b0f..978dc15 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java @@ -1,14 +1,12 @@ package group.goforward.ballistic.repos; import group.goforward.ballistic.model.ProductOffer; -import group.goforward.ballistic.model.Product; -import group.goforward.ballistic.model.Merchant; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -public interface ProductOfferRepository extends JpaRepository { - Optional findByMerchantAndAvantlinkProductId(Merchant merchant, String avantlinkProductId); - List findByProductAndInStockTrueOrderByPriceAsc(Product product); +import java.util.Collection; +import java.util.List; + +public interface ProductOfferRepository extends JpaRepository { + + List findByProductIdIn(Collection productIds); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java index 82955a9..3915542 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; import java.util.UUID; import java.util.List; +import java.util.Collection; public interface ProductRepository extends JpaRepository { @@ -17,4 +18,10 @@ public interface ProductRepository extends JpaRepository { List findAllByBrandAndMpn(Brand brand, String mpn); List findAllByBrandAndUpc(Brand brand, String upc); + + // All products for a given platform (e.g. "AR-15") + List findByPlatform(String platform); + + // Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.) + List findByPlatformAndPartRoleIn(String platform, Collection partRoles); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/ProductSummaryDto.java b/src/main/java/group/goforward/ballistic/web/dto/ProductSummaryDto.java new file mode 100644 index 0000000..39c556a --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/ProductSummaryDto.java @@ -0,0 +1,79 @@ +package group.goforward.ballistic.web.dto; + +import java.math.BigDecimal; + +public class ProductSummaryDto { + + private String id; // product UUID as string + private String name; + private String brand; + private String platform; + private String partRole; + private String categoryKey; + private BigDecimal price; + private String buyUrl; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getPartRole() { + return partRole; + } + + public void setPartRole(String partRole) { + this.partRole = partRole; + } + + public String getCategoryKey() { + return categoryKey; + } + + public void setCategoryKey(String categoryKey) { + this.categoryKey = categoryKey; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public String getBuyUrl() { + return buyUrl; + } + + public void setBuyUrl(String buyUrl) { + this.buyUrl = buyUrl; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/mapper/ProductMapper.java b/src/main/java/group/goforward/ballistic/web/mapper/ProductMapper.java new file mode 100644 index 0000000..1e3fa4c --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/mapper/ProductMapper.java @@ -0,0 +1,30 @@ +package group.goforward.ballistic.web.mapper; + +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.web.dto.ProductSummaryDto; + +import java.math.BigDecimal; + +public class ProductMapper { + + public static ProductSummaryDto toSummary(Product product, BigDecimal price, String buyUrl) { + ProductSummaryDto dto = new ProductSummaryDto(); + + // Product ID -> String + dto.setId(String.valueOf(product.getId())); + + dto.setName(product.getName()); + dto.setBrand(product.getBrand() != null ? product.getBrand().getName() : null); + dto.setPlatform(product.getPlatform()); + dto.setPartRole(product.getPartRole()); + + // Use rawCategoryKey from the Product entity + dto.setCategoryKey(product.getRawCategoryKey()); + + // Price + buy URL from offers + dto.setPrice(price); + dto.setBuyUrl(buyUrl); + + return dto; + } +} \ No newline at end of file