From 0baae3bc6c949d8ba6b47d6bd31f718c3bba053e Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 29 Nov 2025 10:18:54 -0500 Subject: [PATCH 01/33] project compiles --- .../ballistic/BallisticApplication.java | 19 +- .../imports/MerchantFeedImportService.java | 5 + .../MerchantFeedImportServiceImpl.java | 16 + .../imports/dto/MerchantFeedRow.java | 17 + .../goforward/ballistic/model/Brand.java | 106 +++--- .../goforward/ballistic/model/FeedImport.java | 35 +- .../ballistic/model/PriceHistory.java | 25 +- .../goforward/ballistic/model/Product.java | 312 +++++++----------- .../ballistic/repos/BrandRepository.java | 8 + .../ballistic/repos/BuildItemRepository.java | 12 + .../ballistic/repos/BuildRepository.java | 10 + .../repos/CategoryMappingRepository.java | 7 + .../ballistic/repos/FeedImportRepository.java | 7 + .../ballistic/repos/MerchantRepository.java | 7 + .../repos/PartCategoryRepository.java | 9 + .../repos/PriceHistoryRepository.java | 7 + .../repos/ProductOfferRepository.java | 14 + .../ballistic/repos/ProductRepository.java | 11 + .../ballistic/repos/UserRepository.java | 11 + .../ballistic/web/ImportController.java | 22 ++ .../web/MerchantDebugController.java | 23 ++ .../ballistic/web/PingController.java | 13 + src/main/resources/application.properties | 2 +- 23 files changed, 420 insertions(+), 278 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/imports/MerchantFeedImportService.java create mode 100644 src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java create mode 100644 src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java create mode 100644 src/main/java/group/goforward/ballistic/repos/BrandRepository.java create mode 100644 src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java create mode 100644 src/main/java/group/goforward/ballistic/repos/BuildRepository.java create mode 100644 src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java create mode 100644 src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java create mode 100644 src/main/java/group/goforward/ballistic/repos/MerchantRepository.java create mode 100644 src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java create mode 100644 src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java create mode 100644 src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java create mode 100644 src/main/java/group/goforward/ballistic/repos/ProductRepository.java create mode 100644 src/main/java/group/goforward/ballistic/repos/UserRepository.java create mode 100644 src/main/java/group/goforward/ballistic/web/ImportController.java create mode 100644 src/main/java/group/goforward/ballistic/web/MerchantDebugController.java create mode 100644 src/main/java/group/goforward/ballistic/web/PingController.java diff --git a/src/main/java/group/goforward/ballistic/BallisticApplication.java b/src/main/java/group/goforward/ballistic/BallisticApplication.java index bb2d1e2..6ef40b0 100644 --- a/src/main/java/group/goforward/ballistic/BallisticApplication.java +++ b/src/main/java/group/goforward/ballistic/BallisticApplication.java @@ -4,18 +4,15 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -@Configuration -@EntityScan("group.goforward.ballistic.model") -@ComponentScan("group.goforward.ballistic.configuration") -@ComponentScan("group.goforward.ballistic.controllers") -@ComponentScan("group.goforward.ballistic.service") @SpringBootApplication +@ComponentScan(basePackages = "group.goforward.ballistic") +@EntityScan(basePackages = "group.goforward.ballistic.model") +@EnableJpaRepositories(basePackages = "group.goforward.ballistic.repos") public class BallisticApplication { - public static void main(String[] args) { - SpringApplication.run(BallisticApplication.class, args); - } - -} + public static void main(String[] args) { + SpringApplication.run(BallisticApplication.class, args); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportService.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportService.java new file mode 100644 index 0000000..cf069bd --- /dev/null +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportService.java @@ -0,0 +1,5 @@ +package group.goforward.ballistic.imports; + +public interface MerchantFeedImportService { + void importMerchantFeed(Integer merchantId); +} \ 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 new file mode 100644 index 0000000..eb7e88f --- /dev/null +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java @@ -0,0 +1,16 @@ +package group.goforward.ballistic.imports; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { + + @Override + public void importMerchantFeed(Integer merchantId) { + // TODO: real import logic will be re-added. + // This stub exists to fix the repository/package mixup and get the project compiling again. + throw new UnsupportedOperationException("Merchant feed import not yet implemented after refactor."); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java b/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java new file mode 100644 index 0000000..516053f --- /dev/null +++ b/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java @@ -0,0 +1,17 @@ +package group.goforward.ballistic.imports.dto; + +import java.math.BigDecimal; + +public record MerchantFeedRow( + String brandName, + String productName, + String mpn, + String upc, + String avantlinkProductId, + String sku, + String categoryPath, + String buyUrl, + BigDecimal price, + BigDecimal originalPrice, + boolean inStock +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/Brand.java b/src/main/java/group/goforward/ballistic/model/Brand.java index 7a05dc9..f022d90 100644 --- a/src/main/java/group/goforward/ballistic/model/Brand.java +++ b/src/main/java/group/goforward/ballistic/model/Brand.java @@ -1,10 +1,8 @@ package group.goforward.ballistic.model; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import org.hibernate.annotations.ColumnDefault; +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; import java.time.Instant; import java.util.UUID; @@ -12,39 +10,48 @@ import java.util.UUID; @Entity @Table(name = "brands") public class Brand { + @Id - @Column(name = "id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; - @Column(name = "name", nullable = false, length = 100) + @Column(nullable = false, unique = true) private String name; - @ColumnDefault("now()") + @Column(nullable = false, unique = true) + private UUID uuid; + + @Column(unique = true) + private String slug; + + @Column(name = "website") + private String website; + + @Column(name = "logo_url") + private String logoUrl; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp @Column(name = "updated_at", nullable = false) private Instant updatedAt; - @ColumnDefault("now()") - @Column(name = "created_at", nullable = false) - private Instant createdAt; - @Column(name = "deleted_at") private Instant deletedAt; - @ColumnDefault("gen_random_uuid()") - @Column(name = "uuid") - private UUID uuid; - - @Column(name = "url", length = Integer.MAX_VALUE) - private String url; - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; + @PrePersist + public void prePersist() { + if (uuid == null) { + uuid = UUID.randomUUID(); + } + if (slug == null && name != null) { + slug = name.toLowerCase().replace(" ", "-"); + } } + // Getters and Setters public Integer getId() { return id; } @@ -61,12 +68,36 @@ public class Brand { this.name = name; } - public Instant getUpdatedAt() { - return updatedAt; + public UUID getUuid() { + return uuid; } - public void setUpdatedAt(Instant updatedAt) { - this.updatedAt = updatedAt; + public void setUuid(UUID uuid) { + this.uuid = uuid; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getWebsite() { + return website; + } + + public void setWebsite(String website) { + this.website = website; + } + + public String getLogoUrl() { + return logoUrl; + } + + public void setLogoUrl(String logoUrl) { + this.logoUrl = logoUrl; } public Instant getCreatedAt() { @@ -77,6 +108,14 @@ public class Brand { this.createdAt = createdAt; } + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + public Instant getDeletedAt() { return deletedAt; } @@ -84,13 +123,4 @@ public class Brand { public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; } - - public UUID getUuid() { - return uuid; - } - - public void setUuid(UUID uuid) { - this.uuid = uuid; - } - } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/FeedImport.java b/src/main/java/group/goforward/ballistic/model/FeedImport.java index a117543..3b84d22 100644 --- a/src/main/java/group/goforward/ballistic/model/FeedImport.java +++ b/src/main/java/group/goforward/ballistic/model/FeedImport.java @@ -1,28 +1,27 @@ package group.goforward.ballistic.model; import jakarta.persistence.*; -import org.hibernate.annotations.ColumnDefault; -import java.time.OffsetDateTime; +import java.time.Instant; @Entity @Table(name = "feed_imports") public class FeedImport { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) + // merchant_id + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "merchant_id", nullable = false) private Merchant merchant; - @ColumnDefault("now()") @Column(name = "started_at", nullable = false) - private OffsetDateTime startedAt; + private Instant startedAt; @Column(name = "finished_at") - private OffsetDateTime finishedAt; + private Instant finishedAt; @Column(name = "rows_total") private Integer rowsTotal; @@ -36,21 +35,18 @@ public class FeedImport { @Column(name = "rows_updated") private Integer rowsUpdated; - @ColumnDefault("'running'") - @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) - private String status; + @Column(name = "status", nullable = false) + private String status = "running"; - @Column(name = "error_message", length = Integer.MAX_VALUE) + @Column(name = "error_message") private String errorMessage; + // getters & setters + public Long getId() { return id; } - public void setId(Long id) { - this.id = id; - } - public Merchant getMerchant() { return merchant; } @@ -59,19 +55,19 @@ public class FeedImport { this.merchant = merchant; } - public OffsetDateTime getStartedAt() { + public Instant getStartedAt() { return startedAt; } - public void setStartedAt(OffsetDateTime startedAt) { + public void setStartedAt(Instant startedAt) { this.startedAt = startedAt; } - public OffsetDateTime getFinishedAt() { + public Instant getFinishedAt() { return finishedAt; } - public void setFinishedAt(OffsetDateTime finishedAt) { + public void setFinishedAt(Instant finishedAt) { this.finishedAt = finishedAt; } @@ -122,5 +118,4 @@ public class FeedImport { public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } - } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/PriceHistory.java b/src/main/java/group/goforward/ballistic/model/PriceHistory.java index 9f9eeb6..2266b26 100644 --- a/src/main/java/group/goforward/ballistic/model/PriceHistory.java +++ b/src/main/java/group/goforward/ballistic/model/PriceHistory.java @@ -1,41 +1,35 @@ package group.goforward.ballistic.model; import jakarta.persistence.*; -import org.hibernate.annotations.ColumnDefault; -import org.hibernate.annotations.OnDelete; -import org.hibernate.annotations.OnDeleteAction; import java.math.BigDecimal; -import java.time.OffsetDateTime; +import java.time.Instant; @Entity @Table(name = "price_history") public class PriceHistory { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @OnDelete(action = OnDeleteAction.CASCADE) + // product_offer_id + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "product_offer_id", nullable = false) private ProductOffer productOffer; @Column(name = "price", nullable = false, precision = 10, scale = 2) private BigDecimal price; - @ColumnDefault("now()") @Column(name = "recorded_at", nullable = false) - private OffsetDateTime recordedAt; + private Instant recordedAt; + + // getters & setters public Long getId() { return id; } - public void setId(Long id) { - this.id = id; - } - public ProductOffer getProductOffer() { return productOffer; } @@ -52,12 +46,11 @@ public class PriceHistory { this.price = price; } - public OffsetDateTime getRecordedAt() { + public Instant getRecordedAt() { return recordedAt; } - public void setRecordedAt(OffsetDateTime recordedAt) { + public void setRecordedAt(Instant recordedAt) { this.recordedAt = recordedAt; } - } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/Product.java b/src/main/java/group/goforward/ballistic/model/Product.java index 5c49905..ec584d9 100644 --- a/src/main/java/group/goforward/ballistic/model/Product.java +++ b/src/main/java/group/goforward/ballistic/model/Product.java @@ -1,185 +1,98 @@ package group.goforward.ballistic.model; import jakarta.persistence.*; -import org.hibernate.annotations.ColumnDefault; - -import java.math.BigDecimal; import java.time.Instant; +import java.util.UUID; @Entity @Table(name = "products") public class Product { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // uses products_id_seq in Postgres @Column(name = "id", nullable = false) private Integer id; + @Column(name = "uuid", nullable = false, updatable = false) + private UUID uuid; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "brand_id", nullable = false) + private Brand brand; + @Column(name = "name", nullable = false) private String name; - @Column(name = "description", nullable = false, length = Integer.MAX_VALUE) + @Column(name = "slug", nullable = false) + private String slug; + + @Column(name = "mpn") + private String mpn; + + @Column(name = "upc") + private String upc; + + @Column(name = "platform") + private String platform; + + @Column(name = "part_role") + private String partRole; + + @Column(name = "short_description") + private String shortDescription; + + @Column(name = "description") private String description; - @Column(name = "price", nullable = false) - private BigDecimal price; + @Column(name = "main_image_url") + private String mainImageUrl; - @Column(name = "reseller_id", nullable = false) - private Integer resellerId; - - @Column(name = "category_id", nullable = false) - private Integer categoryId; - - @ColumnDefault("0") - @Column(name = "stock_qty") - private Integer stockQty; - - @ColumnDefault("now()") - @Column(name = "updated_at", nullable = false) - private Instant updatedAt; - - @ColumnDefault("now()") @Column(name = "created_at", nullable = false) private Instant createdAt; + @Column(name = "updated_at") + private Instant updatedAt; + @Column(name = "deleted_at") private Instant deletedAt; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "brand_id") - private Brand brand; + // --- lifecycle hooks --- - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "part_category_id", nullable = false) - private PartCategory partCategory; - - @Column(name = "slug", nullable = false, length = Integer.MAX_VALUE) - private String slug; - - @Column(name = "caliber", length = Integer.MAX_VALUE) - private String caliber; - - @Column(name = "barrel_length_mm") - private Integer barrelLengthMm; - - @Column(name = "gas_system", length = Integer.MAX_VALUE) - private String gasSystem; - - @Column(name = "handguard_length_mm") - private Integer handguardLengthMm; - - @Column(name = "image_url", length = Integer.MAX_VALUE) - private String imageUrl; - - @Column(name = "spec_sheet_url", length = Integer.MAX_VALUE) - private String specSheetUrl; - - @Column(name = "msrp", precision = 10, scale = 2) - private BigDecimal msrp; - - @Column(name = "current_lowest_price", precision = 10, scale = 2) - private BigDecimal currentLowestPrice; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "current_lowest_merchant_id") - private Merchant currentLowestMerchant; - - @ColumnDefault("true") - @Column(name = "is_active", nullable = false) - private Boolean isActive = false; - - public Boolean getIsActive() { - return isActive; + @PrePersist + public void prePersist() { + if (uuid == null) { + uuid = UUID.randomUUID(); + } + Instant now = Instant.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } } - public void setIsActive(Boolean isActive) { - this.isActive = isActive; + @PreUpdate + public void preUpdate() { + updatedAt = Instant.now(); } - public Merchant getCurrentLowestMerchant() { - return currentLowestMerchant; + // --- getters & setters --- + + public Integer getId() { + return id; } - public void setCurrentLowestMerchant(Merchant currentLowestMerchant) { - this.currentLowestMerchant = currentLowestMerchant; + public void setId(Integer id) { + this.id = id; } - public BigDecimal getCurrentLowestPrice() { - return currentLowestPrice; + public UUID getUuid() { + return uuid; } - public void setCurrentLowestPrice(BigDecimal currentLowestPrice) { - this.currentLowestPrice = currentLowestPrice; - } - - public BigDecimal getMsrp() { - return msrp; - } - - public void setMsrp(BigDecimal msrp) { - this.msrp = msrp; - } - - public String getSpecSheetUrl() { - return specSheetUrl; - } - - public void setSpecSheetUrl(String specSheetUrl) { - this.specSheetUrl = specSheetUrl; - } - - public String getImageUrl() { - return imageUrl; - } - - public void setImageUrl(String imageUrl) { - this.imageUrl = imageUrl; - } - - public Integer getHandguardLengthMm() { - return handguardLengthMm; - } - - public void setHandguardLengthMm(Integer handguardLengthMm) { - this.handguardLengthMm = handguardLengthMm; - } - - public String getGasSystem() { - return gasSystem; - } - - public void setGasSystem(String gasSystem) { - this.gasSystem = gasSystem; - } - - public Integer getBarrelLengthMm() { - return barrelLengthMm; - } - - public void setBarrelLengthMm(Integer barrelLengthMm) { - this.barrelLengthMm = barrelLengthMm; - } - - public String getCaliber() { - return caliber; - } - - public void setCaliber(String caliber) { - this.caliber = caliber; - } - - public String getSlug() { - return slug; - } - - public void setSlug(String slug) { - this.slug = slug; - } - - public PartCategory getPartCategory() { - return partCategory; - } - - public void setPartCategory(PartCategory partCategory) { - this.partCategory = partCategory; + public void setUuid(UUID uuid) { + this.uuid = uuid; } public Brand getBrand() { @@ -190,14 +103,6 @@ public class Product { this.brand = brand; } - public Integer getId() { - return id; - } - - public void setId(Integer id) { - this.id = id; - } - public String getName() { return name; } @@ -206,6 +111,54 @@ public class Product { this.name = name; } + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getMpn() { + return mpn; + } + + public void setMpn(String mpn) { + this.mpn = mpn; + } + + public String getUpc() { + return upc; + } + + public void setUpc(String upc) { + this.upc = upc; + } + + 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 getShortDescription() { + return shortDescription; + } + + public void setShortDescription(String shortDescription) { + this.shortDescription = shortDescription; + } + public String getDescription() { return description; } @@ -214,44 +167,12 @@ public class Product { this.description = description; } - public BigDecimal getPrice() { - return price; + public String getMainImageUrl() { + return mainImageUrl; } - public void setPrice(BigDecimal price) { - this.price = price; - } - - public Integer getResellerId() { - return resellerId; - } - - public void setResellerId(Integer resellerId) { - this.resellerId = resellerId; - } - - public Integer getCategoryId() { - return categoryId; - } - - public void setCategoryId(Integer categoryId) { - this.categoryId = categoryId; - } - - public Integer getStockQty() { - return stockQty; - } - - public void setStockQty(Integer stockQty) { - this.stockQty = stockQty; - } - - public Instant getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(Instant updatedAt) { - this.updatedAt = updatedAt; + public void setMainImageUrl(String mainImageUrl) { + this.mainImageUrl = mainImageUrl; } public Instant getCreatedAt() { @@ -262,6 +183,14 @@ public class Product { this.createdAt = createdAt; } + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + public Instant getDeletedAt() { return deletedAt; } @@ -269,5 +198,4 @@ public class Product { public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; } - } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/BrandRepository.java b/src/main/java/group/goforward/ballistic/repos/BrandRepository.java new file mode 100644 index 0000000..4235a7f --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/BrandRepository.java @@ -0,0 +1,8 @@ +package group.goforward.ballistic.repos; +import group.goforward.ballistic.model.Brand; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface BrandRepository extends JpaRepository { + Optional findByNameIgnoreCase(String name); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java b/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java new file mode 100644 index 0000000..0856d01 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java @@ -0,0 +1,12 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.BuildsComponent; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface BuildItemRepository extends JpaRepository { + List findByBuildId(Integer buildId); + Optional findByUuid(UUID uuid); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/BuildRepository.java b/src/main/java/group/goforward/ballistic/repos/BuildRepository.java new file mode 100644 index 0000000..1270fe2 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/BuildRepository.java @@ -0,0 +1,10 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Build; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import java.util.UUID; + +public interface BuildRepository extends JpaRepository { + Optional findByUuid(UUID uuid); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java new file mode 100644 index 0000000..cbaa5ee --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java @@ -0,0 +1,7 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.AffiliateCategoryMap; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryMappingRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java b/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java new file mode 100644 index 0000000..eafaeaf --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java @@ -0,0 +1,7 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.FeedImport; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FeedImportRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java new file mode 100644 index 0000000..dbef844 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java @@ -0,0 +1,7 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Merchant; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MerchantRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java b/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java new file mode 100644 index 0000000..32e41af --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java @@ -0,0 +1,9 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.PartCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface PartCategoryRepository extends JpaRepository { + Optional findBySlug(String slug); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java b/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java new file mode 100644 index 0000000..ec87f45 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java @@ -0,0 +1,7 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.PriceHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PriceHistoryRepository extends JpaRepository { +} \ 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 new file mode 100644 index 0000000..a841b0f --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java @@ -0,0 +1,14 @@ +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); +} \ 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 new file mode 100644 index 0000000..5812b90 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -0,0 +1,11 @@ +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.model.Brand; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import java.util.UUID; + +public interface ProductRepository extends JpaRepository { + Optional findByUuid(UUID uuid); + Optional findByBrandAndMpn(Brand brand, String mpn); + Optional findByBrandAndUpc(Brand brand, String upc); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/UserRepository.java b/src/main/java/group/goforward/ballistic/repos/UserRepository.java new file mode 100644 index 0000000..f861570 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/UserRepository.java @@ -0,0 +1,11 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + Optional findByUuid(UUID uuid); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/ImportController.java b/src/main/java/group/goforward/ballistic/web/ImportController.java new file mode 100644 index 0000000..a24dd5e --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/ImportController.java @@ -0,0 +1,22 @@ +package group.goforward.ballistic.web; + +import group.goforward.ballistic.imports.MerchantFeedImportService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/imports") +public class ImportController { + + private final MerchantFeedImportService importService; + + public ImportController(MerchantFeedImportService importService) { + this.importService = importService; + } + + @PostMapping("/{merchantId}") + public ResponseEntity importMerchant(@PathVariable Integer merchantId) { + importService.importMerchantFeed(merchantId); + return ResponseEntity.accepted().build(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/MerchantDebugController.java b/src/main/java/group/goforward/ballistic/web/MerchantDebugController.java new file mode 100644 index 0000000..c2c1c0a --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/MerchantDebugController.java @@ -0,0 +1,23 @@ +package group.goforward.ballistic.web; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.repos.MerchantRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +public class MerchantDebugController { + + private final MerchantRepository merchantRepository; + + public MerchantDebugController(MerchantRepository merchantRepository) { + this.merchantRepository = merchantRepository; + } + + @GetMapping("/admin/debug/merchants") + public List listMerchants() { + return merchantRepository.findAll(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/PingController.java b/src/main/java/group/goforward/ballistic/web/PingController.java new file mode 100644 index 0000000..2a26737 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/PingController.java @@ -0,0 +1,13 @@ +package group.goforward.ballistic.web; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class PingController { + + @GetMapping("/ping") + public String ping() { + return "pong"; + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 92ccfa4..2e1f374 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,6 +1,6 @@ spring.application.name=ballistic # Database connection properties -spring.datasource.url=jdbc:postgresql://r710.gofwd.group:5433/ballistic +spring.datasource.url=jdbc:postgresql://r710.gofwd.group:5433/ss_builder spring.datasource.username=postgres spring.datasource.password=cul8rman spring.datasource.driver-class-name=org.postgresql.Driver From 5407c245ebbebc584633995d37b2f7db19d23e87 Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 29 Nov 2025 10:55:56 -0500 Subject: [PATCH 02/33] working pipeline. curl admin/imports/4 imported a test product into the db. --- .../imports/MerchantFeedImportService.java | 4 + .../MerchantFeedImportServiceImpl.java | 98 ++++++++++++++++++- .../ballistic/imports/MerchantFeedRow.java | 30 ++++++ .../ballistic/repos/ProductRepository.java | 6 ++ 4 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportService.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportService.java index cf069bd..d3aae5d 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportService.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportService.java @@ -1,5 +1,9 @@ package group.goforward.ballistic.imports; public interface MerchantFeedImportService { + + /** + * Import the feed for a given merchant id. + */ void importMerchantFeed(Integer merchantId); } \ 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 eb7e88f..97a8622 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java @@ -1,16 +1,108 @@ package group.goforward.ballistic.imports; +import group.goforward.ballistic.model.Brand; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.repos.BrandRepository; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.repos.ProductRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; + @Service @Transactional public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { + private final MerchantRepository merchantRepository; + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + + public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, + BrandRepository brandRepository, + ProductRepository productRepository) { + this.merchantRepository = merchantRepository; + this.brandRepository = brandRepository; + this.productRepository = productRepository; + } + @Override public void importMerchantFeed(Integer merchantId) { - // TODO: real import logic will be re-added. - // This stub exists to fix the repository/package mixup and get the project compiling again. - throw new UnsupportedOperationException("Merchant feed import not yet implemented after refactor."); + System.out.println("IMPORT >>> importMerchantFeed(" + merchantId + ")"); + + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); + + // For now, just pick a brand to prove inserts work. + Brand brand = brandRepository.findByNameIgnoreCase("Aero Precision") + .orElseThrow(() -> new IllegalStateException("Brand 'Aero Precision' not found")); + + // Fake a single row – we’ll swap this for real CSV parsing once the plumbing works + MerchantFeedRow row = new MerchantFeedRow( + "TEST-SKU-001", + "APPG100002", + brand.getName(), + "Test Product From Import", + null, null, null, null, null, + null, null, null, null, null, + null, null, + null, null, null, null, null, null, null, null + ); + + Product p = createProduct(brand, row); + System.out.println("IMPORT >>> created product id=" + p.getId() + + ", name=" + p.getName() + + ", merchant=" + merchant.getName()); + } + + private Product createProduct(Brand brand, MerchantFeedRow row) { + System.out.println("IMPORT >>> createProduct brand=" + brand.getName() + + ", sku=" + row.sku() + + ", productName=" + row.productName()); + + Product p = new Product(); + p.setBrand(brand); + + String name = row.productName(); + if (name == null || name.isBlank()) { + name = row.sku(); + } + if (name == null || name.isBlank()) { + name = "Unknown Product"; + } + + // Set required fields: name and slug + p.setName(name); + + // Generate a simple slug from the name (fallback to SKU if needed) + String baseForSlug = name; + if (baseForSlug == null || baseForSlug.isBlank()) { + baseForSlug = row.sku(); + } + if (baseForSlug == null || baseForSlug.isBlank()) { + baseForSlug = "product-" + System.currentTimeMillis(); + } + + String slug = baseForSlug + .toLowerCase() + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-|-$)", ""); + + if (slug.isBlank()) { + slug = "product-" + System.currentTimeMillis(); + } + + p.setSlug(slug); + + if (p.getPlatform() == null || p.getPlatform().isBlank()) { + p.setPlatform("AR-15"); + } + + if (p.getPartRole() == null || p.getPartRole().isBlank()) { + p.setPartRole("unknown"); + } + + + return productRepository.save(p); } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java new file mode 100644 index 0000000..0cc0c42 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java @@ -0,0 +1,30 @@ +package group.goforward.ballistic.imports; + +import java.math.BigDecimal; + +public record MerchantFeedRow( + String sku, + String manufacturerId, + String brandName, + String productName, + String longDescription, + String shortDescription, + String department, + String category, + String subCategory, + String thumbUrl, + String imageUrl, + String buyLink, + String keywords, + String reviews, + BigDecimal retailPrice, + BigDecimal salePrice, + String brandPageLink, + String brandLogoImage, + String productPageViewTracking, + String variantsXml, + String mediumImageUrl, + String productContentWidget, + String googleCategorization, + String itemBasedCommission +) {} \ 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 5812b90..b91bed1 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -1,11 +1,17 @@ +package group.goforward.ballistic.repos; + import group.goforward.ballistic.model.Product; import group.goforward.ballistic.model.Brand; import org.springframework.data.jpa.repository.JpaRepository; + import java.util.Optional; import java.util.UUID; public interface ProductRepository extends JpaRepository { + Optional findByUuid(UUID uuid); + Optional findByBrandAndMpn(Brand brand, String mpn); + Optional findByBrandAndUpc(Brand brand, String upc); } \ No newline at end of file From 4c0a3bd12d1dec169ba160bff9bf4a847ef21ae3 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Sat, 29 Nov 2025 23:18:04 -0500 Subject: [PATCH 03/33] moving controllers to controllers package --- .../java/group/goforward/ballistic/BallisticApplication.java | 2 +- .../ballistic/{web => controllers}/ImportController.java | 2 +- .../ballistic/{web => controllers}/MerchantDebugController.java | 2 +- .../ballistic/{web => controllers}/PingController.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/main/java/group/goforward/ballistic/{web => controllers}/ImportController.java (93%) rename src/main/java/group/goforward/ballistic/{web => controllers}/MerchantDebugController.java (93%) rename src/main/java/group/goforward/ballistic/{web => controllers}/PingController.java (84%) diff --git a/src/main/java/group/goforward/ballistic/BallisticApplication.java b/src/main/java/group/goforward/ballistic/BallisticApplication.java index 6ef40b0..ea5fc0f 100644 --- a/src/main/java/group/goforward/ballistic/BallisticApplication.java +++ b/src/main/java/group/goforward/ballistic/BallisticApplication.java @@ -7,7 +7,7 @@ import org.springframework.context.annotation.ComponentScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication -@ComponentScan(basePackages = "group.goforward.ballistic") + //@ComponentScan(basePackages = "group.goforward.ballistic") @EntityScan(basePackages = "group.goforward.ballistic.model") @EnableJpaRepositories(basePackages = "group.goforward.ballistic.repos") public class BallisticApplication { diff --git a/src/main/java/group/goforward/ballistic/web/ImportController.java b/src/main/java/group/goforward/ballistic/controllers/ImportController.java similarity index 93% rename from src/main/java/group/goforward/ballistic/web/ImportController.java rename to src/main/java/group/goforward/ballistic/controllers/ImportController.java index a24dd5e..da7bcec 100644 --- a/src/main/java/group/goforward/ballistic/web/ImportController.java +++ b/src/main/java/group/goforward/ballistic/controllers/ImportController.java @@ -1,4 +1,4 @@ -package group.goforward.ballistic.web; +package group.goforward.ballistic.controllers; import group.goforward.ballistic.imports.MerchantFeedImportService; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/group/goforward/ballistic/web/MerchantDebugController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantDebugController.java similarity index 93% rename from src/main/java/group/goforward/ballistic/web/MerchantDebugController.java rename to src/main/java/group/goforward/ballistic/controllers/MerchantDebugController.java index c2c1c0a..5d43b71 100644 --- a/src/main/java/group/goforward/ballistic/web/MerchantDebugController.java +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantDebugController.java @@ -1,4 +1,4 @@ -package group.goforward.ballistic.web; +package group.goforward.ballistic.controllers; import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.repos.MerchantRepository; diff --git a/src/main/java/group/goforward/ballistic/web/PingController.java b/src/main/java/group/goforward/ballistic/controllers/PingController.java similarity index 84% rename from src/main/java/group/goforward/ballistic/web/PingController.java rename to src/main/java/group/goforward/ballistic/controllers/PingController.java index 2a26737..40957c3 100644 --- a/src/main/java/group/goforward/ballistic/web/PingController.java +++ b/src/main/java/group/goforward/ballistic/controllers/PingController.java @@ -1,4 +1,4 @@ -package group.goforward.ballistic.web; +package group.goforward.ballistic.controllers; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; From 87b3c4bff87eed664b4058701bf3ceb647563faf Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 30 Nov 2025 05:18:07 -0500 Subject: [PATCH 04/33] slug handling changes --- .../MerchantFeedImportServiceImpl.java | 197 ++++++++++++++---- .../ballistic/repos/ProductRepository.java | 3 + 2 files changed, 165 insertions(+), 35 deletions(-) diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java index 97a8622..a4643d0 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java @@ -1,4 +1,6 @@ package group.goforward.ballistic.imports; +import java.math.BigDecimal; +import java.util.Optional; import group.goforward.ballistic.model.Brand; import group.goforward.ballistic.model.Merchant; @@ -9,7 +11,6 @@ import group.goforward.ballistic.repos.ProductRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - @Service @Transactional public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { @@ -33,25 +34,48 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService Merchant merchant = merchantRepository.findById(merchantId) .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); - // For now, just pick a brand to prove inserts work. + // For now, just pick a brand to prove inserts work (Aero Precision for merchant 4). + // Later we can switch to row.brandName() + auto-create brands. Brand brand = brandRepository.findByNameIgnoreCase("Aero Precision") - .orElseThrow(() -> new IllegalStateException("Brand 'Aero Precision' not found")); + .orElseThrow(() -> new IllegalStateException("Brand 'Aero Precision' not found")); - // Fake a single row – we’ll swap this for real CSV parsing once the plumbing works + // TODO: replace this with real feed parsing: + // List rows = feedClient.fetch(merchant); + // rows.forEach(row -> upsertProduct(merchant, row)); MerchantFeedRow row = new MerchantFeedRow( - "TEST-SKU-001", - "APPG100002", - brand.getName(), - "Test Product From Import", - null, null, null, null, null, - null, null, null, null, null, - null, null, - null, null, null, null, null, null, null, null - ); + "TEST-SKU-001", + "APPG100002", + brand.getName(), + "Test Product From Import", + "This is a long description from AvantLink.", + "Short description from AvantLink.", + "Rifles", + "AR-15 Parts", + "Handguards & Rails", + "https://example.com/thumb.jpg", + "https://example.com/image.jpg", + "https://example.com/buy-link", + "ar-15, handguard, aero", + null, + new BigDecimal("199.99"), // retailPrice + new BigDecimal("149.99"), // salePrice + null, + null, + null, + null, + "https://example.com/medium.jpg", + null, + null, + null + ); Product p = createProduct(brand, row); + System.out.println("IMPORT >>> created product id=" + p.getId() + ", name=" + p.getName() + + ", slug=" + p.getSlug() + + ", platform=" + p.getPlatform() + + ", partRole=" + p.getPartRole() + ", merchant=" + merchant.getName()); } @@ -63,23 +87,24 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService Product p = new Product(); p.setBrand(brand); - String name = row.productName(); - if (name == null || name.isBlank()) { - name = row.sku(); - } - if (name == null || name.isBlank()) { + // ---------- NAME ---------- + String name = coalesce( + trimOrNull(row.productName()), + trimOrNull(row.shortDescription()), + trimOrNull(row.longDescription()), + trimOrNull(row.sku()) + ); + if (name == null) { name = "Unknown Product"; } - - // Set required fields: name and slug p.setName(name); - // Generate a simple slug from the name (fallback to SKU if needed) - String baseForSlug = name; - if (baseForSlug == null || baseForSlug.isBlank()) { - baseForSlug = row.sku(); - } - if (baseForSlug == null || baseForSlug.isBlank()) { + // ---------- SLUG ---------- + String baseForSlug = coalesce( + trimOrNull(name), + trimOrNull(row.sku()) + ); + if (baseForSlug == null) { baseForSlug = "product-" + System.currentTimeMillis(); } @@ -87,22 +112,124 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService .toLowerCase() .replaceAll("[^a-z0-9]+", "-") .replaceAll("(^-|-$)", ""); - if (slug.isBlank()) { slug = "product-" + System.currentTimeMillis(); } - p.setSlug(slug); + // Ensure slug is unique by appending a numeric suffix if needed + String uniqueSlug = generateUniqueSlug(slug); + p.setSlug(uniqueSlug); - if (p.getPlatform() == null || p.getPlatform().isBlank()) { - p.setPlatform("AR-15"); - } - - if (p.getPartRole() == null || p.getPartRole().isBlank()) { - p.setPartRole("unknown"); - } + // ---------- DESCRIPTIONS ---------- + p.setShortDescription(trimOrNull(row.shortDescription())); + p.setDescription(trimOrNull(row.longDescription())); + // ---------- IMAGE ---------- + String mainImage = coalesce( + trimOrNull(row.imageUrl()), + trimOrNull(row.mediumImageUrl()), + trimOrNull(row.thumbUrl()) + ); + p.setMainImageUrl(mainImage); + + // ---------- IDENTIFIERS ---------- + // AvantLink "Manufacturer Id" is a good fit for MPN. + String mpn = coalesce( + trimOrNull(row.manufacturerId()), + trimOrNull(row.sku()) + ); + p.setMpn(mpn); + + // Feed doesn’t give us UPC in the header you showed. + // We’ll leave UPC null for now. + p.setUpc(null); + + // ---------- PLATFORM ---------- + // For now, hard-code to AR-15 to satisfy not-null constraint. + // Later we can infer from row.category()/row.department(). + String platform = inferPlatform(row); + p.setPlatform(platform != null ? platform : "AR-15"); + + // ---------- PART ROLE ---------- + // We can do a tiny heuristic off category/subcategory. + String partRole = inferPartRole(row); + if (partRole == null || partRole.isBlank()) { + partRole = "unknown"; + } + p.setPartRole(partRole); return productRepository.save(p); } + + // --- Helpers ---------------------------------------------------------- + + private String trimOrNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String coalesce(String... values) { + if (values == null) return null; + for (String v : values) { + if (v != null && !v.isBlank()) { + return v; + } + } + return null; + } + + private String generateUniqueSlug(String baseSlug) { + String candidate = baseSlug; + int suffix = 1; + while (productRepository.existsBySlug(candidate)) { + candidate = baseSlug + "-" + suffix; + suffix++; + } + return candidate; + } + + private String inferPlatform(MerchantFeedRow row) { + String department = coalesce(trimOrNull(row.department()), trimOrNull(row.category())); + if (department == null) return null; + + String lower = department.toLowerCase(); + if (lower.contains("ar-15") || lower.contains("ar15")) return "AR-15"; + if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10"; + if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47"; + + // Default: treat Aero as AR-15 universe for now + return "AR-15"; + } + + private String inferPartRole(MerchantFeedRow row) { + String cat = coalesce( + trimOrNull(row.subCategory()), + trimOrNull(row.category()) + ); + if (cat == null) return null; + + String lower = cat.toLowerCase(); + + if (lower.contains("handguard") || lower.contains("rail")) { + return "handguard"; + } + if (lower.contains("barrel")) { + return "barrel"; + } + if (lower.contains("upper")) { + return "upper-receiver"; + } + if (lower.contains("lower")) { + return "lower-receiver"; + } + if (lower.contains("stock") || lower.contains("buttstock")) { + return "stock"; + } + if (lower.contains("grip")) { + return "grip"; + } + + return "unknown"; + } } \ 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 b91bed1..f438159 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -14,4 +14,7 @@ public interface ProductRepository extends JpaRepository { Optional findByBrandAndMpn(Brand brand, String mpn); Optional findByBrandAndUpc(Brand brand, String upc); + + boolean existsBySlug(String slug); + } \ No newline at end of file From f539c64588adb2d8c14cf9798418b24b55dc2579 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 30 Nov 2025 05:40:59 -0500 Subject: [PATCH 05/33] small changes. still working --- .../MerchantFeedImportServiceImpl.java | 150 +++++++++++------- .../ballistic/repos/ProductRepository.java | 8 +- 2 files changed, 98 insertions(+), 60 deletions(-) diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java index a4643d0..622a87a 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java @@ -1,6 +1,6 @@ package group.goforward.ballistic.imports; + import java.math.BigDecimal; -import java.util.Optional; import group.goforward.ballistic.model.Brand; import group.goforward.ballistic.model.Merchant; @@ -41,37 +41,37 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService // TODO: replace this with real feed parsing: // List rows = feedClient.fetch(merchant); - // rows.forEach(row -> upsertProduct(merchant, row)); + // rows.forEach(row -> upsertProduct(merchant, brand, row)); MerchantFeedRow row = new MerchantFeedRow( - "TEST-SKU-001", - "APPG100002", - brand.getName(), - "Test Product From Import", - "This is a long description from AvantLink.", - "Short description from AvantLink.", - "Rifles", - "AR-15 Parts", - "Handguards & Rails", - "https://example.com/thumb.jpg", - "https://example.com/image.jpg", - "https://example.com/buy-link", - "ar-15, handguard, aero", - null, - new BigDecimal("199.99"), // retailPrice - new BigDecimal("149.99"), // salePrice - null, - null, - null, - null, - "https://example.com/medium.jpg", - null, - null, - null - ); + "TEST-SKU-001", + "APPG100002", + brand.getName(), + "Test Product From Import", + "This is a long description from AvantLink.", + "Short description from AvantLink.", + "Rifles", + "AR-15 Parts", + "Handguards & Rails", + "https://example.com/thumb.jpg", + "https://example.com/image.jpg", + "https://example.com/buy-link", + "ar-15, handguard, aero", + null, + new BigDecimal("199.99"), // retailPrice + new BigDecimal("149.99"), // salePrice + null, + null, + null, + null, + "https://example.com/medium.jpg", + null, + null, + null + ); - Product p = createProduct(brand, row); + Product p = upsertProduct(merchant, brand, row); - System.out.println("IMPORT >>> created product id=" + p.getId() + System.out.println("IMPORT >>> upserted product id=" + p.getId() + ", name=" + p.getName() + ", slug=" + p.getSlug() + ", platform=" + p.getPlatform() @@ -79,13 +79,54 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService + ", merchant=" + merchant.getName()); } - private Product createProduct(Brand brand, MerchantFeedRow row) { - System.out.println("IMPORT >>> createProduct brand=" + brand.getName() + /** + * Upsert logic: + * - Try Brand+MPN, then Brand+UPC (for now using sku as a stand-in) + * - If found, update fields but keep existing slug + * - If not found, create a new Product and generate a unique slug + */ + private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { + System.out.println("IMPORT >>> upsertProduct brand=" + brand.getName() + ", sku=" + row.sku() + ", productName=" + row.productName()); - Product p = new Product(); - p.setBrand(brand); + String mpn = trimOrNull(row.manufacturerId()); + String upc = trimOrNull(row.sku()); // later: real UPC column + + java.util.List candidates = java.util.Collections.emptyList(); + + if (mpn != null) { + candidates = productRepository.findAllByBrandAndMpn(brand, mpn); + } + if ((candidates == null || candidates.isEmpty()) && upc != null) { + candidates = productRepository.findAllByBrandAndUpc(brand, upc); + } + + Product p; + boolean isNew = (candidates == null || candidates.isEmpty()); + + if (isNew) { + p = new Product(); + p.setBrand(brand); + } else { + if (candidates.size() > 1) { + System.out.println("IMPORT !!! WARNING: multiple existing products found for brand=" + + brand.getName() + ", mpn=" + mpn + ", upc=" + upc + + ". Using the first match (id=" + candidates.get(0).getId() + ")"); + } + p = candidates.get(0); + } + + updateProductFromRow(p, row, isNew); + + return productRepository.save(p); + } + + /** + * Shared mapping logic from feed row -> Product entity. + * If isNew = true, we generate a slug. Otherwise we leave the slug alone. + */ + private void updateProductFromRow(Product p, MerchantFeedRow row, boolean isNew) { // ---------- NAME ---------- String name = coalesce( @@ -100,25 +141,27 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p.setName(name); // ---------- SLUG ---------- - String baseForSlug = coalesce( - trimOrNull(name), - trimOrNull(row.sku()) - ); - if (baseForSlug == null) { - baseForSlug = "product-" + System.currentTimeMillis(); - } + if (isNew || p.getSlug() == null || p.getSlug().isBlank()) { + String baseForSlug = coalesce( + trimOrNull(name), + trimOrNull(row.sku()) + ); + if (baseForSlug == null) { + baseForSlug = "product-" + System.currentTimeMillis(); + } - String slug = baseForSlug - .toLowerCase() - .replaceAll("[^a-z0-9]+", "-") - .replaceAll("(^-|-$)", ""); - if (slug.isBlank()) { - slug = "product-" + System.currentTimeMillis(); - } + String slug = baseForSlug + .toLowerCase() + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-|-$)", ""); + if (slug.isBlank()) { + slug = "product-" + System.currentTimeMillis(); + } - // Ensure slug is unique by appending a numeric suffix if needed - String uniqueSlug = generateUniqueSlug(slug); - p.setSlug(uniqueSlug); + // Ensure slug is unique by appending a numeric suffix if needed + String uniqueSlug = generateUniqueSlug(slug); + p.setSlug(uniqueSlug); + } // ---------- DESCRIPTIONS ---------- p.setShortDescription(trimOrNull(row.shortDescription())); @@ -141,24 +184,19 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p.setMpn(mpn); // Feed doesn’t give us UPC in the header you showed. - // We’ll leave UPC null for now. + // We’ll leave UPC null for now (or map later). p.setUpc(null); // ---------- PLATFORM ---------- - // For now, hard-code to AR-15 to satisfy not-null constraint. - // Later we can infer from row.category()/row.department(). String platform = inferPlatform(row); p.setPlatform(platform != null ? platform : "AR-15"); // ---------- PART ROLE ---------- - // We can do a tiny heuristic off category/subcategory. String partRole = inferPartRole(row); if (partRole == null || partRole.isBlank()) { partRole = "unknown"; } p.setPartRole(partRole); - - return productRepository.save(p); } // --- Helpers ---------------------------------------------------------- diff --git a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java index f438159..82955a9 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -6,15 +6,15 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; import java.util.UUID; +import java.util.List; public interface ProductRepository extends JpaRepository { Optional findByUuid(UUID uuid); - Optional findByBrandAndMpn(Brand brand, String mpn); - - Optional findByBrandAndUpc(Brand brand, String upc); - boolean existsBySlug(String slug); + List findAllByBrandAndMpn(Brand brand, String mpn); + + List findAllByBrandAndUpc(Brand brand, String upc); } \ No newline at end of file From 140b75621fba38586813a0cd7c69a1f75861a403 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 30 Nov 2025 06:38:51 -0500 Subject: [PATCH 06/33] running import from csv off Avant. Needs some clean up on category matching. --- pom.xml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index db0c6d5..d264e54 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,7 @@ Sean Strawsburg - don@goforward.group + sean@goforward.group Forward Group, LLC @@ -53,7 +53,6 @@ org.springframework.boot spring-boot-starter-web - org.springframework.boot spring-boot-devtools @@ -87,6 +86,11 @@ spring-boot-starter-test test + + org.apache.commons + commons-csv + 1.11.0 + From 52c49c723833c1e0d2080e931732df26ecb49370 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 30 Nov 2025 06:39:07 -0500 Subject: [PATCH 07/33] no idea? --- .../MerchantFeedImportServiceImpl.java | 190 ++++++++++++------ 1 file changed, 129 insertions(+), 61 deletions(-) diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java index 622a87a..7b9a4f4 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java @@ -1,6 +1,17 @@ package group.goforward.ballistic.imports; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.io.Reader; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; import group.goforward.ballistic.model.Brand; import group.goforward.ballistic.model.Merchant; @@ -34,66 +45,37 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService Merchant merchant = merchantRepository.findById(merchantId) .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); - // For now, just pick a brand to prove inserts work (Aero Precision for merchant 4). - // Later we can switch to row.brandName() + auto-create brands. - Brand brand = brandRepository.findByNameIgnoreCase("Aero Precision") - .orElseThrow(() -> new IllegalStateException("Brand 'Aero Precision' not found")); + // Read all rows from the merchant feed + List rows = readFeedRowsForMerchant(merchant); + System.out.println("IMPORT >>> read " + rows.size() + " rows for merchant=" + merchant.getName()); - // TODO: replace this with real feed parsing: - // List rows = feedClient.fetch(merchant); - // rows.forEach(row -> upsertProduct(merchant, brand, row)); - MerchantFeedRow row = new MerchantFeedRow( - "TEST-SKU-001", - "APPG100002", - brand.getName(), - "Test Product From Import", - "This is a long description from AvantLink.", - "Short description from AvantLink.", - "Rifles", - "AR-15 Parts", - "Handguards & Rails", - "https://example.com/thumb.jpg", - "https://example.com/image.jpg", - "https://example.com/buy-link", - "ar-15, handguard, aero", - null, - new BigDecimal("199.99"), // retailPrice - new BigDecimal("149.99"), // salePrice - null, - null, - null, - null, - "https://example.com/medium.jpg", - null, - null, - null - ); + for (MerchantFeedRow row : rows) { + // Resolve brand from the row (fallback to "Aero Precision" or whatever you want as default) + Brand brand = resolveBrand(row); + Product p = upsertProduct(merchant, brand, row); - Product p = upsertProduct(merchant, brand, row); - - System.out.println("IMPORT >>> upserted product id=" + p.getId() - + ", name=" + p.getName() - + ", slug=" + p.getSlug() - + ", platform=" + p.getPlatform() - + ", partRole=" + p.getPartRole() - + ", merchant=" + merchant.getName()); + System.out.println("IMPORT >>> upserted product id=" + p.getId() + + ", name=" + p.getName() + + ", slug=" + p.getSlug() + + ", platform=" + p.getPlatform() + + ", partRole=" + p.getPartRole() + + ", merchant=" + merchant.getName()); + } } - /** - * Upsert logic: - * - Try Brand+MPN, then Brand+UPC (for now using sku as a stand-in) - * - If found, update fields but keep existing slug - * - If not found, create a new Product and generate a unique slug - */ + // --------------------------------------------------------------------- + // Upsert logic + // --------------------------------------------------------------------- + private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { System.out.println("IMPORT >>> upsertProduct brand=" + brand.getName() + ", sku=" + row.sku() + ", productName=" + row.productName()); String mpn = trimOrNull(row.manufacturerId()); - String upc = trimOrNull(row.sku()); // later: real UPC column + String upc = trimOrNull(row.sku()); // placeholder until real UPC field - java.util.List candidates = java.util.Collections.emptyList(); + List candidates = Collections.emptyList(); if (mpn != null) { candidates = productRepository.findAllByBrandAndMpn(brand, mpn); @@ -118,16 +100,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } updateProductFromRow(p, row, isNew); - return productRepository.save(p); } - /** - * Shared mapping logic from feed row -> Product entity. - * If isNew = true, we generate a slug. Otherwise we leave the slug alone. - */ private void updateProductFromRow(Product p, MerchantFeedRow row, boolean isNew) { - // ---------- NAME ---------- String name = coalesce( trimOrNull(row.productName()), @@ -158,7 +134,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService slug = "product-" + System.currentTimeMillis(); } - // Ensure slug is unique by appending a numeric suffix if needed String uniqueSlug = generateUniqueSlug(slug); p.setSlug(uniqueSlug); } @@ -176,15 +151,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p.setMainImageUrl(mainImage); // ---------- IDENTIFIERS ---------- - // AvantLink "Manufacturer Id" is a good fit for MPN. String mpn = coalesce( trimOrNull(row.manufacturerId()), trimOrNull(row.sku()) ); p.setMpn(mpn); - // Feed doesn’t give us UPC in the header you showed. - // We’ll leave UPC null for now (or map later). + // UPC placeholder p.setUpc(null); // ---------- PLATFORM ---------- @@ -199,7 +172,100 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p.setPartRole(partRole); } - // --- Helpers ---------------------------------------------------------- + // --------------------------------------------------------------------- + // Feed reading + brand resolution + // --------------------------------------------------------------------- + + private List readFeedRowsForMerchant(Merchant merchant) { + String rawFeedUrl = merchant.getFeedUrl(); + if (rawFeedUrl == null || rawFeedUrl.isBlank()) { + throw new IllegalStateException("Merchant " + merchant.getName() + " has no feed_url configured"); + } + + String feedUrl = rawFeedUrl.trim(); + System.out.println("IMPORT >>> reading feed for merchant=" + merchant.getName() + " from: " + feedUrl); + + List rows = new ArrayList<>(); + + try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) + ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) + : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); + CSVParser parser = CSVFormat.DEFAULT + .withFirstRecordAsHeader() + .withIgnoreSurroundingSpaces() + .withTrim() + .parse(reader)) { + + for (CSVRecord rec : parser) { + MerchantFeedRow row = new MerchantFeedRow( + rec.get("SKU"), + rec.get("Manufacturer Id"), + rec.get("Brand Name"), + rec.get("Product Name"), + rec.get("Long Description"), + rec.get("Short Description"), + rec.get("Department"), + rec.get("Category"), + rec.get("SubCategory"), + rec.get("Thumb URL"), + rec.get("Image URL"), + rec.get("Buy Link"), + rec.get("Keywords"), + rec.get("Reviews"), + parseBigDecimal(rec.get("Retail Price")), + parseBigDecimal(rec.get("Sale Price")), + rec.get("Brand Page Link"), + rec.get("Brand Logo Image"), + rec.get("Product Page View Tracking"), + rec.get("Variants XML"), + rec.get("Medium Image URL"), + rec.get("Product Content Widget"), + rec.get("Google Categorization"), + rec.get("Item Based Commission") + ); + + rows.add(row); + } + } catch (Exception ex) { + throw new RuntimeException("Failed to read feed for merchant " + + merchant.getName() + " from " + feedUrl, ex); + } + + System.out.println("IMPORT >>> parsed " + rows.size() + " rows for merchant=" + merchant.getName()); + return rows; + } + + private Brand resolveBrand(MerchantFeedRow row) { + String rawBrand = trimOrNull(row.brandName()); + final String brandName = (rawBrand != null) ? rawBrand : "Aero Precision"; + + return brandRepository.findByNameIgnoreCase(brandName) + .orElseGet(() -> { + Brand b = new Brand(); + b.setName(brandName); + return brandRepository.save(b); + }); + } + + private String getCol(String[] cols, int index) { + return (index >= 0 && index < cols.length) ? cols[index] : null; + } + + private BigDecimal parseBigDecimal(String raw) { + if (raw == null) return null; + String trimmed = raw.trim(); + if (trimmed.isEmpty()) return null; + try { + return new BigDecimal(trimmed); + } catch (NumberFormatException ex) { + System.out.println("IMPORT !!! bad BigDecimal value: '" + raw + "', skipping"); + return null; + } + } + + // --------------------------------------------------------------------- + // Misc helpers + // --------------------------------------------------------------------- private String trimOrNull(String value) { if (value == null) return null; @@ -236,7 +302,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10"; if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47"; - // Default: treat Aero as AR-15 universe for now return "AR-15"; } @@ -261,6 +326,9 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService if (lower.contains("lower")) { return "lower-receiver"; } + if (lower.contains("magazine") || lower.contains("mag")) { + return "magazine"; + } if (lower.contains("stock") || lower.contains("buttstock")) { return "stock"; } From 2ef96939f444fb32b0ec8f4a362da5075dc56c37 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 30 Nov 2025 08:08:17 -0500 Subject: [PATCH 08/33] category mapping work. app runs still :D --- .../MerchantFeedImportServiceImpl.java | 66 ++++++- .../ballistic/model/MerchantCategoryMap.java | 86 +++++++++ .../goforward/ballistic/model/Product.java | 11 ++ .../repos/MerchantCategoryMapRepository.java | 12 ++ .../ballistic/repos/MerchantRepository.java | 4 + .../seed/MerchantCategoryMapSeeder.java | 179 ++++++++++++++++++ 6 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java create mode 100644 src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java create mode 100644 src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java index 7b9a4f4..929e05d 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java @@ -19,6 +19,8 @@ import group.goforward.ballistic.model.Product; import group.goforward.ballistic.repos.BrandRepository; import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.repos.MerchantCategoryMapRepository; +import group.goforward.ballistic.model.MerchantCategoryMap; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,13 +31,16 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService private final MerchantRepository merchantRepository; private final BrandRepository brandRepository; private final ProductRepository productRepository; + private final MerchantCategoryMapRepository merchantCategoryMapRepository; public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, BrandRepository brandRepository, - ProductRepository productRepository) { + ProductRepository productRepository, + MerchantCategoryMapRepository merchantCategoryMapRepository) { this.merchantRepository = merchantRepository; this.brandRepository = brandRepository; this.productRepository = productRepository; + this.merchantCategoryMapRepository = merchantCategoryMapRepository; } @Override @@ -99,11 +104,11 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p = candidates.get(0); } - updateProductFromRow(p, row, isNew); + updateProductFromRow(p, merchant, row, isNew); return productRepository.save(p); } - private void updateProductFromRow(Product p, MerchantFeedRow row, boolean isNew) { + private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) { // ---------- NAME ---------- String name = coalesce( trimOrNull(row.productName()), @@ -164,14 +169,50 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService String platform = inferPlatform(row); p.setPlatform(platform != null ? platform : "AR-15"); + // ---------- RAW CATEGORY KEY (for debugging / analytics) ---------- + String rawCategoryKey = buildRawCategoryKey(row); + p.setRawCategoryKey(rawCategoryKey); + // ---------- PART ROLE ---------- - String partRole = inferPartRole(row); + String partRole = resolvePartRole(merchant, row); if (partRole == null || partRole.isBlank()) { partRole = "unknown"; } p.setPartRole(partRole); } + private String resolvePartRole(Merchant merchant, MerchantFeedRow row) { + // Build a merchant-specific raw category key like "Department > Category > SubCategory" + String rawCategoryKey = buildRawCategoryKey(row); + + if (rawCategoryKey != null) { + MerchantCategoryMap mapping = merchantCategoryMapRepository + .findByMerchantAndRawCategoryIgnoreCase(merchant, rawCategoryKey) + .orElse(null); + + if (mapping != null && mapping.isEnabled()) { + String mappedPartRole = trimOrNull(mapping.getPartRole()); + if (mappedPartRole != null && !mappedPartRole.isBlank()) { + return mappedPartRole; + } + } + } + + // Fallback: keyword-based inference + String keywordRole = inferPartRole(row); + if (keywordRole != null && !keywordRole.isBlank()) { + return keywordRole; + } + + // Last resort: log as unmapped and return null/unknown + System.out.println("IMPORT !!! UNMAPPED CATEGORY for merchant=" + merchant.getName() + + ", rawCategoryKey='" + rawCategoryKey + "'" + + ", sku=" + row.sku() + + ", productName=" + row.productName()); + + return null; + } + // --------------------------------------------------------------------- // Feed reading + brand resolution // --------------------------------------------------------------------- @@ -293,6 +334,23 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return candidate; } + private String buildRawCategoryKey(MerchantFeedRow row) { + String dept = trimOrNull(row.department()); + String cat = trimOrNull(row.category()); + String sub = trimOrNull(row.subCategory()); + + java.util.List parts = new java.util.ArrayList<>(); + if (dept != null) parts.add(dept); + if (cat != null) parts.add(cat); + if (sub != null) parts.add(sub); + + if (parts.isEmpty()) { + return null; + } + + return String.join(" > ", parts); + } + private String inferPlatform(MerchantFeedRow row) { String department = coalesce(trimOrNull(row.department()), trimOrNull(row.category())); if (department == null) return null; diff --git a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java new file mode 100644 index 0000000..b3bfa31 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java @@ -0,0 +1,86 @@ +package group.goforward.ballistic.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "merchant_category_map") +public class MerchantCategoryMap { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "merchant_id", nullable = false) + private Merchant merchant; + + @Column(name = "raw_category", nullable = false, length = 255) + private String rawCategory; + + // NEW FIELDS + @Column(name = "platform") + private String platform; // e.g. "AR-15", "AR-10" + + @Column(name = "part_role") + private String partRole; // e.g. "barrel", "handguard" + + @Column(name = "canonical_category") + private String canonicalCategory; // e.g. "Rifle Barrels" + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + // --- getters & setters --- + + public Integer getId() { + return id; + } + + public Merchant getMerchant() { + return merchant; + } + + public void setMerchant(Merchant merchant) { + this.merchant = merchant; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + 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 getCanonicalCategory() { + return canonicalCategory; + } + + public void setCanonicalCategory(String canonicalCategory) { + this.canonicalCategory = canonicalCategory; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/Product.java b/src/main/java/group/goforward/ballistic/model/Product.java index ec584d9..f441762 100644 --- a/src/main/java/group/goforward/ballistic/model/Product.java +++ b/src/main/java/group/goforward/ballistic/model/Product.java @@ -55,6 +55,9 @@ public class Product { @Column(name = "deleted_at") private Instant deletedAt; + + @Column(name = "raw_category_key") + private String rawCategoryKey; // --- lifecycle hooks --- @@ -77,6 +80,14 @@ public class Product { updatedAt = Instant.now(); } + public String getRawCategoryKey() { + return rawCategoryKey; + } + + public void setRawCategoryKey(String rawCategoryKey) { + this.rawCategoryKey = rawCategoryKey; + } + // --- getters & setters --- public Integer getId() { diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java new file mode 100644 index 0000000..74781b8 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java @@ -0,0 +1,12 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMap; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MerchantCategoryMapRepository extends JpaRepository { + + Optional findByMerchantAndRawCategoryIgnoreCase(Merchant merchant, String rawCategory); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java index dbef844..23baf5f 100644 --- a/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java @@ -3,5 +3,9 @@ package group.goforward.ballistic.repos; import group.goforward.ballistic.model.Merchant; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MerchantRepository extends JpaRepository { + + Optional findByNameIgnoreCase(String name); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java b/src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java new file mode 100644 index 0000000..a84f34b --- /dev/null +++ b/src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java @@ -0,0 +1,179 @@ +package group.goforward.ballistic.seed; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMap; +import group.goforward.ballistic.repos.MerchantCategoryMapRepository; +import group.goforward.ballistic.repos.MerchantRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MerchantCategoryMapSeeder { + + @Bean + public CommandLineRunner seedMerchantCategoryMaps(MerchantRepository merchantRepository, + MerchantCategoryMapRepository mapRepository) { + return args -> { + // --- Guard: only seed if table is (mostly) empty --- + long existing = mapRepository.count(); + if (existing > 0) { + System.out.println("CategoryMapSeeder: found " + existing + " existing mappings, skipping seeding."); + return; + } + + System.out.println("CategoryMapSeeder: seeding initial MerchantCategoryMap rows..."); + + // Adjust merchant names if they differ in your DB + seedAeroPrecision(merchantRepository, mapRepository); + seedBrownells(merchantRepository, mapRepository); + seedPSA(merchantRepository, mapRepository); + + System.out.println("CategoryMapSeeder: seeding complete."); + }; + } + + // --------------------------------------------------------------------- + // AERO PRECISION + // --------------------------------------------------------------------- + + private void seedAeroPrecision(MerchantRepository merchantRepository, + MerchantCategoryMapRepository mapRepository) { + + merchantRepository.findByNameIgnoreCase("Aero Precision").ifPresent(merchant -> { + + // Keys come from Department | Category | SubCategory combos + upsert(merchant, "Charging Handles", + "AR-15", "charging-handle", "Charging Handles", true, mapRepository); + + upsert(merchant, "Shop All Barrels", + null, "barrel", "Rifle Barrels", true, mapRepository); + + upsert(merchant, "Lower Parts Kits", + "AR-15", "lower-parts-kit", "Lower Parts Kits", true, mapRepository); + + upsert(merchant, "Handguards", + "AR-15", "handguard", "Handguards & Rails", true, mapRepository); + + upsert(merchant, "Upper Receivers", + "AR-15", "upper-receiver", "Upper Receivers", true, mapRepository); + + // Platform-only hints (let your existing heuristics decide part_role) + upsert(merchant, ".308 Winchester", + "AR-10", null, "AR-10 / .308 Parts", true, mapRepository); + + upsert(merchant, "6.5 Creedmoor", + "AR-10", null, "6.5 Creedmoor Parts", true, mapRepository); + + upsert(merchant, "5.56 Nato / .223 Wylde", + "AR-15", null, "5.56 / .223 Wylde Parts", true, mapRepository); + }); + } + + // --------------------------------------------------------------------- + // BROWNELLS + // --------------------------------------------------------------------- + + private void seedBrownells(MerchantRepository merchantRepository, + MerchantCategoryMapRepository mapRepository) { + + merchantRepository.findByNameIgnoreCase("Brownells").ifPresent(merchant -> { + + upsert(merchant, "Rifle Parts | Receiver Parts | Receivers", + null, "receiver", "Rifle Receivers", true, mapRepository); + + upsert(merchant, "Rifle Parts | Barrel Parts | Rifle Barrels", + null, "barrel", "Rifle Barrels", true, mapRepository); + + upsert(merchant, "Rifle Parts | Stock Parts | Rifle Stocks", + null, "stock", "Rifle Stocks", true, mapRepository); + + upsert(merchant, "Rifle Parts | Muzzle Devices | Compensators & Muzzle Brakes", + null, "muzzle-device", "Muzzle Devices", true, mapRepository); + + upsert(merchant, "Rifle Parts | Trigger Parts | Triggers", + null, "trigger", "Triggers", true, mapRepository); + + upsert(merchant, "Rifle Parts | Receiver Parts | Magazine Parts", + null, "magazine", "Magazine & Mag Parts", true, mapRepository); + + upsert(merchant, "Rifle Parts | Sights | Front Sights", + null, "sight", "Iron Sights", true, mapRepository); + + upsert(merchant, "Rifle Parts | Sights | Rear Sights", + null, "sight", "Iron Sights", true, mapRepository); + + upsert(merchant, "Rifle Parts | Receiver Parts | Buffer Tube Parts", + null, "buffer-tube", "Buffer Tubes & Parts", true, mapRepository); + + upsert(merchant, "Rifle Parts | Stock Parts | Buttstocks", + null, "stock", "Buttstocks", true, mapRepository); + }); + } + + // --------------------------------------------------------------------- + // PALMETTO STATE ARMORY (PSA) + // --------------------------------------------------------------------- + + private void seedPSA(MerchantRepository merchantRepository, + MerchantCategoryMapRepository mapRepository) { + + merchantRepository.findByNameIgnoreCase("Palmetto State Armory").ifPresent(merchant -> { + + upsert(merchant, "AR-15 Parts | Upper Parts | Stripped Uppers", + "AR-15", "upper-receiver", "AR-15 Stripped Uppers", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Upper Parts | Complete Uppers", + "AR-15", "complete-upper", "AR-15 Complete Uppers", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Barrel Parts | Barrels", + "AR-15", "barrel", "AR-15 Barrels", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Lower Parts | Stripped Lowers", + "AR-15", "lower-receiver", "AR-15 Stripped Lowers", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Handguard Parts | Handguards", + "AR-15", "handguard", "AR-15 Handguards", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Bolt Carrier Groups | Bolt Carrier Groups", + "AR-15", "bcg", "AR-15 BCGs", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Trigger Parts | Triggers", + "AR-15", "trigger", "AR-15 Triggers", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Stock Parts | Stocks", + "AR-15", "stock", "AR-15 Stocks", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Muzzle Devices | Muzzle Devices", + "AR-15", "muzzle-device", "AR-15 Muzzle Devices", true, mapRepository); + }); + } + + // --------------------------------------------------------------------- + // Helper + // --------------------------------------------------------------------- + + private void upsert(Merchant merchant, + String rawCategory, + String platform, + String partRole, + String canonicalCategory, + boolean enabled, + MerchantCategoryMapRepository mapRepository) { + + MerchantCategoryMap map = mapRepository + .findByMerchantAndRawCategoryIgnoreCase(merchant, rawCategory) + .orElseGet(MerchantCategoryMap::new); + + map.setMerchant(merchant); + map.setRawCategory(rawCategory); + + // These fields are optional – null means “let heuristics or defaults handle it” + map.setPlatform(platform); + map.setPartRole(partRole); + map.setCanonicalCategory(canonicalCategory); + map.setEnabled(enabled); + + mapRepository.save(map); + } +} \ No newline at end of file From 855f1c23c9d316407f0283b1fd8435f923934e84 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 30 Nov 2025 10:27:38 -0500 Subject: [PATCH 09/33] 90ish percent working. data flows, offers are create and pricings is pulling. --- .../ballistic/configuration/CorsConfig.java | 5 +- .../controllers/ProductController.java | 86 +++++++++++++++++++ .../MerchantFeedImportServiceImpl.java | 74 +++++++++++++++- .../ballistic/model/ProductOffer.java | 18 +++- .../repos/ProductOfferRepository.java | 14 ++- .../ballistic/repos/ProductRepository.java | 7 ++ .../ballistic/web/dto/ProductSummaryDto.java | 79 +++++++++++++++++ .../ballistic/web/mapper/ProductMapper.java | 30 +++++++ 8 files changed, 298 insertions(+), 15 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/controllers/ProductController.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/ProductSummaryDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/mapper/ProductMapper.java 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 From 7166b92d327fa4a5f2512c2aacf610671b290e0b Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 30 Nov 2025 21:08:29 -0500 Subject: [PATCH 10/33] some tweaks for product offers. --- .../controllers/ProductController.java | 20 ++++++ .../repos/ProductOfferRepository.java | 3 + .../ballistic/web/dto/ProductOfferDto.java | 70 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java diff --git a/src/main/java/group/goforward/ballistic/controllers/ProductController.java b/src/main/java/group/goforward/ballistic/controllers/ProductController.java index d8ba863..1485be0 100644 --- a/src/main/java/group/goforward/ballistic/controllers/ProductController.java +++ b/src/main/java/group/goforward/ballistic/controllers/ProductController.java @@ -3,6 +3,7 @@ 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.web.dto.ProductOfferDto; import group.goforward.ballistic.repos.ProductRepository; import group.goforward.ballistic.web.dto.ProductSummaryDto; import group.goforward.ballistic.web.mapper.ProductMapper; @@ -71,6 +72,25 @@ public class ProductController { }) .toList(); } + + @GetMapping("/{id}/offers") + public List getOffersForProduct(@PathVariable("id") Integer productId) { + List offers = productOfferRepository.findByProductId(productId); + + return offers.stream() + .map(offer -> { + ProductOfferDto dto = new ProductOfferDto(); + dto.setId(offer.getId().toString()); + dto.setMerchantName(offer.getMerchant().getName()); + dto.setPrice(offer.getEffectivePrice()); + dto.setOriginalPrice(offer.getOriginalPrice()); + dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); + dto.setBuyUrl(offer.getBuyUrl()); + dto.setLastUpdated(offer.getLastSeenAt()); + return dto; + }) + .toList(); + } private ProductOffer pickBestOffer(List offers) { if (offers == null || offers.isEmpty()) { diff --git a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java index 978dc15..cf4ff64 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java @@ -8,5 +8,8 @@ import java.util.List; public interface ProductOfferRepository extends JpaRepository { + List findByProductId(Integer productId); + + // Used by the /api/products/gunbuilder endpoint List findByProductIdIn(Collection productIds); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java b/src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java new file mode 100644 index 0000000..3fd40d4 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java @@ -0,0 +1,70 @@ +package group.goforward.ballistic.web.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +public class ProductOfferDto { + private String id; + private String merchantName; + private BigDecimal price; + private BigDecimal originalPrice; + private boolean inStock; + private String buyUrl; + private OffsetDateTime lastUpdated; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getMerchantName() { + return merchantName; + } + + public void setMerchantName(String merchantName) { + this.merchantName = merchantName; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public BigDecimal getOriginalPrice() { + return originalPrice; + } + + public void setOriginalPrice(BigDecimal originalPrice) { + this.originalPrice = originalPrice; + } + + public boolean isInStock() { + return inStock; + } + + public void setInStock(boolean inStock) { + this.inStock = inStock; + } + + public String getBuyUrl() { + return buyUrl; + } + + public void setBuyUrl(String buyUrl) { + this.buyUrl = buyUrl; + } + + public OffsetDateTime getLastUpdated() { + return lastUpdated; + } + + public void setLastUpdated(OffsetDateTime lastUpdated) { + this.lastUpdated = lastUpdated; + } +} \ No newline at end of file From 0f5978fd11d4f8d09b97d36f2b877c0a0012d34c Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 1 Dec 2025 07:54:05 -0500 Subject: [PATCH 11/33] allow for category mapping from GB UI. Add platform locked flag to products. --- .../controllers/AdminMerchantController.java | 24 +++ .../MerchantCategoryMappingController.java | 65 +++++++ .../MerchantFeedImportServiceImpl.java | 91 +++++---- .../ballistic/model/MerchantCategoryMap.java | 86 --------- .../model/MerchantCategoryMapping.java | 91 +++++++++ .../goforward/ballistic/model/Product.java | 14 ++ .../repos/MerchantCategoryMapRepository.java | 12 -- .../MerchantCategoryMappingRepository.java | 17 ++ .../repos/ProductOfferRepository.java | 7 + .../seed/MerchantCategoryMapSeeder.java | 179 ------------------ .../MerchantCategoryMappingService.java | 77 ++++++++ .../web/dto/MerchantCategoryMappingDto.java | 50 +++++ .../UpsertMerchantCategoryMappingRequest.java | 32 ++++ 13 files changed, 427 insertions(+), 318 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java create mode 100644 src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java delete mode 100644 src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java create mode 100644 src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java delete mode 100644 src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java create mode 100644 src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java delete mode 100644 src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java create mode 100644 src/main/java/group/goforward/ballistic/service/MerchantCategoryMappingService.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java diff --git a/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java b/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java new file mode 100644 index 0000000..af9de5e --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java @@ -0,0 +1,24 @@ +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.repos.MerchantRepository; +import java.util.List; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/merchants") +@CrossOrigin // adjust later if you want +public class AdminMerchantController { + + private final MerchantRepository merchantRepository; + + public AdminMerchantController(MerchantRepository merchantRepository) { + this.merchantRepository = merchantRepository; + } + + @GetMapping + public List getMerchants() { + // If you want a DTO here, you can wrap it, but this is fine for internal admin + return merchantRepository.findAll(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java new file mode 100644 index 0000000..681eabf --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java @@ -0,0 +1,65 @@ +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.service.MerchantCategoryMappingService; +import group.goforward.ballistic.web.dto.MerchantCategoryMappingDto; +import group.goforward.ballistic.web.dto.UpsertMerchantCategoryMappingRequest; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/merchant-category-mappings") +@CrossOrigin +public class MerchantCategoryMappingController { + + private final MerchantCategoryMappingService mappingService; + private final MerchantRepository merchantRepository; + + public MerchantCategoryMappingController( + MerchantCategoryMappingService mappingService, + MerchantRepository merchantRepository + ) { + this.mappingService = mappingService; + this.merchantRepository = merchantRepository; + } + + @GetMapping + public List listMappings( + @RequestParam("merchantId") Integer merchantId + ) { + List mappings = mappingService.findByMerchant(merchantId); + return mappings.stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @PostMapping + public MerchantCategoryMappingDto upsertMapping( + @RequestBody UpsertMerchantCategoryMappingRequest request + ) { + Merchant merchant = merchantRepository + .findById(request.getMerchantId()) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + request.getMerchantId())); + + MerchantCategoryMapping mapping = mappingService.upsertMapping( + merchant, + request.getRawCategory(), + request.getMappedPartRole() + ); + + return toDto(mapping); + } + + private MerchantCategoryMappingDto toDto(MerchantCategoryMapping mapping) { + MerchantCategoryMappingDto dto = new MerchantCategoryMappingDto(); + dto.setId(mapping.getId()); + dto.setMerchantId(mapping.getMerchant().getId()); + dto.setMerchantName(mapping.getMerchant().getName()); + dto.setRawCategory(mapping.getRawCategory()); + dto.setMappedPartRole(mapping.getMappedPartRole()); + return dto; + } +} \ 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 c40033f..4e9b97c 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java @@ -19,8 +19,8 @@ import group.goforward.ballistic.model.Product; import group.goforward.ballistic.repos.BrandRepository; import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.ProductRepository; -import group.goforward.ballistic.repos.MerchantCategoryMapRepository; -import group.goforward.ballistic.model.MerchantCategoryMap; +import group.goforward.ballistic.service.MerchantCategoryMappingService; +import group.goforward.ballistic.service.MerchantCategoryMappingService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import group.goforward.ballistic.repos.ProductOfferRepository; @@ -35,18 +35,18 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService private final MerchantRepository merchantRepository; private final BrandRepository brandRepository; private final ProductRepository productRepository; - private final MerchantCategoryMapRepository merchantCategoryMapRepository; + private final MerchantCategoryMappingService merchantCategoryMappingService; private final ProductOfferRepository productOfferRepository; public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, BrandRepository brandRepository, ProductRepository productRepository, - MerchantCategoryMapRepository merchantCategoryMapRepository, + MerchantCategoryMappingService merchantCategoryMappingService, ProductOfferRepository productOfferRepository) { this.merchantRepository = merchantRepository; this.brandRepository = brandRepository; this.productRepository = productRepository; - this.merchantCategoryMapRepository = merchantCategoryMapRepository; + this.merchantCategoryMappingService = merchantCategoryMappingService; this.productOfferRepository = productOfferRepository; } @@ -180,8 +180,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p.setUpc(null); // ---------- PLATFORM ---------- - String platform = inferPlatform(row); - p.setPlatform(platform != null ? platform : "AR-15"); + if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) { + String platform = inferPlatform(row); + p.setPlatform(platform != null ? platform : "AR-15"); + } // ---------- RAW CATEGORY KEY (for debugging / analytics) ---------- String rawCategoryKey = buildRawCategoryKey(row); @@ -203,51 +205,60 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService 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); - + + // Idempotent upsert: look for an existing offer for this merchant + AvantLink product id + ProductOffer offer = productOfferRepository + .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .orElseGet(ProductOffer::new); + + // If this is a brand‑new offer, initialize key fields + if (offer.getId() == null) { + offer.setMerchant(merchant); + offer.setProduct(product); + offer.setAvantlinkProductId(avantlinkProductId); + offer.setFirstSeenAt(OffsetDateTime.now()); + } else { + // Make sure associations stay in sync if anything changed + offer.setMerchant(merchant); + offer.setProduct(product); + } + // 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) { + + // Prefer sale price if it exists and is less than or equal to retail + if (sale != null && (retail == null || sale.compareTo(retail) <= 0)) { effectivePrice = sale; originalPrice = (retail != null ? retail : sale); } else { - effectivePrice = retail; - originalPrice = retail; + // Otherwise fall back to retail or whatever is present + effectivePrice = (retail != null ? retail : sale); + originalPrice = (retail != null ? retail : sale); } - + 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 - + + // Update "last seen" on every import pass + offer.setLastSeenAt(OffsetDateTime.now()); + productOfferRepository.save(offer); } @@ -256,15 +267,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService String rawCategoryKey = buildRawCategoryKey(row); if (rawCategoryKey != null) { - MerchantCategoryMap mapping = merchantCategoryMapRepository - .findByMerchantAndRawCategoryIgnoreCase(merchant, rawCategoryKey) - .orElse(null); - - if (mapping != null && mapping.isEnabled()) { - String mappedPartRole = trimOrNull(mapping.getPartRole()); - if (mappedPartRole != null && !mappedPartRole.isBlank()) { - return mappedPartRole; - } + // Delegate to the mapping service, which will: + // - Look up an existing mapping + // - If none exists, create a placeholder row with null mappedPartRole + // - Return the mapped partRole, or null if not yet mapped + String mapped = merchantCategoryMappingService.resolvePartRole(merchant, rawCategoryKey); + if (mapped != null && !mapped.isBlank()) { + return mapped; } } @@ -274,7 +283,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return keywordRole; } - // Last resort: log as unmapped and return null/unknown + // Last resort: log as unmapped and return null System.out.println("IMPORT !!! UNMAPPED CATEGORY for merchant=" + merchant.getName() + ", rawCategoryKey='" + rawCategoryKey + "'" + ", sku=" + row.sku() diff --git a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java deleted file mode 100644 index b3bfa31..0000000 --- a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java +++ /dev/null @@ -1,86 +0,0 @@ -package group.goforward.ballistic.model; - -import jakarta.persistence.*; - -@Entity -@Table(name = "merchant_category_map") -public class MerchantCategoryMap { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Integer id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "merchant_id", nullable = false) - private Merchant merchant; - - @Column(name = "raw_category", nullable = false, length = 255) - private String rawCategory; - - // NEW FIELDS - @Column(name = "platform") - private String platform; // e.g. "AR-15", "AR-10" - - @Column(name = "part_role") - private String partRole; // e.g. "barrel", "handguard" - - @Column(name = "canonical_category") - private String canonicalCategory; // e.g. "Rifle Barrels" - - @Column(name = "enabled", nullable = false) - private boolean enabled = true; - - // --- getters & setters --- - - public Integer getId() { - return id; - } - - public Merchant getMerchant() { - return merchant; - } - - public void setMerchant(Merchant merchant) { - this.merchant = merchant; - } - - public String getRawCategory() { - return rawCategory; - } - - public void setRawCategory(String rawCategory) { - this.rawCategory = rawCategory; - } - - 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 getCanonicalCategory() { - return canonicalCategory; - } - - public void setCanonicalCategory(String canonicalCategory) { - this.canonicalCategory = canonicalCategory; - } - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } -} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java new file mode 100644 index 0000000..d90e561 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java @@ -0,0 +1,91 @@ +package group.goforward.ballistic.model; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; + +@Entity +@Table( + name = "merchant_category_mappings", + uniqueConstraints = @UniqueConstraint( + name = "uq_merchant_category", + columnNames = { "merchant_id", "raw_category" } + ) +) +public class MerchantCategoryMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL + @Column(name = "id", nullable = false) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "merchant_id", nullable = false) + private Merchant merchant; + + @Column(name = "raw_category", nullable = false, length = 512) + private String rawCategory; + + @Column(name = "mapped_part_role", length = 128) + private String mappedPartRole; // e.g. "upper-receiver", "barrel" + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + @PreUpdate + public void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + + // getters & setters + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Merchant getMerchant() { + return merchant; + } + + public void setMerchant(Merchant merchant) { + this.merchant = merchant; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + public String getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/Product.java b/src/main/java/group/goforward/ballistic/model/Product.java index f441762..0b0540e 100644 --- a/src/main/java/group/goforward/ballistic/model/Product.java +++ b/src/main/java/group/goforward/ballistic/model/Product.java @@ -59,6 +59,11 @@ public class Product { @Column(name = "raw_category_key") private String rawCategoryKey; + @Column(name = "platform_locked", nullable = false) + private Boolean platformLocked = false; + + + // --- lifecycle hooks --- @PrePersist @@ -209,4 +214,13 @@ public class Product { public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; } + + public Boolean getPlatformLocked() { + return platformLocked; + } + + public void setPlatformLocked(Boolean platformLocked) { + this.platformLocked = platformLocked; + } + } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java deleted file mode 100644 index 74781b8..0000000 --- a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.model.MerchantCategoryMap; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface MerchantCategoryMapRepository extends JpaRepository { - - Optional findByMerchantAndRawCategoryIgnoreCase(Merchant merchant, String rawCategory); -} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java new file mode 100644 index 0000000..bddeed5 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java @@ -0,0 +1,17 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.MerchantCategoryMapping; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MerchantCategoryMappingRepository + extends JpaRepository { + + Optional findByMerchantIdAndRawCategoryIgnoreCase( + Integer merchantId, + String rawCategory + ); + + List findByMerchantIdOrderByRawCategoryAsc(Integer merchantId); +} \ 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 cf4ff64..caaa372 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; import java.util.List; +import java.util.Optional; public interface ProductOfferRepository extends JpaRepository { @@ -12,4 +13,10 @@ public interface ProductOfferRepository extends JpaRepository findByProductIdIn(Collection productIds); + + // Unique offer lookup for importer upsert + Optional findByMerchantIdAndAvantlinkProductId( + Integer merchantId, + String avantlinkProductId + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java b/src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java deleted file mode 100644 index a84f34b..0000000 --- a/src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java +++ /dev/null @@ -1,179 +0,0 @@ -package group.goforward.ballistic.seed; - -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.model.MerchantCategoryMap; -import group.goforward.ballistic.repos.MerchantCategoryMapRepository; -import group.goforward.ballistic.repos.MerchantRepository; -import org.springframework.boot.CommandLineRunner; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class MerchantCategoryMapSeeder { - - @Bean - public CommandLineRunner seedMerchantCategoryMaps(MerchantRepository merchantRepository, - MerchantCategoryMapRepository mapRepository) { - return args -> { - // --- Guard: only seed if table is (mostly) empty --- - long existing = mapRepository.count(); - if (existing > 0) { - System.out.println("CategoryMapSeeder: found " + existing + " existing mappings, skipping seeding."); - return; - } - - System.out.println("CategoryMapSeeder: seeding initial MerchantCategoryMap rows..."); - - // Adjust merchant names if they differ in your DB - seedAeroPrecision(merchantRepository, mapRepository); - seedBrownells(merchantRepository, mapRepository); - seedPSA(merchantRepository, mapRepository); - - System.out.println("CategoryMapSeeder: seeding complete."); - }; - } - - // --------------------------------------------------------------------- - // AERO PRECISION - // --------------------------------------------------------------------- - - private void seedAeroPrecision(MerchantRepository merchantRepository, - MerchantCategoryMapRepository mapRepository) { - - merchantRepository.findByNameIgnoreCase("Aero Precision").ifPresent(merchant -> { - - // Keys come from Department | Category | SubCategory combos - upsert(merchant, "Charging Handles", - "AR-15", "charging-handle", "Charging Handles", true, mapRepository); - - upsert(merchant, "Shop All Barrels", - null, "barrel", "Rifle Barrels", true, mapRepository); - - upsert(merchant, "Lower Parts Kits", - "AR-15", "lower-parts-kit", "Lower Parts Kits", true, mapRepository); - - upsert(merchant, "Handguards", - "AR-15", "handguard", "Handguards & Rails", true, mapRepository); - - upsert(merchant, "Upper Receivers", - "AR-15", "upper-receiver", "Upper Receivers", true, mapRepository); - - // Platform-only hints (let your existing heuristics decide part_role) - upsert(merchant, ".308 Winchester", - "AR-10", null, "AR-10 / .308 Parts", true, mapRepository); - - upsert(merchant, "6.5 Creedmoor", - "AR-10", null, "6.5 Creedmoor Parts", true, mapRepository); - - upsert(merchant, "5.56 Nato / .223 Wylde", - "AR-15", null, "5.56 / .223 Wylde Parts", true, mapRepository); - }); - } - - // --------------------------------------------------------------------- - // BROWNELLS - // --------------------------------------------------------------------- - - private void seedBrownells(MerchantRepository merchantRepository, - MerchantCategoryMapRepository mapRepository) { - - merchantRepository.findByNameIgnoreCase("Brownells").ifPresent(merchant -> { - - upsert(merchant, "Rifle Parts | Receiver Parts | Receivers", - null, "receiver", "Rifle Receivers", true, mapRepository); - - upsert(merchant, "Rifle Parts | Barrel Parts | Rifle Barrels", - null, "barrel", "Rifle Barrels", true, mapRepository); - - upsert(merchant, "Rifle Parts | Stock Parts | Rifle Stocks", - null, "stock", "Rifle Stocks", true, mapRepository); - - upsert(merchant, "Rifle Parts | Muzzle Devices | Compensators & Muzzle Brakes", - null, "muzzle-device", "Muzzle Devices", true, mapRepository); - - upsert(merchant, "Rifle Parts | Trigger Parts | Triggers", - null, "trigger", "Triggers", true, mapRepository); - - upsert(merchant, "Rifle Parts | Receiver Parts | Magazine Parts", - null, "magazine", "Magazine & Mag Parts", true, mapRepository); - - upsert(merchant, "Rifle Parts | Sights | Front Sights", - null, "sight", "Iron Sights", true, mapRepository); - - upsert(merchant, "Rifle Parts | Sights | Rear Sights", - null, "sight", "Iron Sights", true, mapRepository); - - upsert(merchant, "Rifle Parts | Receiver Parts | Buffer Tube Parts", - null, "buffer-tube", "Buffer Tubes & Parts", true, mapRepository); - - upsert(merchant, "Rifle Parts | Stock Parts | Buttstocks", - null, "stock", "Buttstocks", true, mapRepository); - }); - } - - // --------------------------------------------------------------------- - // PALMETTO STATE ARMORY (PSA) - // --------------------------------------------------------------------- - - private void seedPSA(MerchantRepository merchantRepository, - MerchantCategoryMapRepository mapRepository) { - - merchantRepository.findByNameIgnoreCase("Palmetto State Armory").ifPresent(merchant -> { - - upsert(merchant, "AR-15 Parts | Upper Parts | Stripped Uppers", - "AR-15", "upper-receiver", "AR-15 Stripped Uppers", true, mapRepository); - - upsert(merchant, "AR-15 Parts | Upper Parts | Complete Uppers", - "AR-15", "complete-upper", "AR-15 Complete Uppers", true, mapRepository); - - upsert(merchant, "AR-15 Parts | Barrel Parts | Barrels", - "AR-15", "barrel", "AR-15 Barrels", true, mapRepository); - - upsert(merchant, "AR-15 Parts | Lower Parts | Stripped Lowers", - "AR-15", "lower-receiver", "AR-15 Stripped Lowers", true, mapRepository); - - upsert(merchant, "AR-15 Parts | Handguard Parts | Handguards", - "AR-15", "handguard", "AR-15 Handguards", true, mapRepository); - - upsert(merchant, "AR-15 Parts | Bolt Carrier Groups | Bolt Carrier Groups", - "AR-15", "bcg", "AR-15 BCGs", true, mapRepository); - - upsert(merchant, "AR-15 Parts | Trigger Parts | Triggers", - "AR-15", "trigger", "AR-15 Triggers", true, mapRepository); - - upsert(merchant, "AR-15 Parts | Stock Parts | Stocks", - "AR-15", "stock", "AR-15 Stocks", true, mapRepository); - - upsert(merchant, "AR-15 Parts | Muzzle Devices | Muzzle Devices", - "AR-15", "muzzle-device", "AR-15 Muzzle Devices", true, mapRepository); - }); - } - - // --------------------------------------------------------------------- - // Helper - // --------------------------------------------------------------------- - - private void upsert(Merchant merchant, - String rawCategory, - String platform, - String partRole, - String canonicalCategory, - boolean enabled, - MerchantCategoryMapRepository mapRepository) { - - MerchantCategoryMap map = mapRepository - .findByMerchantAndRawCategoryIgnoreCase(merchant, rawCategory) - .orElseGet(MerchantCategoryMap::new); - - map.setMerchant(merchant); - map.setRawCategory(rawCategory); - - // These fields are optional – null means “let heuristics or defaults handle it” - map.setPlatform(platform); - map.setPartRole(partRole); - map.setCanonicalCategory(canonicalCategory); - map.setEnabled(enabled); - - mapRepository.save(map); - } -} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/service/MerchantCategoryMappingService.java b/src/main/java/group/goforward/ballistic/service/MerchantCategoryMappingService.java new file mode 100644 index 0000000..89df3f0 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/service/MerchantCategoryMappingService.java @@ -0,0 +1,77 @@ +package group.goforward.ballistic.service; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; +import jakarta.transaction.Transactional; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; + +@Service +public class MerchantCategoryMappingService { + + private final MerchantCategoryMappingRepository mappingRepository; + + public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) { + this.mappingRepository = mappingRepository; + } + + public List findByMerchant(Integer merchantId) { + return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId); + } + + /** + * Resolve a partRole for a given raw category. + * If not found, create a row with null mappedPartRole and return null (so importer can skip). + */ + @Transactional + public String resolvePartRole(Merchant merchant, String rawCategory) { + if (rawCategory == null || rawCategory.isBlank()) { + return null; + } + + String trimmed = rawCategory.trim(); + + Optional existingOpt = + mappingRepository.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed); + + if (existingOpt.isPresent()) { + return existingOpt.get().getMappedPartRole(); + } + + // Create placeholder row + MerchantCategoryMapping mapping = new MerchantCategoryMapping(); + mapping.setMerchant(merchant); + mapping.setRawCategory(trimmed); + mapping.setMappedPartRole(null); + + mappingRepository.save(mapping); + + // No mapping yet → importer should skip this product + return null; + } + + /** + * Upsert mapping (admin UI). + */ + @Transactional + public MerchantCategoryMapping upsertMapping(Merchant merchant, String rawCategory, String mappedPartRole) { + String trimmed = rawCategory.trim(); + + MerchantCategoryMapping mapping = mappingRepository + .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) + .orElseGet(() -> { + MerchantCategoryMapping m = new MerchantCategoryMapping(); + m.setMerchant(merchant); + m.setRawCategory(trimmed); + return m; + }); + + mapping.setMappedPartRole( + (mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim() + ); + + return mappingRepository.save(mapping); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java b/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java new file mode 100644 index 0000000..bb7a703 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java @@ -0,0 +1,50 @@ +package group.goforward.ballistic.web.dto; + +public class MerchantCategoryMappingDto { + + private Integer id; + private Integer merchantId; + private String merchantName; + private String rawCategory; + private String mappedPartRole; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getMerchantId() { + return merchantId; + } + + public void setMerchantId(Integer merchantId) { + this.merchantId = merchantId; + } + + public String getMerchantName() { + return merchantName; + } + + public void setMerchantName(String merchantName) { + this.merchantName = merchantName; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + public String getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java b/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java new file mode 100644 index 0000000..f0d102a --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java @@ -0,0 +1,32 @@ +package group.goforward.ballistic.web.dto; + +public class UpsertMerchantCategoryMappingRequest { + + private Integer merchantId; + private String rawCategory; + private String mappedPartRole; // can be null to "unmap" + + public Integer getMerchantId() { + return merchantId; + } + + public void setMerchantId(Integer merchantId) { + this.merchantId = merchantId; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + public String getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } +} \ No newline at end of file From 66d45a111387796b8caa27e0d35bf4a3d683ba26 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Mon, 1 Dec 2025 15:41:55 -0500 Subject: [PATCH 12/33] adding package-info files and move a controller to the package with tthe res of them --- .../ballistic/BallisticApplication.java | 4 +- .../ballistic/configuration/package-info.java | 12 +++ .../controllers/ImportController.java | 2 +- .../MerchantCategoryMappingController.java | 2 +- .../ballistic/controllers/PsaController.java | 2 +- .../controllers/StateController.java | 2 +- .../ballistic/controllers/package-info.java | 1 + .../ballistic/imports/dto/package-info.java | 1 + .../ballistic/model/package-info.java | 1 + .../ballistic/repos/package-info.java | 13 ++++ .../MerchantCategoryMappingService.java | 2 +- .../MerchantFeedImportService.java | 2 +- .../{service => services}/PsaService.java | 76 ++++++++++--------- .../{service => services}/StatesService.java | 32 ++++---- .../impl}/MerchantFeedImportServiceImpl.java | 7 +- .../ballistic/services/impl/PsaService.java | 17 +++++ .../impl/StatesServiceImpl.java | 76 +++++++++---------- .../ballistic/services/impl/package-info.java | 1 + .../ballistic/services/package-info.java | 1 + 19 files changed, 154 insertions(+), 100 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/controllers/package-info.java create mode 100644 src/main/java/group/goforward/ballistic/imports/dto/package-info.java create mode 100644 src/main/java/group/goforward/ballistic/model/package-info.java create mode 100644 src/main/java/group/goforward/ballistic/repos/package-info.java rename src/main/java/group/goforward/ballistic/{service => services}/MerchantCategoryMappingService.java (98%) rename src/main/java/group/goforward/ballistic/{imports => services}/MerchantFeedImportService.java (78%) rename src/main/java/group/goforward/ballistic/{service => services}/PsaService.java (80%) rename src/main/java/group/goforward/ballistic/{service => services}/StatesService.java (81%) rename src/main/java/group/goforward/ballistic/{imports => services/impl}/MerchantFeedImportServiceImpl.java (98%) create mode 100644 src/main/java/group/goforward/ballistic/services/impl/PsaService.java rename src/main/java/group/goforward/ballistic/{service => services}/impl/StatesServiceImpl.java (83%) create mode 100644 src/main/java/group/goforward/ballistic/services/impl/package-info.java create mode 100644 src/main/java/group/goforward/ballistic/services/package-info.java diff --git a/src/main/java/group/goforward/ballistic/BallisticApplication.java b/src/main/java/group/goforward/ballistic/BallisticApplication.java index ea5fc0f..611f7dc 100644 --- a/src/main/java/group/goforward/ballistic/BallisticApplication.java +++ b/src/main/java/group/goforward/ballistic/BallisticApplication.java @@ -7,7 +7,9 @@ import org.springframework.context.annotation.ComponentScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication - //@ComponentScan(basePackages = "group.goforward.ballistic") +@ComponentScan("group.goforward.ballistic.controllers") +@ComponentScan("group.goforward.ballistic.repos") +@ComponentScan("group.goforward.ballistic.services") @EntityScan(basePackages = "group.goforward.ballistic.model") @EnableJpaRepositories(basePackages = "group.goforward.ballistic.repos") public class BallisticApplication { diff --git a/src/main/java/group/goforward/ballistic/configuration/package-info.java b/src/main/java/group/goforward/ballistic/configuration/package-info.java index cd2627a..abe5a7c 100644 --- a/src/main/java/group/goforward/ballistic/configuration/package-info.java +++ b/src/main/java/group/goforward/ballistic/configuration/package-info.java @@ -1 +1,13 @@ +/** + * Provides the classes necessary for the Spring Configurations for the ballistic -Builder application. + * This package includes Configurations for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Don Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.configuration; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/ImportController.java b/src/main/java/group/goforward/ballistic/controllers/ImportController.java index da7bcec..b1ea350 100644 --- a/src/main/java/group/goforward/ballistic/controllers/ImportController.java +++ b/src/main/java/group/goforward/ballistic/controllers/ImportController.java @@ -1,6 +1,6 @@ package group.goforward.ballistic.controllers; -import group.goforward.ballistic.imports.MerchantFeedImportService; +import group.goforward.ballistic.services.MerchantFeedImportService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java index 681eabf..e2cc758 100644 --- a/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java @@ -3,7 +3,7 @@ package group.goforward.ballistic.controllers; import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.MerchantCategoryMapping; import group.goforward.ballistic.repos.MerchantRepository; -import group.goforward.ballistic.service.MerchantCategoryMappingService; +import group.goforward.ballistic.services.MerchantCategoryMappingService; import group.goforward.ballistic.web.dto.MerchantCategoryMappingDto; import group.goforward.ballistic.web.dto.UpsertMerchantCategoryMappingRequest; import java.util.List; diff --git a/src/main/java/group/goforward/ballistic/controllers/PsaController.java b/src/main/java/group/goforward/ballistic/controllers/PsaController.java index bb394e9..8efec1b 100644 --- a/src/main/java/group/goforward/ballistic/controllers/PsaController.java +++ b/src/main/java/group/goforward/ballistic/controllers/PsaController.java @@ -1,7 +1,7 @@ package group.goforward.ballistic.controllers; import group.goforward.ballistic.model.Psa; -import group.goforward.ballistic.service.PsaService; +import group.goforward.ballistic.services.PsaService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/group/goforward/ballistic/controllers/StateController.java b/src/main/java/group/goforward/ballistic/controllers/StateController.java index 76fef10..59d0c78 100644 --- a/src/main/java/group/goforward/ballistic/controllers/StateController.java +++ b/src/main/java/group/goforward/ballistic/controllers/StateController.java @@ -3,7 +3,7 @@ package group.goforward.ballistic.controllers; import group.goforward.ballistic.ApiResponse; import group.goforward.ballistic.model.State; import group.goforward.ballistic.repos.StateRepository; -import group.goforward.ballistic.service.StatesService; +import group.goforward.ballistic.services.StatesService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/group/goforward/ballistic/controllers/package-info.java b/src/main/java/group/goforward/ballistic/controllers/package-info.java new file mode 100644 index 0000000..e61736b --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/package-info.java @@ -0,0 +1 @@ +package group.goforward.ballistic.controllers; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/dto/package-info.java b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java new file mode 100644 index 0000000..686bebd --- /dev/null +++ b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java @@ -0,0 +1 @@ +package group.goforward.ballistic.imports.dto; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/package-info.java b/src/main/java/group/goforward/ballistic/model/package-info.java new file mode 100644 index 0000000..002a39a --- /dev/null +++ b/src/main/java/group/goforward/ballistic/model/package-info.java @@ -0,0 +1 @@ +package group.goforward.ballistic.model; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/package-info.java b/src/main/java/group/goforward/ballistic/repos/package-info.java new file mode 100644 index 0000000..570163b --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/package-info.java @@ -0,0 +1,13 @@ +/** + * Provides the classes necessary for the Spring Repository for the ballistic -Builder application. + * This package includes Repository for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Sean Strawsburg + * @version 1.1 + */ +package group.goforward.ballistic.repos; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/service/MerchantCategoryMappingService.java b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java similarity index 98% rename from src/main/java/group/goforward/ballistic/service/MerchantCategoryMappingService.java rename to src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java index 89df3f0..c93d162 100644 --- a/src/main/java/group/goforward/ballistic/service/MerchantCategoryMappingService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java @@ -1,4 +1,4 @@ -package group.goforward.ballistic.service; +package group.goforward.ballistic.services; import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.MerchantCategoryMapping; diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportService.java b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java similarity index 78% rename from src/main/java/group/goforward/ballistic/imports/MerchantFeedImportService.java rename to src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java index d3aae5d..33cd776 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java @@ -1,4 +1,4 @@ -package group.goforward.ballistic.imports; +package group.goforward.ballistic.services; public interface MerchantFeedImportService { diff --git a/src/main/java/group/goforward/ballistic/service/PsaService.java b/src/main/java/group/goforward/ballistic/services/PsaService.java similarity index 80% rename from src/main/java/group/goforward/ballistic/service/PsaService.java rename to src/main/java/group/goforward/ballistic/services/PsaService.java index 939477b..29cb6f3 100644 --- a/src/main/java/group/goforward/ballistic/service/PsaService.java +++ b/src/main/java/group/goforward/ballistic/services/PsaService.java @@ -1,36 +1,40 @@ -package group.goforward.ballistic.service; -import group.goforward.ballistic.model.Psa; -import group.goforward.ballistic.repos.PsaRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Service -public class PsaService { - - private final PsaRepository psaRepository; - - @Autowired - public PsaService(PsaRepository psaRepository) { - this.psaRepository = psaRepository; - } - - public List findAll() { - return psaRepository.findAll(); - } - - public Optional findById(UUID id) { - return psaRepository.findById(id); - } - - public Psa save(Psa psa) { - return psaRepository.save(psa); - } - - public void deleteById(UUID id) { - psaRepository.deleteById(id); - } -} +package group.goforward.ballistic.services; +import group.goforward.ballistic.model.Psa; +import group.goforward.ballistic.repos.PsaRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class PsaService implements group.goforward.ballistic.services.impl.PsaService { + + private final PsaRepository psaRepository; + + @Autowired + public PsaService(PsaRepository psaRepository) { + this.psaRepository = psaRepository; + } + + @Override + public List findAll() { + return psaRepository.findAll(); + } + + @Override + public Optional findById(UUID id) { + return psaRepository.findById(id); + } + + @Override + public Psa save(Psa psa) { + return psaRepository.save(psa); + } + + @Override + public void deleteById(UUID id) { + psaRepository.deleteById(id); + } +} diff --git a/src/main/java/group/goforward/ballistic/service/StatesService.java b/src/main/java/group/goforward/ballistic/services/StatesService.java similarity index 81% rename from src/main/java/group/goforward/ballistic/service/StatesService.java rename to src/main/java/group/goforward/ballistic/services/StatesService.java index f3a290a..a8d74c1 100644 --- a/src/main/java/group/goforward/ballistic/service/StatesService.java +++ b/src/main/java/group/goforward/ballistic/services/StatesService.java @@ -1,16 +1,16 @@ -package group.goforward.ballistic.service; - -import group.goforward.ballistic.model.State; - -import java.util.List; -import java.util.Optional; - -public interface StatesService { - - List findAll(); - - Optional findById(Integer id); - - State save(State item); - void deleteById(Integer id); -} +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.State; + +import java.util.List; +import java.util.Optional; + +public interface StatesService { + + List findAll(); + + Optional findById(Integer id); + + State save(State item); + void deleteById(Integer id); +} diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java similarity index 98% rename from src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java rename to src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index 4e9b97c..5c34c2f 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java @@ -1,4 +1,4 @@ -package group.goforward.ballistic.imports; +package group.goforward.ballistic.services.impl; import java.math.BigDecimal; import java.util.ArrayList; @@ -9,6 +9,8 @@ import java.io.InputStreamReader; import java.net.URL; import java.nio.charset.StandardCharsets; +import group.goforward.ballistic.imports.MerchantFeedRow; +import group.goforward.ballistic.services.MerchantFeedImportService; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVRecord; @@ -19,8 +21,7 @@ import group.goforward.ballistic.model.Product; import group.goforward.ballistic.repos.BrandRepository; import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.ProductRepository; -import group.goforward.ballistic.service.MerchantCategoryMappingService; -import group.goforward.ballistic.service.MerchantCategoryMappingService; +import group.goforward.ballistic.services.MerchantCategoryMappingService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import group.goforward.ballistic.repos.ProductOfferRepository; diff --git a/src/main/java/group/goforward/ballistic/services/impl/PsaService.java b/src/main/java/group/goforward/ballistic/services/impl/PsaService.java new file mode 100644 index 0000000..e84f141 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/impl/PsaService.java @@ -0,0 +1,17 @@ +package group.goforward.ballistic.services.impl; + +import group.goforward.ballistic.model.Psa; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface PsaService { + List findAll(); + + Optional findById(UUID id); + + Psa save(Psa psa); + + void deleteById(UUID id); +} diff --git a/src/main/java/group/goforward/ballistic/service/impl/StatesServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java similarity index 83% rename from src/main/java/group/goforward/ballistic/service/impl/StatesServiceImpl.java rename to src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java index b1fdb4d..8d3d44a 100644 --- a/src/main/java/group/goforward/ballistic/service/impl/StatesServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java @@ -1,38 +1,38 @@ -package group.goforward.ballistic.service.impl; - - -import group.goforward.ballistic.model.State; -import group.goforward.ballistic.repos.StateRepository; -import group.goforward.ballistic.service.StatesService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; - -@Service -public class StatesServiceImpl implements StatesService { - - @Autowired - private StateRepository repo; - - @Override - public List findAll() { - return repo.findAll(); - } - - @Override - public Optional findById(Integer id) { - return repo.findById(id); - } - - @Override - public State save(State item) { - return null; - } - - @Override - public void deleteById(Integer id) { - deleteById(id); - } -} +package group.goforward.ballistic.services.impl; + + +import group.goforward.ballistic.model.State; +import group.goforward.ballistic.repos.StateRepository; +import group.goforward.ballistic.services.StatesService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class StatesServiceImpl implements StatesService { + + @Autowired + private StateRepository repo; + + @Override + public List findAll() { + return repo.findAll(); + } + + @Override + public Optional findById(Integer id) { + return repo.findById(id); + } + + @Override + public State save(State item) { + return null; + } + + @Override + public void deleteById(Integer id) { + deleteById(id); + } +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/package-info.java b/src/main/java/group/goforward/ballistic/services/impl/package-info.java new file mode 100644 index 0000000..a5d264b --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/impl/package-info.java @@ -0,0 +1 @@ +package group.goforward.ballistic.services.impl; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/package-info.java b/src/main/java/group/goforward/ballistic/services/package-info.java new file mode 100644 index 0000000..1bfc5e3 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/package-info.java @@ -0,0 +1 @@ +package group.goforward.ballistic.services; \ No newline at end of file From f1dcd10a79b9d98ca9a8a30af61e626e42d7a9c7 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 1 Dec 2025 16:08:42 -0500 Subject: [PATCH 13/33] new categories and mapping logic --- .../model/MerchantCategoryMapping.java | 14 +++++ .../goforward/ballistic/model/Product.java | 15 ++++- .../ballistic/model/ProductConfiguration.java | 10 +++ .../MerchantCategoryMappingService.java | 61 ++++++++++++------- .../impl/MerchantFeedImportServiceImpl.java | 51 ++++++---------- 5 files changed, 98 insertions(+), 53 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/model/ProductConfiguration.java diff --git a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java index d90e561..9bb833c 100644 --- a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java +++ b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java @@ -3,6 +3,8 @@ package group.goforward.ballistic.model; import jakarta.persistence.*; import java.time.OffsetDateTime; +import group.goforward.ballistic.model.ProductConfiguration; + @Entity @Table( name = "merchant_category_mappings", @@ -28,6 +30,10 @@ public class MerchantCategoryMapping { @Column(name = "mapped_part_role", length = 128) private String mappedPartRole; // e.g. "upper-receiver", "barrel" + @Column(name = "mapped_configuration") + @Enumerated(EnumType.STRING) + private ProductConfiguration mappedConfiguration; + @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt = OffsetDateTime.now(); @@ -73,6 +79,14 @@ public class MerchantCategoryMapping { this.mappedPartRole = mappedPartRole; } + public ProductConfiguration getMappedConfiguration() { + return mappedConfiguration; + } + + public void setMappedConfiguration(ProductConfiguration mappedConfiguration) { + this.mappedConfiguration = mappedConfiguration; + } + public OffsetDateTime getCreatedAt() { return createdAt; } diff --git a/src/main/java/group/goforward/ballistic/model/Product.java b/src/main/java/group/goforward/ballistic/model/Product.java index 0b0540e..785f928 100644 --- a/src/main/java/group/goforward/ballistic/model/Product.java +++ b/src/main/java/group/goforward/ballistic/model/Product.java @@ -4,6 +4,8 @@ import jakarta.persistence.*; import java.time.Instant; import java.util.UUID; +import group.goforward.ballistic.model.ProductConfiguration; + @Entity @Table(name = "products") public class Product { @@ -38,6 +40,10 @@ public class Product { @Column(name = "part_role") private String partRole; + @Column(name = "configuration") + @Enumerated(EnumType.STRING) + private ProductConfiguration configuration; + @Column(name = "short_description") private String shortDescription; @@ -223,4 +229,11 @@ public class Product { this.platformLocked = platformLocked; } -} \ No newline at end of file + public ProductConfiguration getConfiguration() { + return configuration; + } + + public void setConfiguration(ProductConfiguration configuration) { + this.configuration = configuration; + } +} diff --git a/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java b/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java new file mode 100644 index 0000000..7bda4e9 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java @@ -0,0 +1,10 @@ +package group.goforward.ballistic.model; + +public enum ProductConfiguration { + STRIPPED, // bare receiver / component + ASSEMBLED, // built up but not fully complete + BARRELED, // upper + barrel + gas system, no BCG/CH + COMPLETE, // full assembly ready to run + KIT, // collection of parts (LPK, trigger kits, etc.) + OTHER // fallback / unknown +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java index c93d162..06e808c 100644 --- a/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java @@ -2,6 +2,7 @@ package group.goforward.ballistic.services; import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.model.ProductConfiguration; import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; import jakarta.transaction.Transactional; import java.util.List; @@ -22,41 +23,44 @@ public class MerchantCategoryMappingService { } /** - * Resolve a partRole for a given raw category. - * If not found, create a row with null mappedPartRole and return null (so importer can skip). + * Resolve (or create) a mapping row for this merchant + raw category. + * - If it exists, returns it (with whatever mappedPartRole / mappedConfiguration are set). + * - If it doesn't exist, creates a placeholder row with null mappings and returns it. + * + * The importer can then: + * - skip rows where mappedPartRole is still null + * - use mappedConfiguration if present */ @Transactional - public String resolvePartRole(Merchant merchant, String rawCategory) { + public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) { if (rawCategory == null || rawCategory.isBlank()) { return null; } String trimmed = rawCategory.trim(); - Optional existingOpt = - mappingRepository.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed); - - if (existingOpt.isPresent()) { - return existingOpt.get().getMappedPartRole(); - } - - // Create placeholder row - MerchantCategoryMapping mapping = new MerchantCategoryMapping(); - mapping.setMerchant(merchant); - mapping.setRawCategory(trimmed); - mapping.setMappedPartRole(null); - - mappingRepository.save(mapping); - - // No mapping yet → importer should skip this product - return null; + return mappingRepository + .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) + .orElseGet(() -> { + MerchantCategoryMapping mapping = new MerchantCategoryMapping(); + mapping.setMerchant(merchant); + mapping.setRawCategory(trimmed); + mapping.setMappedPartRole(null); + mapping.setMappedConfiguration(null); + return mappingRepository.save(mapping); + }); } /** * Upsert mapping (admin UI). */ @Transactional - public MerchantCategoryMapping upsertMapping(Merchant merchant, String rawCategory, String mappedPartRole) { + public MerchantCategoryMapping upsertMapping( + Merchant merchant, + String rawCategory, + String mappedPartRole, + ProductConfiguration mappedConfiguration + ) { String trimmed = rawCategory.trim(); MerchantCategoryMapping mapping = mappingRepository @@ -72,6 +76,21 @@ public class MerchantCategoryMappingService { (mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim() ); + mapping.setMappedConfiguration(mappedConfiguration); + return mappingRepository.save(mapping); } + /** + * Backwards-compatible overload for existing callers (e.g. controller) + * that don’t care about productConfiguration yet. + */ + @Transactional + public MerchantCategoryMapping upsertMapping( + Merchant merchant, + String rawCategory, + String mappedPartRole + ) { + // Delegate to the new method with `null` configuration + return upsertMapping(merchant, rawCategory, mappedPartRole, null); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index 5c34c2f..c56f021 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java @@ -22,6 +22,7 @@ import group.goforward.ballistic.repos.BrandRepository; import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.ProductRepository; import group.goforward.ballistic.services.MerchantCategoryMappingService; +import group.goforward.ballistic.model.MerchantCategoryMapping; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import group.goforward.ballistic.repos.ProductOfferRepository; @@ -190,11 +191,28 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService String rawCategoryKey = buildRawCategoryKey(row); p.setRawCategoryKey(rawCategoryKey); - // ---------- PART ROLE ---------- - String partRole = resolvePartRole(merchant, row); + // ---------- PART ROLE (via category mapping, with keyword fallback) ---------- + String partRole = null; + + if (rawCategoryKey != null) { + // Ask the mapping service for (or to create) a mapping row + MerchantCategoryMapping mapping = + merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey); + + if (mapping != null && mapping.getMappedPartRole() != null && !mapping.getMappedPartRole().isBlank()) { + partRole = mapping.getMappedPartRole().trim(); + } + } + + // Fallback: keyword-based inference if we still don't have a mapped partRole + if (partRole == null || partRole.isBlank()) { + partRole = inferPartRole(row); + } + if (partRole == null || partRole.isBlank()) { partRole = "unknown"; } + p.setPartRole(partRole); } private void upsertOfferFromRow(Product product, Merchant merchant, MerchantFeedRow row) { @@ -263,35 +281,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService productOfferRepository.save(offer); } - private String resolvePartRole(Merchant merchant, MerchantFeedRow row) { - // Build a merchant-specific raw category key like "Department > Category > SubCategory" - String rawCategoryKey = buildRawCategoryKey(row); - - if (rawCategoryKey != null) { - // Delegate to the mapping service, which will: - // - Look up an existing mapping - // - If none exists, create a placeholder row with null mappedPartRole - // - Return the mapped partRole, or null if not yet mapped - String mapped = merchantCategoryMappingService.resolvePartRole(merchant, rawCategoryKey); - if (mapped != null && !mapped.isBlank()) { - return mapped; - } - } - - // Fallback: keyword-based inference - String keywordRole = inferPartRole(row); - if (keywordRole != null && !keywordRole.isBlank()) { - return keywordRole; - } - - // Last resort: log as unmapped and return null - System.out.println("IMPORT !!! UNMAPPED CATEGORY for merchant=" + merchant.getName() - + ", rawCategoryKey='" + rawCategoryKey + "'" - + ", sku=" + row.sku() - + ", productName=" + row.productName()); - - return null; - } // --------------------------------------------------------------------- // Feed reading + brand resolution From 0b2b3afd0c7db3e2fcc98fee3e76840d095c3b69 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Mon, 1 Dec 2025 16:58:49 -0500 Subject: [PATCH 14/33] adding more package-info dfiles --- .../ballistic/controllers/package-info.java | 12 ++++++++++++ .../ballistic/imports/dto/package-info.java | 12 ++++++++++++ .../ballistic/services/impl/package-info.java | 12 ++++++++++++ 3 files changed, 36 insertions(+) diff --git a/src/main/java/group/goforward/ballistic/controllers/package-info.java b/src/main/java/group/goforward/ballistic/controllers/package-info.java index e61736b..7af4a39 100644 --- a/src/main/java/group/goforward/ballistic/controllers/package-info.java +++ b/src/main/java/group/goforward/ballistic/controllers/package-info.java @@ -1 +1,13 @@ +/** + * Provides the classes necessary for the Spring Controllers for the ballistic -Builder application. + * This package includes Controllers for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Don Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.controllers; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/dto/package-info.java b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java index 686bebd..35c6703 100644 --- a/src/main/java/group/goforward/ballistic/imports/dto/package-info.java +++ b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java @@ -1 +1,13 @@ +/** + * Provides the classes necessary for the Spring Data Transfer Objects for the ballistic -Builder application. + * This package includes DTO for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Sean Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.imports.dto; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/impl/package-info.java b/src/main/java/group/goforward/ballistic/services/impl/package-info.java index a5d264b..8caffec 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/package-info.java +++ b/src/main/java/group/goforward/ballistic/services/impl/package-info.java @@ -1 +1,13 @@ +/** + * Provides the classes necessary for the Spring Services implementations for the ballistic -Builder application. + * This package includes Services implementations for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Don Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.services.impl; \ No newline at end of file From c4d2adad1a5aa30bba43445bbe66ab8d10490a73 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 1 Dec 2025 21:28:39 -0500 Subject: [PATCH 15/33] running build. New merchant import admin page. --- .../controllers/AdminMerchantController.java | 24 ---- .../controllers/ImportController.java | 27 ++++- .../controllers/MerchantAdminController.java | 63 ++++++++++ .../goforward/ballistic/model/Merchant.java | 45 ++++++- .../services/MerchantFeedImportService.java | 7 +- .../impl/MerchantFeedImportServiceImpl.java | 110 +++++++++++++++++- .../ballistic/web/dto/MerchantAdminDto.java | 70 +++++++++++ 7 files changed, 311 insertions(+), 35 deletions(-) delete mode 100644 src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java create mode 100644 src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java diff --git a/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java b/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java deleted file mode 100644 index af9de5e..0000000 --- a/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java +++ /dev/null @@ -1,24 +0,0 @@ -package group.goforward.ballistic.controllers; - -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.repos.MerchantRepository; -import java.util.List; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/admin/merchants") -@CrossOrigin // adjust later if you want -public class AdminMerchantController { - - private final MerchantRepository merchantRepository; - - public AdminMerchantController(MerchantRepository merchantRepository) { - this.merchantRepository = merchantRepository; - } - - @GetMapping - public List getMerchants() { - // If you want a DTO here, you can wrap it, but this is fine for internal admin - return merchantRepository.findAll(); - } -} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/ImportController.java b/src/main/java/group/goforward/ballistic/controllers/ImportController.java index b1ea350..996e460 100644 --- a/src/main/java/group/goforward/ballistic/controllers/ImportController.java +++ b/src/main/java/group/goforward/ballistic/controllers/ImportController.java @@ -6,17 +6,34 @@ import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/admin/imports") +@CrossOrigin(origins = "http://localhost:3000") public class ImportController { - private final MerchantFeedImportService importService; + private final MerchantFeedImportService merchantFeedImportService; - public ImportController(MerchantFeedImportService importService) { - this.importService = importService; + public ImportController(MerchantFeedImportService merchantFeedImportService) { + this.merchantFeedImportService = merchantFeedImportService; } + /** + * Full product + offer import for a merchant. + * + * POST /admin/imports/{merchantId} + */ @PostMapping("/{merchantId}") public ResponseEntity importMerchant(@PathVariable Integer merchantId) { - importService.importMerchantFeed(merchantId); - return ResponseEntity.accepted().build(); + merchantFeedImportService.importMerchantFeed(merchantId); + return ResponseEntity.noContent().build(); + } + + /** + * Offers-only sync (price/stock) for a merchant. + * + * POST /admin/imports/{merchantId}/offers-only + */ + @PostMapping("/{merchantId}/offers-only") + public ResponseEntity syncOffersOnly(@PathVariable Integer merchantId) { + merchantFeedImportService.syncOffersOnly(merchantId); + return ResponseEntity.noContent().build(); } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java new file mode 100644 index 0000000..511ea9b --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java @@ -0,0 +1,63 @@ +// MerchantAdminController.java +package group.goforward.ballistic.controllers; + +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.web.dto.MerchantAdminDto; +import org.springframework.web.bind.annotation.*; + +import java.time.OffsetDateTime; +import java.util.List; + +@RestController +@RequestMapping("/admin/merchants") +@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug +public class MerchantAdminController { + + private final MerchantRepository merchantRepository; + + public MerchantAdminController(MerchantRepository merchantRepository) { + this.merchantRepository = merchantRepository; + } + + @GetMapping + public List listMerchants() { + return merchantRepository.findAll().stream().map(this::toDto).toList(); + } + + @PutMapping("/{id}") + public MerchantAdminDto updateMerchant( + @PathVariable Integer id, + @RequestBody MerchantAdminDto payload + ) { + Merchant merchant = merchantRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Merchant not found")); + + merchant.setFeedUrl(payload.getFeedUrl()); + merchant.setOfferFeedUrl(payload.getOfferFeedUrl()); + merchant.setIsActive(payload.getIsActive() != null ? payload.getIsActive() : true); + // don’t touch last* here; those are set by import jobs + + merchant = merchantRepository.save(merchant); + return toDto(merchant); + } + + private MerchantAdminDto toDto(Merchant m) { + MerchantAdminDto dto = new MerchantAdminDto(); + dto.setId(m.getId()); + dto.setName(m.getName()); + dto.setFeedUrl(m.getFeedUrl()); + dto.setOfferFeedUrl(m.getOfferFeedUrl()); + dto.setIsActive(m.getIsActive()); + dto.setLastFullImportAt(m.getLastFullImportAt()); + dto.setLastOfferSyncAt(m.getLastOfferSyncAt()); + return dto; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/Merchant.java b/src/main/java/group/goforward/ballistic/model/Merchant.java index fe43897..a8fb407 100644 --- a/src/main/java/group/goforward/ballistic/model/Merchant.java +++ b/src/main/java/group/goforward/ballistic/model/Merchant.java @@ -8,6 +8,7 @@ import java.time.OffsetDateTime; @Entity @Table(name = "merchants") public class Merchant { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) @@ -22,9 +23,18 @@ public class Merchant { @Column(name = "feed_url", nullable = false, length = Integer.MAX_VALUE) private String feedUrl; + @Column(name = "offer_feed_url") + private String offerFeedUrl; + + @Column(name = "last_full_import_at") + private OffsetDateTime lastFullImportAt; + + @Column(name = "last_offer_sync_at") + private OffsetDateTime lastOfferSyncAt; + @ColumnDefault("true") @Column(name = "is_active", nullable = false) - private Boolean isActive = false; + private Boolean isActive = true; @ColumnDefault("now()") @Column(name = "created_at", nullable = false) @@ -34,6 +44,10 @@ public class Merchant { @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; + // ----------------------- + // GETTERS & SETTERS + // ----------------------- + public Integer getId() { return id; } @@ -66,12 +80,36 @@ public class Merchant { this.feedUrl = feedUrl; } + public String getOfferFeedUrl() { + return offerFeedUrl; + } + + public void setOfferFeedUrl(String offerFeedUrl) { + this.offerFeedUrl = offerFeedUrl; + } + + public OffsetDateTime getLastFullImportAt() { + return lastFullImportAt; + } + + public void setLastFullImportAt(OffsetDateTime lastFullImportAt) { + this.lastFullImportAt = lastFullImportAt; + } + + public OffsetDateTime getLastOfferSyncAt() { + return lastOfferSyncAt; + } + + public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) { + this.lastOfferSyncAt = lastOfferSyncAt; + } + public Boolean getIsActive() { return isActive; } - public void setIsActive(Boolean isActive) { - this.isActive = isActive; + public void setIsActive(Boolean active) { + this.isActive = active; } public OffsetDateTime getCreatedAt() { @@ -89,5 +127,4 @@ public class Merchant { public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } - } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java index 33cd776..5fea407 100644 --- a/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java @@ -3,7 +3,12 @@ package group.goforward.ballistic.services; public interface MerchantFeedImportService { /** - * Import the feed for a given merchant id. + * Full product + offer import for a given merchant. */ void importMerchantFeed(Integer merchantId); + + /** + * Offers-only sync (price / stock) for a given merchant. + */ + void syncOffersOnly(Integer merchantId); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index c56f021..5208769 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java @@ -4,6 +4,8 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.HashMap; import java.io.Reader; import java.io.InputStreamReader; import java.net.URL; @@ -123,7 +125,38 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return saved; } - + private List> fetchFeedRows(String feedUrl) { + System.out.println("OFFERS >>> reading offer feed from: " + feedUrl); + + List> rows = new ArrayList<>(); + + try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) + ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) + : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); + CSVParser parser = CSVFormat.DEFAULT + .withFirstRecordAsHeader() + .withIgnoreSurroundingSpaces() + .withTrim() + .parse(reader)) { + + // capture header names from the CSV + List headers = new ArrayList<>(parser.getHeaderMap().keySet()); + + for (CSVRecord rec : parser) { + Map row = new HashMap<>(); + for (String header : headers) { + row.put(header, rec.get(header)); + } + rows.add(row); + } + } catch (Exception ex) { + throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex); + } + + System.out.println("OFFERS >>> parsed " + rows.size() + " offer rows"); + return rows; + } + private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) { // ---------- NAME ---------- String name = coalesce( @@ -465,4 +498,79 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return "unknown"; } + public void syncOffersOnly(Integer merchantId) { + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new RuntimeException("Merchant not found")); + + if (Boolean.FALSE.equals(merchant.getIsActive())) { + return; + } + + // Use offerFeedUrl if present, else fall back to feedUrl + String feedUrl = merchant.getOfferFeedUrl() != null + ? merchant.getOfferFeedUrl() + : merchant.getFeedUrl(); + + if (feedUrl == null) { + throw new RuntimeException("No offer feed URL configured for merchant " + merchantId); + } + + List> rows = fetchFeedRows(feedUrl); + + for (Map row : rows) { + upsertOfferOnlyFromRow(merchant, row); + } + + merchant.setLastOfferSyncAt(OffsetDateTime.now()); + merchantRepository.save(merchant); + } + private void upsertOfferOnlyFromRow(Merchant merchant, Map row) { + // For the offer-only sync, we key offers by the same identifier we used when creating them. + // In the current AvantLink-style feed, that is the SKU column. + String avantlinkProductId = trimOrNull(row.get("SKU")); + if (avantlinkProductId == null || avantlinkProductId.isBlank()) { + return; + } + + // Find existing offer + ProductOffer offer = productOfferRepository + .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .orElse(null); + + if (offer == null) { + // This is a *sync* pass, not full ETL – if we don't already have an offer, skip. + return; + } + + // Parse price fields (column names match the main product feed) + BigDecimal price = parseBigDecimal(row.get("Sale Price")); + BigDecimal originalPrice = parseBigDecimal(row.get("Retail Price")); + + // Update only *offer* fields – do not touch Product + offer.setPrice(price); + offer.setOriginalPrice(originalPrice); + offer.setInStock(parseInStock(row)); + + // Prefer a fresh Buy Link from the feed if present, otherwise keep existing + String newBuyUrl = trimOrNull(row.get("Buy Link")); + offer.setBuyUrl(coalesce(newBuyUrl, offer.getBuyUrl())); + + offer.setLastSeenAt(OffsetDateTime.now()); + + productOfferRepository.save(offer); + } + private Boolean parseInStock(Map row) { + String inStock = trimOrNull(row.get("In Stock")); + if (inStock == null) return Boolean.FALSE; + + String lower = inStock.toLowerCase(); + if (lower.contains("true") || lower.contains("yes") || lower.contains("1")) { + return Boolean.TRUE; + } + if (lower.contains("false") || lower.contains("no") || lower.contains("0")) { + return Boolean.FALSE; + } + + return Boolean.FALSE; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java b/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java new file mode 100644 index 0000000..26d6f6c --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java @@ -0,0 +1,70 @@ +// MerchantAdminDto.java +package group.goforward.ballistic.web.dto; + +import java.time.OffsetDateTime; + +public class MerchantAdminDto { + private Integer id; + private String name; + private String feedUrl; + private String offerFeedUrl; + private Boolean isActive; + private OffsetDateTime lastFullImportAt; + private OffsetDateTime lastOfferSyncAt; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getFeedUrl() { + return feedUrl; + } + + public void setFeedUrl(String feedUrl) { + this.feedUrl = feedUrl; + } + + public String getOfferFeedUrl() { + return offerFeedUrl; + } + + public void setOfferFeedUrl(String offerFeedUrl) { + this.offerFeedUrl = offerFeedUrl; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } + + public OffsetDateTime getLastFullImportAt() { + return lastFullImportAt; + } + + public void setLastFullImportAt(OffsetDateTime lastFullImportAt) { + this.lastFullImportAt = lastFullImportAt; + } + + public OffsetDateTime getLastOfferSyncAt() { + return lastOfferSyncAt; + } + + public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) { + this.lastOfferSyncAt = lastOfferSyncAt; + } +} \ No newline at end of file From 7fb24fdde385869e054e40ee3880e633fb3b891a Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 2 Dec 2025 05:41:17 -0500 Subject: [PATCH 16/33] buffer --- .../impl/MerchantFeedImportServiceImpl.java | 97 ++++++++++++------- 1 file changed, 63 insertions(+), 34 deletions(-) diff --git a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index 5208769..2d8d8e2 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import java.util.HashMap; import java.io.Reader; +import java.io.BufferedReader; import java.io.InputStreamReader; import java.net.URL; import java.nio.charset.StandardCharsets; @@ -330,44 +331,72 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService List rows = new ArrayList<>(); - try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) + try (Reader baseReader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); - CSVParser parser = CSVFormat.DEFAULT - .withFirstRecordAsHeader() - .withIgnoreSurroundingSpaces() - .withTrim() - .parse(reader)) { + BufferedReader reader = new BufferedReader(baseReader)) { - for (CSVRecord rec : parser) { - MerchantFeedRow row = new MerchantFeedRow( - rec.get("SKU"), - rec.get("Manufacturer Id"), - rec.get("Brand Name"), - rec.get("Product Name"), - rec.get("Long Description"), - rec.get("Short Description"), - rec.get("Department"), - rec.get("Category"), - rec.get("SubCategory"), - rec.get("Thumb URL"), - rec.get("Image URL"), - rec.get("Buy Link"), - rec.get("Keywords"), - rec.get("Reviews"), - parseBigDecimal(rec.get("Retail Price")), - parseBigDecimal(rec.get("Sale Price")), - rec.get("Brand Page Link"), - rec.get("Brand Logo Image"), - rec.get("Product Page View Tracking"), - rec.get("Variants XML"), - rec.get("Medium Image URL"), - rec.get("Product Content Widget"), - rec.get("Google Categorization"), - rec.get("Item Based Commission") - ); + // --- Step 1: peek at the first line to detect delimiter --- + reader.mark(10_000); + String firstLine = reader.readLine(); + reader.reset(); - rows.add(row); + if (firstLine == null || firstLine.isEmpty()) { + throw new RuntimeException("Empty feed received from " + feedUrl); + } + + // --- Step 2: detect delimiter (TSV vs CSV) --- + char delimiter; + if (firstLine.contains("\t")) { + delimiter = '\t'; // TSV (AvantLink-style) + } else if (firstLine.contains(",")) { + delimiter = ','; // CSV + } else { + // Fallback: default to comma + delimiter = ','; + } + + // --- Step 3: build CSVFormat with detected delimiter --- + CSVFormat format = CSVFormat.DEFAULT.builder() + .setDelimiter(delimiter) + .setHeader() + .setSkipHeaderRecord(true) + .setIgnoreSurroundingSpaces(true) + .setTrim(true) + .build(); + + // --- Step 4: parse the rows into MerchantFeedRow records --- + try (CSVParser parser = new CSVParser(reader, format)) { + for (CSVRecord rec : parser) { + MerchantFeedRow row = new MerchantFeedRow( + rec.get("SKU"), + rec.get("Manufacturer Id"), + rec.get("Brand Name"), + rec.get("Product Name"), + rec.get("Long Description"), + rec.get("Short Description"), + rec.get("Department"), + rec.get("Category"), + rec.get("SubCategory"), + rec.get("Thumb URL"), + rec.get("Image URL"), + rec.get("Buy Link"), + rec.get("Keywords"), + rec.get("Reviews"), + parseBigDecimal(rec.get("Retail Price")), + parseBigDecimal(rec.get("Sale Price")), + rec.get("Brand Page Link"), + rec.get("Brand Logo Image"), + rec.get("Product Page View Tracking"), + rec.get("Variants XML"), + rec.get("Medium Image URL"), + rec.get("Product Content Widget"), + rec.get("Google Categorization"), + rec.get("Item Based Commission") + ); + + rows.add(row); + } } } catch (Exception ex) { throw new RuntimeException("Failed to read feed for merchant " From d7ae362c23ae574b25a311a18b30919bbd9bc346 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 2 Dec 2025 07:21:23 -0500 Subject: [PATCH 17/33] readme docs --- README.md | 67 ++++++ importLogic.md | 213 ++++++++++++++++++ .../impl/MerchantFeedImportServiceImpl.java | 180 ++++++++++----- 3 files changed, 403 insertions(+), 57 deletions(-) create mode 100644 importLogic.md diff --git a/README.md b/README.md index e69de29..4e939ed 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,67 @@ +# Ballistic Backend +### Internal Engine for the Builder Ecosystem + +The Ballistic Backend is the operational backbone behind the Shadown System Armory and its admin tools. It ingests merchant feeds, normalizes product data, manages categories, synchronizes prices, and powers the compatibility, pricing, and product logic behind the consumer-facing Builder. + +It’s built for reliability, longevity, and clean extensibility — the kind of foundation you want when scaling from a small beta to a fully public platform. + +--- + +## What This Backend Does + +### **Merchant Feed Ingestion** +- Pulls AvantLink feeds (CSV or TSV) +- Automatically detects delimiters +- Normalizes raw merchant fields +- Creates or updates product records +- Upserts price and stock offers +- Tracks first-seen / last-seen timestamps +- Safely handles malformed or incomplete rows +- Ensures repeat imports never duplicate offers + +### **Category Mapping Engine** +- Identifies every unique raw category coming from each merchant feed +- Exposes *unmapped* categories in the admin UI +- Allows you to assign: + - Part Role + - Product Configuration (Stripped, Complete, Kit, etc.) +- Applies mappings automatically on future imports +- Respects manual overrides such as `platform_locked` + +### **Builder Support** +The frontend Builder depends on this backend for: + +- Loading parts grouped by role +- Offering compatible options +- Calculating build cost +- Comparing offers across merchants +- Providing product metadata, imagery, and offer data + +**Future expansions include:** price history, compatibility engine, build exports, public build pages, multi-merchant aggregation, and automated anomaly detection. + +--- + +## Tech Stack + +- **Spring Boot 3.x** +- **Java 17** +- **PostgreSQL** +- **Hibernate (JPA)** +- **HikariCP** +- **Apache Commons CSV** +- **Maven** +- **REST API** + +--- + +## Local Development + +### Requirements +- Java 17 or newer +- PostgreSQL running locally +- Port 8080 open (default backend port) + +### Run Development Server + +```bash +./mvnw spring-boot:run \ No newline at end of file diff --git a/importLogic.md b/importLogic.md new file mode 100644 index 0000000..d91feb7 --- /dev/null +++ b/importLogic.md @@ -0,0 +1,213 @@ +# Ballistic Import Pipeline +A high-level overview of how merchant data flows through the Spring ETL system. + +--- + +## Purpose + +This document explains how the Ballistic backend: + +1. Fetches merchant product feeds (CSV/TSV) +2. Normalizes raw data into structured entities +3. Updates products and offers in an idempotent way +4. Supports two sync modes: + - Full Import + - Offer-Only Sync + +--- + +# 1. High-Level Flow + +## ASCII Diagram + +``` + ┌──────────────────────────┐ + │ /admin/imports/{id} │ + │ (Full Import Trigger) │ + └─────────────┬────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ importMerchantFeed(merchantId)│ + └─────────────┬────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ readFeedRowsForMerchant() │ + │ - auto-detect delimiter │ + │ - parse CSV/TSV → MerchantFeedRow objects │ + └─────────────────┬──────────────────────────────────────┘ + │ List + ▼ + ┌──────────────────────────────────────┐ + │ For each MerchantFeedRow row: │ + │ resolveBrand() │ + │ upsertProduct() │ + │ - find existing via brand+mpn/upc │ + │ - update fields (mapped partRole) │ + │ upsertOfferFromRow() │ + └──────────────────────────────────────┘ +``` + +--- + +# 2. Full Import Explained + +Triggered by: + +``` +POST /admin/imports/{merchantId} +``` + +### Step 1 — Load merchant +Using `merchantRepository.findById()`. + +### Step 2 — Parse feed rows +`readFeedRowsForMerchant()`: +- Auto-detects delimiter (`\t`, `,`, `;`) +- Validates required headers +- Parses each row into `MerchantFeedRow` + +### Step 3 — Process each row + +For each parsed row: + +#### a. resolveBrand() +- Finds or creates brand +- Defaults to “Aero Precision” if missing + +#### b. upsertProduct() +Dedupes by: + +1. Brand + MPN +2. Brand + UPC (currently SKU placeholder) + +If no match → create new product. + +Then applies: +- Name + slug +- Descriptions +- Images +- MPN/identifiers +- Platform inference +- Category mapping +- Part role inference + +#### c. upsertOfferFromRow() +Creates or updates a ProductOffer: +- Prices +- Stock +- Buy URL +- lastSeenAt +- firstSeenAt when newly created + +Idempotent — does not duplicate offers. + +--- + +# 3. Offer-Only Sync + +Triggered by: + +``` +POST /admin/imports/{merchantId}/offers-only +``` + +Does NOT: +- Create products +- Update product fields + +It only updates: +- price +- originalPrice +- inStock +- buyUrl +- lastSeenAt + +If the offer does not exist, it is skipped. + +--- + +# 4. Auto-Detecting CSV/TSV Parser + +The parser: + +- Attempts multiple delimiters +- Validates headers +- Handles malformed or short rows +- Never throws on missing columns +- Returns clean MerchantFeedRow objects + +Designed for messy merchant feeds. + +--- + +# 5. Entities Updated During Import + +### Product +- name +- slug +- short/long description +- main image +- mpn +- upc (future) +- platform +- rawCategoryKey +- partRole + +### ProductOffer +- merchant +- product +- avantlinkProductId (SKU placeholder) +- price +- originalPrice +- inStock +- buyUrl +- lastSeenAt +- firstSeenAt + +### Merchant +- lastFullImportAt +- lastOfferSyncAt + +--- + +# 6. Extension Points + +You can extend the import pipeline in these areas: + +- Add per-merchant column mapping +- Add true UPC parsing +- Support multi-platform parts +- Improve partRole inference +- Implement global deduplication across merchants + +--- + +# 7. Quick Reference: Main Methods + +| Method | Purpose | +|--------|---------| +| importMerchantFeed | Full product + offer import | +| readFeedRowsForMerchant | Detect delimiter + parse feed | +| resolveBrand | Normalize brand names | +| upsertProduct | Idempotent product write | +| updateProductFromRow | Apply product fields | +| upsertOfferFromRow | Idempotent offer write | +| syncOffersOnly | Offer-only sync | +| upsertOfferOnlyFromRow | Update existing offers | +| detectCsvFormat | Auto-detect delimiter | +| fetchFeedRows | Simpler parser for offers | + +--- + +# 8. Summary + +The Ballistic importer is: + +- Robust against bad data +- Idempotent and safe +- Flexible for multiple merchants +- Extensible for long-term scaling + +This pipeline powers the product catalog and offer data for the Ballistic ecosystem. \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index 2d8d8e2..df36c69 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java @@ -320,6 +320,77 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService // Feed reading + brand resolution // --------------------------------------------------------------------- + /** + * Open a Reader for either an HTTP(S) URL or a local file path. + */ + private Reader openFeedReader(String feedUrl) throws java.io.IOException { + if (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) { + return new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8); + } else { + return java.nio.file.Files.newBufferedReader( + java.nio.file.Paths.get(feedUrl), + StandardCharsets.UTF_8 + ); + } + } + + /** + * Try a few common delimiters (tab, comma, semicolon) and pick the one + * that yields the expected AvantLink-style header set. + */ + private CSVFormat detectCsvFormat(String feedUrl) throws Exception { + char[] delimiters = new char[]{'\t', ',', ';'}; + java.util.List requiredHeaders = + java.util.Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name"); + + Exception lastException = null; + + for (char delimiter : delimiters) { + try (Reader reader = openFeedReader(feedUrl); + CSVParser parser = CSVFormat.DEFAULT.builder() + .setDelimiter(delimiter) + .setHeader() + .setSkipHeaderRecord(true) + .setIgnoreSurroundingSpaces(true) + .setTrim(true) + .build() + .parse(reader)) { + + Map headerMap = parser.getHeaderMap(); + if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) { + System.out.println( + "IMPORT >>> detected delimiter '" + + (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) + + "' for feed: " + feedUrl + ); + + return CSVFormat.DEFAULT.builder() + .setDelimiter(delimiter) + .setHeader() + .setSkipHeaderRecord(true) + .setIgnoreSurroundingSpaces(true) + .setTrim(true) + .build(); + } else if (headerMap != null) { + System.out.println( + "IMPORT !!! delimiter '" + + (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) + + "' produced headers: " + headerMap.keySet() + ); + } + } catch (Exception ex) { + lastException = ex; + System.out.println("IMPORT !!! error probing delimiter '" + delimiter + + "' for " + feedUrl + ": " + ex.getMessage()); + } + } + + if (lastException != null) { + throw lastException; + } + throw new RuntimeException("Could not auto-detect delimiter for feed: " + feedUrl); + } + private List readFeedRowsForMerchant(Merchant merchant) { String rawFeedUrl = merchant.getFeedUrl(); if (rawFeedUrl == null || rawFeedUrl.isBlank()) { @@ -331,68 +402,41 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService List rows = new ArrayList<>(); - try (Reader baseReader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) - ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) - : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); - BufferedReader reader = new BufferedReader(baseReader)) { + try { + // Auto-detect delimiter (TSV/CSV/semicolon) based on header row + CSVFormat format = detectCsvFormat(feedUrl); - // --- Step 1: peek at the first line to detect delimiter --- - reader.mark(10_000); - String firstLine = reader.readLine(); - reader.reset(); + try (Reader reader = openFeedReader(feedUrl); + CSVParser parser = new CSVParser(reader, format)) { - if (firstLine == null || firstLine.isEmpty()) { - throw new RuntimeException("Empty feed received from " + feedUrl); - } + System.out.println("IMPORT >>> detected headers: " + parser.getHeaderMap().keySet()); - // --- Step 2: detect delimiter (TSV vs CSV) --- - char delimiter; - if (firstLine.contains("\t")) { - delimiter = '\t'; // TSV (AvantLink-style) - } else if (firstLine.contains(",")) { - delimiter = ','; // CSV - } else { - // Fallback: default to comma - delimiter = ','; - } - - // --- Step 3: build CSVFormat with detected delimiter --- - CSVFormat format = CSVFormat.DEFAULT.builder() - .setDelimiter(delimiter) - .setHeader() - .setSkipHeaderRecord(true) - .setIgnoreSurroundingSpaces(true) - .setTrim(true) - .build(); - - // --- Step 4: parse the rows into MerchantFeedRow records --- - try (CSVParser parser = new CSVParser(reader, format)) { for (CSVRecord rec : parser) { MerchantFeedRow row = new MerchantFeedRow( - rec.get("SKU"), - rec.get("Manufacturer Id"), - rec.get("Brand Name"), - rec.get("Product Name"), - rec.get("Long Description"), - rec.get("Short Description"), - rec.get("Department"), - rec.get("Category"), - rec.get("SubCategory"), - rec.get("Thumb URL"), - rec.get("Image URL"), - rec.get("Buy Link"), - rec.get("Keywords"), - rec.get("Reviews"), - parseBigDecimal(rec.get("Retail Price")), - parseBigDecimal(rec.get("Sale Price")), - rec.get("Brand Page Link"), - rec.get("Brand Logo Image"), - rec.get("Product Page View Tracking"), - rec.get("Variants XML"), - rec.get("Medium Image URL"), - rec.get("Product Content Widget"), - rec.get("Google Categorization"), - rec.get("Item Based Commission") + getCsvValue(rec, "SKU"), + getCsvValue(rec, "Manufacturer Id"), + getCsvValue(rec, "Brand Name"), + getCsvValue(rec, "Product Name"), + getCsvValue(rec, "Long Description"), + getCsvValue(rec, "Short Description"), + getCsvValue(rec, "Department"), + getCsvValue(rec, "Category"), + getCsvValue(rec, "SubCategory"), + getCsvValue(rec, "Thumb URL"), + getCsvValue(rec, "Image URL"), + getCsvValue(rec, "Buy Link"), + getCsvValue(rec, "Keywords"), + getCsvValue(rec, "Reviews"), + parseBigDecimal(getCsvValue(rec, "Retail Price")), + parseBigDecimal(getCsvValue(rec, "Sale Price")), + getCsvValue(rec, "Brand Page Link"), + getCsvValue(rec, "Brand Logo Image"), + getCsvValue(rec, "Product Page View Tracking"), + null, + getCsvValue(rec, "Medium Image URL"), + getCsvValue(rec, "Product Content Widget"), + getCsvValue(rec, "Google Categorization"), + getCsvValue(rec, "Item Based Commission") ); rows.add(row); @@ -435,6 +479,28 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } } + /** + * Safely get a column value by header name. If the record is "short" + * (fewer values than headers) or the header is missing, return null + * instead of throwing IllegalArgumentException. + */ + private String getCsvValue(CSVRecord rec, String header) { + if (rec == null || header == null) { + return null; + } + if (!rec.isMapped(header)) { + // Header not present at all + return null; + } + try { + return rec.get(header); + } catch (IllegalArgumentException ex) { + System.out.println("IMPORT !!! short record #" + rec.getRecordNumber() + + " missing column '" + header + "', treating as null"); + return null; + } + } + // --------------------------------------------------------------------- // Misc helpers // --------------------------------------------------------------------- From 9779bdb5c0c36393761dc192b219fff5db540ec0 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Tue, 2 Dec 2025 13:55:01 -0500 Subject: [PATCH 18/33] package-infos and a few other changes --- pom.xml | 5 ++- .../goforward/ballistic/ApiResponse.java | 3 ++ .../ballistic/controllers/PsaController.java | 6 +-- .../{model => repos}/AccountRepository.java | 15 +++---- .../ballistic/services/PsaService.java | 35 +++------------- .../ballistic/services/impl/PsaService.java | 17 -------- .../services/impl/PsaServiceImpl.java | 41 +++++++++++++++++++ 7 files changed, 64 insertions(+), 58 deletions(-) rename src/main/java/group/goforward/ballistic/{model => repos}/AccountRepository.java (64%) delete mode 100644 src/main/java/group/goforward/ballistic/services/impl/PsaService.java create mode 100644 src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java diff --git a/pom.xml b/pom.xml index d264e54..9c3e160 100644 --- a/pom.xml +++ b/pom.xml @@ -30,8 +30,8 @@ - - + + scm:git:https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git @@ -79,6 +79,7 @@ org.postgresql postgresql + 42.7.7 runtime diff --git a/src/main/java/group/goforward/ballistic/ApiResponse.java b/src/main/java/group/goforward/ballistic/ApiResponse.java index eb22fff..3dd23a6 100644 --- a/src/main/java/group/goforward/ballistic/ApiResponse.java +++ b/src/main/java/group/goforward/ballistic/ApiResponse.java @@ -2,6 +2,9 @@ package group.goforward.ballistic; import java.time.LocalDateTime; +/** + * @param + */ public class ApiResponse { private static final String API_SUCCESS = "success"; diff --git a/src/main/java/group/goforward/ballistic/controllers/PsaController.java b/src/main/java/group/goforward/ballistic/controllers/PsaController.java index 8efec1b..bf442a9 100644 --- a/src/main/java/group/goforward/ballistic/controllers/PsaController.java +++ b/src/main/java/group/goforward/ballistic/controllers/PsaController.java @@ -1,7 +1,7 @@ package group.goforward.ballistic.controllers; import group.goforward.ballistic.model.Psa; -import group.goforward.ballistic.services.PsaService; +import group.goforward.ballistic.services.impl.PsaServiceImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -14,10 +14,10 @@ import java.util.UUID; @RequestMapping("/api/psa") public class PsaController { - private final PsaService psaService; + private final PsaServiceImpl psaService; @Autowired - public PsaController(PsaService psaService) { + public PsaController(PsaServiceImpl psaService) { this.psaService = psaService; } diff --git a/src/main/java/group/goforward/ballistic/model/AccountRepository.java b/src/main/java/group/goforward/ballistic/repos/AccountRepository.java similarity index 64% rename from src/main/java/group/goforward/ballistic/model/AccountRepository.java rename to src/main/java/group/goforward/ballistic/repos/AccountRepository.java index abb4486..1c8702f 100644 --- a/src/main/java/group/goforward/ballistic/model/AccountRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/AccountRepository.java @@ -1,8 +1,9 @@ -package group.goforward.ballistic.model; - -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.UUID; - -public interface AccountRepository extends JpaRepository { +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Account; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface AccountRepository extends JpaRepository { } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/PsaService.java b/src/main/java/group/goforward/ballistic/services/PsaService.java index 29cb6f3..337d278 100644 --- a/src/main/java/group/goforward/ballistic/services/PsaService.java +++ b/src/main/java/group/goforward/ballistic/services/PsaService.java @@ -1,40 +1,17 @@ package group.goforward.ballistic.services; + import group.goforward.ballistic.model.Psa; -import group.goforward.ballistic.repos.PsaRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; import java.util.List; import java.util.Optional; import java.util.UUID; -@Service -public class PsaService implements group.goforward.ballistic.services.impl.PsaService { +public interface PsaService { + List findAll(); - private final PsaRepository psaRepository; + Optional findById(UUID id); - @Autowired - public PsaService(PsaRepository psaRepository) { - this.psaRepository = psaRepository; - } + Psa save(Psa psa); - @Override - public List findAll() { - return psaRepository.findAll(); - } - - @Override - public Optional findById(UUID id) { - return psaRepository.findById(id); - } - - @Override - public Psa save(Psa psa) { - return psaRepository.save(psa); - } - - @Override - public void deleteById(UUID id) { - psaRepository.deleteById(id); - } + void deleteById(UUID id); } diff --git a/src/main/java/group/goforward/ballistic/services/impl/PsaService.java b/src/main/java/group/goforward/ballistic/services/impl/PsaService.java deleted file mode 100644 index e84f141..0000000 --- a/src/main/java/group/goforward/ballistic/services/impl/PsaService.java +++ /dev/null @@ -1,17 +0,0 @@ -package group.goforward.ballistic.services.impl; - -import group.goforward.ballistic.model.Psa; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface PsaService { - List findAll(); - - Optional findById(UUID id); - - Psa save(Psa psa); - - void deleteById(UUID id); -} diff --git a/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java new file mode 100644 index 0000000..1729056 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java @@ -0,0 +1,41 @@ +package group.goforward.ballistic.services.impl; +import group.goforward.ballistic.model.Psa; +import group.goforward.ballistic.repos.PsaRepository; +import group.goforward.ballistic.services.PsaService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class PsaServiceImpl implements PsaService { + + private final PsaRepository psaRepository; + + @Autowired + public PsaServiceImpl(PsaRepository psaRepository) { + this.psaRepository = psaRepository; + } + + @Override + public List findAll() { + return psaRepository.findAll(); + } + + @Override + public Optional findById(UUID id) { + return psaRepository.findById(id); + } + + @Override + public Psa save(Psa psa) { + return psaRepository.save(psa); + } + + @Override + public void deleteById(UUID id) { + psaRepository.deleteById(id); + } +} From 346ccc3813644e3b111e890001e7ee8abcdb7166 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Tue, 2 Dec 2025 14:42:34 -0500 Subject: [PATCH 19/33] got ride of the swagger geneerated api end oints --- action1.yaml | 39 +++++++++++++++++++++++++++++++++++++++ pom.xml | 4 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 action1.yaml diff --git a/action1.yaml b/action1.yaml new file mode 100644 index 0000000..d3f5dbb --- /dev/null +++ b/action1.yaml @@ -0,0 +1,39 @@ +# File: .gitea/workflows/build-and-upload.yml +name: Build and Upload Artifact + +on: + push: + branches: + - main + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # Step 1: Check out repository code + - name: Checkout code + uses: actions/checkout@v4 + + # Step 2: Set up Node.js (example for a JS project; adjust for your stack) + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + # Step 3: Install dependencies + - name: Install dependencies + run: npm ci + + # Step 4: Build project + - name: Build project + run: npm run build + + # Step 5: Upload build output as artifact + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: build-output + path: dist/ # Change to your build output directory + retention-days: 7 # Optional: how long to keep artifact diff --git a/pom.xml b/pom.xml index 9c3e160..de79ff3 100644 --- a/pom.xml +++ b/pom.xml @@ -45,10 +45,10 @@ org.springframework.boot spring-boot-starter-data-jpa - + org.springframework.boot spring-boot-starter-web From 9fabf30406215f817e9d141a8e3aea3697516b56 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Tue, 2 Dec 2025 17:18:26 -0500 Subject: [PATCH 20/33] getting ready for docker deployment --- docker-compose.yaml | 63 +++ .../ballistic/controllers/UserController.java | 50 ++ .../group/goforward/ballistic/model/User.java | 436 ++++++------------ .../ballistic/services/UsersService.java | 16 + .../services/impl/UsersServiceImpl.java | 37 ++ 5 files changed, 305 insertions(+), 297 deletions(-) create mode 100644 docker-compose.yaml create mode 100644 src/main/java/group/goforward/ballistic/controllers/UserController.java create mode 100644 src/main/java/group/goforward/ballistic/services/UsersService.java create mode 100644 src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..3495250 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,63 @@ +version: '3.8' + +services: + # --- 1. Spring API Service (Backend) --- + spring-api: + build: + context: ./backend # Path to your Spring project's root folder + dockerfile: Dockerfile # Assumes you have a Dockerfile in ./backend + container_name: spring-api + ports: + - "8080:8080" # Map host port 8080 to container port 8080 + environment: + # These environment variables link the API to the database service defined below + - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mydatabase + - SPRING_DATASOURCE_USERNAME=myuser + - SPRING_DATASOURCE_PASSWORD=mypassword + depends_on: + - db + networks: + - app-network + + # --- 2. Next.js App Service (Frontend) --- + nextjs-app: + build: + context: ./frontend # Path to your Next.js project's root folder + dockerfile: Dockerfile # Assumes you have a Dockerfile in ./frontend + container_name: nextjs-app + ports: + - "3000:3000" # Map host port 3000 to container port 3000 + environment: + # This variable is crucial: Next.js needs the URL for the Spring API + # Use the Docker internal service name 'spring-api' and its port 8080 + - NEXT_PUBLIC_API_URL=http://spring-api:8080 + # For local testing, you might need the host IP for Next.js to call back + # - NEXT_PUBLIC_API_URL_LOCAL=http://localhost:8080 + depends_on: + - spring-api + networks: + - app-network + + # --- 3. PostgreSQL Database Service (Example Dependency) --- + db: + image: postgres:15-alpine # Lightweight and stable PostgreSQL image + container_name: postgres-db + environment: + - POSTGRES_DB=mydatabase + - POSTGRES_USER=myuser + - POSTGRES_PASSWORD=mypassword + volumes: + - postgres_data:/var/lib/postgresql/data # Persist the database data + ports: + - "5432:5432" # Optional: Map DB port for external access (e.g., DBeaver) + networks: + - app-network + +# --- Docker Volume for Persistent Data --- +volumes: + postgres_data: + +# --- Docker Network for Inter-Container Communication --- +networks: + app-network: + driver: bridge \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/UserController.java b/src/main/java/group/goforward/ballistic/controllers/UserController.java new file mode 100644 index 0000000..b28ecc3 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/UserController.java @@ -0,0 +1,50 @@ +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.User; +import group.goforward.ballistic.repos.UserRepository; +import group.goforward.ballistic.services.UsersService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@RestController +@RequestMapping() +public class UserController { + @Autowired + private UserRepository repo; + @Autowired + private UsersService usersService; + + @GetMapping("/api/getAllUsers") + public ResponseEntity> getAllUsers() { + List data = repo.findAll(); + return ResponseEntity.ok(data); + } + + + @GetMapping("/api/getAllUsersById/{id}") + public ResponseEntity getAllStatesById(@PathVariable Integer id) { + return repo.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + @PostMapping("/api/addUser") + public ResponseEntity createUser(@RequestBody User item) { + User created = usersService.save(item); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @DeleteMapping("/api/deleteUser/{id}") + public ResponseEntity deleteItem(@PathVariable Integer id) { + return usersService.findById(id) + .map(item -> { + usersService.deleteById(id); + return ResponseEntity.noContent().build(); + }) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/src/main/java/group/goforward/ballistic/model/User.java b/src/main/java/group/goforward/ballistic/model/User.java index ac49425..225798c 100644 --- a/src/main/java/group/goforward/ballistic/model/User.java +++ b/src/main/java/group/goforward/ballistic/model/User.java @@ -1,298 +1,140 @@ -package group.goforward.ballistic.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import org.hibernate.annotations.ColumnDefault; - -import java.time.Instant; -import java.time.LocalDate; -import java.util.UUID; - -@Entity -@Table(name = "users") -public class User { - @Id - @Column(name = "id", nullable = false, length = 21) - private String id; - - @Column(name = "name", length = Integer.MAX_VALUE) - private String name; - - @Column(name = "username", length = 50) - private String username; - - @Column(name = "email", nullable = false) - private String email; - - @Column(name = "first_name", length = 50) - private String firstName; - - @Column(name = "last_name", length = 50) - private String lastName; - - @Column(name = "full_name", length = 50) - private String fullName; - - @Column(name = "profile_picture") - private String profilePicture; - - @Column(name = "image", length = Integer.MAX_VALUE) - private String image; - - @Column(name = "date_of_birth") - private LocalDate dateOfBirth; - - @Column(name = "phone_number", length = 20) - private String phoneNumber; - - @ColumnDefault("CURRENT_TIMESTAMP") - @Column(name = "created_at") - private Instant createdAt; - - @ColumnDefault("CURRENT_TIMESTAMP") - @Column(name = "updated_at") - private Instant updatedAt; - - @ColumnDefault("false") - @Column(name = "is_admin") - private Boolean isAdmin; - - @Column(name = "last_login") - private Instant lastLogin; - - @ColumnDefault("false") - @Column(name = "email_verified", nullable = false) - private Boolean emailVerified = false; - - @ColumnDefault("'public'") - @Column(name = "build_privacy_setting", length = Integer.MAX_VALUE) - private String buildPrivacySetting; - - @ColumnDefault("gen_random_uuid()") - @Column(name = "uuid") - private UUID uuid; - - @Column(name = "discord_id") - private String discordId; - - @Column(name = "hashed_password") - private String hashedPassword; - - @Column(name = "avatar") - private String avatar; - - @Column(name = "stripe_subscription_id", length = 191) - private String stripeSubscriptionId; - - @Column(name = "stripe_price_id", length = 191) - private String stripePriceId; - - @Column(name = "stripe_customer_id", length = 191) - private String stripeCustomerId; - - @Column(name = "stripe_current_period_end") - private Instant stripeCurrentPeriodEnd; - - 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 getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } - - public String getFirstName() { - return firstName; - } - - public void setFirstName(String firstName) { - this.firstName = firstName; - } - - public String getLastName() { - return lastName; - } - - public void setLastName(String lastName) { - this.lastName = lastName; - } - - public String getFullName() { - return fullName; - } - - public void setFullName(String fullName) { - this.fullName = fullName; - } - - public String getProfilePicture() { - return profilePicture; - } - - public void setProfilePicture(String profilePicture) { - this.profilePicture = profilePicture; - } - - public String getImage() { - return image; - } - - public void setImage(String image) { - this.image = image; - } - - public LocalDate getDateOfBirth() { - return dateOfBirth; - } - - public void setDateOfBirth(LocalDate dateOfBirth) { - this.dateOfBirth = dateOfBirth; - } - - public String getPhoneNumber() { - return phoneNumber; - } - - public void setPhoneNumber(String phoneNumber) { - this.phoneNumber = phoneNumber; - } - - public Instant getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(Instant createdAt) { - this.createdAt = createdAt; - } - - public Instant getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(Instant updatedAt) { - this.updatedAt = updatedAt; - } - - public Boolean getIsAdmin() { - return isAdmin; - } - - public void setIsAdmin(Boolean isAdmin) { - this.isAdmin = isAdmin; - } - - public Instant getLastLogin() { - return lastLogin; - } - - public void setLastLogin(Instant lastLogin) { - this.lastLogin = lastLogin; - } - - public Boolean getEmailVerified() { - return emailVerified; - } - - public void setEmailVerified(Boolean emailVerified) { - this.emailVerified = emailVerified; - } - - public String getBuildPrivacySetting() { - return buildPrivacySetting; - } - - public void setBuildPrivacySetting(String buildPrivacySetting) { - this.buildPrivacySetting = buildPrivacySetting; - } - - public UUID getUuid() { - return uuid; - } - - public void setUuid(UUID uuid) { - this.uuid = uuid; - } - - public String getDiscordId() { - return discordId; - } - - public void setDiscordId(String discordId) { - this.discordId = discordId; - } - - public String getHashedPassword() { - return hashedPassword; - } - - public void setHashedPassword(String hashedPassword) { - this.hashedPassword = hashedPassword; - } - - public String getAvatar() { - return avatar; - } - - public void setAvatar(String avatar) { - this.avatar = avatar; - } - - public String getStripeSubscriptionId() { - return stripeSubscriptionId; - } - - public void setStripeSubscriptionId(String stripeSubscriptionId) { - this.stripeSubscriptionId = stripeSubscriptionId; - } - - public String getStripePriceId() { - return stripePriceId; - } - - public void setStripePriceId(String stripePriceId) { - this.stripePriceId = stripePriceId; - } - - public String getStripeCustomerId() { - return stripeCustomerId; - } - - public void setStripeCustomerId(String stripeCustomerId) { - this.stripeCustomerId = stripeCustomerId; - } - - public Instant getStripeCurrentPeriodEnd() { - return stripeCurrentPeriodEnd; - } - - public void setStripeCurrentPeriodEnd(Instant stripeCurrentPeriodEnd) { - this.stripeCurrentPeriodEnd = stripeCurrentPeriodEnd; - } - +package group.goforward.ballistic.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "users") +public class User { + @Id + @NotNull + @Column(name = "id", nullable = false) + private Integer id; + + @NotNull + @ColumnDefault("gen_random_uuid()") + @Column(name = "uuid", nullable = false) + private UUID uuid; + + @NotNull + @Column(name = "email", nullable = false, length = Integer.MAX_VALUE) + private String email; + + @NotNull + @Column(name = "password_hash", nullable = false, length = Integer.MAX_VALUE) + private String passwordHash; + + @Column(name = "display_name", length = Integer.MAX_VALUE) + private String displayName; + + @NotNull + @ColumnDefault("'USER'") + @Column(name = "role", nullable = false, length = Integer.MAX_VALUE) + private String role; + + @NotNull + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive = false; + + @NotNull + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @NotNull + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @Column(name = "deleted_at") + private OffsetDateTime deletedAt; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public UUID getUuid() { + return uuid; + } + + public void setUuid(UUID uuid) { + this.uuid = uuid; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public OffsetDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(OffsetDateTime deletedAt) { + this.deletedAt = deletedAt; + } + } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/UsersService.java b/src/main/java/group/goforward/ballistic/services/UsersService.java new file mode 100644 index 0000000..59ebe13 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/UsersService.java @@ -0,0 +1,16 @@ +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.User; + +import java.util.List; +import java.util.Optional; + +public interface UsersService { + + List findAll(); + + Optional findById(Integer id); + + User save(User item); + void deleteById(Integer id); +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java new file mode 100644 index 0000000..a3b1cf8 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java @@ -0,0 +1,37 @@ +package group.goforward.ballistic.services.impl; + +import group.goforward.ballistic.model.User; +import group.goforward.ballistic.repos.UserRepository; +import group.goforward.ballistic.services.UsersService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class UsersServiceImpl implements UsersService { + + @Autowired + private UserRepository repo; + + @Override + public List findAll() { + return repo.findAll(); + } + + @Override + public Optional findById(Integer id) { + return repo.findById(id); + } + + @Override + public User save(User item) { + return null; + } + + @Override + public void deleteById(Integer id) { + deleteById(id); + } +} From 009e512a667c4c99a5ff9e2e30e29ee58dcec42b Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Tue, 2 Dec 2025 17:23:32 -0500 Subject: [PATCH 21/33] docker... --- docker/bb-spring/Dockerfile | 17 ++++++++++++++ .../docker-compose.yaml | 0 docker/ss_builder/Dockerfile | 22 +++++++++++++++++++ .../ballistic/controllers/Dockerfile | 17 ++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 docker/bb-spring/Dockerfile rename docker-compose.yaml => docker/docker-compose.yaml (100%) create mode 100644 docker/ss_builder/Dockerfile create mode 100644 src/main/java/group/goforward/ballistic/controllers/Dockerfile diff --git a/docker/bb-spring/Dockerfile b/docker/bb-spring/Dockerfile new file mode 100644 index 0000000..399820d --- /dev/null +++ b/docker/bb-spring/Dockerfile @@ -0,0 +1,17 @@ +# Stage 1: Build the application +FROM openjdk:17-jdk-slim as build +WORKDIR /app +COPY gradlew . +COPY settings.gradle . +COPY build.gradle . +COPY src ./src +# Adjust the build command for Maven: ./mvnw package -DskipTests +RUN ./gradlew bootJar + +# Stage 2: Create the final lightweight image +FROM openjdk:17-jre-slim +WORKDIR /app +# Get the built JAR from the build stage +COPY --from=build /app/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker/docker-compose.yaml similarity index 100% rename from docker-compose.yaml rename to docker/docker-compose.yaml diff --git a/docker/ss_builder/Dockerfile b/docker/ss_builder/Dockerfile new file mode 100644 index 0000000..c9d0091 --- /dev/null +++ b/docker/ss_builder/Dockerfile @@ -0,0 +1,22 @@ +# Stage 1: Build the static assets +FROM node:20-alpine as builder +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm install +COPY . . +# Run the Next.js build command +RUN npm run build + +# Stage 2: Run the production application (Next.js server) +FROM node:20-alpine +WORKDIR /app +# Copy only the necessary files for running the app +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/public ./public +# Set environment variables +ENV NODE_ENV production +EXPOSE 3000 +# Run the Next.js production server +CMD ["npm", "start"] \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/Dockerfile b/src/main/java/group/goforward/ballistic/controllers/Dockerfile new file mode 100644 index 0000000..399820d --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/Dockerfile @@ -0,0 +1,17 @@ +# Stage 1: Build the application +FROM openjdk:17-jdk-slim as build +WORKDIR /app +COPY gradlew . +COPY settings.gradle . +COPY build.gradle . +COPY src ./src +# Adjust the build command for Maven: ./mvnw package -DskipTests +RUN ./gradlew bootJar + +# Stage 2: Create the final lightweight image +FROM openjdk:17-jre-slim +WORKDIR /app +# Get the built JAR from the build stage +COPY --from=build /app/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file From 7e1b33efdf4356fb9e97a6ba63f5474b1c8cbbf6 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 2 Dec 2025 17:46:51 -0500 Subject: [PATCH 22/33] added java caching and optimized controller queries --- README.md | 4 +- .../ballistic/BallisticApplication.java | 2 + .../ballistic/configuration/CacheConfig.java | 16 ++++ .../controllers/ProductController.java | 67 ++++++++++---- .../ballistic/repos/ProductRepository.java | 26 ++++++ .../impl/MerchantFeedImportServiceImpl.java | 87 ++++++++----------- 6 files changed, 133 insertions(+), 69 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/configuration/CacheConfig.java diff --git a/README.md b/README.md index 4e939ed..26ef0a4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# Ballistic Backend -### Internal Engine for the Builder Ecosystem +# Ballistic Builder ( The Armory?) Backend +### Internal Engine for the Shadow System Armory? The Ballistic Backend is the operational backbone behind the Shadown System Armory and its admin tools. It ingests merchant feeds, normalizes product data, manages categories, synchronizes prices, and powers the compatibility, pricing, and product logic behind the consumer-facing Builder. diff --git a/src/main/java/group/goforward/ballistic/BallisticApplication.java b/src/main/java/group/goforward/ballistic/BallisticApplication.java index 611f7dc..e528833 100644 --- a/src/main/java/group/goforward/ballistic/BallisticApplication.java +++ b/src/main/java/group/goforward/ballistic/BallisticApplication.java @@ -5,8 +5,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.cache.annotation.EnableCaching; @SpringBootApplication +@EnableCaching @ComponentScan("group.goforward.ballistic.controllers") @ComponentScan("group.goforward.ballistic.repos") @ComponentScan("group.goforward.ballistic.services") diff --git a/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java b/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java new file mode 100644 index 0000000..e86d919 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java @@ -0,0 +1,16 @@ +package group.goforward.ballistic.configuration; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + // Simple in-memory cache for dev/local + return new ConcurrentMapCacheManager("gunbuilderProducts"); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/ProductController.java b/src/main/java/group/goforward/ballistic/controllers/ProductController.java index 1485be0..61feb24 100644 --- a/src/main/java/group/goforward/ballistic/controllers/ProductController.java +++ b/src/main/java/group/goforward/ballistic/controllers/ProductController.java @@ -7,6 +7,7 @@ import group.goforward.ballistic.web.dto.ProductOfferDto; import group.goforward.ballistic.repos.ProductRepository; import group.goforward.ballistic.web.dto.ProductSummaryDto; import group.goforward.ballistic.web.mapper.ProductMapper; +import org.springframework.cache.annotation.Cacheable; import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; @@ -30,35 +31,54 @@ public class ProductController { } @GetMapping("/gunbuilder") + @Cacheable( + value = "gunbuilderProducts", + key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())" + ) public List getGunbuilderProducts( @RequestParam(defaultValue = "AR-15") String platform, @RequestParam(required = false, name = "partRoles") List partRoles ) { - // 1) Load products + long started = System.currentTimeMillis(); + System.out.println("getGunbuilderProducts: start, platform=" + platform + + ", partRoles=" + (partRoles == null ? "null" : partRoles)); + + // 1) Load products (with brand pre-fetched) + long tProductsStart = System.currentTimeMillis(); List products; if (partRoles == null || partRoles.isEmpty()) { - products = productRepository.findByPlatform(platform); + products = productRepository.findByPlatformWithBrand(platform); } else { - products = productRepository.findByPlatformAndPartRoleIn(platform, partRoles); + products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles); } + long tProductsEnd = System.currentTimeMillis(); + System.out.println("getGunbuilderProducts: loaded products: " + + products.size() + " in " + (tProductsEnd - tProductsStart) + " ms"); if (products.isEmpty()) { + long took = System.currentTimeMillis() - started; + System.out.println("getGunbuilderProducts: 0 products in " + took + " ms"); return List.of(); } - // 2) Load offers for these product IDs (Integer IDs) + // 2) Load offers for these product IDs + long tOffersStart = System.currentTimeMillis(); List productIds = products.stream() .map(Product::getId) .toList(); List allOffers = productOfferRepository.findByProductIdIn(productIds); + long tOffersEnd = System.currentTimeMillis(); + System.out.println("getGunbuilderProducts: loaded offers: " + + allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms"); Map> offersByProductId = allOffers.stream() .collect(Collectors.groupingBy(o -> o.getProduct().getId())); // 3) Map to DTOs with price and buyUrl - return products.stream() + long tMapStart = System.currentTimeMillis(); + List result = products.stream() .map(p -> { List offersForProduct = offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); @@ -71,25 +91,36 @@ public class ProductController { return ProductMapper.toSummary(p, price, buyUrl); }) .toList(); + long tMapEnd = System.currentTimeMillis(); + long took = System.currentTimeMillis() - started; + + System.out.println("getGunbuilderProducts: mapping to DTOs took " + + (tMapEnd - tMapStart) + " ms"); + System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" + + "products=" + (tProductsEnd - tProductsStart) + " ms, " + + "offers=" + (tOffersEnd - tOffersStart) + " ms, " + + "map=" + (tMapEnd - tMapStart) + " ms)"); + + return result; } - + @GetMapping("/{id}/offers") public List getOffersForProduct(@PathVariable("id") Integer productId) { List offers = productOfferRepository.findByProductId(productId); return offers.stream() - .map(offer -> { - ProductOfferDto dto = new ProductOfferDto(); - dto.setId(offer.getId().toString()); - dto.setMerchantName(offer.getMerchant().getName()); - dto.setPrice(offer.getEffectivePrice()); - dto.setOriginalPrice(offer.getOriginalPrice()); - dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); - dto.setBuyUrl(offer.getBuyUrl()); - dto.setLastUpdated(offer.getLastSeenAt()); - return dto; - }) - .toList(); + .map(offer -> { + ProductOfferDto dto = new ProductOfferDto(); + dto.setId(offer.getId().toString()); + dto.setMerchantName(offer.getMerchant().getName()); + dto.setPrice(offer.getEffectivePrice()); + dto.setOriginalPrice(offer.getOriginalPrice()); + dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); + dto.setBuyUrl(offer.getBuyUrl()); + dto.setLastUpdated(offer.getLastSeenAt()); + return dto; + }) + .toList(); } private ProductOffer pickBestOffer(List offers) { diff --git a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java index 3915542..ff601f1 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -3,6 +3,8 @@ package group.goforward.ballistic.repos; import group.goforward.ballistic.model.Product; import group.goforward.ballistic.model.Brand; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; import java.util.UUID; @@ -24,4 +26,28 @@ public interface ProductRepository extends JpaRepository { // Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.) List findByPlatformAndPartRoleIn(String platform, Collection partRoles); + + // ---------- Optimized variants for Gunbuilder (fetch brand to avoid N+1) ---------- + + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.platform = :platform + AND p.deletedAt IS NULL + """) + List findByPlatformWithBrand(@Param("platform") String platform); + + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.platform = :platform + AND p.partRole IN :partRoles + AND p.deletedAt IS NULL + """) + List findByPlatformAndPartRoleInWithBrand( + @Param("platform") String platform, + @Param("partRoles") Collection partRoles + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index df36c69..c4440e0 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java @@ -11,12 +11,15 @@ import java.io.BufferedReader; import java.io.InputStreamReader; import java.net.URL; import java.nio.charset.StandardCharsets; +import org.springframework.cache.annotation.CacheEvict; import group.goforward.ballistic.imports.MerchantFeedRow; import group.goforward.ballistic.services.MerchantFeedImportService; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import group.goforward.ballistic.model.Brand; import group.goforward.ballistic.model.Merchant; @@ -36,6 +39,7 @@ import java.time.OffsetDateTime; @Service @Transactional public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { + private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class); private final MerchantRepository merchantRepository; private final BrandRepository brandRepository; @@ -56,27 +60,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } @Override + @CacheEvict(value = "gunbuilderProducts", allEntries = true) public void importMerchantFeed(Integer merchantId) { - System.out.println("IMPORT >>> importMerchantFeed(" + merchantId + ")"); + log.info("Starting full import for merchantId={}", merchantId); Merchant merchant = merchantRepository.findById(merchantId) .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); // Read all rows from the merchant feed List rows = readFeedRowsForMerchant(merchant); - System.out.println("IMPORT >>> read " + rows.size() + " rows for merchant=" + merchant.getName()); + log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName()); for (MerchantFeedRow row : rows) { - // Resolve brand from the row (fallback to "Aero Precision" or whatever you want as default) Brand brand = resolveBrand(row); Product p = upsertProduct(merchant, brand, row); - - System.out.println("IMPORT >>> upserted product id=" + p.getId() - + ", name=" + p.getName() - + ", slug=" + p.getSlug() - + ", platform=" + p.getPlatform() - + ", partRole=" + p.getPartRole() - + ", merchant=" + merchant.getName()); + log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}", + p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName()); } } @@ -85,9 +84,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService // --------------------------------------------------------------------- private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { - System.out.println("IMPORT >>> upsertProduct brand=" + brand.getName() - + ", sku=" + row.sku() - + ", productName=" + row.productName()); + log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName()); String mpn = trimOrNull(row.manufacturerId()); String upc = trimOrNull(row.sku()); // placeholder until real UPC field @@ -109,9 +106,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p.setBrand(brand); } else { if (candidates.size() > 1) { - System.out.println("IMPORT !!! WARNING: multiple existing products found for brand=" - + brand.getName() + ", mpn=" + mpn + ", upc=" + upc - + ". Using the first match (id=" + candidates.get(0).getId() + ")"); + log.warn("Multiple existing products found for brand={}, mpn={}, upc={}. Using first match id={}", + brand.getName(), mpn, upc, candidates.get(0).getId()); } p = candidates.get(0); } @@ -127,10 +123,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return saved; } private List> fetchFeedRows(String feedUrl) { - System.out.println("OFFERS >>> reading offer feed from: " + feedUrl); - + log.info("Reading offer feed from {}", feedUrl); + List> rows = new ArrayList<>(); - + try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); @@ -139,10 +135,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService .withIgnoreSurroundingSpaces() .withTrim() .parse(reader)) { - + // capture header names from the CSV List headers = new ArrayList<>(parser.getHeaderMap().keySet()); - + for (CSVRecord rec : parser) { Map row = new HashMap<>(); for (String header : headers) { @@ -153,8 +149,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } catch (Exception ex) { throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex); } - - System.out.println("OFFERS >>> parsed " + rows.size() + " offer rows"); + + log.info("Parsed {} offer rows from offer feed {}", rows.size(), feedUrl); return rows; } @@ -255,7 +251,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService 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()); + log.debug("Skipping offer row with no SKU for product id={}", product.getId()); return; } @@ -358,11 +354,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService Map headerMap = parser.getHeaderMap(); if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) { - System.out.println( - "IMPORT >>> detected delimiter '" + - (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) + - "' for feed: " + feedUrl - ); + log.info("Detected delimiter '{}' for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), feedUrl); return CSVFormat.DEFAULT.builder() .setDelimiter(delimiter) @@ -372,16 +364,11 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService .setTrim(true) .build(); } else if (headerMap != null) { - System.out.println( - "IMPORT !!! delimiter '" + - (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) + - "' produced headers: " + headerMap.keySet() - ); + log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl); } } catch (Exception ex) { lastException = ex; - System.out.println("IMPORT !!! error probing delimiter '" + delimiter + - "' for " + feedUrl + ": " + ex.getMessage()); + log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage()); } } @@ -398,7 +385,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } String feedUrl = rawFeedUrl.trim(); - System.out.println("IMPORT >>> reading feed for merchant=" + merchant.getName() + " from: " + feedUrl); + log.info("Reading product feed for merchant={} from {}", merchant.getName(), feedUrl); List rows = new ArrayList<>(); @@ -409,7 +396,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService try (Reader reader = openFeedReader(feedUrl); CSVParser parser = new CSVParser(reader, format)) { - System.out.println("IMPORT >>> detected headers: " + parser.getHeaderMap().keySet()); + log.debug("Detected feed headers for merchant {}: {}", merchant.getName(), parser.getHeaderMap().keySet()); for (CSVRecord rec : parser) { MerchantFeedRow row = new MerchantFeedRow( @@ -447,7 +434,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService + merchant.getName() + " from " + feedUrl, ex); } - System.out.println("IMPORT >>> parsed " + rows.size() + " rows for merchant=" + merchant.getName()); + log.info("Parsed {} product rows for merchant={}", rows.size(), merchant.getName()); return rows; } @@ -474,7 +461,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService try { return new BigDecimal(trimmed); } catch (NumberFormatException ex) { - System.out.println("IMPORT !!! bad BigDecimal value: '" + raw + "', skipping"); + log.debug("Skipping invalid numeric value '{}'", raw); return null; } } @@ -495,8 +482,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService try { return rec.get(header); } catch (IllegalArgumentException ex) { - System.out.println("IMPORT !!! short record #" + rec.getRecordNumber() - + " missing column '" + header + "', treating as null"); + log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header); return null; } } @@ -593,32 +579,35 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return "unknown"; } + @CacheEvict(value = "gunbuilderProducts", allEntries = true) public void syncOffersOnly(Integer merchantId) { + log.info("Starting offers-only sync for merchantId={}", merchantId); Merchant merchant = merchantRepository.findById(merchantId) .orElseThrow(() -> new RuntimeException("Merchant not found")); - + if (Boolean.FALSE.equals(merchant.getIsActive())) { return; } - - // Use offerFeedUrl if present, else fall back to feedUrl + String feedUrl = merchant.getOfferFeedUrl() != null ? merchant.getOfferFeedUrl() : merchant.getFeedUrl(); - + if (feedUrl == null) { throw new RuntimeException("No offer feed URL configured for merchant " + merchantId); } - + List> rows = fetchFeedRows(feedUrl); - + for (Map row : rows) { upsertOfferOnlyFromRow(merchant, row); } - + merchant.setLastOfferSyncAt(OffsetDateTime.now()); merchantRepository.save(merchant); + log.info("Completed offers-only sync for merchantId={} ({} rows processed)", merchantId, rows.size()); } + private void upsertOfferOnlyFromRow(Merchant merchant, Map row) { // For the offer-only sync, we key offers by the same identifier we used when creating them. // In the current AvantLink-style feed, that is the SKU column. From 85b00e9d991f2e80661af4896081c8dd823acdda Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 3 Dec 2025 10:55:35 -0500 Subject: [PATCH 23/33] jaca security auth - working --- pom.xml | 63 ++++++++--- .../ballistic/BallisticApplication.java | 6 +- .../configuration/PasswordConfig.java | 16 +++ .../configuration/SecurityConfig.java | 54 ++++++++++ .../ballistic/controllers/AuthController.java | 102 ++++++++++++++++++ .../group/goforward/ballistic/model/User.java | 100 +++++++++++++++-- .../ballistic/repos/UserRepository.java | 7 +- .../ballistic/security/CustomUserDetails.java | 59 ++++++++++ .../security/CustomUserDetailsService.java | 25 +++++ .../security/JwtAuthenticationEntryPoint.java | 26 +++++ .../security/JwtAuthenticationFilter.java | 80 ++++++++++++++ .../ballistic/security/JwtService.java | 71 ++++++++++++ .../ballistic/web/dto/auth/AuthResponse.java | 49 +++++++++ .../ballistic/web/dto/auth/LoginRequest.java | 22 ++++ .../web/dto/auth/RegisterRequest.java | 31 ++++++ src/main/resources/application.properties | 3 + .../ballistic/BallisticApplicationTests.java | 2 +- 17 files changed, 688 insertions(+), 28 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/configuration/PasswordConfig.java create mode 100644 src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java create mode 100644 src/main/java/group/goforward/ballistic/controllers/AuthController.java create mode 100644 src/main/java/group/goforward/ballistic/security/CustomUserDetails.java create mode 100644 src/main/java/group/goforward/ballistic/security/CustomUserDetailsService.java create mode 100644 src/main/java/group/goforward/ballistic/security/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/group/goforward/ballistic/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/group/goforward/ballistic/security/JwtService.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/auth/AuthResponse.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/auth/LoginRequest.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/auth/RegisterRequest.java diff --git a/pom.xml b/pom.xml index de79ff3..b1b5eae 100644 --- a/pom.xml +++ b/pom.xml @@ -1,22 +1,28 @@ - 4.0.0 + org.springframework.boot spring-boot-starter-parent 3.4.3 - + + group.goforward ballistic 0.0.1-SNAPSHOT ballistic Ballistic Builder API + + + Don Strawsburg @@ -29,69 +35,101 @@ Forward Group, LLC + scm:git:https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git + 17 ${java.version} ${java.version} + org.springframework.boot spring-boot-starter-data-jpa - + + --> + org.springframework.boot spring-boot-starter-web + org.springframework.boot spring-boot-devtools runtime true + org.springdoc springdoc-openapi-starter-webmvc-ui 2.8.5 - - + + jakarta.persistence jakarta.persistence-api 3.1.0 + org.postgresql postgresql 42.7.7 runtime + org.springframework.boot spring-boot-starter-test test + org.apache.commons commons-csv 1.11.0 + + + + org.springframework.boot + spring-boot-starter-security + + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + @@ -105,6 +143,7 @@ ${maven.compiler.target} + org.springframework.boot spring-boot-maven-plugin @@ -112,4 +151,4 @@ - + \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/BallisticApplication.java b/src/main/java/group/goforward/ballistic/BallisticApplication.java index e528833..fbf9d94 100644 --- a/src/main/java/group/goforward/ballistic/BallisticApplication.java +++ b/src/main/java/group/goforward/ballistic/BallisticApplication.java @@ -3,15 +3,11 @@ package group.goforward.ballistic; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication @EnableCaching -@ComponentScan("group.goforward.ballistic.controllers") -@ComponentScan("group.goforward.ballistic.repos") -@ComponentScan("group.goforward.ballistic.services") @EntityScan(basePackages = "group.goforward.ballistic.model") @EnableJpaRepositories(basePackages = "group.goforward.ballistic.repos") public class BallisticApplication { diff --git a/src/main/java/group/goforward/ballistic/configuration/PasswordConfig.java b/src/main/java/group/goforward/ballistic/configuration/PasswordConfig.java new file mode 100644 index 0000000..662156b --- /dev/null +++ b/src/main/java/group/goforward/ballistic/configuration/PasswordConfig.java @@ -0,0 +1,16 @@ +package group.goforward.ballistic.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +// @Configuration +// public class PasswordConfig { + +// @Bean +// public PasswordEncoder passwordEncoder() { +// // BCrypt default password +// return new BCryptPasswordEncoder(); +// } +// } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java b/src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java new file mode 100644 index 0000000..3a0a3c4 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java @@ -0,0 +1,54 @@ +package group.goforward.ballistic.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + // Auth endpoints always open + .requestMatchers("/api/auth/**").permitAll() + // Swagger / docs + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + // Health + .requestMatchers("/actuator/health", "/actuator/info").permitAll() + // Public product endpoints + .requestMatchers("/api/products/gunbuilder/**").permitAll() + // Everything else (for now) also open – we can tighten later + .anyRequest().permitAll() + ); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + // BCrypt is a solid default for user passwords + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager( + AuthenticationConfiguration configuration + ) throws Exception { + return configuration.getAuthenticationManager(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/AuthController.java b/src/main/java/group/goforward/ballistic/controllers/AuthController.java new file mode 100644 index 0000000..4456e1f --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/AuthController.java @@ -0,0 +1,102 @@ +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.User; +import group.goforward.ballistic.repos.UserRepository; +import group.goforward.ballistic.security.JwtService; +import group.goforward.ballistic.web.dto.auth.AuthResponse; +import group.goforward.ballistic.web.dto.auth.LoginRequest; +import group.goforward.ballistic.web.dto.auth.RegisterRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@RestController +@RequestMapping("/api/auth") +@CrossOrigin +public class AuthController { + + private final UserRepository users; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + + public AuthController( + UserRepository users, + PasswordEncoder passwordEncoder, + JwtService jwtService + ) { + this.users = users; + this.passwordEncoder = passwordEncoder; + this.jwtService = jwtService; + } + + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterRequest request) { + String email = request.getEmail().trim().toLowerCase(); + + if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body("Email is already registered"); + } + + User user = new User(); + // Let DB generate id + user.setUuid(UUID.randomUUID()); + user.setEmail(email); + user.setPasswordHash(passwordEncoder.encode(request.getPassword())); + user.setDisplayName(request.getDisplayName()); + user.setRole("USER"); + user.setIsActive(true); + user.setCreatedAt(OffsetDateTime.now()); + user.setUpdatedAt(OffsetDateTime.now()); + + users.save(user); + + String token = jwtService.generateToken(user); + + AuthResponse response = new AuthResponse( + token, + user.getEmail(), + user.getDisplayName(), + user.getRole() + ); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request) { + String email = request.getEmail().trim().toLowerCase(); + + User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) + .orElse(null); + + if (user == null || !user.getIsActive()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials"); + } + + if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials"); + } + + user.setLastLoginAt(OffsetDateTime.now()); + user.incrementLoginCount(); + user.setUpdatedAt(OffsetDateTime.now()); + users.save(user); + + String token = jwtService.generateToken(user); + + AuthResponse response = new AuthResponse( + token, + user.getEmail(), + user.getDisplayName(), + user.getRole() + ); + + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/User.java b/src/main/java/group/goforward/ballistic/model/User.java index 225798c..fa3661a 100644 --- a/src/main/java/group/goforward/ballistic/model/User.java +++ b/src/main/java/group/goforward/ballistic/model/User.java @@ -1,9 +1,6 @@ package group.goforward.ballistic.model; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import org.hibernate.annotations.ColumnDefault; @@ -13,8 +10,9 @@ import java.util.UUID; @Entity @Table(name = "users") public class User { + @Id - @NotNull + @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) private Integer id; @@ -42,7 +40,7 @@ public class User { @NotNull @ColumnDefault("true") @Column(name = "is_active", nullable = false) - private Boolean isActive = false; + private boolean isActive = true; @NotNull @ColumnDefault("now()") @@ -57,6 +55,29 @@ public class User { @Column(name = "deleted_at") private OffsetDateTime deletedAt; + // NEW FIELDS + + @Column(name = "email_verified_at") + private OffsetDateTime emailVerifiedAt; + + @Column(name = "verification_token", length = Integer.MAX_VALUE) + private String verificationToken; + + @Column(name = "reset_password_token", length = Integer.MAX_VALUE) + private String resetPasswordToken; + + @Column(name = "reset_password_expires_at") + private OffsetDateTime resetPasswordExpiresAt; + + @Column(name = "last_login_at") + private OffsetDateTime lastLoginAt; + + @ColumnDefault("0") + @Column(name = "login_count", nullable = false) + private Integer loginCount = 0; + + // --- Getters / setters --- + public Integer getId() { return id; } @@ -105,12 +126,12 @@ public class User { this.role = role; } - public Boolean getIsActive() { + public boolean getIsActive() { return isActive; } - public void setIsActive(Boolean isActive) { - this.isActive = isActive; + public void setIsActive(boolean active) { + isActive = active; } public OffsetDateTime getCreatedAt() { @@ -137,4 +158,65 @@ public class User { this.deletedAt = deletedAt; } + public OffsetDateTime getEmailVerifiedAt() { + return emailVerifiedAt; + } + + public void setEmailVerifiedAt(OffsetDateTime emailVerifiedAt) { + this.emailVerifiedAt = emailVerifiedAt; + } + + public String getVerificationToken() { + return verificationToken; + } + + public void setVerificationToken(String verificationToken) { + this.verificationToken = verificationToken; + } + + public String getResetPasswordToken() { + return resetPasswordToken; + } + + public void setResetPasswordToken(String resetPasswordToken) { + this.resetPasswordToken = resetPasswordToken; + } + + public OffsetDateTime getResetPasswordExpiresAt() { + return resetPasswordExpiresAt; + } + + public void setResetPasswordExpiresAt(OffsetDateTime resetPasswordExpiresAt) { + this.resetPasswordExpiresAt = resetPasswordExpiresAt; + } + + public OffsetDateTime getLastLoginAt() { + return lastLoginAt; + } + + public void setLastLoginAt(OffsetDateTime lastLoginAt) { + this.lastLoginAt = lastLoginAt; + } + + public Integer getLoginCount() { + return loginCount; + } + + public void setLoginCount(Integer loginCount) { + this.loginCount = loginCount; + } + + // convenience helpers + + @Transient + public boolean isEmailVerified() { + return emailVerifiedAt != null; + } + + public void incrementLoginCount() { + if (loginCount == null) { + loginCount = 0; + } + loginCount++; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/UserRepository.java b/src/main/java/group/goforward/ballistic/repos/UserRepository.java index f861570..28f7ba4 100644 --- a/src/main/java/group/goforward/ballistic/repos/UserRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/UserRepository.java @@ -2,10 +2,15 @@ package group.goforward.ballistic.repos; import group.goforward.ballistic.model.User; import org.springframework.data.jpa.repository.JpaRepository; + import java.util.Optional; import java.util.UUID; public interface UserRepository extends JpaRepository { - Optional findByEmail(String email); + + Optional findByEmailIgnoreCaseAndDeletedAtIsNull(String email); + + boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email); + Optional findByUuid(UUID uuid); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/security/CustomUserDetails.java b/src/main/java/group/goforward/ballistic/security/CustomUserDetails.java new file mode 100644 index 0000000..94604ff --- /dev/null +++ b/src/main/java/group/goforward/ballistic/security/CustomUserDetails.java @@ -0,0 +1,59 @@ +package group.goforward.ballistic.security; + +import group.goforward.ballistic.model.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +public class CustomUserDetails implements UserDetails { + + private final User user; + private final List authorities; + + public CustomUserDetails(User user) { + this.user = user; + this.authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole())); + } + + public User getUser() { + return user; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return user.getPasswordHash(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return user.getDeletedAt() == null; + } + + @Override + public boolean isAccountNonLocked() { + return user.getIsActive(); + } + + @Override + public boolean isCredentialsNonExpired() { + return user.getDeletedAt() == null; + } + + @Override + public boolean isEnabled() { + return user.getIsActive() && user.getDeletedAt() == null; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/security/CustomUserDetailsService.java b/src/main/java/group/goforward/ballistic/security/CustomUserDetailsService.java new file mode 100644 index 0000000..93efe9f --- /dev/null +++ b/src/main/java/group/goforward/ballistic/security/CustomUserDetailsService.java @@ -0,0 +1,25 @@ +package group.goforward.ballistic.security; + +import group.goforward.ballistic.model.User; +import group.goforward.ballistic.repos.UserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository users; + + public CustomUserDetailsService(UserRepository users) { + this.users = users; + } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + return new CustomUserDetails(user); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/security/JwtAuthenticationEntryPoint.java b/src/main/java/group/goforward/ballistic/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..f9ff76b --- /dev/null +++ b/src/main/java/group/goforward/ballistic/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,26 @@ +package group.goforward.ballistic.security; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + // Simple JSON 401 response + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Unauthorized\"}"); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/security/JwtAuthenticationFilter.java b/src/main/java/group/goforward/ballistic/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..6a8d70b --- /dev/null +++ b/src/main/java/group/goforward/ballistic/security/JwtAuthenticationFilter.java @@ -0,0 +1,80 @@ +package group.goforward.ballistic.security; + +import group.goforward.ballistic.model.User; +import group.goforward.ballistic.repos.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UserRepository userRepository; + + public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) { + this.jwtService = jwtService; + this.userRepository = userRepository; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(7); + + if (!jwtService.isTokenValid(token)) { + filterChain.doFilter(request, response); + return; + } + + UUID userUuid = jwtService.extractUserUuid(token); + + if (userUuid == null || SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } + + User user = userRepository.findByUuid(userUuid) + .orElse(null); + + if (user == null || !user.getIsActive()) { + filterChain.doFilter(request, response); + return; + } + + CustomUserDetails userDetails = new CustomUserDetails(user); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/security/JwtService.java b/src/main/java/group/goforward/ballistic/security/JwtService.java new file mode 100644 index 0000000..9cef8d0 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/security/JwtService.java @@ -0,0 +1,71 @@ +package group.goforward.ballistic.security; + +import group.goforward.ballistic.model.User; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.security.Key; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +@Service +public class JwtService { + + private final Key key; + private final long accessTokenMinutes; + + public JwtService( + @Value("${security.jwt.secret}") String secret, + @Value("${security.jwt.access-token-minutes:60}") long accessTokenMinutes + ) { + this.key = Keys.hmacShaKeyFor(secret.getBytes()); + this.accessTokenMinutes = accessTokenMinutes; + } + + public String generateToken(User user) { + Instant now = Instant.now(); + Instant expiry = now.plus(accessTokenMinutes, ChronoUnit.MINUTES); + + return Jwts.builder() + .setSubject(user.getUuid().toString()) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(expiry)) + .addClaims(Map.of( + "email", user.getEmail(), + "role", user.getRole(), + "displayName", user.getDisplayName() + )) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public UUID extractUserUuid(String token) { + Claims claims = parseClaims(token); + return UUID.fromString(claims.getSubject()); + } + + public boolean isTokenValid(String token) { + try { + parseClaims(token); + return true; + } catch (JwtException | IllegalArgumentException ex) { + return false; + } + } + + private Claims parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/auth/AuthResponse.java b/src/main/java/group/goforward/ballistic/web/dto/auth/AuthResponse.java new file mode 100644 index 0000000..9983203 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/auth/AuthResponse.java @@ -0,0 +1,49 @@ +package group.goforward.ballistic.web.dto.auth; + +public class AuthResponse { + private String token; + private String email; + private String displayName; + private String role; + + public AuthResponse() {} + + public AuthResponse(String token, String email, String displayName, String role) { + this.token = token; + this.email = email; + this.displayName = displayName; + this.role = role; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/auth/LoginRequest.java b/src/main/java/group/goforward/ballistic/web/dto/auth/LoginRequest.java new file mode 100644 index 0000000..6e37282 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/auth/LoginRequest.java @@ -0,0 +1,22 @@ +package group.goforward.ballistic.web.dto.auth; + +public class LoginRequest { + private String email; + private String password; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/auth/RegisterRequest.java b/src/main/java/group/goforward/ballistic/web/dto/auth/RegisterRequest.java new file mode 100644 index 0000000..f1688b8 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/auth/RegisterRequest.java @@ -0,0 +1,31 @@ +package group.goforward.ballistic.web.dto.auth; + +public class RegisterRequest { + private String email; + private String password; + private String displayName; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2e1f374..87d55f9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,3 +9,6 @@ spring.datasource.driver-class-name=org.postgresql.Driver #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 \ No newline at end of file diff --git a/src/test/java/group/goforward/ballistic/BallisticApplicationTests.java b/src/test/java/group/goforward/ballistic/BallisticApplicationTests.java index 3a17a51..5c4543c 100644 --- a/src/test/java/group/goforward/ballistic/BallisticApplicationTests.java +++ b/src/test/java/group/goforward/ballistic/BallisticApplicationTests.java @@ -3,7 +3,7 @@ package group.goforward.ballistic; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +// @SpringBootTest class BallisticApplicationTests { @Test From 4138edf45d619583a4ecd9da71323168a133cdd7 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Wed, 3 Dec 2025 11:22:50 -0500 Subject: [PATCH 24/33] lots of changes that I don't think I made, must of been the pull --- README.md | 132 +- action1.yaml | 78 +- docker/backend/Dockerfile | 34 + docker/bb-spring/Dockerfile | 17 - docker/docker-compose.yaml | 124 +- docker/{ss_builder => frontend}/Dockerfile | 42 +- importLogic.md | 424 +++--- .../ballistic/configuration/CacheConfig.java | 30 +- .../ballistic/configuration/package-info.java | 24 +- .../ballistic/controllers/Dockerfile | 32 +- .../controllers/ImportController.java | 76 +- .../controllers/MerchantAdminController.java | 124 +- .../MerchantCategoryMappingController.java | 128 +- .../controllers/MerchantDebugController.java | 44 +- .../ballistic/controllers/PingController.java | 24 +- .../controllers/ProductController.java | 272 ++-- .../ballistic/controllers/UserController.java | 103 +- .../ballistic/controllers/package-info.java | 24 +- .../ballistic/imports/MerchantFeedRow.java | 58 +- .../imports/dto/MerchantFeedRow.java | 32 +- .../ballistic/imports/dto/package-info.java | 24 +- .../model/MerchantCategoryMapping.java | 208 +-- .../ballistic/model/ProductConfiguration.java | 18 +- .../ballistic/repos/AccountRepository.java | 16 +- .../ballistic/repos/BrandRepository.java | 14 +- .../ballistic/repos/BuildItemRepository.java | 22 +- .../ballistic/repos/BuildRepository.java | 18 +- .../repos/CategoryMappingRepository.java | 12 +- .../ballistic/repos/FeedImportRepository.java | 12 +- .../MerchantCategoryMappingRepository.java | 32 +- .../ballistic/repos/MerchantRepository.java | 20 +- .../repos/PartCategoryRepository.java | 16 +- .../repos/PriceHistoryRepository.java | 12 +- .../repos/ProductOfferRepository.java | 42 +- .../ballistic/repos/ProductRepository.java | 104 +- .../ballistic/repos/package-info.java | 24 +- .../MerchantCategoryMappingService.java | 190 +-- .../services/MerchantFeedImportService.java | 26 +- .../ballistic/services/PsaService.java | 34 +- .../ballistic/services/StatesService.java | 32 +- .../ballistic/services/UsersService.java | 32 +- .../impl/MerchantFeedImportServiceImpl.java | 1318 ++++++++--------- .../services/impl/PsaServiceImpl.java | 82 +- .../services/impl/StatesServiceImpl.java | 76 +- .../services/impl/UsersServiceImpl.java | 74 +- .../ballistic/services/impl/package-info.java | 24 +- .../ballistic/web/dto/MerchantAdminDto.java | 138 +- .../web/dto/MerchantCategoryMappingDto.java | 98 +- .../ballistic/web/dto/ProductOfferDto.java | 138 +- .../ballistic/web/dto/ProductSummaryDto.java | 156 +- .../UpsertMerchantCategoryMappingRequest.java | 62 +- .../ballistic/web/mapper/ProductMapper.java | 58 +- 52 files changed, 2487 insertions(+), 2467 deletions(-) create mode 100644 docker/backend/Dockerfile delete mode 100644 docker/bb-spring/Dockerfile rename docker/{ss_builder => frontend}/Dockerfile (96%) diff --git a/README.md b/README.md index 26ef0a4..14785e2 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,67 @@ -# Ballistic Builder ( The Armory?) Backend -### Internal Engine for the Shadow System Armory? - -The Ballistic Backend is the operational backbone behind the Shadown System Armory and its admin tools. It ingests merchant feeds, normalizes product data, manages categories, synchronizes prices, and powers the compatibility, pricing, and product logic behind the consumer-facing Builder. - -It’s built for reliability, longevity, and clean extensibility — the kind of foundation you want when scaling from a small beta to a fully public platform. - ---- - -## What This Backend Does - -### **Merchant Feed Ingestion** -- Pulls AvantLink feeds (CSV or TSV) -- Automatically detects delimiters -- Normalizes raw merchant fields -- Creates or updates product records -- Upserts price and stock offers -- Tracks first-seen / last-seen timestamps -- Safely handles malformed or incomplete rows -- Ensures repeat imports never duplicate offers - -### **Category Mapping Engine** -- Identifies every unique raw category coming from each merchant feed -- Exposes *unmapped* categories in the admin UI -- Allows you to assign: - - Part Role - - Product Configuration (Stripped, Complete, Kit, etc.) -- Applies mappings automatically on future imports -- Respects manual overrides such as `platform_locked` - -### **Builder Support** -The frontend Builder depends on this backend for: - -- Loading parts grouped by role -- Offering compatible options -- Calculating build cost -- Comparing offers across merchants -- Providing product metadata, imagery, and offer data - -**Future expansions include:** price history, compatibility engine, build exports, public build pages, multi-merchant aggregation, and automated anomaly detection. - ---- - -## Tech Stack - -- **Spring Boot 3.x** -- **Java 17** -- **PostgreSQL** -- **Hibernate (JPA)** -- **HikariCP** -- **Apache Commons CSV** -- **Maven** -- **REST API** - ---- - -## Local Development - -### Requirements -- Java 17 or newer -- PostgreSQL running locally -- Port 8080 open (default backend port) - -### Run Development Server - -```bash +# Ballistic Builder ( The Armory?) Backend +### Internal Engine for the Shadow System Armory? + +The Ballistic Backend is the operational backbone behind the Shadown System Armory and its admin tools. It ingests merchant feeds, normalizes product data, manages categories, synchronizes prices, and powers the compatibility, pricing, and product logic behind the consumer-facing Builder. + +It’s built for reliability, longevity, and clean extensibility — the kind of foundation you want when scaling from a small beta to a fully public platform. + +--- + +## What This Backend Does + +### **Merchant Feed Ingestion** +- Pulls AvantLink feeds (CSV or TSV) +- Automatically detects delimiters +- Normalizes raw merchant fields +- Creates or updates product records +- Upserts price and stock offers +- Tracks first-seen / last-seen timestamps +- Safely handles malformed or incomplete rows +- Ensures repeat imports never duplicate offers + +### **Category Mapping Engine** +- Identifies every unique raw category coming from each merchant feed +- Exposes *unmapped* categories in the admin UI +- Allows you to assign: + - Part Role + - Product Configuration (Stripped, Complete, Kit, etc.) +- Applies mappings automatically on future imports +- Respects manual overrides such as `platform_locked` + +### **Builder Support** +The frontend Builder depends on this backend for: + +- Loading parts grouped by role +- Offering compatible options +- Calculating build cost +- Comparing offers across merchants +- Providing product metadata, imagery, and offer data + +**Future expansions include:** price history, compatibility engine, build exports, public build pages, multi-merchant aggregation, and automated anomaly detection. + +--- + +## Tech Stack + +- **Spring Boot 3.x** +- **Java 17** +- **PostgreSQL** +- **Hibernate (JPA)** +- **HikariCP** +- **Apache Commons CSV** +- **Maven** +- **REST API** + +--- + +## Local Development + +### Requirements +- Java 17 or newer +- PostgreSQL running locally +- Port 8080 open (default backend port) + +### Run Development Server + +```bash ./mvnw spring-boot:run \ No newline at end of file diff --git a/action1.yaml b/action1.yaml index d3f5dbb..4689143 100644 --- a/action1.yaml +++ b/action1.yaml @@ -1,39 +1,39 @@ -# File: .gitea/workflows/build-and-upload.yml -name: Build and Upload Artifact - -on: - push: - branches: - - main - pull_request: - -jobs: - build: - runs-on: ubuntu-latest - - steps: - # Step 1: Check out repository code - - name: Checkout code - uses: actions/checkout@v4 - - # Step 2: Set up Node.js (example for a JS project; adjust for your stack) - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - # Step 3: Install dependencies - - name: Install dependencies - run: npm ci - - # Step 4: Build project - - name: Build project - run: npm run build - - # Step 5: Upload build output as artifact - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: build-output - path: dist/ # Change to your build output directory - retention-days: 7 # Optional: how long to keep artifact +# File: .gitea/workflows/build-and-upload.yml +name: Build and Upload Artifact + +on: + push: + branches: + - main + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # Step 1: Check out repository code + - name: Checkout code + uses: actions/checkout@v4 + + # Step 2: Set up Node.js (example for a JS project; adjust for your stack) + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + # Step 3: Install dependencies + - name: Install dependencies + run: npm ci + + # Step 4: Build project + - name: Build project + run: npm run build + + # Step 5: Upload build output as artifact + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: build-output + path: dist/ # Change to your build output directory + retention-days: 7 # Optional: how long to keep artifact diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile new file mode 100644 index 0000000..38beb6c --- /dev/null +++ b/docker/backend/Dockerfile @@ -0,0 +1,34 @@ +# Stage 1: Build the application (The Build Stage) +# Use a Java SDK image with Maven pre-installed +FROM maven:3.9-jdk-17-slim AS build + +# Set the working directory inside the container +WORKDIR /app + +# Copy the Maven project files (pom.xml) first to leverage Docker layer caching +COPY pom.xml . + +# Copy the source code +COPY src ./src + +# Build the Spring Boot application, skipping tests to speed up the Docker build +# This creates the executable JAR file in the 'target' directory +RUN mvn clean package -DskipTests + +# Stage 2: Create the final lightweight image (The Runtime Stage) +# Use a smaller Java Runtime Environment (JRE) image for a smaller footprint +FROM openjdk:17-jre-slim + +# Set the working directory in the final image +WORKDIR /app + +# Copy the built JAR file from the 'build' stage into the final image +# The JAR file is typically named 'target/-.jar' +# You may need to adjust the name if you have a non-standard pom.xml +COPY --from=build /app/target/*.jar app.jar + +# Expose the default Spring Boot port +EXPOSE 8080 + +# Define the command to run the application +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/docker/bb-spring/Dockerfile b/docker/bb-spring/Dockerfile deleted file mode 100644 index 399820d..0000000 --- a/docker/bb-spring/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -# Stage 1: Build the application -FROM openjdk:17-jdk-slim as build -WORKDIR /app -COPY gradlew . -COPY settings.gradle . -COPY build.gradle . -COPY src ./src -# Adjust the build command for Maven: ./mvnw package -DskipTests -RUN ./gradlew bootJar - -# Stage 2: Create the final lightweight image -FROM openjdk:17-jre-slim -WORKDIR /app -# Get the built JAR from the build stage -COPY --from=build /app/build/libs/*.jar app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 3495250..7cb22e9 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,63 +1,63 @@ -version: '3.8' - -services: - # --- 1. Spring API Service (Backend) --- - spring-api: - build: - context: ./backend # Path to your Spring project's root folder - dockerfile: Dockerfile # Assumes you have a Dockerfile in ./backend - container_name: spring-api - ports: - - "8080:8080" # Map host port 8080 to container port 8080 - environment: - # These environment variables link the API to the database service defined below - - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mydatabase - - SPRING_DATASOURCE_USERNAME=myuser - - SPRING_DATASOURCE_PASSWORD=mypassword - depends_on: - - db - networks: - - app-network - - # --- 2. Next.js App Service (Frontend) --- - nextjs-app: - build: - context: ./frontend # Path to your Next.js project's root folder - dockerfile: Dockerfile # Assumes you have a Dockerfile in ./frontend - container_name: nextjs-app - ports: - - "3000:3000" # Map host port 3000 to container port 3000 - environment: - # This variable is crucial: Next.js needs the URL for the Spring API - # Use the Docker internal service name 'spring-api' and its port 8080 - - NEXT_PUBLIC_API_URL=http://spring-api:8080 - # For local testing, you might need the host IP for Next.js to call back - # - NEXT_PUBLIC_API_URL_LOCAL=http://localhost:8080 - depends_on: - - spring-api - networks: - - app-network - - # --- 3. PostgreSQL Database Service (Example Dependency) --- - db: - image: postgres:15-alpine # Lightweight and stable PostgreSQL image - container_name: postgres-db - environment: - - POSTGRES_DB=mydatabase - - POSTGRES_USER=myuser - - POSTGRES_PASSWORD=mypassword - volumes: - - postgres_data:/var/lib/postgresql/data # Persist the database data - ports: - - "5432:5432" # Optional: Map DB port for external access (e.g., DBeaver) - networks: - - app-network - -# --- Docker Volume for Persistent Data --- -volumes: - postgres_data: - -# --- Docker Network for Inter-Container Communication --- -networks: - app-network: +version: '3.8' + +services: + # --- 1. Spring API Service (Backend) --- + spring-api: + build: + context: ./backend # Path to your Spring project's root folder + dockerfile: Dockerfile # Assumes you have a Dockerfile in ./backend + container_name: spring-api + ports: + - "8080:8080" # Map host port 8080 to container port 8080 + environment: + # These environment variables link the API to the database service defined below + - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mydatabase + - SPRING_DATASOURCE_USERNAME=myuser + - SPRING_DATASOURCE_PASSWORD=mypassword + depends_on: + - db + networks: + - app-network + + # --- 2. Next.js App Service (Frontend) --- + nextjs-app: + build: + context: ./frontend # Path to your Next.js project's root folder + dockerfile: Dockerfile # Assumes you have a Dockerfile in ./frontend + container_name: nextjs-app + ports: + - "3000:3000" # Map host port 3000 to container port 3000 + environment: + # This variable is crucial: Next.js needs the URL for the Spring API + # Use the Docker internal service name 'spring-api' and its port 8080 + - NEXT_PUBLIC_API_URL=http://spring-api:8080 + # For local testing, you might need the host IP for Next.js to call back + # - NEXT_PUBLIC_API_URL_LOCAL=http://localhost:8080 + depends_on: + - spring-api + networks: + - app-network + + # --- 3. PostgreSQL Database Service (Example Dependency) --- + db: + image: postgres:15-alpine # Lightweight and stable PostgreSQL image + container_name: postgres-db + environment: + - POSTGRES_DB=mydatabase + - POSTGRES_USER=myuser + - POSTGRES_PASSWORD=mypassword + volumes: + - postgres_data:/var/lib/postgresql/data # Persist the database data + ports: + - "5432:5432" # Optional: Map DB port for external access (e.g., DBeaver) + networks: + - app-network + +# --- Docker Volume for Persistent Data --- +volumes: + postgres_data: + +# --- Docker Network for Inter-Container Communication --- +networks: + app-network: driver: bridge \ No newline at end of file diff --git a/docker/ss_builder/Dockerfile b/docker/frontend/Dockerfile similarity index 96% rename from docker/ss_builder/Dockerfile rename to docker/frontend/Dockerfile index c9d0091..d03598a 100644 --- a/docker/ss_builder/Dockerfile +++ b/docker/frontend/Dockerfile @@ -1,22 +1,22 @@ -# Stage 1: Build the static assets -FROM node:20-alpine as builder -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm install -COPY . . -# Run the Next.js build command -RUN npm run build - -# Stage 2: Run the production application (Next.js server) -FROM node:20-alpine -WORKDIR /app -# Copy only the necessary files for running the app -COPY --from=builder /app/.next ./.next -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/package.json ./package.json -COPY --from=builder /app/public ./public -# Set environment variables -ENV NODE_ENV production -EXPOSE 3000 -# Run the Next.js production server +# Stage 1: Build the static assets +FROM node:20-alpine as builder +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm install +COPY . . +# Run the Next.js build command +RUN npm run build + +# Stage 2: Run the production application (Next.js server) +FROM node:20-alpine +WORKDIR /app +# Copy only the necessary files for running the app +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/public ./public +# Set environment variables +ENV NODE_ENV production +EXPOSE 3000 +# Run the Next.js production server CMD ["npm", "start"] \ No newline at end of file diff --git a/importLogic.md b/importLogic.md index d91feb7..863e7b6 100644 --- a/importLogic.md +++ b/importLogic.md @@ -1,213 +1,213 @@ -# Ballistic Import Pipeline -A high-level overview of how merchant data flows through the Spring ETL system. - ---- - -## Purpose - -This document explains how the Ballistic backend: - -1. Fetches merchant product feeds (CSV/TSV) -2. Normalizes raw data into structured entities -3. Updates products and offers in an idempotent way -4. Supports two sync modes: - - Full Import - - Offer-Only Sync - ---- - -# 1. High-Level Flow - -## ASCII Diagram - -``` - ┌──────────────────────────┐ - │ /admin/imports/{id} │ - │ (Full Import Trigger) │ - └─────────────┬────────────┘ - │ - ▼ - ┌──────────────────────────────┐ - │ importMerchantFeed(merchantId)│ - └─────────────┬────────────────┘ - │ - ▼ - ┌────────────────────────────────────────────────────────┐ - │ readFeedRowsForMerchant() │ - │ - auto-detect delimiter │ - │ - parse CSV/TSV → MerchantFeedRow objects │ - └─────────────────┬──────────────────────────────────────┘ - │ List - ▼ - ┌──────────────────────────────────────┐ - │ For each MerchantFeedRow row: │ - │ resolveBrand() │ - │ upsertProduct() │ - │ - find existing via brand+mpn/upc │ - │ - update fields (mapped partRole) │ - │ upsertOfferFromRow() │ - └──────────────────────────────────────┘ -``` - ---- - -# 2. Full Import Explained - -Triggered by: - -``` -POST /admin/imports/{merchantId} -``` - -### Step 1 — Load merchant -Using `merchantRepository.findById()`. - -### Step 2 — Parse feed rows -`readFeedRowsForMerchant()`: -- Auto-detects delimiter (`\t`, `,`, `;`) -- Validates required headers -- Parses each row into `MerchantFeedRow` - -### Step 3 — Process each row - -For each parsed row: - -#### a. resolveBrand() -- Finds or creates brand -- Defaults to “Aero Precision” if missing - -#### b. upsertProduct() -Dedupes by: - -1. Brand + MPN -2. Brand + UPC (currently SKU placeholder) - -If no match → create new product. - -Then applies: -- Name + slug -- Descriptions -- Images -- MPN/identifiers -- Platform inference -- Category mapping -- Part role inference - -#### c. upsertOfferFromRow() -Creates or updates a ProductOffer: -- Prices -- Stock -- Buy URL -- lastSeenAt -- firstSeenAt when newly created - -Idempotent — does not duplicate offers. - ---- - -# 3. Offer-Only Sync - -Triggered by: - -``` -POST /admin/imports/{merchantId}/offers-only -``` - -Does NOT: -- Create products -- Update product fields - -It only updates: -- price -- originalPrice -- inStock -- buyUrl -- lastSeenAt - -If the offer does not exist, it is skipped. - ---- - -# 4. Auto-Detecting CSV/TSV Parser - -The parser: - -- Attempts multiple delimiters -- Validates headers -- Handles malformed or short rows -- Never throws on missing columns -- Returns clean MerchantFeedRow objects - -Designed for messy merchant feeds. - ---- - -# 5. Entities Updated During Import - -### Product -- name -- slug -- short/long description -- main image -- mpn -- upc (future) -- platform -- rawCategoryKey -- partRole - -### ProductOffer -- merchant -- product -- avantlinkProductId (SKU placeholder) -- price -- originalPrice -- inStock -- buyUrl -- lastSeenAt -- firstSeenAt - -### Merchant -- lastFullImportAt -- lastOfferSyncAt - ---- - -# 6. Extension Points - -You can extend the import pipeline in these areas: - -- Add per-merchant column mapping -- Add true UPC parsing -- Support multi-platform parts -- Improve partRole inference -- Implement global deduplication across merchants - ---- - -# 7. Quick Reference: Main Methods - -| Method | Purpose | -|--------|---------| -| importMerchantFeed | Full product + offer import | -| readFeedRowsForMerchant | Detect delimiter + parse feed | -| resolveBrand | Normalize brand names | -| upsertProduct | Idempotent product write | -| updateProductFromRow | Apply product fields | -| upsertOfferFromRow | Idempotent offer write | -| syncOffersOnly | Offer-only sync | -| upsertOfferOnlyFromRow | Update existing offers | -| detectCsvFormat | Auto-detect delimiter | -| fetchFeedRows | Simpler parser for offers | - ---- - -# 8. Summary - -The Ballistic importer is: - -- Robust against bad data -- Idempotent and safe -- Flexible for multiple merchants -- Extensible for long-term scaling - +# Ballistic Import Pipeline +A high-level overview of how merchant data flows through the Spring ETL system. + +--- + +## Purpose + +This document explains how the Ballistic backend: + +1. Fetches merchant product feeds (CSV/TSV) +2. Normalizes raw data into structured entities +3. Updates products and offers in an idempotent way +4. Supports two sync modes: + - Full Import + - Offer-Only Sync + +--- + +# 1. High-Level Flow + +## ASCII Diagram + +``` + ┌──────────────────────────┐ + │ /admin/imports/{id} │ + │ (Full Import Trigger) │ + └─────────────┬────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ importMerchantFeed(merchantId)│ + └─────────────┬────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ readFeedRowsForMerchant() │ + │ - auto-detect delimiter │ + │ - parse CSV/TSV → MerchantFeedRow objects │ + └─────────────────┬──────────────────────────────────────┘ + │ List + ▼ + ┌──────────────────────────────────────┐ + │ For each MerchantFeedRow row: │ + │ resolveBrand() │ + │ upsertProduct() │ + │ - find existing via brand+mpn/upc │ + │ - update fields (mapped partRole) │ + │ upsertOfferFromRow() │ + └──────────────────────────────────────┘ +``` + +--- + +# 2. Full Import Explained + +Triggered by: + +``` +POST /admin/imports/{merchantId} +``` + +### Step 1 — Load merchant +Using `merchantRepository.findById()`. + +### Step 2 — Parse feed rows +`readFeedRowsForMerchant()`: +- Auto-detects delimiter (`\t`, `,`, `;`) +- Validates required headers +- Parses each row into `MerchantFeedRow` + +### Step 3 — Process each row + +For each parsed row: + +#### a. resolveBrand() +- Finds or creates brand +- Defaults to “Aero Precision” if missing + +#### b. upsertProduct() +Dedupes by: + +1. Brand + MPN +2. Brand + UPC (currently SKU placeholder) + +If no match → create new product. + +Then applies: +- Name + slug +- Descriptions +- Images +- MPN/identifiers +- Platform inference +- Category mapping +- Part role inference + +#### c. upsertOfferFromRow() +Creates or updates a ProductOffer: +- Prices +- Stock +- Buy URL +- lastSeenAt +- firstSeenAt when newly created + +Idempotent — does not duplicate offers. + +--- + +# 3. Offer-Only Sync + +Triggered by: + +``` +POST /admin/imports/{merchantId}/offers-only +``` + +Does NOT: +- Create products +- Update product fields + +It only updates: +- price +- originalPrice +- inStock +- buyUrl +- lastSeenAt + +If the offer does not exist, it is skipped. + +--- + +# 4. Auto-Detecting CSV/TSV Parser + +The parser: + +- Attempts multiple delimiters +- Validates headers +- Handles malformed or short rows +- Never throws on missing columns +- Returns clean MerchantFeedRow objects + +Designed for messy merchant feeds. + +--- + +# 5. Entities Updated During Import + +### Product +- name +- slug +- short/long description +- main image +- mpn +- upc (future) +- platform +- rawCategoryKey +- partRole + +### ProductOffer +- merchant +- product +- avantlinkProductId (SKU placeholder) +- price +- originalPrice +- inStock +- buyUrl +- lastSeenAt +- firstSeenAt + +### Merchant +- lastFullImportAt +- lastOfferSyncAt + +--- + +# 6. Extension Points + +You can extend the import pipeline in these areas: + +- Add per-merchant column mapping +- Add true UPC parsing +- Support multi-platform parts +- Improve partRole inference +- Implement global deduplication across merchants + +--- + +# 7. Quick Reference: Main Methods + +| Method | Purpose | +|--------|---------| +| importMerchantFeed | Full product + offer import | +| readFeedRowsForMerchant | Detect delimiter + parse feed | +| resolveBrand | Normalize brand names | +| upsertProduct | Idempotent product write | +| updateProductFromRow | Apply product fields | +| upsertOfferFromRow | Idempotent offer write | +| syncOffersOnly | Offer-only sync | +| upsertOfferOnlyFromRow | Update existing offers | +| detectCsvFormat | Auto-detect delimiter | +| fetchFeedRows | Simpler parser for offers | + +--- + +# 8. Summary + +The Ballistic importer is: + +- Robust against bad data +- Idempotent and safe +- Flexible for multiple merchants +- Extensible for long-term scaling + This pipeline powers the product catalog and offer data for the Ballistic ecosystem. \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java b/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java index e86d919..f48d7b3 100644 --- a/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java +++ b/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java @@ -1,16 +1,16 @@ -package group.goforward.ballistic.configuration; - -import org.springframework.cache.CacheManager; -import org.springframework.cache.concurrent.ConcurrentMapCacheManager; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class CacheConfig { - - @Bean - public CacheManager cacheManager() { - // Simple in-memory cache for dev/local - return new ConcurrentMapCacheManager("gunbuilderProducts"); - } +package group.goforward.ballistic.configuration; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + // Simple in-memory cache for dev/local + return new ConcurrentMapCacheManager("gunbuilderProducts"); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/configuration/package-info.java b/src/main/java/group/goforward/ballistic/configuration/package-info.java index abe5a7c..34f56da 100644 --- a/src/main/java/group/goforward/ballistic/configuration/package-info.java +++ b/src/main/java/group/goforward/ballistic/configuration/package-info.java @@ -1,13 +1,13 @@ -/** - * Provides the classes necessary for the Spring Configurations for the ballistic -Builder application. - * This package includes Configurations for Spring-Boot application - * - * - *

The main entry point for managing the inventory is the - * {@link group.goforward.ballistic.BallisticApplication} class.

- * - * @since 1.0 - * @author Don Strawsburg - * @version 1.1 - */ +/** + * Provides the classes necessary for the Spring Configurations for the ballistic -Builder application. + * This package includes Configurations for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Don Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.configuration; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/Dockerfile b/src/main/java/group/goforward/ballistic/controllers/Dockerfile index 399820d..a4fba4f 100644 --- a/src/main/java/group/goforward/ballistic/controllers/Dockerfile +++ b/src/main/java/group/goforward/ballistic/controllers/Dockerfile @@ -1,17 +1,17 @@ -# Stage 1: Build the application -FROM openjdk:17-jdk-slim as build -WORKDIR /app -COPY gradlew . -COPY settings.gradle . -COPY build.gradle . -COPY src ./src -# Adjust the build command for Maven: ./mvnw package -DskipTests -RUN ./gradlew bootJar - -# Stage 2: Create the final lightweight image -FROM openjdk:17-jre-slim -WORKDIR /app -# Get the built JAR from the build stage -COPY --from=build /app/build/libs/*.jar app.jar -EXPOSE 8080 +# Stage 1: Build the application +FROM openjdk:17-jdk-slim as build +WORKDIR /app +COPY gradlew . +COPY settings.gradle . +COPY build.gradle . +COPY src ./src +# Adjust the build command for Maven: ./mvnw package -DskipTests +RUN ./gradlew bootJar + +# Stage 2: Create the final lightweight image +FROM openjdk:17-jre-slim +WORKDIR /app +# Get the built JAR from the build stage +COPY --from=build /app/build/libs/*.jar app.jar +EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/ImportController.java b/src/main/java/group/goforward/ballistic/controllers/ImportController.java index 996e460..d251b01 100644 --- a/src/main/java/group/goforward/ballistic/controllers/ImportController.java +++ b/src/main/java/group/goforward/ballistic/controllers/ImportController.java @@ -1,39 +1,39 @@ -package group.goforward.ballistic.controllers; - -import group.goforward.ballistic.services.MerchantFeedImportService; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/admin/imports") -@CrossOrigin(origins = "http://localhost:3000") -public class ImportController { - - private final MerchantFeedImportService merchantFeedImportService; - - public ImportController(MerchantFeedImportService merchantFeedImportService) { - this.merchantFeedImportService = merchantFeedImportService; - } - - /** - * Full product + offer import for a merchant. - * - * POST /admin/imports/{merchantId} - */ - @PostMapping("/{merchantId}") - public ResponseEntity importMerchant(@PathVariable Integer merchantId) { - merchantFeedImportService.importMerchantFeed(merchantId); - return ResponseEntity.noContent().build(); - } - - /** - * Offers-only sync (price/stock) for a merchant. - * - * POST /admin/imports/{merchantId}/offers-only - */ - @PostMapping("/{merchantId}/offers-only") - public ResponseEntity syncOffersOnly(@PathVariable Integer merchantId) { - merchantFeedImportService.syncOffersOnly(merchantId); - return ResponseEntity.noContent().build(); - } +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.services.MerchantFeedImportService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/imports") +@CrossOrigin(origins = "http://localhost:3000") +public class ImportController { + + private final MerchantFeedImportService merchantFeedImportService; + + public ImportController(MerchantFeedImportService merchantFeedImportService) { + this.merchantFeedImportService = merchantFeedImportService; + } + + /** + * Full product + offer import for a merchant. + * + * POST /admin/imports/{merchantId} + */ + @PostMapping("/{merchantId}") + public ResponseEntity importMerchant(@PathVariable Integer merchantId) { + merchantFeedImportService.importMerchantFeed(merchantId); + return ResponseEntity.noContent().build(); + } + + /** + * Offers-only sync (price/stock) for a merchant. + * + * POST /admin/imports/{merchantId}/offers-only + */ + @PostMapping("/{merchantId}/offers-only") + public ResponseEntity syncOffersOnly(@PathVariable Integer merchantId) { + merchantFeedImportService.syncOffersOnly(merchantId); + return ResponseEntity.noContent().build(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java index 511ea9b..7aeb3ce 100644 --- a/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java @@ -1,63 +1,63 @@ -// MerchantAdminController.java -package group.goforward.ballistic.controllers; - -import org.springframework.web.bind.annotation.CrossOrigin; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.repos.MerchantRepository; -import group.goforward.ballistic.web.dto.MerchantAdminDto; -import org.springframework.web.bind.annotation.*; - -import java.time.OffsetDateTime; -import java.util.List; - -@RestController -@RequestMapping("/admin/merchants") -@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug -public class MerchantAdminController { - - private final MerchantRepository merchantRepository; - - public MerchantAdminController(MerchantRepository merchantRepository) { - this.merchantRepository = merchantRepository; - } - - @GetMapping - public List listMerchants() { - return merchantRepository.findAll().stream().map(this::toDto).toList(); - } - - @PutMapping("/{id}") - public MerchantAdminDto updateMerchant( - @PathVariable Integer id, - @RequestBody MerchantAdminDto payload - ) { - Merchant merchant = merchantRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Merchant not found")); - - merchant.setFeedUrl(payload.getFeedUrl()); - merchant.setOfferFeedUrl(payload.getOfferFeedUrl()); - merchant.setIsActive(payload.getIsActive() != null ? payload.getIsActive() : true); - // don’t touch last* here; those are set by import jobs - - merchant = merchantRepository.save(merchant); - return toDto(merchant); - } - - private MerchantAdminDto toDto(Merchant m) { - MerchantAdminDto dto = new MerchantAdminDto(); - dto.setId(m.getId()); - dto.setName(m.getName()); - dto.setFeedUrl(m.getFeedUrl()); - dto.setOfferFeedUrl(m.getOfferFeedUrl()); - dto.setIsActive(m.getIsActive()); - dto.setLastFullImportAt(m.getLastFullImportAt()); - dto.setLastOfferSyncAt(m.getLastOfferSyncAt()); - return dto; - } +// MerchantAdminController.java +package group.goforward.ballistic.controllers; + +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.web.dto.MerchantAdminDto; +import org.springframework.web.bind.annotation.*; + +import java.time.OffsetDateTime; +import java.util.List; + +@RestController +@RequestMapping("/admin/merchants") +@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug +public class MerchantAdminController { + + private final MerchantRepository merchantRepository; + + public MerchantAdminController(MerchantRepository merchantRepository) { + this.merchantRepository = merchantRepository; + } + + @GetMapping + public List listMerchants() { + return merchantRepository.findAll().stream().map(this::toDto).toList(); + } + + @PutMapping("/{id}") + public MerchantAdminDto updateMerchant( + @PathVariable Integer id, + @RequestBody MerchantAdminDto payload + ) { + Merchant merchant = merchantRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Merchant not found")); + + merchant.setFeedUrl(payload.getFeedUrl()); + merchant.setOfferFeedUrl(payload.getOfferFeedUrl()); + merchant.setIsActive(payload.getIsActive() != null ? payload.getIsActive() : true); + // don’t touch last* here; those are set by import jobs + + merchant = merchantRepository.save(merchant); + return toDto(merchant); + } + + private MerchantAdminDto toDto(Merchant m) { + MerchantAdminDto dto = new MerchantAdminDto(); + dto.setId(m.getId()); + dto.setName(m.getName()); + dto.setFeedUrl(m.getFeedUrl()); + dto.setOfferFeedUrl(m.getOfferFeedUrl()); + dto.setIsActive(m.getIsActive()); + dto.setLastFullImportAt(m.getLastFullImportAt()); + dto.setLastOfferSyncAt(m.getLastOfferSyncAt()); + return dto; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java index e2cc758..e801bb0 100644 --- a/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java @@ -1,65 +1,65 @@ -package group.goforward.ballistic.controllers; - -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.model.MerchantCategoryMapping; -import group.goforward.ballistic.repos.MerchantRepository; -import group.goforward.ballistic.services.MerchantCategoryMappingService; -import group.goforward.ballistic.web.dto.MerchantCategoryMappingDto; -import group.goforward.ballistic.web.dto.UpsertMerchantCategoryMappingRequest; -import java.util.List; -import java.util.stream.Collectors; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/admin/merchant-category-mappings") -@CrossOrigin -public class MerchantCategoryMappingController { - - private final MerchantCategoryMappingService mappingService; - private final MerchantRepository merchantRepository; - - public MerchantCategoryMappingController( - MerchantCategoryMappingService mappingService, - MerchantRepository merchantRepository - ) { - this.mappingService = mappingService; - this.merchantRepository = merchantRepository; - } - - @GetMapping - public List listMappings( - @RequestParam("merchantId") Integer merchantId - ) { - List mappings = mappingService.findByMerchant(merchantId); - return mappings.stream() - .map(this::toDto) - .collect(Collectors.toList()); - } - - @PostMapping - public MerchantCategoryMappingDto upsertMapping( - @RequestBody UpsertMerchantCategoryMappingRequest request - ) { - Merchant merchant = merchantRepository - .findById(request.getMerchantId()) - .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + request.getMerchantId())); - - MerchantCategoryMapping mapping = mappingService.upsertMapping( - merchant, - request.getRawCategory(), - request.getMappedPartRole() - ); - - return toDto(mapping); - } - - private MerchantCategoryMappingDto toDto(MerchantCategoryMapping mapping) { - MerchantCategoryMappingDto dto = new MerchantCategoryMappingDto(); - dto.setId(mapping.getId()); - dto.setMerchantId(mapping.getMerchant().getId()); - dto.setMerchantName(mapping.getMerchant().getName()); - dto.setRawCategory(mapping.getRawCategory()); - dto.setMappedPartRole(mapping.getMappedPartRole()); - return dto; - } +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.services.MerchantCategoryMappingService; +import group.goforward.ballistic.web.dto.MerchantCategoryMappingDto; +import group.goforward.ballistic.web.dto.UpsertMerchantCategoryMappingRequest; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/merchant-category-mappings") +@CrossOrigin +public class MerchantCategoryMappingController { + + private final MerchantCategoryMappingService mappingService; + private final MerchantRepository merchantRepository; + + public MerchantCategoryMappingController( + MerchantCategoryMappingService mappingService, + MerchantRepository merchantRepository + ) { + this.mappingService = mappingService; + this.merchantRepository = merchantRepository; + } + + @GetMapping + public List listMappings( + @RequestParam("merchantId") Integer merchantId + ) { + List mappings = mappingService.findByMerchant(merchantId); + return mappings.stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @PostMapping + public MerchantCategoryMappingDto upsertMapping( + @RequestBody UpsertMerchantCategoryMappingRequest request + ) { + Merchant merchant = merchantRepository + .findById(request.getMerchantId()) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + request.getMerchantId())); + + MerchantCategoryMapping mapping = mappingService.upsertMapping( + merchant, + request.getRawCategory(), + request.getMappedPartRole() + ); + + return toDto(mapping); + } + + private MerchantCategoryMappingDto toDto(MerchantCategoryMapping mapping) { + MerchantCategoryMappingDto dto = new MerchantCategoryMappingDto(); + dto.setId(mapping.getId()); + dto.setMerchantId(mapping.getMerchant().getId()); + dto.setMerchantName(mapping.getMerchant().getName()); + dto.setRawCategory(mapping.getRawCategory()); + dto.setMappedPartRole(mapping.getMappedPartRole()); + return dto; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/MerchantDebugController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantDebugController.java index 5d43b71..cd632ab 100644 --- a/src/main/java/group/goforward/ballistic/controllers/MerchantDebugController.java +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantDebugController.java @@ -1,23 +1,23 @@ -package group.goforward.ballistic.controllers; - -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.repos.MerchantRepository; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; - -@RestController -public class MerchantDebugController { - - private final MerchantRepository merchantRepository; - - public MerchantDebugController(MerchantRepository merchantRepository) { - this.merchantRepository = merchantRepository; - } - - @GetMapping("/admin/debug/merchants") - public List listMerchants() { - return merchantRepository.findAll(); - } +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.repos.MerchantRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +public class MerchantDebugController { + + private final MerchantRepository merchantRepository; + + public MerchantDebugController(MerchantRepository merchantRepository) { + this.merchantRepository = merchantRepository; + } + + @GetMapping("/admin/debug/merchants") + public List listMerchants() { + return merchantRepository.findAll(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/PingController.java b/src/main/java/group/goforward/ballistic/controllers/PingController.java index 40957c3..224fc94 100644 --- a/src/main/java/group/goforward/ballistic/controllers/PingController.java +++ b/src/main/java/group/goforward/ballistic/controllers/PingController.java @@ -1,13 +1,13 @@ -package group.goforward.ballistic.controllers; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class PingController { - - @GetMapping("/ping") - public String ping() { - return "pong"; - } +package group.goforward.ballistic.controllers; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class PingController { + + @GetMapping("/ping") + public String ping() { + return "pong"; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/ProductController.java b/src/main/java/group/goforward/ballistic/controllers/ProductController.java index 61feb24..bca4876 100644 --- a/src/main/java/group/goforward/ballistic/controllers/ProductController.java +++ b/src/main/java/group/goforward/ballistic/controllers/ProductController.java @@ -1,137 +1,137 @@ -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.web.dto.ProductOfferDto; -import group.goforward.ballistic.repos.ProductRepository; -import group.goforward.ballistic.web.dto.ProductSummaryDto; -import group.goforward.ballistic.web.mapper.ProductMapper; -import org.springframework.cache.annotation.Cacheable; -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") - @Cacheable( - value = "gunbuilderProducts", - key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())" - ) - public List getGunbuilderProducts( - @RequestParam(defaultValue = "AR-15") String platform, - @RequestParam(required = false, name = "partRoles") List partRoles - ) { - long started = System.currentTimeMillis(); - System.out.println("getGunbuilderProducts: start, platform=" + platform + - ", partRoles=" + (partRoles == null ? "null" : partRoles)); - - // 1) Load products (with brand pre-fetched) - long tProductsStart = System.currentTimeMillis(); - List products; - if (partRoles == null || partRoles.isEmpty()) { - products = productRepository.findByPlatformWithBrand(platform); - } else { - products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles); - } - long tProductsEnd = System.currentTimeMillis(); - System.out.println("getGunbuilderProducts: loaded products: " + - products.size() + " in " + (tProductsEnd - tProductsStart) + " ms"); - - if (products.isEmpty()) { - long took = System.currentTimeMillis() - started; - System.out.println("getGunbuilderProducts: 0 products in " + took + " ms"); - return List.of(); - } - - // 2) Load offers for these product IDs - long tOffersStart = System.currentTimeMillis(); - List productIds = products.stream() - .map(Product::getId) - .toList(); - - List allOffers = - productOfferRepository.findByProductIdIn(productIds); - long tOffersEnd = System.currentTimeMillis(); - System.out.println("getGunbuilderProducts: loaded offers: " + - allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms"); - - Map> offersByProductId = allOffers.stream() - .collect(Collectors.groupingBy(o -> o.getProduct().getId())); - - // 3) Map to DTOs with price and buyUrl - long tMapStart = System.currentTimeMillis(); - List result = 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(); - long tMapEnd = System.currentTimeMillis(); - long took = System.currentTimeMillis() - started; - - System.out.println("getGunbuilderProducts: mapping to DTOs took " + - (tMapEnd - tMapStart) + " ms"); - System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" + - "products=" + (tProductsEnd - tProductsStart) + " ms, " + - "offers=" + (tOffersEnd - tOffersStart) + " ms, " + - "map=" + (tMapEnd - tMapStart) + " ms)"); - - return result; - } - - @GetMapping("/{id}/offers") - public List getOffersForProduct(@PathVariable("id") Integer productId) { - List offers = productOfferRepository.findByProductId(productId); - - return offers.stream() - .map(offer -> { - ProductOfferDto dto = new ProductOfferDto(); - dto.setId(offer.getId().toString()); - dto.setMerchantName(offer.getMerchant().getName()); - dto.setPrice(offer.getEffectivePrice()); - dto.setOriginalPrice(offer.getOriginalPrice()); - dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); - dto.setBuyUrl(offer.getBuyUrl()); - dto.setLastUpdated(offer.getLastSeenAt()); - return dto; - }) - .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); - } +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.web.dto.ProductOfferDto; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.web.dto.ProductSummaryDto; +import group.goforward.ballistic.web.mapper.ProductMapper; +import org.springframework.cache.annotation.Cacheable; +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") + @Cacheable( + value = "gunbuilderProducts", + key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())" + ) + public List getGunbuilderProducts( + @RequestParam(defaultValue = "AR-15") String platform, + @RequestParam(required = false, name = "partRoles") List partRoles + ) { + long started = System.currentTimeMillis(); + System.out.println("getGunbuilderProducts: start, platform=" + platform + + ", partRoles=" + (partRoles == null ? "null" : partRoles)); + + // 1) Load products (with brand pre-fetched) + long tProductsStart = System.currentTimeMillis(); + List products; + if (partRoles == null || partRoles.isEmpty()) { + products = productRepository.findByPlatformWithBrand(platform); + } else { + products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles); + } + long tProductsEnd = System.currentTimeMillis(); + System.out.println("getGunbuilderProducts: loaded products: " + + products.size() + " in " + (tProductsEnd - tProductsStart) + " ms"); + + if (products.isEmpty()) { + long took = System.currentTimeMillis() - started; + System.out.println("getGunbuilderProducts: 0 products in " + took + " ms"); + return List.of(); + } + + // 2) Load offers for these product IDs + long tOffersStart = System.currentTimeMillis(); + List productIds = products.stream() + .map(Product::getId) + .toList(); + + List allOffers = + productOfferRepository.findByProductIdIn(productIds); + long tOffersEnd = System.currentTimeMillis(); + System.out.println("getGunbuilderProducts: loaded offers: " + + allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms"); + + Map> offersByProductId = allOffers.stream() + .collect(Collectors.groupingBy(o -> o.getProduct().getId())); + + // 3) Map to DTOs with price and buyUrl + long tMapStart = System.currentTimeMillis(); + List result = 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(); + long tMapEnd = System.currentTimeMillis(); + long took = System.currentTimeMillis() - started; + + System.out.println("getGunbuilderProducts: mapping to DTOs took " + + (tMapEnd - tMapStart) + " ms"); + System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" + + "products=" + (tProductsEnd - tProductsStart) + " ms, " + + "offers=" + (tOffersEnd - tOffersStart) + " ms, " + + "map=" + (tMapEnd - tMapStart) + " ms)"); + + return result; + } + + @GetMapping("/{id}/offers") + public List getOffersForProduct(@PathVariable("id") Integer productId) { + List offers = productOfferRepository.findByProductId(productId); + + return offers.stream() + .map(offer -> { + ProductOfferDto dto = new ProductOfferDto(); + dto.setId(offer.getId().toString()); + dto.setMerchantName(offer.getMerchant().getName()); + dto.setPrice(offer.getEffectivePrice()); + dto.setOriginalPrice(offer.getOriginalPrice()); + dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); + dto.setBuyUrl(offer.getBuyUrl()); + dto.setLastUpdated(offer.getLastSeenAt()); + return dto; + }) + .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/controllers/UserController.java b/src/main/java/group/goforward/ballistic/controllers/UserController.java index b28ecc3..a18fb56 100644 --- a/src/main/java/group/goforward/ballistic/controllers/UserController.java +++ b/src/main/java/group/goforward/ballistic/controllers/UserController.java @@ -1,50 +1,53 @@ -package group.goforward.ballistic.controllers; - -import group.goforward.ballistic.model.User; -import group.goforward.ballistic.repos.UserRepository; -import group.goforward.ballistic.services.UsersService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - - -@RestController -@RequestMapping() -public class UserController { - @Autowired - private UserRepository repo; - @Autowired - private UsersService usersService; - - @GetMapping("/api/getAllUsers") - public ResponseEntity> getAllUsers() { - List data = repo.findAll(); - return ResponseEntity.ok(data); - } - - - @GetMapping("/api/getAllUsersById/{id}") - public ResponseEntity getAllStatesById(@PathVariable Integer id) { - return repo.findById(id) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); - } - @PostMapping("/api/addUser") - public ResponseEntity createUser(@RequestBody User item) { - User created = usersService.save(item); - return ResponseEntity.status(HttpStatus.CREATED).body(created); - } - - @DeleteMapping("/api/deleteUser/{id}") - public ResponseEntity deleteItem(@PathVariable Integer id) { - return usersService.findById(id) - .map(item -> { - usersService.deleteById(id); - return ResponseEntity.noContent().build(); - }) - .orElse(ResponseEntity.notFound().build()); - } -} +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.User; +import group.goforward.ballistic.repos.UserRepository; +import group.goforward.ballistic.services.UsersService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@RestController +@RequestMapping() +public class UserController { + private final UserRepository repo; + private final UsersService usersService; + + public UserController(UserRepository repo, UsersService usersService) { + this.repo = repo; + this.usersService = usersService; + } + + @GetMapping("/api/getAllUsers") + public ResponseEntity> getAllUsers() { + List data = repo.findAll(); + return ResponseEntity.ok(data); + } + + + @GetMapping("/api/getAllUsersById/{id}") + public ResponseEntity getAllStatesById(@PathVariable Integer id) { + return repo.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + @PostMapping("/api/addUser") + public ResponseEntity createUser(@RequestBody User item) { + User created = usersService.save(item); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @DeleteMapping("/api/deleteUser/{id}") + public ResponseEntity deleteItem(@PathVariable Integer id) { + return usersService.findById(id) + .map(item -> { + usersService.deleteById(id); + return ResponseEntity.noContent().build(); + }) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/src/main/java/group/goforward/ballistic/controllers/package-info.java b/src/main/java/group/goforward/ballistic/controllers/package-info.java index 7af4a39..c49339c 100644 --- a/src/main/java/group/goforward/ballistic/controllers/package-info.java +++ b/src/main/java/group/goforward/ballistic/controllers/package-info.java @@ -1,13 +1,13 @@ -/** - * Provides the classes necessary for the Spring Controllers for the ballistic -Builder application. - * This package includes Controllers for Spring-Boot application - * - * - *

The main entry point for managing the inventory is the - * {@link group.goforward.ballistic.BallisticApplication} class.

- * - * @since 1.0 - * @author Don Strawsburg - * @version 1.1 - */ +/** + * Provides the classes necessary for the Spring Controllers for the ballistic -Builder application. + * This package includes Controllers for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Don Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.controllers; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java index 0cc0c42..4ed5dab 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java @@ -1,30 +1,30 @@ -package group.goforward.ballistic.imports; - -import java.math.BigDecimal; - -public record MerchantFeedRow( - String sku, - String manufacturerId, - String brandName, - String productName, - String longDescription, - String shortDescription, - String department, - String category, - String subCategory, - String thumbUrl, - String imageUrl, - String buyLink, - String keywords, - String reviews, - BigDecimal retailPrice, - BigDecimal salePrice, - String brandPageLink, - String brandLogoImage, - String productPageViewTracking, - String variantsXml, - String mediumImageUrl, - String productContentWidget, - String googleCategorization, - String itemBasedCommission +package group.goforward.ballistic.imports; + +import java.math.BigDecimal; + +public record MerchantFeedRow( + String sku, + String manufacturerId, + String brandName, + String productName, + String longDescription, + String shortDescription, + String department, + String category, + String subCategory, + String thumbUrl, + String imageUrl, + String buyLink, + String keywords, + String reviews, + BigDecimal retailPrice, + BigDecimal salePrice, + String brandPageLink, + String brandLogoImage, + String productPageViewTracking, + String variantsXml, + String mediumImageUrl, + String productContentWidget, + String googleCategorization, + String itemBasedCommission ) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java b/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java index 516053f..dfa6eb1 100644 --- a/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java +++ b/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java @@ -1,17 +1,17 @@ -package group.goforward.ballistic.imports.dto; - -import java.math.BigDecimal; - -public record MerchantFeedRow( - String brandName, - String productName, - String mpn, - String upc, - String avantlinkProductId, - String sku, - String categoryPath, - String buyUrl, - BigDecimal price, - BigDecimal originalPrice, - boolean inStock +package group.goforward.ballistic.imports.dto; + +import java.math.BigDecimal; + +public record MerchantFeedRow( + String brandName, + String productName, + String mpn, + String upc, + String avantlinkProductId, + String sku, + String categoryPath, + String buyUrl, + BigDecimal price, + BigDecimal originalPrice, + boolean inStock ) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/dto/package-info.java b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java index 35c6703..586776b 100644 --- a/src/main/java/group/goforward/ballistic/imports/dto/package-info.java +++ b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java @@ -1,13 +1,13 @@ -/** - * Provides the classes necessary for the Spring Data Transfer Objects for the ballistic -Builder application. - * This package includes DTO for Spring-Boot application - * - * - *

The main entry point for managing the inventory is the - * {@link group.goforward.ballistic.BallisticApplication} class.

- * - * @since 1.0 - * @author Sean Strawsburg - * @version 1.1 - */ +/** + * Provides the classes necessary for the Spring Data Transfer Objects for the ballistic -Builder application. + * This package includes DTO for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Sean Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.imports.dto; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java index 9bb833c..3b2338a 100644 --- a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java +++ b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java @@ -1,105 +1,105 @@ -package group.goforward.ballistic.model; - -import jakarta.persistence.*; -import java.time.OffsetDateTime; - -import group.goforward.ballistic.model.ProductConfiguration; - -@Entity -@Table( - name = "merchant_category_mappings", - uniqueConstraints = @UniqueConstraint( - name = "uq_merchant_category", - columnNames = { "merchant_id", "raw_category" } - ) -) -public class MerchantCategoryMapping { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL - @Column(name = "id", nullable = false) - private Integer id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "merchant_id", nullable = false) - private Merchant merchant; - - @Column(name = "raw_category", nullable = false, length = 512) - private String rawCategory; - - @Column(name = "mapped_part_role", length = 128) - private String mappedPartRole; // e.g. "upper-receiver", "barrel" - - @Column(name = "mapped_configuration") - @Enumerated(EnumType.STRING) - private ProductConfiguration mappedConfiguration; - - @Column(name = "created_at", nullable = false) - private OffsetDateTime createdAt = OffsetDateTime.now(); - - @Column(name = "updated_at", nullable = false) - private OffsetDateTime updatedAt = OffsetDateTime.now(); - - @PreUpdate - public void onUpdate() { - this.updatedAt = OffsetDateTime.now(); - } - - // getters & setters - - public Integer getId() { - return id; - } - - public void setId(Integer id) { - this.id = id; - } - - public Merchant getMerchant() { - return merchant; - } - - public void setMerchant(Merchant merchant) { - this.merchant = merchant; - } - - public String getRawCategory() { - return rawCategory; - } - - public void setRawCategory(String rawCategory) { - this.rawCategory = rawCategory; - } - - public String getMappedPartRole() { - return mappedPartRole; - } - - public void setMappedPartRole(String mappedPartRole) { - this.mappedPartRole = mappedPartRole; - } - - public ProductConfiguration getMappedConfiguration() { - return mappedConfiguration; - } - - public void setMappedConfiguration(ProductConfiguration mappedConfiguration) { - this.mappedConfiguration = mappedConfiguration; - } - - public OffsetDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(OffsetDateTime createdAt) { - this.createdAt = createdAt; - } - - public OffsetDateTime getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(OffsetDateTime updatedAt) { - this.updatedAt = updatedAt; - } +package group.goforward.ballistic.model; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; + +import group.goforward.ballistic.model.ProductConfiguration; + +@Entity +@Table( + name = "merchant_category_mappings", + uniqueConstraints = @UniqueConstraint( + name = "uq_merchant_category", + columnNames = { "merchant_id", "raw_category" } + ) +) +public class MerchantCategoryMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL + @Column(name = "id", nullable = false) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "merchant_id", nullable = false) + private Merchant merchant; + + @Column(name = "raw_category", nullable = false, length = 512) + private String rawCategory; + + @Column(name = "mapped_part_role", length = 128) + private String mappedPartRole; // e.g. "upper-receiver", "barrel" + + @Column(name = "mapped_configuration") + @Enumerated(EnumType.STRING) + private ProductConfiguration mappedConfiguration; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + @PreUpdate + public void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + + // getters & setters + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Merchant getMerchant() { + return merchant; + } + + public void setMerchant(Merchant merchant) { + this.merchant = merchant; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + public String getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } + + public ProductConfiguration getMappedConfiguration() { + return mappedConfiguration; + } + + public void setMappedConfiguration(ProductConfiguration mappedConfiguration) { + this.mappedConfiguration = mappedConfiguration; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java b/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java index 7bda4e9..2c8c7d8 100644 --- a/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java +++ b/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java @@ -1,10 +1,10 @@ -package group.goforward.ballistic.model; - -public enum ProductConfiguration { - STRIPPED, // bare receiver / component - ASSEMBLED, // built up but not fully complete - BARRELED, // upper + barrel + gas system, no BCG/CH - COMPLETE, // full assembly ready to run - KIT, // collection of parts (LPK, trigger kits, etc.) - OTHER // fallback / unknown +package group.goforward.ballistic.model; + +public enum ProductConfiguration { + STRIPPED, // bare receiver / component + ASSEMBLED, // built up but not fully complete + BARRELED, // upper + barrel + gas system, no BCG/CH + COMPLETE, // full assembly ready to run + KIT, // collection of parts (LPK, trigger kits, etc.) + OTHER // fallback / unknown } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/AccountRepository.java b/src/main/java/group/goforward/ballistic/repos/AccountRepository.java index 1c8702f..dcf38ae 100644 --- a/src/main/java/group/goforward/ballistic/repos/AccountRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/AccountRepository.java @@ -1,9 +1,9 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.Account; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.UUID; - -public interface AccountRepository extends JpaRepository { +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Account; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface AccountRepository extends JpaRepository { } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/BrandRepository.java b/src/main/java/group/goforward/ballistic/repos/BrandRepository.java index 4235a7f..58b63cc 100644 --- a/src/main/java/group/goforward/ballistic/repos/BrandRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/BrandRepository.java @@ -1,8 +1,8 @@ -package group.goforward.ballistic.repos; -import group.goforward.ballistic.model.Brand; -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; - -public interface BrandRepository extends JpaRepository { - Optional findByNameIgnoreCase(String name); +package group.goforward.ballistic.repos; +import group.goforward.ballistic.model.Brand; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface BrandRepository extends JpaRepository { + Optional findByNameIgnoreCase(String name); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java b/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java index 0856d01..bd9c97f 100644 --- a/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java @@ -1,12 +1,12 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.BuildsComponent; -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface BuildItemRepository extends JpaRepository { - List findByBuildId(Integer buildId); - Optional findByUuid(UUID uuid); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.BuildsComponent; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface BuildItemRepository extends JpaRepository { + List findByBuildId(Integer buildId); + Optional findByUuid(UUID uuid); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/BuildRepository.java b/src/main/java/group/goforward/ballistic/repos/BuildRepository.java index 1270fe2..a849ed8 100644 --- a/src/main/java/group/goforward/ballistic/repos/BuildRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/BuildRepository.java @@ -1,10 +1,10 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.Build; -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; -import java.util.UUID; - -public interface BuildRepository extends JpaRepository { - Optional findByUuid(UUID uuid); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Build; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import java.util.UUID; + +public interface BuildRepository extends JpaRepository { + Optional findByUuid(UUID uuid); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java index cbaa5ee..cea3afc 100644 --- a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java @@ -1,7 +1,7 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.AffiliateCategoryMap; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CategoryMappingRepository extends JpaRepository { +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.AffiliateCategoryMap; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryMappingRepository extends JpaRepository { } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java b/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java index eafaeaf..90adae7 100644 --- a/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java @@ -1,7 +1,7 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.FeedImport; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface FeedImportRepository extends JpaRepository { +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.FeedImport; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FeedImportRepository extends JpaRepository { } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java index bddeed5..f26eca3 100644 --- a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java @@ -1,17 +1,17 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.MerchantCategoryMapping; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface MerchantCategoryMappingRepository - extends JpaRepository { - - Optional findByMerchantIdAndRawCategoryIgnoreCase( - Integer merchantId, - String rawCategory - ); - - List findByMerchantIdOrderByRawCategoryAsc(Integer merchantId); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.MerchantCategoryMapping; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MerchantCategoryMappingRepository + extends JpaRepository { + + Optional findByMerchantIdAndRawCategoryIgnoreCase( + Integer merchantId, + String rawCategory + ); + + List findByMerchantIdOrderByRawCategoryAsc(Integer merchantId); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java index 23baf5f..853687f 100644 --- a/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java @@ -1,11 +1,11 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.Merchant; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface MerchantRepository extends JpaRepository { - - Optional findByNameIgnoreCase(String name); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Merchant; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MerchantRepository extends JpaRepository { + + Optional findByNameIgnoreCase(String name); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java b/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java index 32e41af..78e2c2e 100644 --- a/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java @@ -1,9 +1,9 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.PartCategory; -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; - -public interface PartCategoryRepository extends JpaRepository { - Optional findBySlug(String slug); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.PartCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface PartCategoryRepository extends JpaRepository { + Optional findBySlug(String slug); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java b/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java index ec87f45..2a8423b 100644 --- a/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java @@ -1,7 +1,7 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.PriceHistory; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface PriceHistoryRepository extends JpaRepository { +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.PriceHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PriceHistoryRepository extends JpaRepository { } \ 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 caaa372..6178413 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java @@ -1,22 +1,22 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.ProductOffer; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Collection; -import java.util.List; -import java.util.Optional; - -public interface ProductOfferRepository extends JpaRepository { - - List findByProductId(Integer productId); - - // Used by the /api/products/gunbuilder endpoint - List findByProductIdIn(Collection productIds); - - // Unique offer lookup for importer upsert - Optional findByMerchantIdAndAvantlinkProductId( - Integer merchantId, - String avantlinkProductId - ); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.ProductOffer; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface ProductOfferRepository extends JpaRepository { + + List findByProductId(Integer productId); + + // Used by the /api/products/gunbuilder endpoint + List findByProductIdIn(Collection productIds); + + // Unique offer lookup for importer upsert + Optional findByMerchantIdAndAvantlinkProductId( + Integer merchantId, + String avantlinkProductId + ); } \ 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 ff601f1..179f1a6 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -1,53 +1,53 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.Product; -import group.goforward.ballistic.model.Brand; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.Optional; -import java.util.UUID; -import java.util.List; -import java.util.Collection; - -public interface ProductRepository extends JpaRepository { - - Optional findByUuid(UUID uuid); - - boolean existsBySlug(String slug); - - 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); - - // ---------- Optimized variants for Gunbuilder (fetch brand to avoid N+1) ---------- - - @Query(""" - SELECT p - FROM Product p - JOIN FETCH p.brand b - WHERE p.platform = :platform - AND p.deletedAt IS NULL - """) - List findByPlatformWithBrand(@Param("platform") String platform); - - @Query(""" - SELECT p - FROM Product p - JOIN FETCH p.brand b - WHERE p.platform = :platform - AND p.partRole IN :partRoles - AND p.deletedAt IS NULL - """) - List findByPlatformAndPartRoleInWithBrand( - @Param("platform") String platform, - @Param("partRoles") Collection partRoles - ); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.model.Brand; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; +import java.util.UUID; +import java.util.List; +import java.util.Collection; + +public interface ProductRepository extends JpaRepository { + + Optional findByUuid(UUID uuid); + + boolean existsBySlug(String slug); + + 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); + + // ---------- Optimized variants for Gunbuilder (fetch brand to avoid N+1) ---------- + + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.platform = :platform + AND p.deletedAt IS NULL + """) + List findByPlatformWithBrand(@Param("platform") String platform); + + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.platform = :platform + AND p.partRole IN :partRoles + AND p.deletedAt IS NULL + """) + List findByPlatformAndPartRoleInWithBrand( + @Param("platform") String platform, + @Param("partRoles") Collection partRoles + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/package-info.java b/src/main/java/group/goforward/ballistic/repos/package-info.java index 570163b..c278bd2 100644 --- a/src/main/java/group/goforward/ballistic/repos/package-info.java +++ b/src/main/java/group/goforward/ballistic/repos/package-info.java @@ -1,13 +1,13 @@ -/** - * Provides the classes necessary for the Spring Repository for the ballistic -Builder application. - * This package includes Repository for Spring-Boot application - * - * - *

The main entry point for managing the inventory is the - * {@link group.goforward.ballistic.BallisticApplication} class.

- * - * @since 1.0 - * @author Sean Strawsburg - * @version 1.1 - */ +/** + * Provides the classes necessary for the Spring Repository for the ballistic -Builder application. + * This package includes Repository for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Sean Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.repos; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java index 06e808c..ec963df 100644 --- a/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java @@ -1,96 +1,96 @@ -package group.goforward.ballistic.services; - -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.model.MerchantCategoryMapping; -import group.goforward.ballistic.model.ProductConfiguration; -import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; -import jakarta.transaction.Transactional; -import java.util.List; -import java.util.Optional; -import org.springframework.stereotype.Service; - -@Service -public class MerchantCategoryMappingService { - - private final MerchantCategoryMappingRepository mappingRepository; - - public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) { - this.mappingRepository = mappingRepository; - } - - public List findByMerchant(Integer merchantId) { - return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId); - } - - /** - * Resolve (or create) a mapping row for this merchant + raw category. - * - If it exists, returns it (with whatever mappedPartRole / mappedConfiguration are set). - * - If it doesn't exist, creates a placeholder row with null mappings and returns it. - * - * The importer can then: - * - skip rows where mappedPartRole is still null - * - use mappedConfiguration if present - */ - @Transactional - public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) { - if (rawCategory == null || rawCategory.isBlank()) { - return null; - } - - String trimmed = rawCategory.trim(); - - return mappingRepository - .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) - .orElseGet(() -> { - MerchantCategoryMapping mapping = new MerchantCategoryMapping(); - mapping.setMerchant(merchant); - mapping.setRawCategory(trimmed); - mapping.setMappedPartRole(null); - mapping.setMappedConfiguration(null); - return mappingRepository.save(mapping); - }); - } - - /** - * Upsert mapping (admin UI). - */ - @Transactional - public MerchantCategoryMapping upsertMapping( - Merchant merchant, - String rawCategory, - String mappedPartRole, - ProductConfiguration mappedConfiguration - ) { - String trimmed = rawCategory.trim(); - - MerchantCategoryMapping mapping = mappingRepository - .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) - .orElseGet(() -> { - MerchantCategoryMapping m = new MerchantCategoryMapping(); - m.setMerchant(merchant); - m.setRawCategory(trimmed); - return m; - }); - - mapping.setMappedPartRole( - (mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim() - ); - - mapping.setMappedConfiguration(mappedConfiguration); - - return mappingRepository.save(mapping); - } - /** - * Backwards-compatible overload for existing callers (e.g. controller) - * that don’t care about productConfiguration yet. - */ - @Transactional - public MerchantCategoryMapping upsertMapping( - Merchant merchant, - String rawCategory, - String mappedPartRole - ) { - // Delegate to the new method with `null` configuration - return upsertMapping(merchant, rawCategory, mappedPartRole, null); - } +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.model.ProductConfiguration; +import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; +import jakarta.transaction.Transactional; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; + +@Service +public class MerchantCategoryMappingService { + + private final MerchantCategoryMappingRepository mappingRepository; + + public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) { + this.mappingRepository = mappingRepository; + } + + public List findByMerchant(Integer merchantId) { + return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId); + } + + /** + * Resolve (or create) a mapping row for this merchant + raw category. + * - If it exists, returns it (with whatever mappedPartRole / mappedConfiguration are set). + * - If it doesn't exist, creates a placeholder row with null mappings and returns it. + * + * The importer can then: + * - skip rows where mappedPartRole is still null + * - use mappedConfiguration if present + */ + @Transactional + public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) { + if (rawCategory == null || rawCategory.isBlank()) { + return null; + } + + String trimmed = rawCategory.trim(); + + return mappingRepository + .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) + .orElseGet(() -> { + MerchantCategoryMapping mapping = new MerchantCategoryMapping(); + mapping.setMerchant(merchant); + mapping.setRawCategory(trimmed); + mapping.setMappedPartRole(null); + mapping.setMappedConfiguration(null); + return mappingRepository.save(mapping); + }); + } + + /** + * Upsert mapping (admin UI). + */ + @Transactional + public MerchantCategoryMapping upsertMapping( + Merchant merchant, + String rawCategory, + String mappedPartRole, + ProductConfiguration mappedConfiguration + ) { + String trimmed = rawCategory.trim(); + + MerchantCategoryMapping mapping = mappingRepository + .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) + .orElseGet(() -> { + MerchantCategoryMapping m = new MerchantCategoryMapping(); + m.setMerchant(merchant); + m.setRawCategory(trimmed); + return m; + }); + + mapping.setMappedPartRole( + (mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim() + ); + + mapping.setMappedConfiguration(mappedConfiguration); + + return mappingRepository.save(mapping); + } + /** + * Backwards-compatible overload for existing callers (e.g. controller) + * that don’t care about productConfiguration yet. + */ + @Transactional + public MerchantCategoryMapping upsertMapping( + Merchant merchant, + String rawCategory, + String mappedPartRole + ) { + // Delegate to the new method with `null` configuration + return upsertMapping(merchant, rawCategory, mappedPartRole, null); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java index 5fea407..399c448 100644 --- a/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java @@ -1,14 +1,14 @@ -package group.goforward.ballistic.services; - -public interface MerchantFeedImportService { - - /** - * Full product + offer import for a given merchant. - */ - void importMerchantFeed(Integer merchantId); - - /** - * Offers-only sync (price / stock) for a given merchant. - */ - void syncOffersOnly(Integer merchantId); +package group.goforward.ballistic.services; + +public interface MerchantFeedImportService { + + /** + * Full product + offer import for a given merchant. + */ + void importMerchantFeed(Integer merchantId); + + /** + * Offers-only sync (price / stock) for a given merchant. + */ + void syncOffersOnly(Integer merchantId); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/PsaService.java b/src/main/java/group/goforward/ballistic/services/PsaService.java index 337d278..ecaa265 100644 --- a/src/main/java/group/goforward/ballistic/services/PsaService.java +++ b/src/main/java/group/goforward/ballistic/services/PsaService.java @@ -1,17 +1,17 @@ -package group.goforward.ballistic.services; - -import group.goforward.ballistic.model.Psa; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface PsaService { - List findAll(); - - Optional findById(UUID id); - - Psa save(Psa psa); - - void deleteById(UUID id); -} +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.Psa; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface PsaService { + List findAll(); + + Optional findById(UUID id); + + Psa save(Psa psa); + + void deleteById(UUID id); +} diff --git a/src/main/java/group/goforward/ballistic/services/StatesService.java b/src/main/java/group/goforward/ballistic/services/StatesService.java index a8d74c1..e07d927 100644 --- a/src/main/java/group/goforward/ballistic/services/StatesService.java +++ b/src/main/java/group/goforward/ballistic/services/StatesService.java @@ -1,16 +1,16 @@ -package group.goforward.ballistic.services; - -import group.goforward.ballistic.model.State; - -import java.util.List; -import java.util.Optional; - -public interface StatesService { - - List findAll(); - - Optional findById(Integer id); - - State save(State item); - void deleteById(Integer id); -} +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.State; + +import java.util.List; +import java.util.Optional; + +public interface StatesService { + + List findAll(); + + Optional findById(Integer id); + + State save(State item); + void deleteById(Integer id); +} diff --git a/src/main/java/group/goforward/ballistic/services/UsersService.java b/src/main/java/group/goforward/ballistic/services/UsersService.java index 59ebe13..3717947 100644 --- a/src/main/java/group/goforward/ballistic/services/UsersService.java +++ b/src/main/java/group/goforward/ballistic/services/UsersService.java @@ -1,16 +1,16 @@ -package group.goforward.ballistic.services; - -import group.goforward.ballistic.model.User; - -import java.util.List; -import java.util.Optional; - -public interface UsersService { - - List findAll(); - - Optional findById(Integer id); - - User save(User item); - void deleteById(Integer id); -} +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.User; + +import java.util.List; +import java.util.Optional; + +public interface UsersService { + + List findAll(); + + Optional findById(Integer id); + + User save(User item); + void deleteById(Integer id); +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index c4440e0..c2357a4 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java @@ -1,660 +1,660 @@ -package group.goforward.ballistic.services.impl; - -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.HashMap; -import java.io.Reader; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import org.springframework.cache.annotation.CacheEvict; - -import group.goforward.ballistic.imports.MerchantFeedRow; -import group.goforward.ballistic.services.MerchantFeedImportService; -import org.apache.commons.csv.CSVFormat; -import org.apache.commons.csv.CSVParser; -import org.apache.commons.csv.CSVRecord; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import group.goforward.ballistic.model.Brand; -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.model.Product; -import group.goforward.ballistic.repos.BrandRepository; -import group.goforward.ballistic.repos.MerchantRepository; -import group.goforward.ballistic.repos.ProductRepository; -import group.goforward.ballistic.services.MerchantCategoryMappingService; -import group.goforward.ballistic.model.MerchantCategoryMapping; -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 -public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { - private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class); - - private final MerchantRepository merchantRepository; - private final BrandRepository brandRepository; - private final ProductRepository productRepository; - private final MerchantCategoryMappingService merchantCategoryMappingService; - private final ProductOfferRepository productOfferRepository; - - public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, - BrandRepository brandRepository, - ProductRepository productRepository, - MerchantCategoryMappingService merchantCategoryMappingService, - ProductOfferRepository productOfferRepository) { - this.merchantRepository = merchantRepository; - this.brandRepository = brandRepository; - this.productRepository = productRepository; - this.merchantCategoryMappingService = merchantCategoryMappingService; - this.productOfferRepository = productOfferRepository; - } - - @Override - @CacheEvict(value = "gunbuilderProducts", allEntries = true) - public void importMerchantFeed(Integer merchantId) { - log.info("Starting full import for merchantId={}", merchantId); - - Merchant merchant = merchantRepository.findById(merchantId) - .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); - - // Read all rows from the merchant feed - List rows = readFeedRowsForMerchant(merchant); - log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName()); - - for (MerchantFeedRow row : rows) { - Brand brand = resolveBrand(row); - Product p = upsertProduct(merchant, brand, row); - log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}", - p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName()); - } - } - - // --------------------------------------------------------------------- - // Upsert logic - // --------------------------------------------------------------------- - - private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { - log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName()); - - String mpn = trimOrNull(row.manufacturerId()); - String upc = trimOrNull(row.sku()); // placeholder until real UPC field - - List candidates = Collections.emptyList(); - - if (mpn != null) { - candidates = productRepository.findAllByBrandAndMpn(brand, mpn); - } - if ((candidates == null || candidates.isEmpty()) && upc != null) { - candidates = productRepository.findAllByBrandAndUpc(brand, upc); - } - - Product p; - boolean isNew = (candidates == null || candidates.isEmpty()); - - if (isNew) { - p = new Product(); - p.setBrand(brand); - } else { - if (candidates.size() > 1) { - log.warn("Multiple existing products found for brand={}, mpn={}, upc={}. Using first match id={}", - brand.getName(), mpn, upc, candidates.get(0).getId()); - } - p = candidates.get(0); - } - - updateProductFromRow(p, merchant, row, isNew); - - // Save the product first - Product saved = productRepository.save(p); - - // Then upsert the offer for this row - upsertOfferFromRow(saved, merchant, row); - - return saved; - } - private List> fetchFeedRows(String feedUrl) { - log.info("Reading offer feed from {}", feedUrl); - - List> rows = new ArrayList<>(); - - try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) - ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) - : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); - CSVParser parser = CSVFormat.DEFAULT - .withFirstRecordAsHeader() - .withIgnoreSurroundingSpaces() - .withTrim() - .parse(reader)) { - - // capture header names from the CSV - List headers = new ArrayList<>(parser.getHeaderMap().keySet()); - - for (CSVRecord rec : parser) { - Map row = new HashMap<>(); - for (String header : headers) { - row.put(header, rec.get(header)); - } - rows.add(row); - } - } catch (Exception ex) { - throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex); - } - - log.info("Parsed {} offer rows from offer feed {}", rows.size(), feedUrl); - return rows; - } - - private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) { - // ---------- NAME ---------- - String name = coalesce( - trimOrNull(row.productName()), - trimOrNull(row.shortDescription()), - trimOrNull(row.longDescription()), - trimOrNull(row.sku()) - ); - if (name == null) { - name = "Unknown Product"; - } - p.setName(name); - - // ---------- SLUG ---------- - if (isNew || p.getSlug() == null || p.getSlug().isBlank()) { - String baseForSlug = coalesce( - trimOrNull(name), - trimOrNull(row.sku()) - ); - if (baseForSlug == null) { - baseForSlug = "product-" + System.currentTimeMillis(); - } - - String slug = baseForSlug - .toLowerCase() - .replaceAll("[^a-z0-9]+", "-") - .replaceAll("(^-|-$)", ""); - if (slug.isBlank()) { - slug = "product-" + System.currentTimeMillis(); - } - - String uniqueSlug = generateUniqueSlug(slug); - p.setSlug(uniqueSlug); - } - - // ---------- DESCRIPTIONS ---------- - p.setShortDescription(trimOrNull(row.shortDescription())); - p.setDescription(trimOrNull(row.longDescription())); - - // ---------- IMAGE ---------- - String mainImage = coalesce( - trimOrNull(row.imageUrl()), - trimOrNull(row.mediumImageUrl()), - trimOrNull(row.thumbUrl()) - ); - p.setMainImageUrl(mainImage); - - // ---------- IDENTIFIERS ---------- - String mpn = coalesce( - trimOrNull(row.manufacturerId()), - trimOrNull(row.sku()) - ); - p.setMpn(mpn); - - // UPC placeholder - p.setUpc(null); - - // ---------- PLATFORM ---------- - if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) { - String platform = inferPlatform(row); - p.setPlatform(platform != null ? platform : "AR-15"); - } - - // ---------- RAW CATEGORY KEY (for debugging / analytics) ---------- - String rawCategoryKey = buildRawCategoryKey(row); - p.setRawCategoryKey(rawCategoryKey); - - // ---------- PART ROLE (via category mapping, with keyword fallback) ---------- - String partRole = null; - - if (rawCategoryKey != null) { - // Ask the mapping service for (or to create) a mapping row - MerchantCategoryMapping mapping = - merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey); - - if (mapping != null && mapping.getMappedPartRole() != null && !mapping.getMappedPartRole().isBlank()) { - partRole = mapping.getMappedPartRole().trim(); - } - } - - // Fallback: keyword-based inference if we still don't have a mapped partRole - if (partRole == null || partRole.isBlank()) { - partRole = inferPartRole(row); - } - - if (partRole == null || partRole.isBlank()) { - partRole = "unknown"; - } - - 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. - log.debug("Skipping offer row with no SKU for product id={}", product.getId()); - return; - } - - // Idempotent upsert: look for an existing offer for this merchant + AvantLink product id - ProductOffer offer = productOfferRepository - .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) - .orElseGet(ProductOffer::new); - - // If this is a brand‑new offer, initialize key fields - if (offer.getId() == null) { - offer.setMerchant(merchant); - offer.setProduct(product); - offer.setAvantlinkProductId(avantlinkProductId); - offer.setFirstSeenAt(OffsetDateTime.now()); - } else { - // Make sure associations stay in sync if anything changed - offer.setMerchant(merchant); - offer.setProduct(product); - } - - // 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; - - // Prefer sale price if it exists and is less than or equal to retail - if (sale != null && (retail == null || sale.compareTo(retail) <= 0)) { - effectivePrice = sale; - originalPrice = (retail != null ? retail : sale); - } else { - // Otherwise fall back to retail or whatever is present - effectivePrice = (retail != null ? retail : sale); - originalPrice = (retail != null ? retail : sale); - } - - 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); - - // Update "last seen" on every import pass - offer.setLastSeenAt(OffsetDateTime.now()); - - productOfferRepository.save(offer); - } - - - // --------------------------------------------------------------------- - // Feed reading + brand resolution - // --------------------------------------------------------------------- - - /** - * Open a Reader for either an HTTP(S) URL or a local file path. - */ - private Reader openFeedReader(String feedUrl) throws java.io.IOException { - if (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) { - return new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8); - } else { - return java.nio.file.Files.newBufferedReader( - java.nio.file.Paths.get(feedUrl), - StandardCharsets.UTF_8 - ); - } - } - - /** - * Try a few common delimiters (tab, comma, semicolon) and pick the one - * that yields the expected AvantLink-style header set. - */ - private CSVFormat detectCsvFormat(String feedUrl) throws Exception { - char[] delimiters = new char[]{'\t', ',', ';'}; - java.util.List requiredHeaders = - java.util.Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name"); - - Exception lastException = null; - - for (char delimiter : delimiters) { - try (Reader reader = openFeedReader(feedUrl); - CSVParser parser = CSVFormat.DEFAULT.builder() - .setDelimiter(delimiter) - .setHeader() - .setSkipHeaderRecord(true) - .setIgnoreSurroundingSpaces(true) - .setTrim(true) - .build() - .parse(reader)) { - - Map headerMap = parser.getHeaderMap(); - if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) { - log.info("Detected delimiter '{}' for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), feedUrl); - - return CSVFormat.DEFAULT.builder() - .setDelimiter(delimiter) - .setHeader() - .setSkipHeaderRecord(true) - .setIgnoreSurroundingSpaces(true) - .setTrim(true) - .build(); - } else if (headerMap != null) { - log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl); - } - } catch (Exception ex) { - lastException = ex; - log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage()); - } - } - - if (lastException != null) { - throw lastException; - } - throw new RuntimeException("Could not auto-detect delimiter for feed: " + feedUrl); - } - - private List readFeedRowsForMerchant(Merchant merchant) { - String rawFeedUrl = merchant.getFeedUrl(); - if (rawFeedUrl == null || rawFeedUrl.isBlank()) { - throw new IllegalStateException("Merchant " + merchant.getName() + " has no feed_url configured"); - } - - String feedUrl = rawFeedUrl.trim(); - log.info("Reading product feed for merchant={} from {}", merchant.getName(), feedUrl); - - List rows = new ArrayList<>(); - - try { - // Auto-detect delimiter (TSV/CSV/semicolon) based on header row - CSVFormat format = detectCsvFormat(feedUrl); - - try (Reader reader = openFeedReader(feedUrl); - CSVParser parser = new CSVParser(reader, format)) { - - log.debug("Detected feed headers for merchant {}: {}", merchant.getName(), parser.getHeaderMap().keySet()); - - for (CSVRecord rec : parser) { - MerchantFeedRow row = new MerchantFeedRow( - getCsvValue(rec, "SKU"), - getCsvValue(rec, "Manufacturer Id"), - getCsvValue(rec, "Brand Name"), - getCsvValue(rec, "Product Name"), - getCsvValue(rec, "Long Description"), - getCsvValue(rec, "Short Description"), - getCsvValue(rec, "Department"), - getCsvValue(rec, "Category"), - getCsvValue(rec, "SubCategory"), - getCsvValue(rec, "Thumb URL"), - getCsvValue(rec, "Image URL"), - getCsvValue(rec, "Buy Link"), - getCsvValue(rec, "Keywords"), - getCsvValue(rec, "Reviews"), - parseBigDecimal(getCsvValue(rec, "Retail Price")), - parseBigDecimal(getCsvValue(rec, "Sale Price")), - getCsvValue(rec, "Brand Page Link"), - getCsvValue(rec, "Brand Logo Image"), - getCsvValue(rec, "Product Page View Tracking"), - null, - getCsvValue(rec, "Medium Image URL"), - getCsvValue(rec, "Product Content Widget"), - getCsvValue(rec, "Google Categorization"), - getCsvValue(rec, "Item Based Commission") - ); - - rows.add(row); - } - } - } catch (Exception ex) { - throw new RuntimeException("Failed to read feed for merchant " - + merchant.getName() + " from " + feedUrl, ex); - } - - log.info("Parsed {} product rows for merchant={}", rows.size(), merchant.getName()); - return rows; - } - - private Brand resolveBrand(MerchantFeedRow row) { - String rawBrand = trimOrNull(row.brandName()); - final String brandName = (rawBrand != null) ? rawBrand : "Aero Precision"; - - return brandRepository.findByNameIgnoreCase(brandName) - .orElseGet(() -> { - Brand b = new Brand(); - b.setName(brandName); - return brandRepository.save(b); - }); - } - - private String getCol(String[] cols, int index) { - return (index >= 0 && index < cols.length) ? cols[index] : null; - } - - private BigDecimal parseBigDecimal(String raw) { - if (raw == null) return null; - String trimmed = raw.trim(); - if (trimmed.isEmpty()) return null; - try { - return new BigDecimal(trimmed); - } catch (NumberFormatException ex) { - log.debug("Skipping invalid numeric value '{}'", raw); - return null; - } - } - - /** - * Safely get a column value by header name. If the record is "short" - * (fewer values than headers) or the header is missing, return null - * instead of throwing IllegalArgumentException. - */ - private String getCsvValue(CSVRecord rec, String header) { - if (rec == null || header == null) { - return null; - } - if (!rec.isMapped(header)) { - // Header not present at all - return null; - } - try { - return rec.get(header); - } catch (IllegalArgumentException ex) { - log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header); - return null; - } - } - - // --------------------------------------------------------------------- - // Misc helpers - // --------------------------------------------------------------------- - - private String trimOrNull(String value) { - if (value == null) return null; - String trimmed = value.trim(); - return trimmed.isEmpty() ? null : trimmed; - } - - private String coalesce(String... values) { - if (values == null) return null; - for (String v : values) { - if (v != null && !v.isBlank()) { - return v; - } - } - return null; - } - - private String generateUniqueSlug(String baseSlug) { - String candidate = baseSlug; - int suffix = 1; - while (productRepository.existsBySlug(candidate)) { - candidate = baseSlug + "-" + suffix; - suffix++; - } - return candidate; - } - - private String buildRawCategoryKey(MerchantFeedRow row) { - String dept = trimOrNull(row.department()); - String cat = trimOrNull(row.category()); - String sub = trimOrNull(row.subCategory()); - - java.util.List parts = new java.util.ArrayList<>(); - if (dept != null) parts.add(dept); - if (cat != null) parts.add(cat); - if (sub != null) parts.add(sub); - - if (parts.isEmpty()) { - return null; - } - - return String.join(" > ", parts); - } - - private String inferPlatform(MerchantFeedRow row) { - String department = coalesce(trimOrNull(row.department()), trimOrNull(row.category())); - if (department == null) return null; - - String lower = department.toLowerCase(); - if (lower.contains("ar-15") || lower.contains("ar15")) return "AR-15"; - if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10"; - if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47"; - - return "AR-15"; - } - - private String inferPartRole(MerchantFeedRow row) { - String cat = coalesce( - trimOrNull(row.subCategory()), - trimOrNull(row.category()) - ); - if (cat == null) return null; - - String lower = cat.toLowerCase(); - - if (lower.contains("handguard") || lower.contains("rail")) { - return "handguard"; - } - if (lower.contains("barrel")) { - return "barrel"; - } - if (lower.contains("upper")) { - return "upper-receiver"; - } - if (lower.contains("lower")) { - return "lower-receiver"; - } - if (lower.contains("magazine") || lower.contains("mag")) { - return "magazine"; - } - if (lower.contains("stock") || lower.contains("buttstock")) { - return "stock"; - } - if (lower.contains("grip")) { - return "grip"; - } - - return "unknown"; - } - @CacheEvict(value = "gunbuilderProducts", allEntries = true) - public void syncOffersOnly(Integer merchantId) { - log.info("Starting offers-only sync for merchantId={}", merchantId); - Merchant merchant = merchantRepository.findById(merchantId) - .orElseThrow(() -> new RuntimeException("Merchant not found")); - - if (Boolean.FALSE.equals(merchant.getIsActive())) { - return; - } - - String feedUrl = merchant.getOfferFeedUrl() != null - ? merchant.getOfferFeedUrl() - : merchant.getFeedUrl(); - - if (feedUrl == null) { - throw new RuntimeException("No offer feed URL configured for merchant " + merchantId); - } - - List> rows = fetchFeedRows(feedUrl); - - for (Map row : rows) { - upsertOfferOnlyFromRow(merchant, row); - } - - merchant.setLastOfferSyncAt(OffsetDateTime.now()); - merchantRepository.save(merchant); - log.info("Completed offers-only sync for merchantId={} ({} rows processed)", merchantId, rows.size()); - } - - private void upsertOfferOnlyFromRow(Merchant merchant, Map row) { - // For the offer-only sync, we key offers by the same identifier we used when creating them. - // In the current AvantLink-style feed, that is the SKU column. - String avantlinkProductId = trimOrNull(row.get("SKU")); - if (avantlinkProductId == null || avantlinkProductId.isBlank()) { - return; - } - - // Find existing offer - ProductOffer offer = productOfferRepository - .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) - .orElse(null); - - if (offer == null) { - // This is a *sync* pass, not full ETL – if we don't already have an offer, skip. - return; - } - - // Parse price fields (column names match the main product feed) - BigDecimal price = parseBigDecimal(row.get("Sale Price")); - BigDecimal originalPrice = parseBigDecimal(row.get("Retail Price")); - - // Update only *offer* fields – do not touch Product - offer.setPrice(price); - offer.setOriginalPrice(originalPrice); - offer.setInStock(parseInStock(row)); - - // Prefer a fresh Buy Link from the feed if present, otherwise keep existing - String newBuyUrl = trimOrNull(row.get("Buy Link")); - offer.setBuyUrl(coalesce(newBuyUrl, offer.getBuyUrl())); - - offer.setLastSeenAt(OffsetDateTime.now()); - - productOfferRepository.save(offer); - } - private Boolean parseInStock(Map row) { - String inStock = trimOrNull(row.get("In Stock")); - if (inStock == null) return Boolean.FALSE; - - String lower = inStock.toLowerCase(); - if (lower.contains("true") || lower.contains("yes") || lower.contains("1")) { - return Boolean.TRUE; - } - if (lower.contains("false") || lower.contains("no") || lower.contains("0")) { - return Boolean.FALSE; - } - - return Boolean.FALSE; - } +package group.goforward.ballistic.services.impl; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +import java.io.Reader; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import org.springframework.cache.annotation.CacheEvict; + +import group.goforward.ballistic.imports.MerchantFeedRow; +import group.goforward.ballistic.services.MerchantFeedImportService; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import group.goforward.ballistic.model.Brand; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.repos.BrandRepository; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.services.MerchantCategoryMappingService; +import group.goforward.ballistic.model.MerchantCategoryMapping; +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 +public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { + private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class); + + private final MerchantRepository merchantRepository; + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + private final MerchantCategoryMappingService merchantCategoryMappingService; + private final ProductOfferRepository productOfferRepository; + + public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, + BrandRepository brandRepository, + ProductRepository productRepository, + MerchantCategoryMappingService merchantCategoryMappingService, + ProductOfferRepository productOfferRepository) { + this.merchantRepository = merchantRepository; + this.brandRepository = brandRepository; + this.productRepository = productRepository; + this.merchantCategoryMappingService = merchantCategoryMappingService; + this.productOfferRepository = productOfferRepository; + } + + @Override + @CacheEvict(value = "gunbuilderProducts", allEntries = true) + public void importMerchantFeed(Integer merchantId) { + log.info("Starting full import for merchantId={}", merchantId); + + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); + + // Read all rows from the merchant feed + List rows = readFeedRowsForMerchant(merchant); + log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName()); + + for (MerchantFeedRow row : rows) { + Brand brand = resolveBrand(row); + Product p = upsertProduct(merchant, brand, row); + log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}", + p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName()); + } + } + + // --------------------------------------------------------------------- + // Upsert logic + // --------------------------------------------------------------------- + + private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { + log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName()); + + String mpn = trimOrNull(row.manufacturerId()); + String upc = trimOrNull(row.sku()); // placeholder until real UPC field + + List candidates = Collections.emptyList(); + + if (mpn != null) { + candidates = productRepository.findAllByBrandAndMpn(brand, mpn); + } + if ((candidates == null || candidates.isEmpty()) && upc != null) { + candidates = productRepository.findAllByBrandAndUpc(brand, upc); + } + + Product p; + boolean isNew = (candidates == null || candidates.isEmpty()); + + if (isNew) { + p = new Product(); + p.setBrand(brand); + } else { + if (candidates.size() > 1) { + log.warn("Multiple existing products found for brand={}, mpn={}, upc={}. Using first match id={}", + brand.getName(), mpn, upc, candidates.get(0).getId()); + } + p = candidates.get(0); + } + + updateProductFromRow(p, merchant, row, isNew); + + // Save the product first + Product saved = productRepository.save(p); + + // Then upsert the offer for this row + upsertOfferFromRow(saved, merchant, row); + + return saved; + } + private List> fetchFeedRows(String feedUrl) { + log.info("Reading offer feed from {}", feedUrl); + + List> rows = new ArrayList<>(); + + try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) + ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) + : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); + CSVParser parser = CSVFormat.DEFAULT + .withFirstRecordAsHeader() + .withIgnoreSurroundingSpaces() + .withTrim() + .parse(reader)) { + + // capture header names from the CSV + List headers = new ArrayList<>(parser.getHeaderMap().keySet()); + + for (CSVRecord rec : parser) { + Map row = new HashMap<>(); + for (String header : headers) { + row.put(header, rec.get(header)); + } + rows.add(row); + } + } catch (Exception ex) { + throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex); + } + + log.info("Parsed {} offer rows from offer feed {}", rows.size(), feedUrl); + return rows; + } + + private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) { + // ---------- NAME ---------- + String name = coalesce( + trimOrNull(row.productName()), + trimOrNull(row.shortDescription()), + trimOrNull(row.longDescription()), + trimOrNull(row.sku()) + ); + if (name == null) { + name = "Unknown Product"; + } + p.setName(name); + + // ---------- SLUG ---------- + if (isNew || p.getSlug() == null || p.getSlug().isBlank()) { + String baseForSlug = coalesce( + trimOrNull(name), + trimOrNull(row.sku()) + ); + if (baseForSlug == null) { + baseForSlug = "product-" + System.currentTimeMillis(); + } + + String slug = baseForSlug + .toLowerCase() + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-|-$)", ""); + if (slug.isBlank()) { + slug = "product-" + System.currentTimeMillis(); + } + + String uniqueSlug = generateUniqueSlug(slug); + p.setSlug(uniqueSlug); + } + + // ---------- DESCRIPTIONS ---------- + p.setShortDescription(trimOrNull(row.shortDescription())); + p.setDescription(trimOrNull(row.longDescription())); + + // ---------- IMAGE ---------- + String mainImage = coalesce( + trimOrNull(row.imageUrl()), + trimOrNull(row.mediumImageUrl()), + trimOrNull(row.thumbUrl()) + ); + p.setMainImageUrl(mainImage); + + // ---------- IDENTIFIERS ---------- + String mpn = coalesce( + trimOrNull(row.manufacturerId()), + trimOrNull(row.sku()) + ); + p.setMpn(mpn); + + // UPC placeholder + p.setUpc(null); + + // ---------- PLATFORM ---------- + if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) { + String platform = inferPlatform(row); + p.setPlatform(platform != null ? platform : "AR-15"); + } + + // ---------- RAW CATEGORY KEY (for debugging / analytics) ---------- + String rawCategoryKey = buildRawCategoryKey(row); + p.setRawCategoryKey(rawCategoryKey); + + // ---------- PART ROLE (via category mapping, with keyword fallback) ---------- + String partRole = null; + + if (rawCategoryKey != null) { + // Ask the mapping service for (or to create) a mapping row + MerchantCategoryMapping mapping = + merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey); + + if (mapping != null && mapping.getMappedPartRole() != null && !mapping.getMappedPartRole().isBlank()) { + partRole = mapping.getMappedPartRole().trim(); + } + } + + // Fallback: keyword-based inference if we still don't have a mapped partRole + if (partRole == null || partRole.isBlank()) { + partRole = inferPartRole(row); + } + + if (partRole == null || partRole.isBlank()) { + partRole = "unknown"; + } + + 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. + log.debug("Skipping offer row with no SKU for product id={}", product.getId()); + return; + } + + // Idempotent upsert: look for an existing offer for this merchant + AvantLink product id + ProductOffer offer = productOfferRepository + .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .orElseGet(ProductOffer::new); + + // If this is a brand‑new offer, initialize key fields + if (offer.getId() == null) { + offer.setMerchant(merchant); + offer.setProduct(product); + offer.setAvantlinkProductId(avantlinkProductId); + offer.setFirstSeenAt(OffsetDateTime.now()); + } else { + // Make sure associations stay in sync if anything changed + offer.setMerchant(merchant); + offer.setProduct(product); + } + + // 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; + + // Prefer sale price if it exists and is less than or equal to retail + if (sale != null && (retail == null || sale.compareTo(retail) <= 0)) { + effectivePrice = sale; + originalPrice = (retail != null ? retail : sale); + } else { + // Otherwise fall back to retail or whatever is present + effectivePrice = (retail != null ? retail : sale); + originalPrice = (retail != null ? retail : sale); + } + + 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); + + // Update "last seen" on every import pass + offer.setLastSeenAt(OffsetDateTime.now()); + + productOfferRepository.save(offer); + } + + + // --------------------------------------------------------------------- + // Feed reading + brand resolution + // --------------------------------------------------------------------- + + /** + * Open a Reader for either an HTTP(S) URL or a local file path. + */ + private Reader openFeedReader(String feedUrl) throws java.io.IOException { + if (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) { + return new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8); + } else { + return java.nio.file.Files.newBufferedReader( + java.nio.file.Paths.get(feedUrl), + StandardCharsets.UTF_8 + ); + } + } + + /** + * Try a few common delimiters (tab, comma, semicolon) and pick the one + * that yields the expected AvantLink-style header set. + */ + private CSVFormat detectCsvFormat(String feedUrl) throws Exception { + char[] delimiters = new char[]{'\t', ',', ';'}; + java.util.List requiredHeaders = + java.util.Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name"); + + Exception lastException = null; + + for (char delimiter : delimiters) { + try (Reader reader = openFeedReader(feedUrl); + CSVParser parser = CSVFormat.DEFAULT.builder() + .setDelimiter(delimiter) + .setHeader() + .setSkipHeaderRecord(true) + .setIgnoreSurroundingSpaces(true) + .setTrim(true) + .build() + .parse(reader)) { + + Map headerMap = parser.getHeaderMap(); + if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) { + log.info("Detected delimiter '{}' for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), feedUrl); + + return CSVFormat.DEFAULT.builder() + .setDelimiter(delimiter) + .setHeader() + .setSkipHeaderRecord(true) + .setIgnoreSurroundingSpaces(true) + .setTrim(true) + .build(); + } else if (headerMap != null) { + log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl); + } + } catch (Exception ex) { + lastException = ex; + log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage()); + } + } + + if (lastException != null) { + throw lastException; + } + throw new RuntimeException("Could not auto-detect delimiter for feed: " + feedUrl); + } + + private List readFeedRowsForMerchant(Merchant merchant) { + String rawFeedUrl = merchant.getFeedUrl(); + if (rawFeedUrl == null || rawFeedUrl.isBlank()) { + throw new IllegalStateException("Merchant " + merchant.getName() + " has no feed_url configured"); + } + + String feedUrl = rawFeedUrl.trim(); + log.info("Reading product feed for merchant={} from {}", merchant.getName(), feedUrl); + + List rows = new ArrayList<>(); + + try { + // Auto-detect delimiter (TSV/CSV/semicolon) based on header row + CSVFormat format = detectCsvFormat(feedUrl); + + try (Reader reader = openFeedReader(feedUrl); + CSVParser parser = new CSVParser(reader, format)) { + + log.debug("Detected feed headers for merchant {}: {}", merchant.getName(), parser.getHeaderMap().keySet()); + + for (CSVRecord rec : parser) { + MerchantFeedRow row = new MerchantFeedRow( + getCsvValue(rec, "SKU"), + getCsvValue(rec, "Manufacturer Id"), + getCsvValue(rec, "Brand Name"), + getCsvValue(rec, "Product Name"), + getCsvValue(rec, "Long Description"), + getCsvValue(rec, "Short Description"), + getCsvValue(rec, "Department"), + getCsvValue(rec, "Category"), + getCsvValue(rec, "SubCategory"), + getCsvValue(rec, "Thumb URL"), + getCsvValue(rec, "Image URL"), + getCsvValue(rec, "Buy Link"), + getCsvValue(rec, "Keywords"), + getCsvValue(rec, "Reviews"), + parseBigDecimal(getCsvValue(rec, "Retail Price")), + parseBigDecimal(getCsvValue(rec, "Sale Price")), + getCsvValue(rec, "Brand Page Link"), + getCsvValue(rec, "Brand Logo Image"), + getCsvValue(rec, "Product Page View Tracking"), + null, + getCsvValue(rec, "Medium Image URL"), + getCsvValue(rec, "Product Content Widget"), + getCsvValue(rec, "Google Categorization"), + getCsvValue(rec, "Item Based Commission") + ); + + rows.add(row); + } + } + } catch (Exception ex) { + throw new RuntimeException("Failed to read feed for merchant " + + merchant.getName() + " from " + feedUrl, ex); + } + + log.info("Parsed {} product rows for merchant={}", rows.size(), merchant.getName()); + return rows; + } + + private Brand resolveBrand(MerchantFeedRow row) { + String rawBrand = trimOrNull(row.brandName()); + final String brandName = (rawBrand != null) ? rawBrand : "Aero Precision"; + + return brandRepository.findByNameIgnoreCase(brandName) + .orElseGet(() -> { + Brand b = new Brand(); + b.setName(brandName); + return brandRepository.save(b); + }); + } + + private String getCol(String[] cols, int index) { + return (index >= 0 && index < cols.length) ? cols[index] : null; + } + + private BigDecimal parseBigDecimal(String raw) { + if (raw == null) return null; + String trimmed = raw.trim(); + if (trimmed.isEmpty()) return null; + try { + return new BigDecimal(trimmed); + } catch (NumberFormatException ex) { + log.debug("Skipping invalid numeric value '{}'", raw); + return null; + } + } + + /** + * Safely get a column value by header name. If the record is "short" + * (fewer values than headers) or the header is missing, return null + * instead of throwing IllegalArgumentException. + */ + private String getCsvValue(CSVRecord rec, String header) { + if (rec == null || header == null) { + return null; + } + if (!rec.isMapped(header)) { + // Header not present at all + return null; + } + try { + return rec.get(header); + } catch (IllegalArgumentException ex) { + log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header); + return null; + } + } + + // --------------------------------------------------------------------- + // Misc helpers + // --------------------------------------------------------------------- + + private String trimOrNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String coalesce(String... values) { + if (values == null) return null; + for (String v : values) { + if (v != null && !v.isBlank()) { + return v; + } + } + return null; + } + + private String generateUniqueSlug(String baseSlug) { + String candidate = baseSlug; + int suffix = 1; + while (productRepository.existsBySlug(candidate)) { + candidate = baseSlug + "-" + suffix; + suffix++; + } + return candidate; + } + + private String buildRawCategoryKey(MerchantFeedRow row) { + String dept = trimOrNull(row.department()); + String cat = trimOrNull(row.category()); + String sub = trimOrNull(row.subCategory()); + + java.util.List parts = new java.util.ArrayList<>(); + if (dept != null) parts.add(dept); + if (cat != null) parts.add(cat); + if (sub != null) parts.add(sub); + + if (parts.isEmpty()) { + return null; + } + + return String.join(" > ", parts); + } + + private String inferPlatform(MerchantFeedRow row) { + String department = coalesce(trimOrNull(row.department()), trimOrNull(row.category())); + if (department == null) return null; + + String lower = department.toLowerCase(); + if (lower.contains("ar-15") || lower.contains("ar15")) return "AR-15"; + if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10"; + if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47"; + + return "AR-15"; + } + + private String inferPartRole(MerchantFeedRow row) { + String cat = coalesce( + trimOrNull(row.subCategory()), + trimOrNull(row.category()) + ); + if (cat == null) return null; + + String lower = cat.toLowerCase(); + + if (lower.contains("handguard") || lower.contains("rail")) { + return "handguard"; + } + if (lower.contains("barrel")) { + return "barrel"; + } + if (lower.contains("upper")) { + return "upper-receiver"; + } + if (lower.contains("lower")) { + return "lower-receiver"; + } + if (lower.contains("magazine") || lower.contains("mag")) { + return "magazine"; + } + if (lower.contains("stock") || lower.contains("buttstock")) { + return "stock"; + } + if (lower.contains("grip")) { + return "grip"; + } + + return "unknown"; + } + @CacheEvict(value = "gunbuilderProducts", allEntries = true) + public void syncOffersOnly(Integer merchantId) { + log.info("Starting offers-only sync for merchantId={}", merchantId); + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new RuntimeException("Merchant not found")); + + if (Boolean.FALSE.equals(merchant.getIsActive())) { + return; + } + + String feedUrl = merchant.getOfferFeedUrl() != null + ? merchant.getOfferFeedUrl() + : merchant.getFeedUrl(); + + if (feedUrl == null) { + throw new RuntimeException("No offer feed URL configured for merchant " + merchantId); + } + + List> rows = fetchFeedRows(feedUrl); + + for (Map row : rows) { + upsertOfferOnlyFromRow(merchant, row); + } + + merchant.setLastOfferSyncAt(OffsetDateTime.now()); + merchantRepository.save(merchant); + log.info("Completed offers-only sync for merchantId={} ({} rows processed)", merchantId, rows.size()); + } + + private void upsertOfferOnlyFromRow(Merchant merchant, Map row) { + // For the offer-only sync, we key offers by the same identifier we used when creating them. + // In the current AvantLink-style feed, that is the SKU column. + String avantlinkProductId = trimOrNull(row.get("SKU")); + if (avantlinkProductId == null || avantlinkProductId.isBlank()) { + return; + } + + // Find existing offer + ProductOffer offer = productOfferRepository + .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .orElse(null); + + if (offer == null) { + // This is a *sync* pass, not full ETL – if we don't already have an offer, skip. + return; + } + + // Parse price fields (column names match the main product feed) + BigDecimal price = parseBigDecimal(row.get("Sale Price")); + BigDecimal originalPrice = parseBigDecimal(row.get("Retail Price")); + + // Update only *offer* fields – do not touch Product + offer.setPrice(price); + offer.setOriginalPrice(originalPrice); + offer.setInStock(parseInStock(row)); + + // Prefer a fresh Buy Link from the feed if present, otherwise keep existing + String newBuyUrl = trimOrNull(row.get("Buy Link")); + offer.setBuyUrl(coalesce(newBuyUrl, offer.getBuyUrl())); + + offer.setLastSeenAt(OffsetDateTime.now()); + + productOfferRepository.save(offer); + } + private Boolean parseInStock(Map row) { + String inStock = trimOrNull(row.get("In Stock")); + if (inStock == null) return Boolean.FALSE; + + String lower = inStock.toLowerCase(); + if (lower.contains("true") || lower.contains("yes") || lower.contains("1")) { + return Boolean.TRUE; + } + if (lower.contains("false") || lower.contains("no") || lower.contains("0")) { + return Boolean.FALSE; + } + + return Boolean.FALSE; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java index 1729056..dddc752 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java @@ -1,41 +1,41 @@ -package group.goforward.ballistic.services.impl; -import group.goforward.ballistic.model.Psa; -import group.goforward.ballistic.repos.PsaRepository; -import group.goforward.ballistic.services.PsaService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Service -public class PsaServiceImpl implements PsaService { - - private final PsaRepository psaRepository; - - @Autowired - public PsaServiceImpl(PsaRepository psaRepository) { - this.psaRepository = psaRepository; - } - - @Override - public List findAll() { - return psaRepository.findAll(); - } - - @Override - public Optional findById(UUID id) { - return psaRepository.findById(id); - } - - @Override - public Psa save(Psa psa) { - return psaRepository.save(psa); - } - - @Override - public void deleteById(UUID id) { - psaRepository.deleteById(id); - } -} +package group.goforward.ballistic.services.impl; +import group.goforward.ballistic.model.Psa; +import group.goforward.ballistic.repos.PsaRepository; +import group.goforward.ballistic.services.PsaService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class PsaServiceImpl implements PsaService { + + private final PsaRepository psaRepository; + + @Autowired + public PsaServiceImpl(PsaRepository psaRepository) { + this.psaRepository = psaRepository; + } + + @Override + public List findAll() { + return psaRepository.findAll(); + } + + @Override + public Optional findById(UUID id) { + return psaRepository.findById(id); + } + + @Override + public Psa save(Psa psa) { + return psaRepository.save(psa); + } + + @Override + public void deleteById(UUID id) { + psaRepository.deleteById(id); + } +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java index 8d3d44a..8ae3d86 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java @@ -1,38 +1,38 @@ -package group.goforward.ballistic.services.impl; - - -import group.goforward.ballistic.model.State; -import group.goforward.ballistic.repos.StateRepository; -import group.goforward.ballistic.services.StatesService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; - -@Service -public class StatesServiceImpl implements StatesService { - - @Autowired - private StateRepository repo; - - @Override - public List findAll() { - return repo.findAll(); - } - - @Override - public Optional findById(Integer id) { - return repo.findById(id); - } - - @Override - public State save(State item) { - return null; - } - - @Override - public void deleteById(Integer id) { - deleteById(id); - } -} +package group.goforward.ballistic.services.impl; + + +import group.goforward.ballistic.model.State; +import group.goforward.ballistic.repos.StateRepository; +import group.goforward.ballistic.services.StatesService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class StatesServiceImpl implements StatesService { + + @Autowired + private StateRepository repo; + + @Override + public List findAll() { + return repo.findAll(); + } + + @Override + public Optional findById(Integer id) { + return repo.findById(id); + } + + @Override + public State save(State item) { + return null; + } + + @Override + public void deleteById(Integer id) { + deleteById(id); + } +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java index a3b1cf8..3620bbf 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java @@ -1,37 +1,37 @@ -package group.goforward.ballistic.services.impl; - -import group.goforward.ballistic.model.User; -import group.goforward.ballistic.repos.UserRepository; -import group.goforward.ballistic.services.UsersService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; - -@Service -public class UsersServiceImpl implements UsersService { - - @Autowired - private UserRepository repo; - - @Override - public List findAll() { - return repo.findAll(); - } - - @Override - public Optional findById(Integer id) { - return repo.findById(id); - } - - @Override - public User save(User item) { - return null; - } - - @Override - public void deleteById(Integer id) { - deleteById(id); - } -} +package group.goforward.ballistic.services.impl; + +import group.goforward.ballistic.model.User; +import group.goforward.ballistic.repos.UserRepository; +import group.goforward.ballistic.services.UsersService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class UsersServiceImpl implements UsersService { + + @Autowired + private UserRepository repo; + + @Override + public List findAll() { + return repo.findAll(); + } + + @Override + public Optional findById(Integer id) { + return repo.findById(id); + } + + @Override + public User save(User item) { + return null; + } + + @Override + public void deleteById(Integer id) { + deleteById(id); + } +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/package-info.java b/src/main/java/group/goforward/ballistic/services/impl/package-info.java index 8caffec..e6d7a02 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/package-info.java +++ b/src/main/java/group/goforward/ballistic/services/impl/package-info.java @@ -1,13 +1,13 @@ -/** - * Provides the classes necessary for the Spring Services implementations for the ballistic -Builder application. - * This package includes Services implementations for Spring-Boot application - * - * - *

The main entry point for managing the inventory is the - * {@link group.goforward.ballistic.BallisticApplication} class.

- * - * @since 1.0 - * @author Don Strawsburg - * @version 1.1 - */ +/** + * Provides the classes necessary for the Spring Services implementations for the ballistic -Builder application. + * This package includes Services implementations for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Don Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.services.impl; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java b/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java index 26d6f6c..56da0ab 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java +++ b/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java @@ -1,70 +1,70 @@ -// MerchantAdminDto.java -package group.goforward.ballistic.web.dto; - -import java.time.OffsetDateTime; - -public class MerchantAdminDto { - private Integer id; - private String name; - private String feedUrl; - private String offerFeedUrl; - private Boolean isActive; - private OffsetDateTime lastFullImportAt; - private OffsetDateTime lastOfferSyncAt; - - public Integer getId() { - return id; - } - - public void setId(Integer id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getFeedUrl() { - return feedUrl; - } - - public void setFeedUrl(String feedUrl) { - this.feedUrl = feedUrl; - } - - public String getOfferFeedUrl() { - return offerFeedUrl; - } - - public void setOfferFeedUrl(String offerFeedUrl) { - this.offerFeedUrl = offerFeedUrl; - } - - public Boolean getIsActive() { - return isActive; - } - - public void setIsActive(Boolean isActive) { - this.isActive = isActive; - } - - public OffsetDateTime getLastFullImportAt() { - return lastFullImportAt; - } - - public void setLastFullImportAt(OffsetDateTime lastFullImportAt) { - this.lastFullImportAt = lastFullImportAt; - } - - public OffsetDateTime getLastOfferSyncAt() { - return lastOfferSyncAt; - } - - public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) { - this.lastOfferSyncAt = lastOfferSyncAt; - } +// MerchantAdminDto.java +package group.goforward.ballistic.web.dto; + +import java.time.OffsetDateTime; + +public class MerchantAdminDto { + private Integer id; + private String name; + private String feedUrl; + private String offerFeedUrl; + private Boolean isActive; + private OffsetDateTime lastFullImportAt; + private OffsetDateTime lastOfferSyncAt; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getFeedUrl() { + return feedUrl; + } + + public void setFeedUrl(String feedUrl) { + this.feedUrl = feedUrl; + } + + public String getOfferFeedUrl() { + return offerFeedUrl; + } + + public void setOfferFeedUrl(String offerFeedUrl) { + this.offerFeedUrl = offerFeedUrl; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } + + public OffsetDateTime getLastFullImportAt() { + return lastFullImportAt; + } + + public void setLastFullImportAt(OffsetDateTime lastFullImportAt) { + this.lastFullImportAt = lastFullImportAt; + } + + public OffsetDateTime getLastOfferSyncAt() { + return lastOfferSyncAt; + } + + public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) { + this.lastOfferSyncAt = lastOfferSyncAt; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java b/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java index bb7a703..8c8618e 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java +++ b/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java @@ -1,50 +1,50 @@ -package group.goforward.ballistic.web.dto; - -public class MerchantCategoryMappingDto { - - private Integer id; - private Integer merchantId; - private String merchantName; - private String rawCategory; - private String mappedPartRole; - - public Integer getId() { - return id; - } - - public void setId(Integer id) { - this.id = id; - } - - public Integer getMerchantId() { - return merchantId; - } - - public void setMerchantId(Integer merchantId) { - this.merchantId = merchantId; - } - - public String getMerchantName() { - return merchantName; - } - - public void setMerchantName(String merchantName) { - this.merchantName = merchantName; - } - - public String getRawCategory() { - return rawCategory; - } - - public void setRawCategory(String rawCategory) { - this.rawCategory = rawCategory; - } - - public String getMappedPartRole() { - return mappedPartRole; - } - - public void setMappedPartRole(String mappedPartRole) { - this.mappedPartRole = mappedPartRole; - } +package group.goforward.ballistic.web.dto; + +public class MerchantCategoryMappingDto { + + private Integer id; + private Integer merchantId; + private String merchantName; + private String rawCategory; + private String mappedPartRole; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getMerchantId() { + return merchantId; + } + + public void setMerchantId(Integer merchantId) { + this.merchantId = merchantId; + } + + public String getMerchantName() { + return merchantName; + } + + public void setMerchantName(String merchantName) { + this.merchantName = merchantName; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + public String getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java b/src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java index 3fd40d4..980ecb8 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java +++ b/src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java @@ -1,70 +1,70 @@ -package group.goforward.ballistic.web.dto; - -import java.math.BigDecimal; -import java.time.OffsetDateTime; - -public class ProductOfferDto { - private String id; - private String merchantName; - private BigDecimal price; - private BigDecimal originalPrice; - private boolean inStock; - private String buyUrl; - private OffsetDateTime lastUpdated; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getMerchantName() { - return merchantName; - } - - public void setMerchantName(String merchantName) { - this.merchantName = merchantName; - } - - public BigDecimal getPrice() { - return price; - } - - public void setPrice(BigDecimal price) { - this.price = price; - } - - public BigDecimal getOriginalPrice() { - return originalPrice; - } - - public void setOriginalPrice(BigDecimal originalPrice) { - this.originalPrice = originalPrice; - } - - public boolean isInStock() { - return inStock; - } - - public void setInStock(boolean inStock) { - this.inStock = inStock; - } - - public String getBuyUrl() { - return buyUrl; - } - - public void setBuyUrl(String buyUrl) { - this.buyUrl = buyUrl; - } - - public OffsetDateTime getLastUpdated() { - return lastUpdated; - } - - public void setLastUpdated(OffsetDateTime lastUpdated) { - this.lastUpdated = lastUpdated; - } +package group.goforward.ballistic.web.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +public class ProductOfferDto { + private String id; + private String merchantName; + private BigDecimal price; + private BigDecimal originalPrice; + private boolean inStock; + private String buyUrl; + private OffsetDateTime lastUpdated; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getMerchantName() { + return merchantName; + } + + public void setMerchantName(String merchantName) { + this.merchantName = merchantName; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public BigDecimal getOriginalPrice() { + return originalPrice; + } + + public void setOriginalPrice(BigDecimal originalPrice) { + this.originalPrice = originalPrice; + } + + public boolean isInStock() { + return inStock; + } + + public void setInStock(boolean inStock) { + this.inStock = inStock; + } + + public String getBuyUrl() { + return buyUrl; + } + + public void setBuyUrl(String buyUrl) { + this.buyUrl = buyUrl; + } + + public OffsetDateTime getLastUpdated() { + return lastUpdated; + } + + public void setLastUpdated(OffsetDateTime lastUpdated) { + this.lastUpdated = lastUpdated; + } } \ 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 index 39c556a..c7f3cdc 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/ProductSummaryDto.java +++ b/src/main/java/group/goforward/ballistic/web/dto/ProductSummaryDto.java @@ -1,79 +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; - } +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/dto/UpsertMerchantCategoryMappingRequest.java b/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java index f0d102a..d910b77 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java +++ b/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java @@ -1,32 +1,32 @@ -package group.goforward.ballistic.web.dto; - -public class UpsertMerchantCategoryMappingRequest { - - private Integer merchantId; - private String rawCategory; - private String mappedPartRole; // can be null to "unmap" - - public Integer getMerchantId() { - return merchantId; - } - - public void setMerchantId(Integer merchantId) { - this.merchantId = merchantId; - } - - public String getRawCategory() { - return rawCategory; - } - - public void setRawCategory(String rawCategory) { - this.rawCategory = rawCategory; - } - - public String getMappedPartRole() { - return mappedPartRole; - } - - public void setMappedPartRole(String mappedPartRole) { - this.mappedPartRole = mappedPartRole; - } +package group.goforward.ballistic.web.dto; + +public class UpsertMerchantCategoryMappingRequest { + + private Integer merchantId; + private String rawCategory; + private String mappedPartRole; // can be null to "unmap" + + public Integer getMerchantId() { + return merchantId; + } + + public void setMerchantId(Integer merchantId) { + this.merchantId = merchantId; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + public String getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } } \ 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 index 1e3fa4c..f6025d5 100644 --- a/src/main/java/group/goforward/ballistic/web/mapper/ProductMapper.java +++ b/src/main/java/group/goforward/ballistic/web/mapper/ProductMapper.java @@ -1,30 +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; - } +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 From 31815d3145a131008f0f73cdded650aa548deec2 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Wed, 3 Dec 2025 11:34:08 -0500 Subject: [PATCH 25/33] docker-compose fixes --- docker/docker-compose.yaml | 36 ++++++++---------------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 7cb22e9..be4fa49 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -2,20 +2,18 @@ version: '3.8' services: # --- 1. Spring API Service (Backend) --- - spring-api: + ss_builder-api: build: context: ./backend # Path to your Spring project's root folder dockerfile: Dockerfile # Assumes you have a Dockerfile in ./backend - container_name: spring-api + container_name: ss_builder-api ports: - "8080:8080" # Map host port 8080 to container port 8080 environment: # These environment variables link the API to the database service defined below - - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mydatabase - - SPRING_DATASOURCE_USERNAME=myuser - - SPRING_DATASOURCE_PASSWORD=mypassword - depends_on: - - db + - SPRING_DATASOURCE_URL=jdbc:postgresql://r710.dev.gofwd.group:5433/ss_builder + - SPRING_DATASOURCE_USERNAME=dba + - SPRING_DATASOURCE_PASSWORD=!@#Qwerty networks: - app-network @@ -24,38 +22,20 @@ services: build: context: ./frontend # Path to your Next.js project's root folder dockerfile: Dockerfile # Assumes you have a Dockerfile in ./frontend - container_name: nextjs-app + container_name: ss_builder-app ports: - "3000:3000" # Map host port 3000 to container port 3000 environment: # This variable is crucial: Next.js needs the URL for the Spring API # Use the Docker internal service name 'spring-api' and its port 8080 - - NEXT_PUBLIC_API_URL=http://spring-api:8080 + - NEXT_PUBLIC_API_URL=http://ss_builder-api:8080 # For local testing, you might need the host IP for Next.js to call back # - NEXT_PUBLIC_API_URL_LOCAL=http://localhost:8080 depends_on: - - spring-api + - ss_builder-api networks: - app-network - # --- 3. PostgreSQL Database Service (Example Dependency) --- - db: - image: postgres:15-alpine # Lightweight and stable PostgreSQL image - container_name: postgres-db - environment: - - POSTGRES_DB=mydatabase - - POSTGRES_USER=myuser - - POSTGRES_PASSWORD=mypassword - volumes: - - postgres_data:/var/lib/postgresql/data # Persist the database data - ports: - - "5432:5432" # Optional: Map DB port for external access (e.g., DBeaver) - networks: - - app-network - -# --- Docker Volume for Persistent Data --- -volumes: - postgres_data: # --- Docker Network for Inter-Container Communication --- networks: From 5e3f7d5044019fc33e3e3f34bbe6c036d374a671 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 3 Dec 2025 19:13:43 -0500 Subject: [PATCH 26/33] expanded category grouping from db --- .../admin/AdminCategoryController.java | 40 +++++++++++ .../ballistic/model/AffiliateCategoryMap.java | 53 +++++++++----- .../ballistic/model/PartCategory.java | 70 ++++++++++++++++++- .../model/PartRoleCategoryMapping.java | 56 +++++++++++++++ .../goforward/ballistic/model/Product.java | 54 +++++++++++++- .../ballistic/model/ProductOffer.java | 36 +++++++--- .../repos/CategoryMappingRepository.java | 13 ++++ .../repos/PartCategoryRepository.java | 5 ++ .../PartRoleCategoryMappingRepository.java | 14 ++++ .../ballistic/repos/ProductRepository.java | 39 +++++++---- .../services/GunbuilderProductService.java | 57 +++++++++++++++ .../services/PartCategoryResolverService.java | 38 ++++++++++ .../web/dto/GunbuilderProductDto.java | 53 ++++++++++++++ .../web/dto/admin/PartCategoryDto.java | 35 ++++++++++ .../web/dto/admin/PartRoleMappingDto.java | 69 ++++++++++++++++++ 15 files changed, 584 insertions(+), 48 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryController.java create mode 100644 src/main/java/group/goforward/ballistic/model/PartRoleCategoryMapping.java create mode 100644 src/main/java/group/goforward/ballistic/repos/PartRoleCategoryMappingRepository.java create mode 100644 src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java create mode 100644 src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/GunbuilderProductDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/PartCategoryDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingDto.java diff --git a/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryController.java b/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryController.java new file mode 100644 index 0000000..a5603ab --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryController.java @@ -0,0 +1,40 @@ +package group.goforward.ballistic.controllers.admin; + +import group.goforward.ballistic.model.PartCategory; +import group.goforward.ballistic.repos.PartCategoryRepository; +import group.goforward.ballistic.web.dto.admin.PartCategoryDto; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/categories") +@CrossOrigin +public class AdminCategoryController { + + private final PartCategoryRepository partCategories; + + public AdminCategoryController(PartCategoryRepository partCategories) { + this.partCategories = partCategories; + } + + @GetMapping + public List listCategories() { + return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc() + .stream() + .map(this::toDto) + .toList(); + } + + private PartCategoryDto toDto(PartCategory entity) { + PartCategoryDto dto = new PartCategoryDto(); + dto.setId(entity.getId()); + dto.setSlug(entity.getSlug()); + dto.setName(entity.getName()); + dto.setDescription(entity.getDescription()); + dto.setGroupName(entity.getGroupName()); + dto.setSortOrder(entity.getSortOrder()); + dto.setUuid(entity.getUuid()); + return dto; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java b/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java index 3e623dc..d3e6cf4 100644 --- a/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java +++ b/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java @@ -5,19 +5,27 @@ import jakarta.persistence.*; @Entity @Table(name = "affiliate_category_map") public class AffiliateCategoryMap { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) private Integer id; - @Column(name = "feedname", nullable = false, length = 100) - private String feedname; + // e.g. "PART_ROLE" + @Column(name = "source_type", nullable = false) + private String sourceType; - @Column(name = "affiliatecategory", nullable = false) - private String affiliatecategory; + // e.g. "suppressor" + @Column(name = "source_value", nullable = false) + private String sourceValue; - @Column(name = "buildercategoryid", nullable = false) - private Integer buildercategoryid; + // e.g. "AR-15", nullable + @Column(name = "platform") + private String platform; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "part_category_id", nullable = false) + private PartCategory partCategory; @Column(name = "notes") private String notes; @@ -30,28 +38,36 @@ public class AffiliateCategoryMap { this.id = id; } - public String getFeedname() { - return feedname; + public String getSourceType() { + return sourceType; } - public void setFeedname(String feedname) { - this.feedname = feedname; + public void setSourceType(String sourceType) { + this.sourceType = sourceType; } - public String getAffiliatecategory() { - return affiliatecategory; + public String getSourceValue() { + return sourceValue; } - public void setAffiliatecategory(String affiliatecategory) { - this.affiliatecategory = affiliatecategory; + public void setSourceValue(String sourceValue) { + this.sourceValue = sourceValue; } - public Integer getBuildercategoryid() { - return buildercategoryid; + public String getPlatform() { + return platform; } - public void setBuildercategoryid(Integer buildercategoryid) { - this.buildercategoryid = buildercategoryid; + public void setPlatform(String platform) { + this.platform = platform; + } + + public PartCategory getPartCategory() { + return partCategory; + } + + public void setPartCategory(PartCategory partCategory) { + this.partCategory = partCategory; } public String getNotes() { @@ -61,5 +77,4 @@ public class AffiliateCategoryMap { public void setNotes(String notes) { this.notes = notes; } - } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/PartCategory.java b/src/main/java/group/goforward/ballistic/model/PartCategory.java index 79cd38d..a129b37 100644 --- a/src/main/java/group/goforward/ballistic/model/PartCategory.java +++ b/src/main/java/group/goforward/ballistic/model/PartCategory.java @@ -1,24 +1,49 @@ package group.goforward.ballistic.model; import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.UUID; @Entity @Table(name = "part_categories") public class PartCategory { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) private Integer id; - @Column(name = "slug", nullable = false, length = Integer.MAX_VALUE) + @Column(name = "slug", nullable = false, unique = true) private String slug; - @Column(name = "name", nullable = false, length = Integer.MAX_VALUE) + @Column(name = "name", nullable = false) private String name; - @Column(name = "description", length = Integer.MAX_VALUE) + @Column(name = "description") private String description; + @ColumnDefault("gen_random_uuid()") + @Column(name = "uuid", nullable = false) + private UUID uuid; + + @Column(name = "group_name") + private String groupName; + + @Column(name = "sort_order") + private Integer sortOrder; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + // --- Getters & Setters --- + public Integer getId() { return id; } @@ -51,4 +76,43 @@ public class PartCategory { this.description = description; } + public UUID getUuid() { + return uuid; + } + + public void setUuid(UUID uuid) { + this.uuid = uuid; + } + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/PartRoleCategoryMapping.java b/src/main/java/group/goforward/ballistic/model/PartRoleCategoryMapping.java new file mode 100644 index 0000000..07bdea8 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/model/PartRoleCategoryMapping.java @@ -0,0 +1,56 @@ +package group.goforward.ballistic.model; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "part_role_category_mappings", + uniqueConstraints = @UniqueConstraint(columnNames = {"platform", "part_role"})) +public class PartRoleCategoryMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "platform", nullable = false) + private String platform; + + @Column(name = "part_role", nullable = false) + private String partRole; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_slug", referencedColumnName = "slug", nullable = false) + private PartCategory category; + + @Column(name = "notes") + private String notes; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + // getters/setters… + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + + 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 PartCategory getCategory() { return category; } + public void setCategory(PartCategory category) { this.category = category; } + + public String getNotes() { return notes; } + public void setNotes(String notes) { this.notes = notes; } + + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/Product.java b/src/main/java/group/goforward/ballistic/model/Product.java index 785f928..90cbbaa 100644 --- a/src/main/java/group/goforward/ballistic/model/Product.java +++ b/src/main/java/group/goforward/ballistic/model/Product.java @@ -3,7 +3,13 @@ package group.goforward.ballistic.model; import jakarta.persistence.*; import java.time.Instant; import java.util.UUID; +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.Objects; +import java.util.Set; +import java.util.HashSet; +import group.goforward.ballistic.model.ProductOffer; import group.goforward.ballistic.model.ProductConfiguration; @Entity @@ -68,7 +74,16 @@ public class Product { @Column(name = "platform_locked", nullable = false) private Boolean platformLocked = false; - + @OneToMany(mappedBy = "product", fetch = FetchType.LAZY) + private Set offers = new HashSet<>(); + + public Set getOffers() { + return offers; + } + + public void setOffers(Set offers) { + this.offers = offers; + } // --- lifecycle hooks --- @@ -236,4 +251,41 @@ public class Product { public void setConfiguration(ProductConfiguration configuration) { this.configuration = configuration; } + // Convenience: best offer price for Gunbuilder +public BigDecimal getBestOfferPrice() { + if (offers == null || offers.isEmpty()) { + return BigDecimal.ZERO; + } + + return offers.stream() + // pick sale_price if present, otherwise retail_price + .map(offer -> { + if (offer.getSalePrice() != null) { + return offer.getSalePrice(); + } + return offer.getRetailPrice(); + }) + .filter(Objects::nonNull) + .min(BigDecimal::compareTo) + .orElse(BigDecimal.ZERO); +} + + // Convenience: URL for the best-priced offer + public String getBestOfferBuyUrl() { + if (offers == null || offers.isEmpty()) { + return null; + } + + return offers.stream() + .sorted(Comparator.comparing(offer -> { + if (offer.getSalePrice() != null) { + return offer.getSalePrice(); + } + return offer.getRetailPrice(); + }, Comparator.nullsLast(BigDecimal::compareTo))) + .map(ProductOffer::getAffiliateUrl) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } } diff --git a/src/main/java/group/goforward/ballistic/model/ProductOffer.java b/src/main/java/group/goforward/ballistic/model/ProductOffer.java index d91f32b..dace70a 100644 --- a/src/main/java/group/goforward/ballistic/model/ProductOffer.java +++ b/src/main/java/group/goforward/ballistic/model/ProductOffer.java @@ -7,11 +7,11 @@ import org.hibernate.annotations.OnDeleteAction; import java.math.BigDecimal; import java.time.OffsetDateTime; -import java.util.UUID; @Entity @Table(name = "product_offers") public class ProductOffer { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) @@ -26,16 +26,16 @@ public class ProductOffer { @JoinColumn(name = "merchant_id", nullable = false) private Merchant merchant; - @Column(name = "avantlink_product_id", nullable = false, length = Integer.MAX_VALUE) + @Column(name = "avantlink_product_id", nullable = false) private String avantlinkProductId; - @Column(name = "sku", length = Integer.MAX_VALUE) + @Column(name = "sku") private String sku; - @Column(name = "upc", length = Integer.MAX_VALUE) + @Column(name = "upc") private String upc; - @Column(name = "buy_url", nullable = false, length = Integer.MAX_VALUE) + @Column(name = "buy_url", nullable = false) private String buyUrl; @Column(name = "price", nullable = false, precision = 10, scale = 2) @@ -45,7 +45,7 @@ public class ProductOffer { private BigDecimal originalPrice; @ColumnDefault("'USD'") - @Column(name = "currency", nullable = false, length = Integer.MAX_VALUE) + @Column(name = "currency", nullable = false) private String currency; @ColumnDefault("true") @@ -60,6 +60,10 @@ public class ProductOffer { @Column(name = "first_seen_at", nullable = false) private OffsetDateTime firstSeenAt; + // ----------------------------------------------------- + // Getters & setters + // ----------------------------------------------------- + public Integer getId() { return id; } @@ -164,14 +168,26 @@ public class ProductOffer { this.firstSeenAt = firstSeenAt; } + // ----------------------------------------------------- + // Helper Methods (used by Product entity) + // ----------------------------------------------------- + + public BigDecimal getSalePrice() { + return price; + } + + public BigDecimal getRetailPrice() { + return originalPrice != null ? originalPrice : price; + } + + public String getAffiliateUrl() { + return buyUrl; + } + 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/CategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java index cbaa5ee..cbdbc13 100644 --- a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java @@ -3,5 +3,18 @@ package group.goforward.ballistic.repos; import group.goforward.ballistic.model.AffiliateCategoryMap; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface CategoryMappingRepository extends JpaRepository { + + Optional findBySourceTypeAndSourceValueAndPlatformIgnoreCase( + String sourceType, + String sourceValue, + String platform + ); + + Optional findBySourceTypeAndSourceValueIgnoreCase( + String sourceType, + String sourceValue + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java b/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java index 32e41af..aff326e 100644 --- a/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java @@ -2,8 +2,13 @@ package group.goforward.ballistic.repos; import group.goforward.ballistic.model.PartCategory; import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; import java.util.Optional; public interface PartCategoryRepository extends JpaRepository { + Optional findBySlug(String slug); + + List findAllByOrderByGroupNameAscSortOrderAscNameAsc(); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/PartRoleCategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/PartRoleCategoryMappingRepository.java new file mode 100644 index 0000000..eef8305 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/PartRoleCategoryMappingRepository.java @@ -0,0 +1,14 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.PartRoleCategoryMapping; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PartRoleCategoryMappingRepository extends JpaRepository { + + List findAllByPlatformOrderByPartRoleAsc(String platform); + + Optional findByPlatformAndPartRole(String platform, String partRole); +} \ 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 ff601f1..24f821c 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -1,33 +1,28 @@ package group.goforward.ballistic.repos; -import group.goforward.ballistic.model.Product; import group.goforward.ballistic.model.Brand; +import group.goforward.ballistic.model.Product; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.Optional; -import java.util.UUID; import java.util.List; -import java.util.Collection; public interface ProductRepository extends JpaRepository { - Optional findByUuid(UUID uuid); - - boolean existsBySlug(String slug); + // ------------------------------------------------- + // Used by MerchantFeedImportServiceImpl + // ------------------------------------------------- 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); + boolean existsBySlug(String slug); - // Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.) - List findByPlatformAndPartRoleIn(String platform, Collection partRoles); - - // ---------- Optimized variants for Gunbuilder (fetch brand to avoid N+1) ---------- + // ------------------------------------------------- + // Used by ProductController for platform views + // ------------------------------------------------- @Query(""" SELECT p @@ -43,11 +38,25 @@ public interface ProductRepository extends JpaRepository { FROM Product p JOIN FETCH p.brand b WHERE p.platform = :platform - AND p.partRole IN :partRoles + AND p.partRole IN :roles AND p.deletedAt IS NULL """) List findByPlatformAndPartRoleInWithBrand( @Param("platform") String platform, - @Param("partRoles") Collection partRoles + @Param("roles") List roles ); + + // ------------------------------------------------- + // Used by Gunbuilder service (if you wired this) + // ------------------------------------------------- + + @Query(""" + SELECT DISTINCT p + FROM Product p + LEFT JOIN FETCH p.brand b + LEFT JOIN FETCH p.offers o + WHERE p.platform = :platform + AND p.deletedAt IS NULL + """) + List findSomethingForGunbuilder(@Param("platform") String platform); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java b/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java new file mode 100644 index 0000000..1e074fe --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java @@ -0,0 +1,57 @@ +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.PartCategory; +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.web.dto.GunbuilderProductDto; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class GunbuilderProductService { + + private final ProductRepository productRepository; + private final PartCategoryResolverService partCategoryResolverService; + + public GunbuilderProductService( + ProductRepository productRepository, + PartCategoryResolverService partCategoryResolverService + ) { + this.productRepository = productRepository; + this.partCategoryResolverService = partCategoryResolverService; + } + + public List listGunbuilderProducts(String platform) { + + List products = productRepository.findSomethingForGunbuilder(platform); + + return products.stream() + .map(p -> { + var maybeCategory = partCategoryResolverService + .resolveForPlatformAndPartRole(platform, p.getPartRole()); + + if (maybeCategory.isEmpty()) { + // you can also log here + return null; + } + + PartCategory cat = maybeCategory.get(); + + return new GunbuilderProductDto( + p.getId(), + p.getName(), + p.getBrand().getName(), + platform, + p.getPartRole(), + p.getBestOfferPrice(), + p.getMainImageUrl(), + p.getBestOfferBuyUrl(), + cat.getSlug(), + cat.getGroupName() + ); + }) + .filter(dto -> dto != null) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java new file mode 100644 index 0000000..252ad6f --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java @@ -0,0 +1,38 @@ +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.AffiliateCategoryMap; +import group.goforward.ballistic.model.PartCategory; +import group.goforward.ballistic.repos.CategoryMappingRepository; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class PartCategoryResolverService { + + private final CategoryMappingRepository categoryMappingRepository; + + public PartCategoryResolverService(CategoryMappingRepository categoryMappingRepository) { + this.categoryMappingRepository = categoryMappingRepository; + } + + /** + * Resolve a part category from a platform + partRole (what gunbuilder cares about). + * Returns Optional.empty() if we have no mapping yet. + */ + public Optional resolveForPlatformAndPartRole(String platform, String partRole) { + // sourceType is a convention – you can also enum this + String sourceType = "PART_ROLE"; + + // First try with platform + return categoryMappingRepository + .findBySourceTypeAndSourceValueAndPlatformIgnoreCase(sourceType, partRole, platform) + .map(AffiliateCategoryMap::getPartCategory) + // if that fails, fall back to ANY platform + .or(() -> + categoryMappingRepository + .findBySourceTypeAndSourceValueIgnoreCase(sourceType, partRole) + .map(AffiliateCategoryMap::getPartCategory) + ); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/GunbuilderProductDto.java b/src/main/java/group/goforward/ballistic/web/dto/GunbuilderProductDto.java new file mode 100644 index 0000000..624f9dc --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/GunbuilderProductDto.java @@ -0,0 +1,53 @@ +package group.goforward.ballistic.web.dto; + +import java.math.BigDecimal; + +public class GunbuilderProductDto { + + private Integer id; + private String name; + private String brand; + private String platform; + private String partRole; + private BigDecimal price; + private String imageUrl; + private String buyUrl; + private String categorySlug; + private String categoryGroup; + + public GunbuilderProductDto( + Integer id, + String name, + String brand, + String platform, + String partRole, + BigDecimal price, + String imageUrl, + String buyUrl, + String categorySlug, + String categoryGroup + ) { + this.id = id; + this.name = name; + this.brand = brand; + this.platform = platform; + this.partRole = partRole; + this.price = price; + this.imageUrl = imageUrl; + this.buyUrl = buyUrl; + this.categorySlug = categorySlug; + this.categoryGroup = categoryGroup; + } + + // --- Getters only (DTOs are read-only in most cases) --- + public Integer getId() { return id; } + public String getName() { return name; } + public String getBrand() { return brand; } + public String getPlatform() { return platform; } + public String getPartRole() { return partRole; } + public BigDecimal getPrice() { return price; } + public String getImageUrl() { return imageUrl; } + public String getBuyUrl() { return buyUrl; } + public String getCategorySlug() { return categorySlug; } + public String getCategoryGroup() { return categoryGroup; } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/PartCategoryDto.java b/src/main/java/group/goforward/ballistic/web/dto/admin/PartCategoryDto.java new file mode 100644 index 0000000..69ba09d --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/PartCategoryDto.java @@ -0,0 +1,35 @@ +package group.goforward.ballistic.web.dto.admin; + +import java.util.UUID; + +public class PartCategoryDto { + private Integer id; + private String slug; + private String name; + private String description; + private String groupName; + private Integer sortOrder; + private UUID uuid; + + // getters + setters + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getGroupName() { return groupName; } + public void setGroupName(String groupName) { this.groupName = groupName; } + + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + + public UUID getUuid() { return uuid; } + public void setUuid(UUID uuid) { this.uuid = uuid; } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingDto.java b/src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingDto.java new file mode 100644 index 0000000..93756e4 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingDto.java @@ -0,0 +1,69 @@ +package group.goforward.ballistic.web.dto.admin; + +public class PartRoleMappingDto { + private Integer id; + private String platform; + private String partRole; + private String categorySlug; + private String categoryName; + private String groupName; + private String notes; + + // getters + setters... + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + 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 getCategorySlug() { + return categorySlug; + } + + public void setCategorySlug(String categorySlug) { + this.categorySlug = categorySlug; + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } +} \ No newline at end of file From 74a5c42e261e8f6ece98667249ac4098b7ee16ad Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 3 Dec 2025 21:50:00 -0500 Subject: [PATCH 27/33] dynamic category mapping and updating from admin. --- .../admin/AdminCategoryController.java | 20 +-- .../admin/AdminCategoryMappingController.java | 125 ++++++++++++++++++ .../admin/PartCategoryAdminController.java | 35 +++++ .../ballistic/model/AffiliateCategoryMap.java | 9 +- .../repos/CategoryMappingRepository.java | 9 ++ .../web/dto/admin/PartCategoryDto.java | 41 ++---- .../web/dto/admin/PartRoleMappingDto.java | 75 ++--------- .../web/dto/admin/PartRoleMappingRequest.java | 8 ++ 8 files changed, 208 insertions(+), 114 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java create mode 100644 src/main/java/group/goforward/ballistic/controllers/admin/PartCategoryAdminController.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingRequest.java diff --git a/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryController.java b/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryController.java index a5603ab..3f9be99 100644 --- a/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryController.java +++ b/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryController.java @@ -20,21 +20,21 @@ public class AdminCategoryController { @GetMapping public List listCategories() { - return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc() + return partCategories + .findAllByOrderByGroupNameAscSortOrderAscNameAsc() .stream() .map(this::toDto) .toList(); } private PartCategoryDto toDto(PartCategory entity) { - PartCategoryDto dto = new PartCategoryDto(); - dto.setId(entity.getId()); - dto.setSlug(entity.getSlug()); - dto.setName(entity.getName()); - dto.setDescription(entity.getDescription()); - dto.setGroupName(entity.getGroupName()); - dto.setSortOrder(entity.getSortOrder()); - dto.setUuid(entity.getUuid()); - return dto; + return new PartCategoryDto( + entity.getId(), + entity.getSlug(), + entity.getName(), + entity.getDescription(), + entity.getGroupName(), + entity.getSortOrder() + ); } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java b/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java new file mode 100644 index 0000000..e7553cd --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java @@ -0,0 +1,125 @@ +package group.goforward.ballistic.controllers.admin; + +import group.goforward.ballistic.model.AffiliateCategoryMap; +import group.goforward.ballistic.model.PartCategory; +import group.goforward.ballistic.repos.CategoryMappingRepository; +import group.goforward.ballistic.repos.PartCategoryRepository; +import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto; +import group.goforward.ballistic.web.dto.admin.PartRoleMappingRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/category-mappings") +@CrossOrigin +public class AdminCategoryMappingController { + + private final CategoryMappingRepository categoryMappingRepository; + private final PartCategoryRepository partCategoryRepository; + + public AdminCategoryMappingController( + CategoryMappingRepository categoryMappingRepository, + PartCategoryRepository partCategoryRepository + ) { + this.categoryMappingRepository = categoryMappingRepository; + this.partCategoryRepository = partCategoryRepository; + } + + // GET /api/admin/category-mappings?platform=AR-15 + @GetMapping + public List list( + @RequestParam(name = "platform", defaultValue = "AR-15") String platform + ) { + List mappings = + categoryMappingRepository.findBySourceTypeAndPlatformOrderById("PART_ROLE", platform); + + return mappings.stream() + .map(this::toDto) + .toList(); + } + + // POST /api/admin/category-mappings + @PostMapping + public ResponseEntity create( + @RequestBody PartRoleMappingRequest request + ) { + if (request.platform() == null || request.partRole() == null || request.categorySlug() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "platform, partRole, and categorySlug are required"); + } + + PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unknown category slug: " + request.categorySlug() + )); + + AffiliateCategoryMap mapping = new AffiliateCategoryMap(); + mapping.setSourceType("PART_ROLE"); + mapping.setSourceValue(request.partRole()); + mapping.setPlatform(request.platform()); + mapping.setPartCategory(category); + mapping.setNotes(request.notes()); + + AffiliateCategoryMap saved = categoryMappingRepository.save(mapping); + + return ResponseEntity.status(HttpStatus.CREATED).body(toDto(saved)); + } + + // PUT /api/admin/category-mappings/{id} + @PutMapping("/{id}") + public PartRoleMappingDto update( + @PathVariable Integer id, + @RequestBody PartRoleMappingRequest request + ) { + AffiliateCategoryMap mapping = categoryMappingRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found")); + + if (request.platform() != null) { + mapping.setPlatform(request.platform()); + } + if (request.partRole() != null) { + mapping.setSourceValue(request.partRole()); + } + if (request.categorySlug() != null) { + PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unknown category slug: " + request.categorySlug() + )); + mapping.setPartCategory(category); + } + if (request.notes() != null) { + mapping.setNotes(request.notes()); + } + + AffiliateCategoryMap saved = categoryMappingRepository.save(mapping); + return toDto(saved); + } + + // DELETE /api/admin/category-mappings/{id} + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Integer id) { + if (!categoryMappingRepository.existsById(id)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"); + } + categoryMappingRepository.deleteById(id); + } + + private PartRoleMappingDto toDto(AffiliateCategoryMap map) { + PartCategory cat = map.getPartCategory(); + + return new PartRoleMappingDto( + map.getId(), + map.getPlatform(), + map.getSourceValue(), // partRole + cat != null ? cat.getSlug() : null, // categorySlug + cat != null ? cat.getGroupName() : null, + map.getNotes() + ); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/admin/PartCategoryAdminController.java b/src/main/java/group/goforward/ballistic/controllers/admin/PartCategoryAdminController.java new file mode 100644 index 0000000..511a56f --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/admin/PartCategoryAdminController.java @@ -0,0 +1,35 @@ +package group.goforward.ballistic.controllers.admin; + +import group.goforward.ballistic.model.PartCategory; +import group.goforward.ballistic.repos.PartCategoryRepository; +import group.goforward.ballistic.web.dto.admin.PartCategoryDto; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/part-categories") +@CrossOrigin // keep it loose for now, you can tighten origins later +public class PartCategoryAdminController { + + private final PartCategoryRepository partCategories; + + public PartCategoryAdminController(PartCategoryRepository partCategories) { + this.partCategories = partCategories; + } + + @GetMapping + public List list() { + return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc() + .stream() + .map(pc -> new PartCategoryDto( + pc.getId(), + pc.getSlug(), + pc.getName(), + pc.getDescription(), + pc.getGroupName(), + pc.getSortOrder() + )) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java b/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java index d3e6cf4..eee49ec 100644 --- a/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java +++ b/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java @@ -8,18 +8,17 @@ public class AffiliateCategoryMap { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) private Integer id; - // e.g. "PART_ROLE" + // e.g. "PART_ROLE", "RAW_CATEGORY", etc. @Column(name = "source_type", nullable = false) private String sourceType; - // e.g. "suppressor" + // the value we’re mapping from (e.g. "suppressor", "TRIGGER") @Column(name = "source_value", nullable = false) private String sourceValue; - // e.g. "AR-15", nullable + // optional platform ("AR-15", "PRECISION", etc.) @Column(name = "platform") private String platform; @@ -30,6 +29,8 @@ public class AffiliateCategoryMap { @Column(name = "notes") private String notes; + // --- getters / setters --- + public Integer getId() { return id; } diff --git a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java index cbdbc13..d483e05 100644 --- a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java @@ -3,18 +3,27 @@ package group.goforward.ballistic.repos; import group.goforward.ballistic.model.AffiliateCategoryMap; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface CategoryMappingRepository extends JpaRepository { + // Match by source_type + source_value + platform (case-insensitive) Optional findBySourceTypeAndSourceValueAndPlatformIgnoreCase( String sourceType, String sourceValue, String platform ); + // Fallback: match by source_type + source_value when platform is null/ignored Optional findBySourceTypeAndSourceValueIgnoreCase( String sourceType, String sourceValue ); + + // Used by AdminCategoryMappingController: list mappings for a given source_type + platform + List findBySourceTypeAndPlatformOrderById( + String sourceType, + String platform + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/PartCategoryDto.java b/src/main/java/group/goforward/ballistic/web/dto/admin/PartCategoryDto.java index 69ba09d..17e5963 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/admin/PartCategoryDto.java +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/PartCategoryDto.java @@ -1,35 +1,10 @@ package group.goforward.ballistic.web.dto.admin; -import java.util.UUID; - -public class PartCategoryDto { - private Integer id; - private String slug; - private String name; - private String description; - private String groupName; - private Integer sortOrder; - private UUID uuid; - - // getters + setters - public Integer getId() { return id; } - public void setId(Integer id) { this.id = id; } - - public String getSlug() { return slug; } - public void setSlug(String slug) { this.slug = slug; } - - public String getName() { return name; } - public void setName(String name) { this.name = name; } - - public String getDescription() { return description; } - public void setDescription(String description) { this.description = description; } - - public String getGroupName() { return groupName; } - public void setGroupName(String groupName) { this.groupName = groupName; } - - public Integer getSortOrder() { return sortOrder; } - public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } - - public UUID getUuid() { return uuid; } - public void setUuid(UUID uuid) { this.uuid = uuid; } -} \ No newline at end of file +public record PartCategoryDto( + Integer id, + String slug, + String name, + String description, + String groupName, + Integer sortOrder +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingDto.java b/src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingDto.java index 93756e4..5082f89 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingDto.java +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingDto.java @@ -1,69 +1,10 @@ package group.goforward.ballistic.web.dto.admin; -public class PartRoleMappingDto { - private Integer id; - private String platform; - private String partRole; - private String categorySlug; - private String categoryName; - private String groupName; - private String notes; - - // getters + setters... - - public Integer getId() { - return id; - } - - public void setId(Integer id) { - this.id = id; - } - - 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 getCategorySlug() { - return categorySlug; - } - - public void setCategorySlug(String categorySlug) { - this.categorySlug = categorySlug; - } - - public String getCategoryName() { - return categoryName; - } - - public void setCategoryName(String categoryName) { - this.categoryName = categoryName; - } - - public String getGroupName() { - return groupName; - } - - public void setGroupName(String groupName) { - this.groupName = groupName; - } - - public String getNotes() { - return notes; - } - - public void setNotes(String notes) { - this.notes = notes; - } -} \ No newline at end of file +public record PartRoleMappingDto( + Integer id, + String platform, + String partRole, + String categorySlug, + String groupName, + String notes +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingRequest.java b/src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingRequest.java new file mode 100644 index 0000000..45a4074 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/PartRoleMappingRequest.java @@ -0,0 +1,8 @@ +package group.goforward.ballistic.web.dto.admin; + +public record PartRoleMappingRequest( + String platform, + String partRole, + String categorySlug, + String notes +) {} \ No newline at end of file From d344b372d1ec064131f4c70c9339d0eae7b83014 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Wed, 3 Dec 2025 22:17:46 -0500 Subject: [PATCH 28/33] fixing api endpoints and added brands controllers, repo --- .../ballistic/configuration/CorsConfig.java | 11 ++-- .../controllers/BrandController.java | 51 +++++++++++++++++++ .../controllers/StateController.java | 21 +++----- .../ballistic/controllers/UserController.java | 10 ++-- .../ballistic/services/BrandService.java | 16 ++++++ .../services/impl/BrandServiceImpl.java | 38 ++++++++++++++ wget-log | 0 7 files changed, 121 insertions(+), 26 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/controllers/BrandController.java create mode 100644 src/main/java/group/goforward/ballistic/services/BrandService.java create mode 100644 src/main/java/group/goforward/ballistic/services/impl/BrandServiceImpl.java create mode 100644 wget-log diff --git a/src/main/java/group/goforward/ballistic/configuration/CorsConfig.java b/src/main/java/group/goforward/ballistic/configuration/CorsConfig.java index 834f883..8381986 100644 --- a/src/main/java/group/goforward/ballistic/configuration/CorsConfig.java +++ b/src/main/java/group/goforward/ballistic/configuration/CorsConfig.java @@ -30,14 +30,9 @@ public class CorsConfig { "https://localhost:8080", "http://localhost:3000", "https://localhost:3000", - "http://192.168.11.210:8070", - "https://192.168.11.210:8070", - "http://citysites.gofwd.group", - "https://citysites.gofwd.group", - "http://citysites.gofwd.group:8070", - "https://citysites.gofwd.group:8070" - - )); + "https://localhost:3000/gunbuilder", + "http://localhost:3000/gunbuilder" + )); // Allow all headers config.addAllowedHeader("*"); diff --git a/src/main/java/group/goforward/ballistic/controllers/BrandController.java b/src/main/java/group/goforward/ballistic/controllers/BrandController.java new file mode 100644 index 0000000..bc94044 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/BrandController.java @@ -0,0 +1,51 @@ +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.Brand; +import group.goforward.ballistic.model.State; +import group.goforward.ballistic.repos.BrandRepository; +import group.goforward.ballistic.services.BrandService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@RestController +@RequestMapping("/api/brands") +public class BrandController { + @Autowired + private BrandRepository repo; + @Autowired + private BrandService brandService; +//@Cacheable(value="getAllStates") + @GetMapping("/all") + public ResponseEntity> getAllBrands() { + List brand = repo.findAll(); + return ResponseEntity.ok(brand); + } + + @GetMapping("/{id}") + public ResponseEntity getAllBrandsById(@PathVariable Integer id) { + return repo.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/add") + public ResponseEntity createbrand(@RequestBody Brand item) { + Brand created = brandService.save(item); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @DeleteMapping("/delete/{id}") + public ResponseEntity deleteItem(@PathVariable Integer id) { + return brandService.findById(id) + .map(item -> { + brandService.deleteById(id); + return ResponseEntity.noContent().build(); + }) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/src/main/java/group/goforward/ballistic/controllers/StateController.java b/src/main/java/group/goforward/ballistic/controllers/StateController.java index 59d0c78..e6c7ace 100644 --- a/src/main/java/group/goforward/ballistic/controllers/StateController.java +++ b/src/main/java/group/goforward/ballistic/controllers/StateController.java @@ -5,6 +5,7 @@ import group.goforward.ballistic.model.State; import group.goforward.ballistic.repos.StateRepository; import group.goforward.ballistic.services.StatesService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -13,44 +14,38 @@ import java.util.List; @RestController -@RequestMapping() +@RequestMapping("/api/states") public class StateController { @Autowired private StateRepository repo; @Autowired private StatesService statesService; - - @GetMapping("/api/getAllStates") +//@Cacheable(value="getAllStates") + @GetMapping("/all") public ResponseEntity> getAllStates() { List state = repo.findAll(); return ResponseEntity.ok(state); } - @GetMapping("/api/getAllStatesTest") - public ApiResponse> getAllStatesTest() { - List state = repo.findAll(); - return ApiResponse.success(state); - } - - @GetMapping("/api/getAllStatesById/{id}") + @GetMapping("/{id}") public ResponseEntity getAllStatesById(@PathVariable Integer id) { return repo.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } - @GetMapping("/api/getAllStatesByAbbreviation/{abbreviation}") + @GetMapping("/byAbbrev/{abbreviation}") public ResponseEntity getAllStatesByAbbreviation(@PathVariable String abbreviation) { return repo.findByAbbreviation(abbreviation) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } - @PostMapping("/api/addState") + @PostMapping("/addState") public ResponseEntity createState(@RequestBody State item) { State created = statesService.save(item); return ResponseEntity.status(HttpStatus.CREATED).body(created); } - @DeleteMapping("/api/deleteState/{id}") + @DeleteMapping("/deleteState/{id}") public ResponseEntity deleteItem(@PathVariable Integer id) { return statesService.findById(id) .map(item -> { diff --git a/src/main/java/group/goforward/ballistic/controllers/UserController.java b/src/main/java/group/goforward/ballistic/controllers/UserController.java index a18fb56..1931a37 100644 --- a/src/main/java/group/goforward/ballistic/controllers/UserController.java +++ b/src/main/java/group/goforward/ballistic/controllers/UserController.java @@ -12,7 +12,7 @@ import java.util.List; @RestController -@RequestMapping() +@RequestMapping("/api/user") public class UserController { private final UserRepository repo; private final UsersService usersService; @@ -22,26 +22,26 @@ public class UserController { this.usersService = usersService; } - @GetMapping("/api/getAllUsers") + @GetMapping("/all") public ResponseEntity> getAllUsers() { List data = repo.findAll(); return ResponseEntity.ok(data); } - @GetMapping("/api/getAllUsersById/{id}") + @GetMapping("/byId/{id}") public ResponseEntity getAllStatesById(@PathVariable Integer id) { return repo.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } - @PostMapping("/api/addUser") + @PostMapping("/addUser") public ResponseEntity createUser(@RequestBody User item) { User created = usersService.save(item); return ResponseEntity.status(HttpStatus.CREATED).body(created); } - @DeleteMapping("/api/deleteUser/{id}") + @DeleteMapping("/deleteUser/{id}") public ResponseEntity deleteItem(@PathVariable Integer id) { return usersService.findById(id) .map(item -> { diff --git a/src/main/java/group/goforward/ballistic/services/BrandService.java b/src/main/java/group/goforward/ballistic/services/BrandService.java new file mode 100644 index 0000000..4039db4 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/BrandService.java @@ -0,0 +1,16 @@ +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.Brand; + +import java.util.List; +import java.util.Optional; + +public interface BrandService { + + List findAll(); + + Optional findById(Integer id); + + Brand save(Brand item); + void deleteById(Integer id); +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/BrandServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/BrandServiceImpl.java new file mode 100644 index 0000000..fbd67b7 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/impl/BrandServiceImpl.java @@ -0,0 +1,38 @@ +package group.goforward.ballistic.services.impl; + + +import group.goforward.ballistic.model.Brand; +import group.goforward.ballistic.repos.BrandRepository; +import group.goforward.ballistic.services.BrandService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class BrandServiceImpl implements BrandService { + + @Autowired + private BrandRepository repo; + + @Override + public List findAll() { + return repo.findAll(); + } + + @Override + public Optional findById(Integer id) { + return repo.findById(id); + } + + @Override + public Brand save(Brand item) { + return null; + } + + @Override + public void deleteById(Integer id) { + deleteById(id); + } +} diff --git a/wget-log b/wget-log new file mode 100644 index 0000000..e69de29 From f3626af70965344d58098e6eadbdb65d0330a11a Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Thu, 4 Dec 2025 10:07:04 -0500 Subject: [PATCH 29/33] sharing database connectivity file --- .idea/dataSources.xml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .idea/dataSources.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..f1e7a08 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,31 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://r710.dev.gofwd.group:5433/postgres + + + + + + $ProjectFileDir$ + + + postgresql + true + true + $PROJECT_DIR$/src/main/resources/application.properties + org.postgresql.Driver + jdbc:postgresql://r710.gofwd.group:5433/ss_builder + + + + + + $ProjectFileDir$ + + + \ No newline at end of file From 3ae68f30c0e66702ddef666b4642321993e67eca Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Thu, 4 Dec 2025 12:39:02 -0500 Subject: [PATCH 30/33] added more namedqueries --- .../goforward/ballistic/model/Product.java | 19 +++++++++++++++++++ .../ballistic/repos/ProductRepository.java | 17 ++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/main/java/group/goforward/ballistic/model/Product.java b/src/main/java/group/goforward/ballistic/model/Product.java index 90cbbaa..816099f 100644 --- a/src/main/java/group/goforward/ballistic/model/Product.java +++ b/src/main/java/group/goforward/ballistic/model/Product.java @@ -14,6 +14,25 @@ import group.goforward.ballistic.model.ProductConfiguration; @Entity @Table(name = "products") +@NamedQuery(name="Products.findByPlatformWithBrand", query= "" + + "SELECT p FROM Product p" + + " JOIN FETCH p.brand b" + + " WHERE p.platform = :platform" + + " AND p.deletedAt IS NULL") + +@NamedQuery(name="Product.findByPlatformAndPartRoleInWithBrand", query= "" + + "SELECT p FROM Product p JOIN FETCH p.brand b" + + " WHERE p.platform = :platform" + + " AND p.partRole IN :roles" + + " AND p.deletedAt IS NULL") + +@NamedQuery(name="Product.findProductsbyBrandByOffers", query="" + + " SELECT DISTINCT p FROM Product p" + + " LEFT JOIN FETCH p.brand b" + + " LEFT JOIN FETCH p.offers o" + + " WHERE p.platform = :platform" + + " AND p.deletedAt IS NULL") + public class Product { @Id diff --git a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java index 24f821c..ad91c1e 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -33,14 +33,17 @@ public interface ProductRepository extends JpaRepository { """) List findByPlatformWithBrand(@Param("platform") String platform); +@Query(name="Products.findByPlatformWithBrand") +List findByPlatformWithBrandNQ(@Param("platform") String platform); + @Query(""" - SELECT p - FROM Product p - JOIN FETCH p.brand b - WHERE p.platform = :platform - AND p.partRole IN :roles - AND p.deletedAt IS NULL - """) + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.platform = :platform + AND p.partRole IN :roles + AND p.deletedAt IS NULL + """) List findByPlatformAndPartRoleInWithBrand( @Param("platform") String platform, @Param("roles") List roles From 3d1501cc87a71af86555e1ba7b707674855fdfd1 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 4 Dec 2025 14:43:07 -0500 Subject: [PATCH 31/33] running finally.. --- .../configuration/SecurityConfig.java | 5 + .../admin/AdminCategoryMappingController.java | 154 +++++++++--------- .../repos/CategoryMappingRepository.java | 33 ++-- .../services/PartCategoryResolverService.java | 41 ++--- 4 files changed, 113 insertions(+), 120 deletions(-) diff --git a/src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java b/src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java index 3a0a3c4..2b3f98d 100644 --- a/src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java +++ b/src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java @@ -24,14 +24,19 @@ public class SecurityConfig { sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(auth -> auth + // Auth endpoints always open .requestMatchers("/api/auth/**").permitAll() + // Swagger / docs .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + // Health .requestMatchers("/actuator/health", "/actuator/info").permitAll() + // Public product endpoints .requestMatchers("/api/products/gunbuilder/**").permitAll() + // Everything else (for now) also open – we can tighten later .anyRequest().permitAll() ); diff --git a/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java b/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java index e7553cd..6a9e5e1 100644 --- a/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java +++ b/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java @@ -1,13 +1,15 @@ package group.goforward.ballistic.controllers.admin; -import group.goforward.ballistic.model.AffiliateCategoryMap; +import group.goforward.ballistic.model.CategoryMapping; +import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.PartCategory; import group.goforward.ballistic.repos.CategoryMappingRepository; +import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.PartCategoryRepository; -import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto; -import group.goforward.ballistic.web.dto.admin.PartRoleMappingRequest; +import group.goforward.ballistic.web.dto.admin.MerchantCategoryMappingDto; +import group.goforward.ballistic.web.dto.admin.SimpleMerchantDto; +import group.goforward.ballistic.web.dto.admin.UpdateMerchantCategoryMappingRequest; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -15,111 +17,101 @@ import java.util.List; @RestController @RequestMapping("/api/admin/category-mappings") -@CrossOrigin +@CrossOrigin // you can tighten origins later public class AdminCategoryMappingController { private final CategoryMappingRepository categoryMappingRepository; + private final MerchantRepository merchantRepository; private final PartCategoryRepository partCategoryRepository; public AdminCategoryMappingController( CategoryMappingRepository categoryMappingRepository, + MerchantRepository merchantRepository, PartCategoryRepository partCategoryRepository ) { this.categoryMappingRepository = categoryMappingRepository; + this.merchantRepository = merchantRepository; this.partCategoryRepository = partCategoryRepository; } - // GET /api/admin/category-mappings?platform=AR-15 - @GetMapping - public List list( - @RequestParam(name = "platform", defaultValue = "AR-15") String platform - ) { - List mappings = - categoryMappingRepository.findBySourceTypeAndPlatformOrderById("PART_ROLE", platform); - - return mappings.stream() - .map(this::toDto) + /** + * Merchants that have at least one category_mappings row. + * Used for the "All Merchants" dropdown in the UI. + */ + @GetMapping("/merchants") + public List listMerchantsWithMappings() { + List merchants = categoryMappingRepository.findDistinctMerchantsWithMappings(); + return merchants.stream() + .map(m -> new SimpleMerchantDto(m.getId(), m.getName())) .toList(); } - // POST /api/admin/category-mappings - @PostMapping - public ResponseEntity create( - @RequestBody PartRoleMappingRequest request + /** + * List mappings for a specific merchant, or all mappings if no merchantId is provided. + * GET /api/admin/category-mappings?merchantId=1 + */ + @GetMapping + public List listByMerchant( + @RequestParam(name = "merchantId", required = false) Integer merchantId ) { - if (request.platform() == null || request.partRole() == null || request.categorySlug() == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "platform, partRole, and categorySlug are required"); + List mappings; + + if (merchantId != null) { + mappings = categoryMappingRepository.findByMerchantIdOrderByRawCategoryPathAsc(merchantId); + } else { + // fall back to all mappings; you can add a more specific repository method later if desired + mappings = categoryMappingRepository.findAll(); } - PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Unknown category slug: " + request.categorySlug() - )); - - AffiliateCategoryMap mapping = new AffiliateCategoryMap(); - mapping.setSourceType("PART_ROLE"); - mapping.setSourceValue(request.partRole()); - mapping.setPlatform(request.platform()); - mapping.setPartCategory(category); - mapping.setNotes(request.notes()); - - AffiliateCategoryMap saved = categoryMappingRepository.save(mapping); - - return ResponseEntity.status(HttpStatus.CREATED).body(toDto(saved)); + return mappings.stream() + .map(cm -> new MerchantCategoryMappingDto( + cm.getId(), + cm.getMerchant().getId(), + cm.getMerchant().getName(), + cm.getRawCategoryPath(), + cm.getPartCategory() != null ? cm.getPartCategory().getId() : null, + cm.getPartCategory() != null ? cm.getPartCategory().getName() : null + )) + .toList(); } - // PUT /api/admin/category-mappings/{id} - @PutMapping("/{id}") - public PartRoleMappingDto update( + /** + * Update a single mapping's part_category. + * POST /api/admin/category-mappings/{id} + * Body: { "partCategoryId": 24 } + */ + @PostMapping("/{id}") + public MerchantCategoryMappingDto updateMapping( @PathVariable Integer id, - @RequestBody PartRoleMappingRequest request + @RequestBody UpdateMerchantCategoryMappingRequest request ) { - AffiliateCategoryMap mapping = categoryMappingRepository.findById(id) + CategoryMapping mapping = categoryMappingRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found")); - if (request.platform() != null) { - mapping.setPlatform(request.platform()); - } - if (request.partRole() != null) { - mapping.setSourceValue(request.partRole()); - } - if (request.categorySlug() != null) { - PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Unknown category slug: " + request.categorySlug() - )); - mapping.setPartCategory(category); - } - if (request.notes() != null) { - mapping.setNotes(request.notes()); + PartCategory partCategory = null; + if (request.partCategoryId() != null) { + partCategory = partCategoryRepository.findById(request.partCategoryId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Part category not found")); } - AffiliateCategoryMap saved = categoryMappingRepository.save(mapping); - return toDto(saved); - } + mapping.setPartCategory(partCategory); + mapping = categoryMappingRepository.save(mapping); - // DELETE /api/admin/category-mappings/{id} - @DeleteMapping("/{id}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(@PathVariable Integer id) { - if (!categoryMappingRepository.existsById(id)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"); - } - categoryMappingRepository.deleteById(id); - } - - private PartRoleMappingDto toDto(AffiliateCategoryMap map) { - PartCategory cat = map.getPartCategory(); - - return new PartRoleMappingDto( - map.getId(), - map.getPlatform(), - map.getSourceValue(), // partRole - cat != null ? cat.getSlug() : null, // categorySlug - cat != null ? cat.getGroupName() : null, - map.getNotes() + return new MerchantCategoryMappingDto( + mapping.getId(), + mapping.getMerchant().getId(), + mapping.getMerchant().getName(), + mapping.getRawCategoryPath(), + mapping.getPartCategory() != null ? mapping.getPartCategory().getId() : null, + mapping.getPartCategory() != null ? mapping.getPartCategory().getName() : null ); } + @PutMapping("/{id}") + public MerchantCategoryMappingDto updateMappingPut( + @PathVariable Integer id, + @RequestBody UpdateMerchantCategoryMappingRequest request + ) { + // just delegate so POST & PUT behave the same + return updateMapping(id, request); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java index d483e05..b6132c0 100644 --- a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java @@ -1,29 +1,22 @@ package group.goforward.ballistic.repos; -import group.goforward.ballistic.model.AffiliateCategoryMap; +import group.goforward.ballistic.model.CategoryMapping; +import group.goforward.ballistic.model.Merchant; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.List; -import java.util.Optional; -public interface CategoryMappingRepository extends JpaRepository { +public interface CategoryMappingRepository extends JpaRepository { - // Match by source_type + source_value + platform (case-insensitive) - Optional findBySourceTypeAndSourceValueAndPlatformIgnoreCase( - String sourceType, - String sourceValue, - String platform - ); + // All mappings for a merchant, ordered nicely + List findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId); - // Fallback: match by source_type + source_value when platform is null/ignored - Optional findBySourceTypeAndSourceValueIgnoreCase( - String sourceType, - String sourceValue - ); - - // Used by AdminCategoryMappingController: list mappings for a given source_type + platform - List findBySourceTypeAndPlatformOrderById( - String sourceType, - String platform - ); + // Merchants that actually have mappings (for the dropdown) + @Query(""" + select distinct cm.merchant + from CategoryMapping cm + order by cm.merchant.name asc + """) + List findDistinctMerchantsWithMappings(); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java index 252ad6f..31dd63a 100644 --- a/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java +++ b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java @@ -1,8 +1,7 @@ package group.goforward.ballistic.services; -import group.goforward.ballistic.model.AffiliateCategoryMap; import group.goforward.ballistic.model.PartCategory; -import group.goforward.ballistic.repos.CategoryMappingRepository; +import group.goforward.ballistic.repos.PartCategoryRepository; import org.springframework.stereotype.Service; import java.util.Optional; @@ -10,29 +9,33 @@ import java.util.Optional; @Service public class PartCategoryResolverService { - private final CategoryMappingRepository categoryMappingRepository; + private final PartCategoryRepository partCategoryRepository; - public PartCategoryResolverService(CategoryMappingRepository categoryMappingRepository) { - this.categoryMappingRepository = categoryMappingRepository; + public PartCategoryResolverService(PartCategoryRepository partCategoryRepository) { + this.partCategoryRepository = partCategoryRepository; } /** - * Resolve a part category from a platform + partRole (what gunbuilder cares about). - * Returns Optional.empty() if we have no mapping yet. + * Resolve the canonical PartCategory for a given platform + partRole. + * + * For now we keep it simple: + * - We treat partRole as the slug (e.g. "barrel", "upper", "trigger"). + * - Normalize to lower-kebab (spaces -> dashes, lowercased). + * - Look up by slug in part_categories. + * + * Later, if we want per-merchant / per-platform overrides using category_mappings, + * we can extend this method without changing callers. */ public Optional resolveForPlatformAndPartRole(String platform, String partRole) { - // sourceType is a convention – you can also enum this - String sourceType = "PART_ROLE"; + if (partRole == null || partRole.isBlank()) { + return Optional.empty(); + } - // First try with platform - return categoryMappingRepository - .findBySourceTypeAndSourceValueAndPlatformIgnoreCase(sourceType, partRole, platform) - .map(AffiliateCategoryMap::getPartCategory) - // if that fails, fall back to ANY platform - .or(() -> - categoryMappingRepository - .findBySourceTypeAndSourceValueIgnoreCase(sourceType, partRole) - .map(AffiliateCategoryMap::getPartCategory) - ); + String normalizedSlug = partRole + .trim() + .toLowerCase() + .replace(" ", "-"); + + return partCategoryRepository.findBySlug(normalizedSlug); } } \ No newline at end of file From 9096ddd165149b8064bd2a58d54c2b966fc889f8 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 4 Dec 2025 15:06:29 -0500 Subject: [PATCH 32/33] running finally.. --- .../admin/AdminPartRoleMappingController.java | 123 ++++++++++++++++++ .../ballistic/model/PartRoleMapping.java | 65 +++++++++ .../repos/PartRoleMappingRepository.java | 12 ++ .../dto/admin/AdminPartRoleMappingDto.java | 10 ++ .../admin/CreatePartRoleMappingRequest.java | 8 ++ .../admin/UpdatePartRoleMappingRequest.java | 8 ++ 6 files changed, 226 insertions(+) create mode 100644 src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java create mode 100644 src/main/java/group/goforward/ballistic/model/PartRoleMapping.java create mode 100644 src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/AdminPartRoleMappingDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/CreatePartRoleMappingRequest.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/UpdatePartRoleMappingRequest.java diff --git a/src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java b/src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java new file mode 100644 index 0000000..7300129 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java @@ -0,0 +1,123 @@ +package group.goforward.ballistic.controllers.admin; + +import group.goforward.ballistic.model.PartCategory; +import group.goforward.ballistic.model.PartRoleMapping; +import group.goforward.ballistic.repos.PartCategoryRepository; +import group.goforward.ballistic.repos.PartRoleMappingRepository; +import group.goforward.ballistic.web.dto.admin.AdminPartRoleMappingDto; +import group.goforward.ballistic.web.dto.admin.CreatePartRoleMappingRequest; +import group.goforward.ballistic.web.dto.admin.UpdatePartRoleMappingRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/part-role-mappings") +@CrossOrigin +public class AdminPartRoleMappingController { + + private final PartRoleMappingRepository partRoleMappingRepository; + private final PartCategoryRepository partCategoryRepository; + + public AdminPartRoleMappingController( + PartRoleMappingRepository partRoleMappingRepository, + PartCategoryRepository partCategoryRepository + ) { + this.partRoleMappingRepository = partRoleMappingRepository; + this.partCategoryRepository = partCategoryRepository; + } + + // GET /api/admin/part-role-mappings?platform=AR-15 + @GetMapping + public List list( + @RequestParam(name = "platform", required = false) String platform + ) { + List mappings; + + if (platform != null && !platform.isBlank()) { + mappings = partRoleMappingRepository.findByPlatformOrderByPartRoleAsc(platform); + } else { + mappings = partRoleMappingRepository.findAll(); + } + + return mappings.stream() + .map(this::toDto) + .toList(); + } + + // POST /api/admin/part-role-mappings + @PostMapping + public AdminPartRoleMappingDto create( + @RequestBody CreatePartRoleMappingRequest request + ) { + PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "PartCategory not found for slug: " + request.categorySlug() + )); + + PartRoleMapping mapping = new PartRoleMapping(); + mapping.setPlatform(request.platform()); + mapping.setPartRole(request.partRole()); + mapping.setPartCategory(category); + mapping.setNotes(request.notes()); + + mapping = partRoleMappingRepository.save(mapping); + return toDto(mapping); + } + + // PUT /api/admin/part-role-mappings/{id} + @PutMapping("/{id}") + public AdminPartRoleMappingDto update( + @PathVariable Integer id, + @RequestBody UpdatePartRoleMappingRequest request + ) { + PartRoleMapping mapping = partRoleMappingRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found")); + + if (request.platform() != null) { + mapping.setPlatform(request.platform()); + } + if (request.partRole() != null) { + mapping.setPartRole(request.partRole()); + } + if (request.categorySlug() != null) { + PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "PartCategory not found for slug: " + request.categorySlug() + )); + mapping.setPartCategory(category); + } + if (request.notes() != null) { + mapping.setNotes(request.notes()); + } + + mapping = partRoleMappingRepository.save(mapping); + return toDto(mapping); + } + + // DELETE /api/admin/part-role-mappings/{id} + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Integer id) { + if (!partRoleMappingRepository.existsById(id)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"); + } + partRoleMappingRepository.deleteById(id); + } + + private AdminPartRoleMappingDto toDto(PartRoleMapping mapping) { + PartCategory cat = mapping.getPartCategory(); + return new AdminPartRoleMappingDto( + mapping.getId(), + mapping.getPlatform(), + mapping.getPartRole(), + cat != null ? cat.getSlug() : null, + cat != null ? cat.getGroupName() : null, + mapping.getNotes() + ); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java b/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java new file mode 100644 index 0000000..d336815 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java @@ -0,0 +1,65 @@ +package group.goforward.ballistic.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "part_role_mappings") +public class PartRoleMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false) + private String platform; // e.g. "AR-15" + + @Column(name = "part_role", nullable = false) + private String partRole; // e.g. "UPPER", "BARREL", etc. + + @ManyToOne(optional = false) + @JoinColumn(name = "part_category_id") + private PartCategory partCategory; + + @Column(columnDefinition = "text") + private String notes; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + 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 PartCategory getPartCategory() { + return partCategory; + } + + public void setPartCategory(PartCategory partCategory) { + this.partCategory = partCategory; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java new file mode 100644 index 0000000..b64889e --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java @@ -0,0 +1,12 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.PartRoleMapping; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PartRoleMappingRepository extends JpaRepository { + + // List mappings for a platform, ordered nicely for the UI + List findByPlatformOrderByPartRoleAsc(String platform); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/AdminPartRoleMappingDto.java b/src/main/java/group/goforward/ballistic/web/dto/admin/AdminPartRoleMappingDto.java new file mode 100644 index 0000000..1146848 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/AdminPartRoleMappingDto.java @@ -0,0 +1,10 @@ +package group.goforward.ballistic.web.dto.admin; + +public record AdminPartRoleMappingDto( + Integer id, + String platform, + String partRole, + String categorySlug, + String groupName, + String notes +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/CreatePartRoleMappingRequest.java b/src/main/java/group/goforward/ballistic/web/dto/admin/CreatePartRoleMappingRequest.java new file mode 100644 index 0000000..74445c9 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/CreatePartRoleMappingRequest.java @@ -0,0 +1,8 @@ +package group.goforward.ballistic.web.dto.admin; + +public record CreatePartRoleMappingRequest( + String platform, + String partRole, + String categorySlug, + String notes +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/UpdatePartRoleMappingRequest.java b/src/main/java/group/goforward/ballistic/web/dto/admin/UpdatePartRoleMappingRequest.java new file mode 100644 index 0000000..b70b9f6 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/UpdatePartRoleMappingRequest.java @@ -0,0 +1,8 @@ +package group.goforward.ballistic.web.dto.admin; + +public record UpdatePartRoleMappingRequest( + String platform, + String partRole, + String categorySlug, + String notes +) {} \ No newline at end of file From e986fa97caa15b4e931f4b5af72b5595b033f4c8 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 4 Dec 2025 15:20:42 -0500 Subject: [PATCH 33/33] running finally.. --- .../controllers/CategoryController.java | 34 +++++++ .../ballistic/model/CategoryMapping.java | 98 +++++++++++++++++++ .../dto/admin/MerchantCategoryMappingDto.java | 11 +++ .../web/dto/admin/SimpleMerchantDto.java | 6 ++ .../UpdateMerchantCategoryMappingRequest.java | 5 + 5 files changed, 154 insertions(+) create mode 100644 src/main/java/group/goforward/ballistic/controllers/CategoryController.java create mode 100644 src/main/java/group/goforward/ballistic/model/CategoryMapping.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/MerchantCategoryMappingDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/SimpleMerchantDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/UpdateMerchantCategoryMappingRequest.java diff --git a/src/main/java/group/goforward/ballistic/controllers/CategoryController.java b/src/main/java/group/goforward/ballistic/controllers/CategoryController.java new file mode 100644 index 0000000..4787170 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/CategoryController.java @@ -0,0 +1,34 @@ +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.repos.PartCategoryRepository; +import group.goforward.ballistic.web.dto.admin.PartCategoryDto; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/categories") +@CrossOrigin // you can tighten origins later +public class CategoryController { + + private final PartCategoryRepository partCategories; + + public CategoryController(PartCategoryRepository partCategories) { + this.partCategories = partCategories; + } + + @GetMapping + public List list() { + return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc() + .stream() + .map(pc -> new PartCategoryDto( + pc.getId(), + pc.getSlug(), + pc.getName(), + pc.getDescription(), + pc.getGroupName(), + pc.getSortOrder() + )) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/CategoryMapping.java b/src/main/java/group/goforward/ballistic/model/CategoryMapping.java new file mode 100644 index 0000000..303fd85 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/model/CategoryMapping.java @@ -0,0 +1,98 @@ +// src/main/java/group/goforward/ballistic/model/CategoryMapping.java +package group.goforward.ballistic.model; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "category_mappings") +public class CategoryMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "merchant_id", nullable = false) + private Merchant merchant; + + @Column(name = "raw_category_path", nullable = false) + private String rawCategoryPath; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "part_category_id") + private PartCategory partCategory; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + @PrePersist + public void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + } + + @PreUpdate + public void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + + // --- getters & setters --- + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Merchant getMerchant() { + return merchant; + } + + public void setMerchant(Merchant merchant) { + this.merchant = merchant; + } + + public String getRawCategoryPath() { + return rawCategoryPath; + } + + public void setRawCategoryPath(String rawCategoryPath) { + this.rawCategoryPath = rawCategoryPath; + } + + public PartCategory getPartCategory() { + return partCategory; + } + + public void setPartCategory(PartCategory partCategory) { + this.partCategory = partCategory; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/MerchantCategoryMappingDto.java b/src/main/java/group/goforward/ballistic/web/dto/admin/MerchantCategoryMappingDto.java new file mode 100644 index 0000000..f8e8f76 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/MerchantCategoryMappingDto.java @@ -0,0 +1,11 @@ +// src/main/java/group/goforward/ballistic/web/dto/admin/MerchantCategoryMappingDto.java +package group.goforward.ballistic.web.dto.admin; + +public record MerchantCategoryMappingDto( + Integer id, + Integer merchantId, + String merchantName, + String rawCategoryPath, + Integer partCategoryId, + String partCategoryName +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/SimpleMerchantDto.java b/src/main/java/group/goforward/ballistic/web/dto/admin/SimpleMerchantDto.java new file mode 100644 index 0000000..4f7f2a6 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/SimpleMerchantDto.java @@ -0,0 +1,6 @@ +package group.goforward.ballistic.web.dto.admin; + +public record SimpleMerchantDto( + Integer id, + String name +) { } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/UpdateMerchantCategoryMappingRequest.java b/src/main/java/group/goforward/ballistic/web/dto/admin/UpdateMerchantCategoryMappingRequest.java new file mode 100644 index 0000000..4e55bc4 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/UpdateMerchantCategoryMappingRequest.java @@ -0,0 +1,5 @@ +package group.goforward.ballistic.web.dto.admin; + +public record UpdateMerchantCategoryMappingRequest( + Integer partCategoryId +) {} \ No newline at end of file