fixed the merge conflicts on role-fixed branch and merged

This commit is contained in:
2025-12-08 11:42:48 -05:00
52 changed files with 2382 additions and 1184 deletions

View File

@@ -15,7 +15,7 @@
<artifactId>ballistic</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ballistic</name>
<description>Ballistic Builder API</description>
<description>Battl Builder API</description>
<url/>

View File

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

View File

@@ -4,7 +4,7 @@
*
*
* <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
* {@link group.goforward.ballistic.BattlBuilderApplication} class.</p>
*
* @since 1.0
* @author Don Strawsburg

View File

@@ -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<AdminDashboardOverviewDto> getOverview() {
AdminDashboardOverviewDto dto = adminDashboardService.getOverview();
return ResponseEntity.ok(dto);
}
}

View File

@@ -37,7 +37,8 @@ public class AdminPartRoleMappingController {
List<PartRoleMapping> mappings;
if (platform != null && !platform.isBlank()) {
mappings = partRoleMappingRepository.findByPlatformOrderByPartRoleAsc(platform);
mappings = partRoleMappingRepository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform);
} else {
mappings = partRoleMappingRepository.findAll();
}

View File

@@ -4,7 +4,7 @@
*
*
* <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
* {@link group.goforward.ballistic.BattlBuilderApplication} class.</p>
*
* @since 1.0
* @author Don Strawsburg

View File

@@ -4,7 +4,7 @@
*
*
* <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
* {@link group.goforward.ballistic.BattlBuilderApplication} class.</p>
*
* @since 1.0
* @author Sean Strawsburg

View File

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

View File

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

View File

@@ -9,9 +9,6 @@ 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= "" +
@@ -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;
@@ -93,31 +89,20 @@ public class Product {
@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<ProductOffer> offers = new HashSet<>();
public Set<ProductOffer> getOffers() {
return offers;
}
public void setOffers(Set<ProductOffer> 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;
public ImportStatus getImportStatus() { return importStatus; }
public void setImportStatus(ImportStatus importStatus) {
this.importStatus = importStatus;
}
// Convenience: best offer price for Gunbuilder
public Set<ProductOffer> getOffers() { return offers; }
public void setOffers(Set<ProductOffer> offers) { this.offers = offers; }
// --- computed helpers ---
public BigDecimal getBestOfferPrice() {
if (offers == null || offers.isEmpty()) {
return BigDecimal.ZERO;
}
if (offers == null || offers.isEmpty()) return BigDecimal.ZERO;
return offers.stream()
// pick sale_price if present, otherwise retail_price
.map(offer -> {
if (offer.getSalePrice() != null) {
return offer.getSalePrice();
}
return offer.getRetailPrice();
})
.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)

View File

@@ -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<MerchantCategoryMapping> findByMerchantIdAndRawCategory(
Integer merchantId,
String rawCategory
);
List<MerchantCategoryMapping> findByMerchantIdOrderByRawCategoryAsc(Integer merchantId);
Optional<MerchantCategoryMapping> findByMerchantAndRawCategoryIgnoreCase(
Merchant merchant,
String rawCategory
);
}

View File

@@ -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<PartRoleMapping, Integer> {
// List mappings for a platform, ordered nicely for the UI
List<PartRoleMapping> findByPlatformOrderByPartRoleAsc(String platform);
// For resolver: one mapping per platform + partRole
Optional<PartRoleMapping> findFirstByPlatformAndPartRoleAndDeletedAtIsNull(
String platform,
String partRole
);
// Optional: debug / inspection
List<PartRoleMapping> findAllByPlatformAndPartRoleAndDeletedAtIsNull(
String platform,
String partRole
);
// This is the one PartRoleMappingService needs
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(
String platform
);
List<PartRoleMapping> findByPlatformAndPartCategory_SlugAndDeletedAtIsNull(
String platform,
String slug
);
}

View File

@@ -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<ProductOffer, Inte
Integer merchantId,
String avantlinkProductId
);
@Query("""
SELECT m.name, p.platform, p.importStatus, COUNT(DISTINCT p.id)
FROM ProductOffer o
JOIN o.product p
JOIN o.merchant m
WHERE p.deletedAt IS NULL
GROUP BY m.name, p.platform, p.importStatus
ORDER BY m.name ASC, p.platform ASC, p.importStatus ASC
""")
List<Object[]> countByMerchantPlatformAndStatus();
}

View File

@@ -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<Product, Integer> {
@@ -18,6 +24,8 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
List<Product> findAllByBrandAndUpc(Brand brand, String upc);
long countByImportStatus(ImportStatus importStatus);
boolean existsBySlug(String slug);
// -------------------------------------------------
@@ -50,7 +58,22 @@ List<Product> findByPlatformWithBrandNQ(@Param("platform") String platform);
);
// -------------------------------------------------
// Used by Gunbuilder service (if you wired this)
// Used by /api/gunbuilder/test-products-db
// -------------------------------------------------
@Query("""
SELECT p
FROM Product p
JOIN FETCH p.brand b
WHERE p.platform = :platform
AND p.deletedAt IS NULL
ORDER BY p.id
""")
List<Product> findTop5ByPlatformWithBrand(@Param("platform") String platform);
// -------------------------------------------------
// Used by GunbuilderProductService (builder UI)
// Only returns MAPPED products
// -------------------------------------------------
@Query("""
@@ -59,7 +82,125 @@ List<Product> findByPlatformWithBrandNQ(@Param("platform") String platform);
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<Product> findSomethingForGunbuilder(@Param("platform") String platform);
List<Product> findForGunbuilderByPlatformAndPartRoles(
@Param("platform") String platform,
@Param("partRoles") Collection<String> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Product> 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<Object[]> 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<Object[]> findPendingMappingBucketsForMerchant(
@Param("merchantId") Integer merchantId,
@Param("status") ImportStatus status
);
}

View File

@@ -13,4 +13,6 @@ public interface UserRepository extends JpaRepository<User, Integer> {
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
Optional<User> findByUuid(UUID uuid);
boolean existsByRole(String role);
}

View File

@@ -4,7 +4,7 @@
*
*
* <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
* {@link group.goforward.ballistic.BattlBuilderApplication} class.</p>
*
* @since 1.0
* @author Sean Strawsburg

View File

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

View File

@@ -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<CategoryMappingRecommendationDto> 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<Product> 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";
}
}

View File

@@ -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<GunbuilderProductDto> 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<GunbuilderProductDto> listGunbuilderProducts(String platform, List<String> partRoles) {
List<Product> 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<Product> products =
productRepository.findForGunbuilderByPlatformAndPartRoles(
platform,
partRoles,
ImportStatus.MAPPED
);
System.out.println(">>> GB: repo returned " + products.size() + " products");
Map<String, PartCategory> 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<GunbuilderProductDto> 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<PartRoleMapping> mappings =
partRoleMappingRepository.findByPlatformAndPartCategory_SlugAndDeletedAtIsNull(
platform,
categorySlug
);
List<String> 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<Product> 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);
}
}

View File

@@ -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<ImportStatusSummaryDto> summarizeByStatus() {
return productRepository.aggregateByImportStatus()
.stream()
.map(row -> new ImportStatusSummaryDto(
(ImportStatus) row.get("status"),
(long) row.get("count")
))
.toList();
}
public List<ImportStatusByMerchantDto> 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();
}
}

View File

@@ -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<PendingMappingBucketDto> listPendingBuckets() {
List<Object[]> 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.
}
}

View File

@@ -6,7 +6,6 @@ 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

View File

@@ -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<PartCategory> 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<PartRoleMapping> mappingOpt =
partRoleMappingRepository.findFirstByPlatformAndPartRoleAndDeletedAtIsNull(
platform,
partRole
);
return partCategoryRepository.findBySlug(normalizedSlug);
return mappingOpt.map(PartRoleMapping::getPartCategory);
}
}

View File

@@ -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<PartRoleMappingDto> getMappingsForPlatform(String platform) {
return repository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
.stream()
.map(PartRoleMappingMapper::toDto)
.toList();
}
public List<PartRoleToCategoryDto> getRoleToCategoryMap(String platform) {
return repository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
.stream()
.map(PartRoleMappingMapper::toRoleMapDto)
.toList();
}
}

View File

@@ -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<String> ALLOWED_ROLES = Set.of("USER", "ADMIN");
private final UserRepository userRepository;
public AdminUserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<AdminUserDto> 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);
}
}

View File

@@ -1,44 +1,45 @@
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.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 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.cache.annotation.CacheEvict;
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.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;
@@ -47,11 +48,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
private final MerchantCategoryMappingService merchantCategoryMappingService;
private final ProductOfferRepository productOfferRepository;
public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository,
public MerchantFeedImportServiceImpl(
MerchantRepository merchantRepository,
BrandRepository brandRepository,
ProductRepository productRepository,
MerchantCategoryMappingService merchantCategoryMappingService,
ProductOfferRepository productOfferRepository) {
ProductOfferRepository productOfferRepository
) {
this.merchantRepository = merchantRepository;
this.brandRepository = brandRepository;
this.productRepository = productRepository;
@@ -59,6 +62,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
this.productOfferRepository = productOfferRepository;
}
// ---------------------------------------------------------------------
// Full product + offer import
// ---------------------------------------------------------------------
@Override
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
public void importMerchantFeed(Integer merchantId) {
@@ -67,27 +74,27 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
// Read all rows from the merchant feed
List<MerchantFeedRow> 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={}",
log.debug("Upserted product id={}, name='{}', slug={}, platform={}, partRole={}, merchant={}",
p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
}
}
// ---------------------------------------------------------------------
// Upsert logic
// 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());
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
String upc = trimOrNull(row.sku()); // placeholder until a real UPC column exists
List<Product> candidates = Collections.emptyList();
@@ -114,47 +121,17 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
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<Map<String, String>> fetchFeedRows(String feedUrl) {
log.info("Reading offer feed from {}", feedUrl);
List<Map<String, String>> 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<String> headers = new ArrayList<>(parser.getHeaderMap().keySet());
for (CSVRecord rec : parser) {
Map<String, String> 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) {
private void updateProductFromRow(Product p,
Merchant merchant,
MerchantFeedRow row,
boolean isNew) {
// ---------- NAME ----------
String name = coalesce(
trimOrNull(row.productName()),
@@ -181,6 +158,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
.toLowerCase()
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
if (slug.isBlank()) {
slug = "product-" + System.currentTimeMillis();
}
@@ -207,9 +185,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
trimOrNull(row.sku())
);
p.setMpn(mpn);
// UPC placeholder
p.setUpc(null);
p.setUpc(null); // placeholder
// ---------- PLATFORM ----------
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) {
@@ -217,82 +193,90 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
p.setPlatform(platform != null ? platform : "AR-15");
}
// ---------- RAW CATEGORY KEY (for debugging / analytics) ----------
// ---------- RAW CATEGORY KEY ----------
String rawCategoryKey = buildRawCategoryKey(row);
p.setRawCategoryKey(rawCategoryKey);
// ---------- PART ROLE (via category mapping, with keyword fallback) ----------
// ---------- PART ROLE (mapping + fallback) ----------
String partRole = null;
// 1) First try merchant category mapping
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()) {
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
// 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";
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);
}
private void upsertOfferFromRow(Product product, Merchant merchant, MerchantFeedRow row) {
// For now, well use SKU as the "avantlinkProductId" placeholder.
// If/when you have a real AvantLink product_id in the feed, switch to that.
}
// ---------------------------------------------------------------------
// Offer upsert (full ETL)
// ---------------------------------------------------------------------
private void upsertOfferFromRow(Product product,
Merchant merchant,
MerchantFeedRow row) {
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 brandnew 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 retail = row.retailPrice();
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);
}
@@ -300,25 +284,129 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
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
// 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<Map<String, String>> rows = fetchFeedRows(feedUrl);
for (Map<String, String> 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<String, String> 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<String, String> 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<Map<String, String>> fetchFeedRows(String feedUrl) {
log.info("Reading offer feed from {}", feedUrl);
List<Map<String, String>> rows = new ArrayList<>();
try (Reader reader = openFeedReader(feedUrl);
CSVParser parser = CSVFormat.DEFAULT
.withFirstRecordAsHeader()
.withIgnoreSurroundingSpaces()
.withTrim()
.parse(reader)) {
List<String> headers = new ArrayList<>(parser.getHeaderMap().keySet());
for (CSVRecord rec : parser) {
Map<String, String> 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)
// ---------------------------------------------------------------------
/**
* 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);
@@ -330,14 +418,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
}
}
/**
* 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<String> requiredHeaders =
java.util.Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name");
List<String> requiredHeaders =
Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name");
Exception lastException = null;
@@ -364,7 +448,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
.setTrim(true)
.build();
} else if (headerMap != null) {
log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl);
log.debug("Delimiter '{}' produced headers {} for feed {}",
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)),
headerMap.keySet(),
feedUrl);
}
} catch (Exception ex) {
lastException = ex;
@@ -390,13 +477,14 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
List<MerchantFeedRow> 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());
log.debug("Detected feed headers for merchant {}: {}",
merchant.getName(),
parser.getHeaderMap().keySet());
for (CSVRecord rec : parser) {
MerchantFeedRow row = new MerchantFeedRow(
@@ -450,9 +538,9 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
});
}
private String getCol(String[] cols, int index) {
return (index >= 0 && index < cols.length) ? cols[index] : null;
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private BigDecimal parseBigDecimal(String raw) {
if (raw == null) return null;
@@ -466,31 +554,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
}
}
/**
* Safely get a column value by header name. If the record is "short"
* (fewer values than headers) or the header is missing, return null
* instead of throwing IllegalArgumentException.
*/
private String getCsvValue(CSVRecord rec, String header) {
if (rec == null || header == null) {
return null;
}
if (!rec.isMapped(header)) {
// Header not present at all
return null;
}
try {
return rec.get(header);
} catch (IllegalArgumentException ex) {
log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header);
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();
@@ -522,7 +601,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
String cat = trimOrNull(row.category());
String sub = trimOrNull(row.subCategory());
java.util.List<String> parts = new java.util.ArrayList<>();
List<String> parts = new ArrayList<>();
if (dept != null) parts.add(dept);
if (cat != null) parts.add(cat);
if (sub != null) parts.add(sub);
@@ -535,10 +614,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
}
private String inferPlatform(MerchantFeedRow row) {
String department = coalesce(trimOrNull(row.department()), trimOrNull(row.category()));
String department = coalesce(
trimOrNull(row.department()),
trimOrNull(row.category())
);
if (department == null) return null;
String lower = department.toLowerCase();
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";
@@ -553,7 +635,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
);
if (cat == null) return null;
String lower = cat.toLowerCase();
String lower = cat.toLowerCase(Locale.ROOT);
if (lower.contains("handguard") || lower.contains("rail")) {
return "handguard";
@@ -579,82 +661,4 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
return "unknown";
}
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
public void syncOffersOnly(Integer merchantId) {
log.info("Starting offers-only sync for merchantId={}", merchantId);
Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new RuntimeException("Merchant not found"));
if (Boolean.FALSE.equals(merchant.getIsActive())) {
return;
}
String feedUrl = merchant.getOfferFeedUrl() != null
? merchant.getOfferFeedUrl()
: merchant.getFeedUrl();
if (feedUrl == null) {
throw new RuntimeException("No offer feed URL configured for merchant " + merchantId);
}
List<Map<String, String>> rows = fetchFeedRows(feedUrl);
for (Map<String, String> 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<String, String> 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<String, String> 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;
}
}

View File

@@ -4,7 +4,7 @@
*
*
* <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
* {@link group.goforward.ballistic.BattlBuilderApplication} class.</p>
*
* @since 1.0
* @author Don Strawsburg

View File

@@ -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<String, String> 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<String, Object> testProducts(@RequestParam String platform) {
System.out.println(">>> /api/gunbuilder/test-products hit for platform=" + platform);
Map<String, Object> 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<GunbuilderProductDto> listProducts(
@RequestParam String platform,
@RequestParam(required = false) List<String> partRoles
) {
return gunbuilderProductService.listGunbuilderProducts(platform, partRoles);
}
@GetMapping("/products/by-category")
public List<GunbuilderProductDto> 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<Map<String, Object>> 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<String, Object> 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();
}
}

View File

@@ -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<PartRoleMappingDto> getMappings(@PathVariable String platform) {
return service.getMappingsForPlatform(platform);
}
// Thin mapping for the builder
@GetMapping("/{platform}/map")
public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) {
return service.getRoleToCategoryMap(platform);
}
}

View File

@@ -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<Map<String, Object>> rows = productRepository.aggregateByImportStatus();
long total = 0L;
long mapped = 0L;
long pending = 0L;
for (Map<String, Object> 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<ByMerchantRowDto> byMerchant() {
List<Map<String, Object>> 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();
}
}

View File

@@ -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<PendingMappingBucketDto> listPendingBuckets() {
// Simple: just delegate to service
return mappingAdminService.listPendingBuckets();
}
public record ApplyMappingRequest(
Integer merchantId,
String rawCategoryKey,
String mappedPartRole
) {}
@PostMapping("/apply")
public ResponseEntity<Map<String, Object>> applyMapping(
@RequestBody ApplyMappingRequest request
) {
mappingAdminService.applyMapping(
request.merchantId(),
request.rawCategoryKey(),
request.mappedPartRole()
);
return ResponseEntity.ok(Map.of("ok", true));
}
}

View File

@@ -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<Map<String, Object>> 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"
)
);
}
}

View File

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

View File

@@ -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<PendingMappingBucketDto> listPending() {
return mappingAdminService.listPendingBuckets();
}
public record ApplyMappingRequest(
Integer merchantId,
String rawCategoryKey,
String mappedPartRole
) {}
@PostMapping("/apply")
public ResponseEntity<Void> 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<PendingMappingBucketDto> listPendingBuckets() {
// return mappingAdminService.listPendingBuckets();
// }
//
// @PostMapping("/apply")
// public ResponseEntity<?> applyMapping(@RequestBody Map<String, Object> 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));
// }
// }
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package group.goforward.ballistic.web.dto;
public record PartRoleToCategoryDto(
String platform,
String partRole,
String categorySlug // e.g. "lower", "barrel", "optic"
) {}

View File

@@ -0,0 +1,9 @@
package group.goforward.ballistic.web.dto;
public record PendingMappingBucketDto(
Integer merchantId,
String merchantName,
String rawCategoryKey,
String mappedPartRole,
long productCount
) {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
# Logging
spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=INFO
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=warn