From 7a8ec969b563d909ea2a87a5d4223015b072348e Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 8 Dec 2025 07:10:10 -0500 Subject: [PATCH] new admin-user api --- .../ballistic/repos/UserRepository.java | 2 + .../ballistic/services/BrandService.java | 32 +- .../MerchantCategoryMappingService.java | 188 +-- .../services/MerchantFeedImportService.java | 26 +- .../ballistic/services/PsaService.java | 34 +- .../ballistic/services/StatesService.java | 32 +- .../ballistic/services/UsersService.java | 32 +- .../services/admin/AdminUserService.java | 55 + .../services/impl/BrandServiceImpl.java | 76 +- .../impl/MerchantFeedImportServiceImpl.java | 1326 ++++++++--------- .../services/impl/PsaServiceImpl.java | 82 +- .../services/impl/StatesServiceImpl.java | 76 +- .../services/impl/UsersServiceImpl.java | 74 +- .../ballistic/services/impl/package-info.java | 24 +- .../web/admin/AdminUserController.java | 37 + .../ballistic/web/dto/admin/AdminUserDto.java | 76 + .../web/dto/admin/UpdateUserRoleRequest.java | 21 + src/main/resources/application.properties | 7 +- 18 files changed, 1196 insertions(+), 1004 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/services/admin/AdminUserService.java create mode 100644 src/main/java/group/goforward/ballistic/web/admin/AdminUserController.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/AdminUserDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/UpdateUserRoleRequest.java 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/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/MerchantCategoryMappingService.java b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java index a4553c8..35faaea 100644 --- a/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java @@ -1,95 +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 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/PsaService.java b/src/main/java/group/goforward/ballistic/services/PsaService.java index ecaa265..337d278 100644 --- a/src/main/java/group/goforward/ballistic/services/PsaService.java +++ b/src/main/java/group/goforward/ballistic/services/PsaService.java @@ -1,17 +1,17 @@ -package group.goforward.ballistic.services; - -import group.goforward.ballistic.model.Psa; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface PsaService { - List findAll(); - - Optional findById(UUID id); - - Psa save(Psa psa); - - void deleteById(UUID id); -} +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.Psa; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface PsaService { + List findAll(); + + Optional findById(UUID id); + + Psa save(Psa psa); + + void deleteById(UUID id); +} diff --git a/src/main/java/group/goforward/ballistic/services/StatesService.java b/src/main/java/group/goforward/ballistic/services/StatesService.java index 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 eea24e5..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,664 +1,664 @@ -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"; - } +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/PsaServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java index dddc752..1729056 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java @@ -1,41 +1,41 @@ -package group.goforward.ballistic.services.impl; -import group.goforward.ballistic.model.Psa; -import group.goforward.ballistic.repos.PsaRepository; -import group.goforward.ballistic.services.PsaService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Service -public class PsaServiceImpl implements PsaService { - - private final PsaRepository psaRepository; - - @Autowired - public PsaServiceImpl(PsaRepository psaRepository) { - this.psaRepository = psaRepository; - } - - @Override - public List findAll() { - return psaRepository.findAll(); - } - - @Override - public Optional findById(UUID id) { - return psaRepository.findById(id); - } - - @Override - public Psa save(Psa psa) { - return psaRepository.save(psa); - } - - @Override - public void deleteById(UUID id) { - psaRepository.deleteById(id); - } -} +package group.goforward.ballistic.services.impl; +import group.goforward.ballistic.model.Psa; +import group.goforward.ballistic.repos.PsaRepository; +import group.goforward.ballistic.services.PsaService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class PsaServiceImpl implements PsaService { + + private final PsaRepository psaRepository; + + @Autowired + public PsaServiceImpl(PsaRepository psaRepository) { + this.psaRepository = psaRepository; + } + + @Override + public List findAll() { + return psaRepository.findAll(); + } + + @Override + public Optional findById(UUID id) { + return psaRepository.findById(id); + } + + @Override + public Psa save(Psa psa) { + return psaRepository.save(psa); + } + + @Override + public void deleteById(UUID id) { + psaRepository.deleteById(id); + } +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java index 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 a443585..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.BattlBuilderApplication} 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/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/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/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/resources/application.properties b/src/main/resources/application.properties index de2c898..72de408 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -13,9 +13,10 @@ spring.datasource.driver-class-name=org.postgresql.Driver security.jwt.secret=ballistic-test-secret-key-1234567890-ABCDEFGHIJKLNMOPQRST security.jwt.access-token-minutes=2880 -# Temp disabling logging to find what I fucked up -spring.jpa.show-sql=false -logging.level.org.hibernate.SQL=warn +# Logging + +spring.jpa.show-sql=true +logging.level.org.hibernate.SQL=INFO logging.level.org.hibernate.type.descriptor.sql.BasicBinder=warn