diff --git a/pom.xml b/pom.xml index eff2159..6b038c0 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ ballistic 0.0.1-SNAPSHOT ballistic - Ballistic Builder API + Battl Builder API diff --git a/src/main/java/group/goforward/ballistic/BallisticApplication.java b/src/main/java/group/goforward/ballistic/BattlBuilderApplication.java similarity index 82% rename from src/main/java/group/goforward/ballistic/BallisticApplication.java rename to src/main/java/group/goforward/ballistic/BattlBuilderApplication.java index fbf9d94..e7ed88e 100644 --- a/src/main/java/group/goforward/ballistic/BallisticApplication.java +++ b/src/main/java/group/goforward/ballistic/BattlBuilderApplication.java @@ -10,9 +10,9 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @EnableCaching @EntityScan(basePackages = "group.goforward.ballistic.model") @EnableJpaRepositories(basePackages = "group.goforward.ballistic.repos") -public class BallisticApplication { +public class BattlBuilderApplication { public static void main(String[] args) { - SpringApplication.run(BallisticApplication.class, args); + SpringApplication.run(BattlBuilderApplication.class, args); } } \ 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 34f56da..304c520 100644 --- a/src/main/java/group/goforward/ballistic/configuration/package-info.java +++ b/src/main/java/group/goforward/ballistic/configuration/package-info.java @@ -4,7 +4,7 @@ * * *

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

+ * {@link group.goforward.ballistic.BattlBuilderApplication} class.

* * @since 1.0 * @author Don Strawsburg diff --git a/src/main/java/group/goforward/ballistic/controllers/admin/AdminDashboardController.java b/src/main/java/group/goforward/ballistic/controllers/admin/AdminDashboardController.java new file mode 100644 index 0000000..0b35b39 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/admin/AdminDashboardController.java @@ -0,0 +1,25 @@ +package group.goforward.ballistic.web; + +import group.goforward.ballistic.services.AdminDashboardService; +import group.goforward.ballistic.web.dto.AdminDashboardOverviewDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/dashboard") +public class AdminDashboardController { + + private final AdminDashboardService adminDashboardService; + + public AdminDashboardController(AdminDashboardService adminDashboardService) { + this.adminDashboardService = adminDashboardService; + } + + @GetMapping("/overview") + public ResponseEntity getOverview() { + AdminDashboardOverviewDto dto = adminDashboardService.getOverview(); + return ResponseEntity.ok(dto); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java b/src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java index 7300129..5b79c38 100644 --- a/src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java +++ b/src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java @@ -37,7 +37,8 @@ public class AdminPartRoleMappingController { List mappings; if (platform != null && !platform.isBlank()) { - mappings = partRoleMappingRepository.findByPlatformOrderByPartRoleAsc(platform); + mappings = partRoleMappingRepository + .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform); } else { mappings = partRoleMappingRepository.findAll(); } 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 c49339c..dda4a80 100644 --- a/src/main/java/group/goforward/ballistic/controllers/package-info.java +++ b/src/main/java/group/goforward/ballistic/controllers/package-info.java @@ -4,7 +4,7 @@ * * *

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

+ * {@link group.goforward.ballistic.BattlBuilderApplication} class.

* * @since 1.0 * @author Don Strawsburg 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 586776b..b3a6e59 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 @@ -4,7 +4,7 @@ * * *

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

+ * {@link group.goforward.ballistic.BattlBuilderApplication} class.

* * @since 1.0 * @author Sean Strawsburg diff --git a/src/main/java/group/goforward/ballistic/model/ImportStatus.java b/src/main/java/group/goforward/ballistic/model/ImportStatus.java new file mode 100644 index 0000000..d4a6b64 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/model/ImportStatus.java @@ -0,0 +1,7 @@ +package group.goforward.ballistic.model; + +public enum ImportStatus { + PENDING_MAPPING, // Ingested but not fully mapped / trusted + MAPPED, // Clean + mapped + safe for builder + REJECTED // Junk / not relevant / explicitly excluded +} \ 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 index d336815..4d23d7e 100644 --- a/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java +++ b/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java @@ -1,6 +1,7 @@ package group.goforward.ballistic.model; import jakarta.persistence.*; +import java.time.OffsetDateTime; @Entity @Table(name = "part_role_mappings") @@ -14,15 +15,38 @@ public class PartRoleMapping { private String platform; // e.g. "AR-15" @Column(name = "part_role", nullable = false) - private String partRole; // e.g. "UPPER", "BARREL", etc. + private String partRole; // e.g. "LOWER_RECEIVER_STRIPPED" - @ManyToOne(optional = false) + @ManyToOne(optional = false, fetch = FetchType.LAZY) @JoinColumn(name = "part_category_id") private PartCategory partCategory; @Column(columnDefinition = "text") private String notes; + @Column(name = "created_at", updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at") + private OffsetDateTime updatedAt; + + @Column(name = "deleted_at") + private OffsetDateTime deletedAt; + + @PrePersist + protected void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + + // getters/setters + public Integer getId() { return id; } @@ -62,4 +86,20 @@ public class PartRoleMapping { public void setNotes(String notes) { this.notes = notes; } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return 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/model/Product.java b/src/main/java/group/goforward/ballistic/model/Product.java index 816099f..8e482ae 100644 --- a/src/main/java/group/goforward/ballistic/model/Product.java +++ b/src/main/java/group/goforward/ballistic/model/Product.java @@ -9,22 +9,19 @@ 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 @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") + "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") + "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" + @@ -32,11 +29,10 @@ import group.goforward.ballistic.model.ProductConfiguration; " LEFT JOIN FETCH p.offers o" + " WHERE p.platform = :platform" + " AND p.deletedAt IS NULL") - public class Product { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) // uses products_id_seq in Postgres + @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) private Integer id; @@ -86,38 +82,27 @@ public class Product { @Column(name = "deleted_at") private Instant deletedAt; - + @Column(name = "raw_category_key") private String rawCategoryKey; @Column(name = "platform_locked", nullable = false) private Boolean platformLocked = false; + @Enumerated(EnumType.STRING) + @Column(name = "import_status", nullable = false) + private ImportStatus importStatus = ImportStatus.MAPPED; + @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 --- - @PrePersist public void prePersist() { - if (uuid == null) { - uuid = UUID.randomUUID(); - } + if (uuid == null) uuid = UUID.randomUUID(); Instant now = Instant.now(); - if (createdAt == null) { - createdAt = now; - } - if (updatedAt == null) { - updatedAt = now; - } + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; } @PreUpdate @@ -125,181 +110,101 @@ public class Product { updatedAt = Instant.now(); } - public String getRawCategoryKey() { - return rawCategoryKey; - } - - public void setRawCategoryKey(String rawCategoryKey) { - this.rawCategoryKey = rawCategoryKey; - } - // --- getters & setters --- + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } - 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 Brand getBrand() { - return brand; - } - - public void setBrand(Brand brand) { - this.brand = brand; - } - - public String getName() { - return name; - } - - public void setName(String name) { - 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 UUID getUuid() { return uuid; } + public void setUuid(UUID uuid) { this.uuid = uuid; } + + public Brand getBrand() { return brand; } + public void setBrand(Brand brand) { this.brand = brand; } + + public String getName() { return name; } + public void setName(String name) { 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 ProductConfiguration getConfiguration() { return configuration; } + public void setConfiguration(ProductConfiguration configuration) { + this.configuration = configuration; } + public String getShortDescription() { return shortDescription; } public void setShortDescription(String shortDescription) { this.shortDescription = shortDescription; } - public String getDescription() { - return description; - } - + public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } - public String getMainImageUrl() { - return mainImageUrl; - } - + public String getMainImageUrl() { return mainImageUrl; } public void setMainImageUrl(String mainImageUrl) { this.mainImageUrl = mainImageUrl; } - public Instant getCreatedAt() { - return createdAt; - } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } - public void setCreatedAt(Instant createdAt) { - this.createdAt = createdAt; - } + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } - public Instant getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(Instant updatedAt) { - this.updatedAt = updatedAt; - } - - public Instant getDeletedAt() { - return deletedAt; - } - - public void setDeletedAt(Instant deletedAt) { - this.deletedAt = deletedAt; - } - - public Boolean getPlatformLocked() { - return platformLocked; - } + public Instant getDeletedAt() { return deletedAt; } + public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; } + public Boolean getPlatformLocked() { return platformLocked; } public void setPlatformLocked(Boolean platformLocked) { this.platformLocked = platformLocked; } - - public ProductConfiguration getConfiguration() { - return configuration; + + public String getRawCategoryKey() { return rawCategoryKey; } + public void setRawCategoryKey(String rawCategoryKey) { + this.rawCategoryKey = rawCategoryKey; } - 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; + public ImportStatus getImportStatus() { return importStatus; } + public void setImportStatus(ImportStatus importStatus) { + this.importStatus = importStatus; } - 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); -} + public Set getOffers() { return offers; } + public void setOffers(Set offers) { this.offers = offers; } + + // --- computed helpers --- + + public BigDecimal getBestOfferPrice() { + if (offers == null || offers.isEmpty()) return BigDecimal.ZERO; + + return offers.stream() + .map(offer -> offer.getSalePrice() != null + ? offer.getSalePrice() + : 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; - } + if (offers == null || offers.isEmpty()) return null; return offers.stream() .sorted(Comparator.comparing(offer -> { - if (offer.getSalePrice() != null) { - return offer.getSalePrice(); - } + if (offer.getSalePrice() != null) return offer.getSalePrice(); return offer.getRetailPrice(); }, Comparator.nullsLast(BigDecimal::compareTo))) .map(ProductOffer::getAffiliateUrl) @@ -307,4 +212,4 @@ public BigDecimal getBestOfferPrice() { .findFirst() .orElse(null); } -} +} \ 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 f26eca3..6a4de92 100644 --- a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java @@ -1,5 +1,6 @@ package group.goforward.ballistic.repos; +import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.MerchantCategoryMapping; import java.util.List; import java.util.Optional; @@ -13,5 +14,15 @@ public interface MerchantCategoryMappingRepository String rawCategory ); + Optional findByMerchantIdAndRawCategory( + Integer merchantId, + String rawCategory + ); + List findByMerchantIdOrderByRawCategoryAsc(Integer merchantId); + + Optional findByMerchantAndRawCategoryIgnoreCase( + Merchant merchant, + String rawCategory + ); } \ 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 index b64889e..91b7eb9 100644 --- a/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java @@ -4,9 +4,29 @@ import group.goforward.ballistic.model.PartRoleMapping; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface PartRoleMappingRepository extends JpaRepository { - // List mappings for a platform, ordered nicely for the UI - List findByPlatformOrderByPartRoleAsc(String platform); + // For resolver: one mapping per platform + partRole + Optional findFirstByPlatformAndPartRoleAndDeletedAtIsNull( + String platform, + String partRole + ); + + // Optional: debug / inspection + List findAllByPlatformAndPartRoleAndDeletedAtIsNull( + String platform, + String partRole + ); + + // This is the one PartRoleMappingService needs + List findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc( + String platform + ); + + List findByPlatformAndPartCategory_SlugAndDeletedAtIsNull( + String platform, + String slug + ); } \ 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 6178413..bf59f18 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java @@ -2,6 +2,7 @@ package group.goforward.ballistic.repos; import group.goforward.ballistic.model.ProductOffer; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.Collection; import java.util.List; @@ -19,4 +20,15 @@ public interface ProductOfferRepository extends JpaRepository countByMerchantPlatformAndStatus(); } \ 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 ad91c1e..8886663 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -1,12 +1,18 @@ package group.goforward.ballistic.repos; +import group.goforward.ballistic.model.ImportStatus; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; 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 org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; import java.util.List; +import java.util.Map; public interface ProductRepository extends JpaRepository { @@ -18,6 +24,8 @@ public interface ProductRepository extends JpaRepository { List findAllByBrandAndUpc(Brand brand, String upc); + long countByImportStatus(ImportStatus importStatus); + boolean existsBySlug(String slug); // ------------------------------------------------- @@ -25,16 +33,16 @@ public interface ProductRepository extends JpaRepository { // ------------------------------------------------- @Query(""" - SELECT p - FROM Product p - JOIN FETCH p.brand b - WHERE p.platform = :platform - AND p.deletedAt IS NULL - """) + 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(name="Products.findByPlatformWithBrand") -List findByPlatformWithBrandNQ(@Param("platform") String platform); + @Query(name = "Products.findByPlatformWithBrand") + List findByPlatformWithBrandNQ(@Param("platform") String platform); @Query(""" SELECT p @@ -50,16 +58,149 @@ List findByPlatformWithBrandNQ(@Param("platform") String platform); ); // ------------------------------------------------- - // Used by Gunbuilder service (if you wired this) + // Used by /api/gunbuilder/test-products-db // ------------------------------------------------- @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); + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.platform = :platform + AND p.deletedAt IS NULL + ORDER BY p.id + """) + List findTop5ByPlatformWithBrand(@Param("platform") String platform); + + // ------------------------------------------------- + // Used by GunbuilderProductService (builder UI) + // Only returns MAPPED products + // ------------------------------------------------- + + @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.partRole IN :partRoles + AND p.importStatus = :status + AND p.deletedAt IS NULL + """) + List findForGunbuilderByPlatformAndPartRoles( + @Param("platform") String platform, + @Param("partRoles") Collection partRoles, + @Param("status") ImportStatus status + ); + + // ------------------------------------------------- + // Admin import-status dashboard (summary) + // ------------------------------------------------- + @Query(""" + SELECT p.importStatus AS status, COUNT(p) AS count + FROM Product p + WHERE p.deletedAt IS NULL + GROUP BY p.importStatus + """) + List> aggregateByImportStatus(); + + // ------------------------------------------------- + // Admin import-status dashboard (by merchant) + // ------------------------------------------------- + @Query(""" + SELECT m.id AS merchantId, + m.name AS merchantName, + p.platform AS platform, + p.importStatus AS status, + COUNT(p) AS count + FROM Product p + JOIN p.offers o + JOIN o.merchant m + WHERE p.deletedAt IS NULL + GROUP BY m.id, m.name, p.platform, p.importStatus + ORDER BY m.name ASC, p.platform ASC, p.importStatus ASC + """) + List> aggregateByMerchantAndStatus(); + + // ------------------------------------------------- + // Admin: Unmapped category clusters + // ------------------------------------------------- + @Query(""" + SELECT p.rawCategoryKey AS rawCategoryKey, + m.name AS merchantName, + COUNT(DISTINCT p.id) AS productCount + FROM Product p + JOIN p.offers o + JOIN o.merchant m + WHERE p.deletedAt IS NULL + AND (p.importStatus = group.goforward.ballistic.model.ImportStatus.PENDING_MAPPING + OR p.partRole IS NULL + OR LOWER(p.partRole) = 'unknown') + AND p.rawCategoryKey IS NOT NULL + GROUP BY p.rawCategoryKey, m.name + ORDER BY productCount DESC + """) + List> findUnmappedCategoryGroups(); + + @Query(""" + SELECT p + FROM Product p + JOIN p.offers o + JOIN o.merchant m + WHERE p.deletedAt IS NULL + AND m.name = :merchantName + AND p.rawCategoryKey = :rawCategoryKey + ORDER BY p.id + """) + List findExamplesForCategoryGroup( + @Param("merchantName") String merchantName, + @Param("rawCategoryKey") String rawCategoryKey + ); + + // ------------------------------------------------- + // Mapping admin – pending buckets (all merchants) + // ------------------------------------------------- + @Query(""" + SELECT m.id AS merchantId, + m.name AS merchantName, + p.rawCategoryKey AS rawCategoryKey, + mcm.mappedPartRole AS mappedPartRole, + COUNT(DISTINCT p.id) AS productCount + FROM Product p + JOIN p.offers o + JOIN o.merchant m + LEFT JOIN MerchantCategoryMapping mcm + ON mcm.merchant.id = m.id + AND mcm.rawCategory = p.rawCategoryKey + WHERE p.importStatus = :status + GROUP BY m.id, m.name, p.rawCategoryKey, mcm.mappedPartRole + ORDER BY productCount DESC + """) + List findPendingMappingBuckets( + @Param("status") ImportStatus status + ); + + // ------------------------------------------------- + // Mapping admin – pending buckets for a single merchant + // ------------------------------------------------- + @Query(""" + SELECT m.id AS merchantId, + m.name AS merchantName, + p.rawCategoryKey AS rawCategoryKey, + mcm.mappedPartRole AS mappedPartRole, + COUNT(DISTINCT p.id) AS productCount + FROM Product p + JOIN p.offers o + JOIN o.merchant m + LEFT JOIN MerchantCategoryMapping mcm + ON mcm.merchant.id = m.id + AND mcm.rawCategory = p.rawCategoryKey + WHERE p.importStatus = :status + AND m.id = :merchantId + GROUP BY m.id, m.name, p.rawCategoryKey, mcm.mappedPartRole + ORDER BY productCount DESC + """) + List findPendingMappingBucketsForMerchant( + @Param("merchantId") Integer merchantId, + @Param("status") ImportStatus status + ); } \ 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 28f7ba4..f77d7ed 100644 --- a/src/main/java/group/goforward/ballistic/repos/UserRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/UserRepository.java @@ -13,4 +13,6 @@ public interface UserRepository extends JpaRepository { boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email); Optional findByUuid(UUID uuid); + + boolean existsByRole(String role); } \ 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 c278bd2..e068668 100644 --- a/src/main/java/group/goforward/ballistic/repos/package-info.java +++ b/src/main/java/group/goforward/ballistic/repos/package-info.java @@ -4,7 +4,7 @@ * * *

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

+ * {@link group.goforward.ballistic.BattlBuilderApplication} class.

* * @since 1.0 * @author Sean Strawsburg diff --git a/src/main/java/group/goforward/ballistic/services/AdminDashboardService.java b/src/main/java/group/goforward/ballistic/services/AdminDashboardService.java new file mode 100644 index 0000000..f84238a --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/AdminDashboardService.java @@ -0,0 +1,45 @@ +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.ImportStatus; +import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.web.dto.AdminDashboardOverviewDto; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AdminDashboardService { + + private final ProductRepository productRepository; + private final MerchantRepository merchantRepository; + private final MerchantCategoryMappingRepository merchantCategoryMappingRepository; + + public AdminDashboardService( + ProductRepository productRepository, + MerchantRepository merchantRepository, + MerchantCategoryMappingRepository merchantCategoryMappingRepository + ) { + this.productRepository = productRepository; + this.merchantRepository = merchantRepository; + this.merchantCategoryMappingRepository = merchantCategoryMappingRepository; + } + + @Transactional(readOnly = true) + public AdminDashboardOverviewDto getOverview() { + long totalProducts = productRepository.count(); + long unmappedProducts = productRepository.countByImportStatus(ImportStatus.PENDING_MAPPING); + long mappedProducts = totalProducts - unmappedProducts; + + long merchantCount = merchantRepository.count(); + long categoryMappings = merchantCategoryMappingRepository.count(); + + return new AdminDashboardOverviewDto( + totalProducts, + mappedProducts, + unmappedProducts, + merchantCount, + categoryMappings + ); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/BrandService.java b/src/main/java/group/goforward/ballistic/services/BrandService.java index 4039db4..ddf640d 100644 --- a/src/main/java/group/goforward/ballistic/services/BrandService.java +++ b/src/main/java/group/goforward/ballistic/services/BrandService.java @@ -1,16 +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); -} +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/CategoryMappingRecommendationService.java b/src/main/java/group/goforward/ballistic/services/CategoryMappingRecommendationService.java new file mode 100644 index 0000000..92b1713 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/CategoryMappingRecommendationService.java @@ -0,0 +1,72 @@ +// src/main/java/group/goforward/ballistic/services/CategoryMappingRecommendationService.java +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.web.dto.CategoryMappingRecommendationDto; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class CategoryMappingRecommendationService { + + private final ProductRepository productRepository; + + public CategoryMappingRecommendationService(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + public List listRecommendations() { + var groups = productRepository.findUnmappedCategoryGroups(); + + return groups.stream() + .map(row -> { + String merchantName = (String) row.get("merchantName"); + String rawCategoryKey = (String) row.get("rawCategoryKey"); + long count = (long) row.get("productCount"); + + // Pull one sample product name + List examples = productRepository + .findExamplesForCategoryGroup(merchantName, rawCategoryKey); + String sampleName = examples.isEmpty() + ? null + : examples.get(0).getName(); + + String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName); + + return new CategoryMappingRecommendationDto( + merchantName, + rawCategoryKey, + count, + recommendedRole, + sampleName + ); + }) + .toList(); + } + + private String inferPartRoleFromRawKey(String rawKey, String sampleName) { + String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase(); + + if (blob.contains("handguard") || blob.contains("rail")) return "handguard"; + if (blob.contains("barrel")) return "barrel"; + if (blob.contains("upper")) return "upper-receiver"; + if (blob.contains("lower")) return "lower-receiver"; + if (blob.contains("mag") || blob.contains("magazine")) return "magazine"; + if (blob.contains("stock") || blob.contains("buttstock")) return "stock"; + if (blob.contains("grip")) return "grip"; + if (blob.contains("trigger")) return "trigger"; + if (blob.contains("sight") || blob.contains("iron sights")) return "sights"; + if (blob.contains("optic") || blob.contains("scope") || blob.contains("red dot")) return "optic"; + if (blob.contains("buffer")) return "buffer"; + if (blob.contains("gas block")) return "gas-block"; + if (blob.contains("gas tube")) return "gas-tube"; + if (blob.contains("muzzle")) return "muzzle-device"; + if (blob.contains("sling")) return "sling"; + if (blob.contains("bipod")) return "bipod"; + if (blob.contains("tool")) return "tools"; + + return "UNKNOWN"; + } +} \ 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 index 1e074fe..5733487 100644 --- a/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java +++ b/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java @@ -5,38 +5,82 @@ 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 group.goforward.ballistic.model.PartRoleMapping; +import group.goforward.ballistic.repos.PartRoleMappingRepository; +import group.goforward.ballistic.model.ImportStatus; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; import java.util.List; + @Service public class GunbuilderProductService { private final ProductRepository productRepository; private final PartCategoryResolverService partCategoryResolverService; + private final PartRoleMappingRepository partRoleMappingRepository; public GunbuilderProductService( ProductRepository productRepository, - PartCategoryResolverService partCategoryResolverService + PartCategoryResolverService partCategoryResolverService, + PartRoleMappingRepository partRoleMappingRepository ) { this.productRepository = productRepository; this.partCategoryResolverService = partCategoryResolverService; + this.partRoleMappingRepository = partRoleMappingRepository; } - public List listGunbuilderProducts(String platform) { + /** + * Main builder endpoint. + * For now we ONLY support calls that provide partRoles, + * to avoid pulling the entire catalog into memory. + */ + public List listGunbuilderProducts(String platform, List partRoles) { - List products = productRepository.findSomethingForGunbuilder(platform); + System.out.println(">>> GB: listGunbuilderProducts platform=" + platform + + ", partRoles=" + partRoles); + + if (partRoles == null || partRoles.isEmpty()) { + System.out.println(">>> GB: no partRoles provided, returning empty list"); + return List.of(); + } + + List products = + productRepository.findForGunbuilderByPlatformAndPartRoles( + platform, + partRoles, + ImportStatus.MAPPED + ); + + System.out.println(">>> GB: repo returned " + products.size() + " products"); + + Map categoryCache = new HashMap<>(); return products.stream() .map(p -> { - var maybeCategory = partCategoryResolverService - .resolveForPlatformAndPartRole(platform, p.getPartRole()); + PartCategory cat = categoryCache.computeIfAbsent( + p.getPartRole(), + role -> partCategoryResolverService + .resolveForPlatformAndPartRole(platform, role) + .orElse(null) + ); - if (maybeCategory.isEmpty()) { - // you can also log here - return null; + if (cat == null) { + System.out.println(">>> GB: NO CATEGORY for platform=" + platform + + ", partRole=" + p.getPartRole() + + ", productId=" + p.getId()); + } else { + System.out.println(">>> GB: CATEGORY for productId=" + p.getId() + + " -> slug=" + cat.getSlug() + + ", group=" + cat.getGroupName()); } - PartCategory cat = maybeCategory.get(); + // TEMP: do NOT drop products if category is null. + // Just mark them as "unmapped" so we can see them in the JSON. + String categorySlug = (cat != null) ? cat.getSlug() : "unmapped"; + String categoryGroup = (cat != null) ? cat.getGroupName() : "Unmapped"; return new GunbuilderProductDto( p.getId(), @@ -47,11 +91,54 @@ public class GunbuilderProductService { p.getBestOfferPrice(), p.getMainImageUrl(), p.getBestOfferBuyUrl(), - cat.getSlug(), - cat.getGroupName() + categorySlug, + categoryGroup ); }) - .filter(dto -> dto != null) .toList(); } + + public List listGunbuilderProductsByCategory( + String platform, + String categorySlug + ) { + System.out.println(">>> GB: listGunbuilderProductsByCategory platform=" + platform + + ", categorySlug=" + categorySlug); + + if (platform == null || platform.isBlank() + || categorySlug == null || categorySlug.isBlank()) { + System.out.println(">>> GB: missing platform or categorySlug, returning empty list"); + return List.of(); + } + + List mappings = + partRoleMappingRepository.findByPlatformAndPartCategory_SlugAndDeletedAtIsNull( + platform, + categorySlug + ); + + List partRoles = mappings.stream() + .map(PartRoleMapping::getPartRole) + .distinct() + .toList(); + + System.out.println(">>> GB: resolved " + partRoles.size() + + " partRoles for categorySlug=" + categorySlug + " -> " + partRoles); + + if (partRoles.isEmpty()) { + return List.of(); + } + + // Reuse the existing method that already does all the DTO + category resolution logic + return listGunbuilderProducts(platform, partRoles); + } + + /** + * Tiny helper used ONLY by /test-products-db to prove DB wiring. + */ + public List getSampleProducts(String platform) { + // You already have this wired via ProductRepository.findTop5ByPlatformWithBrand + // If that method exists, keep using it; if not, you can stub a tiny query here. + return productRepository.findTop5ByPlatformWithBrand(platform); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/ImportStatusAdminService.java b/src/main/java/group/goforward/ballistic/services/ImportStatusAdminService.java new file mode 100644 index 0000000..896a493 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/ImportStatusAdminService.java @@ -0,0 +1,42 @@ +// src/main/java/group/goforward/ballistic/services/ImportStatusAdminService.java +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.ImportStatus; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.web.dto.ImportStatusByMerchantDto; +import group.goforward.ballistic.web.dto.ImportStatusSummaryDto; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class ImportStatusAdminService { + + private final ProductRepository productRepository; + + public ImportStatusAdminService(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + public List summarizeByStatus() { + return productRepository.aggregateByImportStatus() + .stream() + .map(row -> new ImportStatusSummaryDto( + (ImportStatus) row.get("status"), + (long) row.get("count") + )) + .toList(); + } + + public List summarizeByMerchant() { + return productRepository.aggregateByMerchantAndStatus() + .stream() + .map(row -> new ImportStatusByMerchantDto( + (String) row.get("merchantName"), + (String) row.get("platform"), + (ImportStatus) row.get("status"), + (long) row.get("count") + )) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/MappingAdminService.java b/src/main/java/group/goforward/ballistic/services/MappingAdminService.java new file mode 100644 index 0000000..90af277 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/MappingAdminService.java @@ -0,0 +1,93 @@ +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.ImportStatus; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.web.dto.PendingMappingBucketDto; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class MappingAdminService { + + private final ProductRepository productRepository; + private final MerchantCategoryMappingRepository merchantCategoryMappingRepository; + private final MerchantRepository merchantRepository; + + public MappingAdminService( + ProductRepository productRepository, + MerchantCategoryMappingRepository merchantCategoryMappingRepository, + MerchantRepository merchantRepository + ) { + this.productRepository = productRepository; + this.merchantCategoryMappingRepository = merchantCategoryMappingRepository; + this.merchantRepository = merchantRepository; + } + + /** + * Returns all pending mapping buckets across all merchants. + * Each row is: + * [0] merchantId (Integer) + * [1] merchantName (String) + * [2] rawCategoryKey (String) + * [3] mappedPartRole (String, currently null from query) + * [4] productCount (Long) + */ + @Transactional(readOnly = true) + public List listPendingBuckets() { + List rows = productRepository.findPendingMappingBuckets( + ImportStatus.PENDING_MAPPING + ); + + return rows.stream() + .map(row -> { + Integer merchantId = (Integer) row[0]; + String merchantName = (String) row[1]; + String rawCategoryKey = (String) row[2]; + String mappedPartRole = (String) row[3]; + Long count = (Long) row[4]; + + return new PendingMappingBucketDto( + merchantId, + merchantName, + rawCategoryKey, + (mappedPartRole != null && !mappedPartRole.isBlank()) ? mappedPartRole : null, + count != null ? count : 0L + ); + }) + .toList(); + } + + /** + * Applies or updates a mapping for (merchant, rawCategoryKey) to a given partRole. + * Does NOT retroactively update Product rows; they will be updated on the next import. + */ + @Transactional + public void applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) { + if (merchantId == null || rawCategoryKey == null || mappedPartRole == null || mappedPartRole.isBlank()) { + throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required"); + } + + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); + + MerchantCategoryMapping mapping = merchantCategoryMappingRepository + .findByMerchantIdAndRawCategory(merchantId, rawCategoryKey) + .orElseGet(() -> { + MerchantCategoryMapping m = new MerchantCategoryMapping(); + m.setMerchant(merchant); + m.setRawCategory(rawCategoryKey); + return m; + }); + + mapping.setMappedPartRole(mappedPartRole.trim()); + merchantCategoryMappingRepository.save(mapping); + + // Products will pick up this mapping on the next merchant import run. + } +} \ 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 ec963df..35faaea 100644 --- a/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java @@ -1,96 +1,95 @@ -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 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 399c448..5fea407 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/PartCategoryResolverService.java b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java index 31dd63a..12e0f52 100644 --- a/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java +++ b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java @@ -1,7 +1,8 @@ package group.goforward.ballistic.services; import group.goforward.ballistic.model.PartCategory; -import group.goforward.ballistic.repos.PartCategoryRepository; +import group.goforward.ballistic.model.PartRoleMapping; +import group.goforward.ballistic.repos.PartRoleMappingRepository; import org.springframework.stereotype.Service; import java.util.Optional; @@ -9,33 +10,29 @@ import java.util.Optional; @Service public class PartCategoryResolverService { - private final PartCategoryRepository partCategoryRepository; + private final PartRoleMappingRepository partRoleMappingRepository; - public PartCategoryResolverService(PartCategoryRepository partCategoryRepository) { - this.partCategoryRepository = partCategoryRepository; + public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) { + this.partRoleMappingRepository = partRoleMappingRepository; } /** - * 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. + * Resolve a PartCategory for a given platform + partRole. + * Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower" */ public Optional resolveForPlatformAndPartRole(String platform, String partRole) { - if (partRole == null || partRole.isBlank()) { + + if (platform == null || partRole == null) { return Optional.empty(); } - String normalizedSlug = partRole - .trim() - .toLowerCase() - .replace(" ", "-"); + // Keep things case-sensitive since your DB values are already uppercase. + Optional mappingOpt = + partRoleMappingRepository.findFirstByPlatformAndPartRoleAndDeletedAtIsNull( + platform, + partRole + ); - return partCategoryRepository.findBySlug(normalizedSlug); + return mappingOpt.map(PartRoleMapping::getPartCategory); } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/PartRoleMappingService.java b/src/main/java/group/goforward/ballistic/services/PartRoleMappingService.java new file mode 100644 index 0000000..cd9f507 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/PartRoleMappingService.java @@ -0,0 +1,35 @@ +package group.goforward.ballistic.services; + +import group.goforward.ballistic.repos.PartRoleMappingRepository; +import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto; +import group.goforward.ballistic.web.dto.PartRoleToCategoryDto; +import group.goforward.ballistic.web.mapper.PartRoleMappingMapper; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PartRoleMappingService { + + private final PartRoleMappingRepository repository; + + public PartRoleMappingService(PartRoleMappingRepository repository) { + this.repository = repository; + } + + public List getMappingsForPlatform(String platform) { + return repository + .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform) + .stream() + .map(PartRoleMappingMapper::toDto) + .toList(); + } + + public List getRoleToCategoryMap(String platform) { + return repository + .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform) + .stream() + .map(PartRoleMappingMapper::toRoleMapDto) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/StatesService.java b/src/main/java/group/goforward/ballistic/services/StatesService.java index e07d927..a8d74c1 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 3717947..59ebe13 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/admin/AdminUserService.java b/src/main/java/group/goforward/ballistic/services/admin/AdminUserService.java new file mode 100644 index 0000000..de6c0f0 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/admin/AdminUserService.java @@ -0,0 +1,55 @@ +package group.goforward.ballistic.services.admin; + +import group.goforward.ballistic.model.User; +import group.goforward.ballistic.repos.UserRepository; +import group.goforward.ballistic.web.dto.admin.AdminUserDto; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Service +public class AdminUserService { + + private static final Set ALLOWED_ROLES = Set.of("USER", "ADMIN"); + + private final UserRepository userRepository; + + public AdminUserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public List getAllUsersForAdmin() { + return userRepository.findAll() + .stream() + .map(AdminUserDto::fromUser) + .toList(); + } + + @Transactional + public AdminUserDto updateUserRole(UUID userUuid, String newRole, Authentication auth) { + if (newRole == null || !ALLOWED_ROLES.contains(newRole.toUpperCase())) { + throw new IllegalArgumentException("Invalid role: " + newRole); + } + + User user = userRepository.findByUuid(userUuid) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + // Optional safety: do not allow demoting yourself (you can loosen this later) + String currentEmail = auth != null ? auth.getName() : null; + boolean isSelf = currentEmail != null + && currentEmail.equalsIgnoreCase(user.getEmail()); + + if (isSelf && !"ADMIN".equalsIgnoreCase(newRole)) { + throw new IllegalStateException("You cannot change your own role to non-admin."); + } + + user.setRole(newRole.toUpperCase()); + // updatedAt will be handled by your entity / DB defaults + + return AdminUserDto.fromUser(user); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/impl/BrandServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/BrandServiceImpl.java index fbd67b7..65c63e5 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/BrandServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/BrandServiceImpl.java @@ -1,38 +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); - } -} +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/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index c2357a4..9370132 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,664 @@ -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 group.goforward.ballistic.imports.MerchantFeedRow; +import group.goforward.ballistic.model.Brand; +import group.goforward.ballistic.model.ImportStatus; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.model.ProductOffer; +import group.goforward.ballistic.repos.BrandRepository; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.repos.ProductOfferRepository; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.services.MerchantCategoryMappingService; +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 org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.InputStreamReader; +import java.io.Reader; +import java.math.BigDecimal; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.*; + +/** + * Merchant feed ETL + offer sync. + * + * - importMerchantFeed: full ETL (products + offers) + * - syncOffersOnly: only refresh offers/prices/stock from an offers feed + */ +@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; + } + + // --------------------------------------------------------------------- + // Full product + offer import + // --------------------------------------------------------------------- + + @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)); + + 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()); + } + } + + // --------------------------------------------------------------------- + // Product upsert + // --------------------------------------------------------------------- + + 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 a real UPC column exists + + 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); + + Product saved = productRepository.save(p); + + upsertOfferFromRow(saved, merchant, row); + + return saved; + } + + 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); + p.setUpc(null); // placeholder + + // ---------- PLATFORM ---------- + if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) { + String platform = inferPlatform(row); + p.setPlatform(platform != null ? platform : "AR-15"); + } + + // ---------- RAW CATEGORY KEY ---------- + String rawCategoryKey = buildRawCategoryKey(row); + p.setRawCategoryKey(rawCategoryKey); + + // ---------- PART ROLE (mapping + fallback) ---------- + String partRole = null; + + // 1) First try merchant category mapping + if (rawCategoryKey != null) { + MerchantCategoryMapping mapping = + merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey); + + if (mapping != null && + mapping.getMappedPartRole() != null && + !mapping.getMappedPartRole().isBlank()) { + partRole = mapping.getMappedPartRole().trim(); + } + } + + // 2) Fallback to keyword-based inference + if (partRole == null || partRole.isBlank()) { + partRole = inferPartRole(row); + } + + // 3) Normalize or default to UNKNOWN + if (partRole == null || partRole.isBlank()) { + partRole = "UNKNOWN"; + } else { + partRole = partRole.trim(); + } + + p.setPartRole(partRole); + + // ---------- IMPORT STATUS ---------- + if ("UNKNOWN".equalsIgnoreCase(partRole)) { + p.setImportStatus(ImportStatus.PENDING_MAPPING); + } else { + p.setImportStatus(ImportStatus.MAPPED); + } + } + + // --------------------------------------------------------------------- + // Offer upsert (full ETL) + // --------------------------------------------------------------------- + + private void upsertOfferFromRow(Product product, + Merchant merchant, + MerchantFeedRow row) { + + String avantlinkProductId = trimOrNull(row.sku()); + if (avantlinkProductId == null) { + log.debug("Skipping offer row with no SKU for product id={}", product.getId()); + return; + } + + ProductOffer offer = productOfferRepository + .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .orElseGet(ProductOffer::new); + + if (offer.getId() == null) { + offer.setMerchant(merchant); + offer.setProduct(product); + offer.setAvantlinkProductId(avantlinkProductId); + offer.setFirstSeenAt(OffsetDateTime.now()); + } else { + offer.setMerchant(merchant); + offer.setProduct(product); + } + + offer.setSku(trimOrNull(row.sku())); + offer.setUpc(null); + + offer.setBuyUrl(trimOrNull(row.buyLink())); + + BigDecimal retail = row.retailPrice(); + BigDecimal sale = row.salePrice(); + + BigDecimal effectivePrice; + BigDecimal originalPrice; + + if (sale != null && (retail == null || sale.compareTo(retail) <= 0)) { + effectivePrice = sale; + originalPrice = (retail != null ? retail : sale); + } else { + effectivePrice = (retail != null ? retail : sale); + originalPrice = (retail != null ? retail : sale); + } + + offer.setPrice(effectivePrice); + offer.setOriginalPrice(originalPrice); + + offer.setCurrency("USD"); + offer.setInStock(Boolean.TRUE); + offer.setLastSeenAt(OffsetDateTime.now()); + + productOfferRepository.save(offer); + } + + // --------------------------------------------------------------------- + // Offers-only sync + // --------------------------------------------------------------------- + + @Override + @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())) { + log.info("Merchant {} is inactive, skipping offers-only sync", merchant.getName()); + return; + } + + String feedUrl = merchant.getOfferFeedUrl() != null + ? merchant.getOfferFeedUrl() + : merchant.getFeedUrl(); + + if (feedUrl == null || feedUrl.isBlank()) { + 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) { + String avantlinkProductId = trimOrNull(row.get("SKU")); + if (avantlinkProductId == null || avantlinkProductId.isBlank()) { + return; + } + + ProductOffer offer = productOfferRepository + .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .orElse(null); + + if (offer == null) { + // Offers-only sync should not create new offers; skip if missing. + return; + } + + BigDecimal price = parseBigDecimal(row.get("Sale Price")); + BigDecimal originalPrice = parseBigDecimal(row.get("Retail Price")); + + offer.setPrice(price); + offer.setOriginalPrice(originalPrice); + offer.setInStock(parseInStock(row)); + + 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(Locale.ROOT); + if (lower.contains("true") || lower.contains("yes") || lower.contains("1") || lower.contains("in stock")) { + return Boolean.TRUE; + } + if (lower.contains("false") || lower.contains("no") || lower.contains("0") || lower.contains("out of stock")) { + return Boolean.FALSE; + } + + return Boolean.FALSE; + } + + private List> fetchFeedRows(String feedUrl) { + log.info("Reading offer feed from {}", feedUrl); + + List> rows = new ArrayList<>(); + + try (Reader reader = openFeedReader(feedUrl); + CSVParser parser = CSVFormat.DEFAULT + .withFirstRecordAsHeader() + .withIgnoreSurroundingSpaces() + .withTrim() + .parse(reader)) { + + 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; + } + + // --------------------------------------------------------------------- + // Feed reading + brand resolution (full ETL) + // --------------------------------------------------------------------- + + 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 + ); + } + } + + private CSVFormat detectCsvFormat(String feedUrl) throws Exception { + char[] delimiters = new char[]{'\t', ',', ';'}; + List requiredHeaders = + 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 { + 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); + }); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + 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; + } + } + + private String getCsvValue(CSVRecord rec, String header) { + if (rec == null || header == null) { + return null; + } + if (!rec.isMapped(header)) { + 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; + } + } + + 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()); + + List parts = new 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(Locale.ROOT); + 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(Locale.ROOT); + + 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"; + } } \ No newline at end of file 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 8ae3d86..8d3d44a 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 3620bbf..a3b1cf8 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 e6d7a02..0419b2e 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.BattlBuilderApplication} 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/GunbuilderProductController.java b/src/main/java/group/goforward/ballistic/web/GunbuilderProductController.java new file mode 100644 index 0000000..4fbd505 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/GunbuilderProductController.java @@ -0,0 +1,84 @@ +package group.goforward.ballistic.web; + +import group.goforward.ballistic.services.GunbuilderProductService; +import group.goforward.ballistic.web.dto.GunbuilderProductDto; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/gunbuilder") +public class GunbuilderProductController { + + private final GunbuilderProductService gunbuilderProductService; + + public GunbuilderProductController(GunbuilderProductService gunbuilderProductService) { + this.gunbuilderProductService = gunbuilderProductService; + System.out.println(">>> GunbuilderProductController initialized"); + } + + // 🔹 sanity check: is this controller even mapped? + @GetMapping("/ping") + public Map ping() { + return Map.of("status", "ok", "source", "GunbuilderProductController"); + } + + // 🔹 super-dumb test: no DB, no service, just prove the route works + @GetMapping("/test-products") + public Map testProducts(@RequestParam String platform) { + System.out.println(">>> /api/gunbuilder/test-products hit for platform=" + platform); + + Map m = new java.util.HashMap<>(); + m.put("platform", platform); + m.put("note", "test endpoint only"); + m.put("ok", true); + return m; + } + + /** + * List products for the builder UI. + * + * Examples: + * GET /api/gunbuilder/products?platform=AR-15 + * GET /api/gunbuilder/products?platform=AR-15&partRoles=LOWER_RECEIVER_STRIPPED&partRoles=LOWER_RECEIVER_COMPLETE + */ + @GetMapping("/products") + public List listProducts( + @RequestParam String platform, + @RequestParam(required = false) List partRoles + ) { + return gunbuilderProductService.listGunbuilderProducts(platform, partRoles); + } + + @GetMapping("/products/by-category") + public List listProductsByCategory( + @RequestParam String platform, + @RequestParam String categorySlug + ) { + return gunbuilderProductService.listGunbuilderProductsByCategory(platform, categorySlug); + } + + // 🔹 DB test: hit repo via service and return a tiny view of products + @GetMapping("/test-products-db") + public List> testProductsDb(@RequestParam String platform) { + System.out.println(">>> /api/gunbuilder/test-products-db hit for platform=" + platform); + + var products = gunbuilderProductService.getSampleProducts(platform); + + return products.stream() + .map(p -> { + Map m = new java.util.HashMap<>(); + m.put("id", p.getId()); + m.put("name", p.getName()); + m.put("brand", p.getBrand() != null ? p.getBrand().getName() : null); + m.put("partRole", p.getPartRole()); + return m; + }) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/PartRoleMappingController.java b/src/main/java/group/goforward/ballistic/web/PartRoleMappingController.java new file mode 100644 index 0000000..d68568c --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/PartRoleMappingController.java @@ -0,0 +1,31 @@ +package group.goforward.ballistic.web; + +import group.goforward.ballistic.services.PartRoleMappingService; +import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto; +import group.goforward.ballistic.web.dto.PartRoleToCategoryDto; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/part-role-mappings") +public class PartRoleMappingController { + + private final PartRoleMappingService service; + + public PartRoleMappingController(PartRoleMappingService service) { + this.service = service; + } + + // Full view for admin UI + @GetMapping("/{platform}") + public List getMappings(@PathVariable String platform) { + return service.getMappingsForPlatform(platform); + } + + // Thin mapping for the builder + @GetMapping("/{platform}/map") + public List getRoleMap(@PathVariable String platform) { + return service.getRoleToCategoryMap(platform); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/admin/AdminImportStatusController.java b/src/main/java/group/goforward/ballistic/web/admin/AdminImportStatusController.java new file mode 100644 index 0000000..962c868 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/admin/AdminImportStatusController.java @@ -0,0 +1,73 @@ +package group.goforward.ballistic.web.admin; + +import group.goforward.ballistic.model.ImportStatus; +import group.goforward.ballistic.repos.ProductRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/import-status") +public class AdminImportStatusController { + + private final ProductRepository productRepository; + + public AdminImportStatusController(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + public record ImportSummaryDto( + long totalProducts, + long mappedProducts, + long pendingProducts + ) {} + + public record ByMerchantRowDto( + Integer merchantId, + String merchantName, + String platform, + ImportStatus status, + long count + ) {} + + @GetMapping("/summary") + public ImportSummaryDto summary() { + List> rows = productRepository.aggregateByImportStatus(); + + long total = 0L; + long mapped = 0L; + long pending = 0L; + + for (Map row : rows) { + ImportStatus status = (ImportStatus) row.get("status"); + long count = ((Number) row.get("count")).longValue(); + total += count; + + if (status == ImportStatus.MAPPED) { + mapped += count; + } else if (status == ImportStatus.PENDING_MAPPING) { + pending += count; + } + } + + return new ImportSummaryDto(total, mapped, pending); + } + + @GetMapping("/by-merchant") + public List byMerchant() { + List> rows = productRepository.aggregateByMerchantAndStatus(); + + return rows.stream() + .map(row -> new ByMerchantRowDto( + (Integer) row.get("merchantId"), + (String) row.get("merchantName"), + (String) row.get("platform"), + (ImportStatus) row.get("status"), + ((Number) row.get("count")).longValue() + )) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/admin/AdminMappingController.java b/src/main/java/group/goforward/ballistic/web/admin/AdminMappingController.java new file mode 100644 index 0000000..0135eec --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/admin/AdminMappingController.java @@ -0,0 +1,45 @@ +package group.goforward.ballistic.web.admin; + +import group.goforward.ballistic.services.MappingAdminService; +import group.goforward.ballistic.web.dto.PendingMappingBucketDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/mapping") +public class AdminMappingController { + + private final MappingAdminService mappingAdminService; + + public AdminMappingController(MappingAdminService mappingAdminService) { + this.mappingAdminService = mappingAdminService; + } + + @GetMapping("/pending-buckets") + public List listPendingBuckets() { + // Simple: just delegate to service + return mappingAdminService.listPendingBuckets(); + } + + public record ApplyMappingRequest( + Integer merchantId, + String rawCategoryKey, + String mappedPartRole + ) {} + + @PostMapping("/apply") + public ResponseEntity> applyMapping( + @RequestBody ApplyMappingRequest request + ) { + mappingAdminService.applyMapping( + request.merchantId(), + request.rawCategoryKey(), + request.mappedPartRole() + ); + + return ResponseEntity.ok(Map.of("ok", true)); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/admin/AdminMerchantController.java b/src/main/java/group/goforward/ballistic/web/admin/AdminMerchantController.java new file mode 100644 index 0000000..ca5ee46 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/admin/AdminMerchantController.java @@ -0,0 +1,35 @@ +package group.goforward.ballistic.web.admin; + +import group.goforward.ballistic.services.MerchantFeedImportService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/merchants") +public class AdminMerchantController { + + private final MerchantFeedImportService merchantFeedImportService; + + public AdminMerchantController(MerchantFeedImportService merchantFeedImportService) { + this.merchantFeedImportService = merchantFeedImportService; + } + + @PostMapping("/{merchantId}/import") + public ResponseEntity> triggerFullImport( + @PathVariable Integer merchantId + ) { + // Fire off the full import for this merchant. + // (Right now this is synchronous; later we can push to a queue if needed.) + merchantFeedImportService.importMerchantFeed(merchantId); + + return ResponseEntity.accepted().body( + Map.of( + "ok", true, + "merchantId", merchantId, + "message", "Import triggered" + ) + ); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/admin/AdminUserController.java b/src/main/java/group/goforward/ballistic/web/admin/AdminUserController.java new file mode 100644 index 0000000..e3fe03c --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/admin/AdminUserController.java @@ -0,0 +1,37 @@ +package group.goforward.ballistic.web.admin; + +import group.goforward.ballistic.services.admin.AdminUserService; +import group.goforward.ballistic.web.dto.admin.AdminUserDto; +import group.goforward.ballistic.web.dto.admin.UpdateUserRoleRequest; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/admin/users") +@PreAuthorize("hasRole('ADMIN')") +public class AdminUserController { + + private final AdminUserService adminUserService; + + public AdminUserController(AdminUserService adminUserService) { + this.adminUserService = adminUserService; + } + + @GetMapping + public List listUsers() { + return adminUserService.getAllUsersForAdmin(); + } + + @PatchMapping("/{uuid}/role") + public AdminUserDto updateRole( + @PathVariable("uuid") UUID uuid, + @RequestBody UpdateUserRoleRequest request, + Authentication auth + ) { + return adminUserService.updateUserRole(uuid, request.getRole(), auth); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/admin/CategoryMappingAdminController.java b/src/main/java/group/goforward/ballistic/web/admin/CategoryMappingAdminController.java new file mode 100644 index 0000000..94d4b35 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/admin/CategoryMappingAdminController.java @@ -0,0 +1,67 @@ +package group.goforward.ballistic.web.admin; + +import group.goforward.ballistic.services.MappingAdminService; +import group.goforward.ballistic.web.dto.PendingMappingBucketDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/mappings") +public class CategoryMappingAdminController { + + private final MappingAdminService mappingAdminService; + + public CategoryMappingAdminController(MappingAdminService mappingAdminService) { + this.mappingAdminService = mappingAdminService; + } + + @GetMapping("/pending") + public List listPending() { + return mappingAdminService.listPendingBuckets(); + } + + public record ApplyMappingRequest( + Integer merchantId, + String rawCategoryKey, + String mappedPartRole + ) {} + + @PostMapping("/apply") + public ResponseEntity apply(@RequestBody ApplyMappingRequest request) { + mappingAdminService.applyMapping( + request.merchantId(), + request.rawCategoryKey(), + request.mappedPartRole() + ); + return ResponseEntity.noContent().build(); + } + +// @RestController +// @RequestMapping("/api/admin/mapping") +// public static class AdminMappingController { +// +// private final MappingAdminService mappingAdminService; +// +// public AdminMappingController(MappingAdminService mappingAdminService) { +// this.mappingAdminService = mappingAdminService; +// } +// +// @GetMapping("/pending-buckets") +// public List listPendingBuckets() { +// return mappingAdminService.listPendingBuckets(); +// } +// +// @PostMapping("/apply") +// public ResponseEntity applyMapping(@RequestBody Map body) { +// Integer merchantId = (Integer) body.get("merchantId"); +// String rawCategoryKey = (String) body.get("rawCategoryKey"); +// String mappedPartRole = (String) body.get("mappedPartRole"); +// +// mappingAdminService.applyMapping(merchantId, rawCategoryKey, mappedPartRole); +// return ResponseEntity.ok(Map.of("ok", true)); +// } +// } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/CategoryMappingRecommendationDto.java b/src/main/java/group/goforward/ballistic/web/dto/CategoryMappingRecommendationDto.java new file mode 100644 index 0000000..04502df --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/CategoryMappingRecommendationDto.java @@ -0,0 +1,10 @@ +// src/main/java/group/goforward/ballistic/web/dto/CategoryMappingRecommendationDto.java +package group.goforward.ballistic.web.dto; + +public record CategoryMappingRecommendationDto( + String merchantName, + String rawCategoryKey, + long productCount, + String recommendedPartRole, + String sampleProductName +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/ImportStatusByMerchantDto.java b/src/main/java/group/goforward/ballistic/web/dto/ImportStatusByMerchantDto.java new file mode 100644 index 0000000..1bb2f92 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/ImportStatusByMerchantDto.java @@ -0,0 +1,11 @@ +// src/main/java/group/goforward/ballistic/web/dto/ImportStatusByMerchantDto.java +package group.goforward.ballistic.web.dto; + +import group.goforward.ballistic.model.ImportStatus; + +public record ImportStatusByMerchantDto( + String merchantName, + String platform, + ImportStatus status, + long count +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/ImportStatusSummaryDto.java b/src/main/java/group/goforward/ballistic/web/dto/ImportStatusSummaryDto.java new file mode 100644 index 0000000..6bf8cb5 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/ImportStatusSummaryDto.java @@ -0,0 +1,9 @@ +// src/main/java/group/goforward/ballistic/web/dto/ImportStatusSummaryDto.java +package group.goforward.ballistic.web.dto; + +import group.goforward.ballistic.model.ImportStatus; + +public record ImportStatusSummaryDto( + ImportStatus status, + long count +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/PartRoleToCategoryDto.java b/src/main/java/group/goforward/ballistic/web/dto/PartRoleToCategoryDto.java new file mode 100644 index 0000000..1dcd3ba --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/PartRoleToCategoryDto.java @@ -0,0 +1,7 @@ +package group.goforward.ballistic.web.dto; + +public record PartRoleToCategoryDto( + String platform, + String partRole, + String categorySlug // e.g. "lower", "barrel", "optic" +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/PendingMappingBucketDto.java b/src/main/java/group/goforward/ballistic/web/dto/PendingMappingBucketDto.java new file mode 100644 index 0000000..74c70a8 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/PendingMappingBucketDto.java @@ -0,0 +1,9 @@ +package group.goforward.ballistic.web.dto; + +public record PendingMappingBucketDto( + Integer merchantId, + String merchantName, + String rawCategoryKey, + String mappedPartRole, + long productCount +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/AdminDashboardOverviewDto.java b/src/main/java/group/goforward/ballistic/web/dto/admin/AdminDashboardOverviewDto.java new file mode 100644 index 0000000..ad9316d --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/AdminDashboardOverviewDto.java @@ -0,0 +1,44 @@ +package group.goforward.ballistic.web.dto; + +public class AdminDashboardOverviewDto { + + private long totalProducts; + private long mappedProducts; + private long unmappedProducts; + private long merchantCount; + private long categoryMappingCount; + + public AdminDashboardOverviewDto( + long totalProducts, + long mappedProducts, + long unmappedProducts, + long merchantCount, + long categoryMappingCount + ) { + this.totalProducts = totalProducts; + this.mappedProducts = mappedProducts; + this.unmappedProducts = unmappedProducts; + this.merchantCount = merchantCount; + this.categoryMappingCount = categoryMappingCount; + } + + public long getTotalProducts() { + return totalProducts; + } + + public long getMappedProducts() { + return mappedProducts; + } + + public long getUnmappedProducts() { + return unmappedProducts; + } + + public long getMerchantCount() { + return merchantCount; + } + + public long getCategoryMappingCount() { + return categoryMappingCount; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/AdminUserDto.java b/src/main/java/group/goforward/ballistic/web/dto/admin/AdminUserDto.java new file mode 100644 index 0000000..6614211 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/AdminUserDto.java @@ -0,0 +1,76 @@ +package group.goforward.ballistic.web.dto.admin; + +import group.goforward.ballistic.model.User; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminUserDto { + + // We'll expose the UUID as the "id" used by the frontend + private UUID id; + private String email; + private String displayName; + private String role; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + private OffsetDateTime lastLoginAt; + + public AdminUserDto(UUID id, + String email, + String displayName, + String role, + OffsetDateTime createdAt, + OffsetDateTime updatedAt, + OffsetDateTime lastLoginAt) { + this.id = id; + this.email = email; + this.displayName = displayName; + this.role = role; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.lastLoginAt = lastLoginAt; + } + + public static AdminUserDto fromUser(User user) { + return new AdminUserDto( + user.getUuid(), // use UUID here (stable id) + user.getEmail(), + user.getDisplayName(), + user.getRole(), // String: "USER" / "ADMIN" + user.getCreatedAt(), + user.getUpdatedAt(), + user.getLastLoginAt() + ); + } + + // Getters (and setters if you want Jackson to use them) + + public UUID getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getDisplayName() { + return displayName; + } + + public String getRole() { + return role; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public OffsetDateTime getLastLoginAt() { + return lastLoginAt; + } +} \ 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 5082f89..64317e1 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 @@ -4,7 +4,8 @@ public record PartRoleMappingDto( Integer id, String platform, String partRole, - String categorySlug, - String groupName, + Integer partCategoryId, + String partCategorySlug, + String partCategoryName, String notes ) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/UpdateUserRoleRequest.java b/src/main/java/group/goforward/ballistic/web/dto/admin/UpdateUserRoleRequest.java new file mode 100644 index 0000000..e03bfb9 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/UpdateUserRoleRequest.java @@ -0,0 +1,21 @@ +package group.goforward.ballistic.web.dto.admin; + +public class UpdateUserRoleRequest { + + private String role; + + public UpdateUserRoleRequest() { + } + + public UpdateUserRoleRequest(String role) { + this.role = role; + } + + 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/mapper/PartRoleMappingMapper.java b/src/main/java/group/goforward/ballistic/web/mapper/PartRoleMappingMapper.java new file mode 100644 index 0000000..ff9a850 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/mapper/PartRoleMappingMapper.java @@ -0,0 +1,37 @@ +package group.goforward.ballistic.web.mapper; + +import group.goforward.ballistic.model.PartCategory; +import group.goforward.ballistic.model.PartRoleMapping; +import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto; +import group.goforward.ballistic.web.dto.PartRoleToCategoryDto; + +public final class PartRoleMappingMapper { + + private PartRoleMappingMapper() { + // utility class + } + + public static PartRoleMappingDto toDto(PartRoleMapping entity) { + PartCategory cat = entity.getPartCategory(); + + return new PartRoleMappingDto( + entity.getId(), + entity.getPlatform(), + entity.getPartRole(), + cat != null ? cat.getId() : null, + cat != null ? cat.getSlug() : null, + cat != null ? cat.getName() : null, + entity.getNotes() + ); + } + + public static PartRoleToCategoryDto toRoleMapDto(PartRoleMapping entity) { + PartCategory cat = entity.getPartCategory(); + + return new PartRoleToCategoryDto( + entity.getPlatform(), + entity.getPartRole(), + cat != null ? cat.getSlug() : null + ); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 87d55f9..72de408 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,4 +1,4 @@ -spring.application.name=ballistic +spring.application.name=BattlBuilderAPI # Database connection properties spring.datasource.url=jdbc:postgresql://r710.gofwd.group:5433/ss_builder spring.datasource.username=postgres @@ -7,8 +7,16 @@ spring.datasource.driver-class-name=org.postgresql.Driver # Hibernate properties #spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +#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 +security.jwt.access-token-minutes=2880 + +# Logging + +spring.jpa.show-sql=true +logging.level.org.hibernate.SQL=INFO +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=warn + +