From 7c65311fad998063d4743702b8c3c3a6542793e6 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Mon, 5 Jan 2026 17:06:03 -0500 Subject: [PATCH] cleanup --- .../classification/PartRoleResolver.java | 172 +-- .../PlatformResolutionResult.java | 48 +- .../classification/PlatformResolver.java | 276 ++-- .../admin/ClassificationReconcileService.java | 488 +++--- .../impl/ProductClassifierImpl.java | 946 ++++++------ .../battlbuilder/cli/BetaInviteCliRunner.java | 88 +- .../battlbuilder/common/package-info.java | 22 +- .../battlbuilder/config/MinioConfig.java | 44 +- .../battlbuilder/config/PasswordConfig.java | 34 +- .../battlbuilder/config/SecurityConfig.java | 184 +-- .../battlbuilder/config/package-info.java | 22 +- .../controller/AuthController.java | 452 +++--- .../BuilderBootstrapController.java | 206 +-- .../controller/CategoryController.java | 87 +- .../controller/EmailTrackingController.java | 106 +- .../controller/ImportController.java | 9 + .../controller/MerchantDebugController.java | 6 + .../controller/PartRoleMappingController.java | 60 +- .../admin/AdminBetaInviteController.java | 100 +- .../admin/AdminCategoryController.java | 78 +- .../admin/AdminDashboardController.java | 48 +- .../admin/AdminPartCategoryController.java | 66 +- .../admin/AdminPartRoleMappingController.java | 246 +-- .../controller/admin/package-info.java | 22 +- .../controller/api/v1/BrandController.java | 21 + .../controller/api/v1/BuildV1Controller.java | 190 +-- .../controller/api/v1/CatalogController.java | 118 +- .../controller/api/v1/EmailController.java | 302 ++-- .../controller/api/v1/MeController.java | 460 +++--- .../api/v1/ProductV1Controller.java | 96 +- .../controller/api/v1/package-info.java | 22 +- .../battlbuilder/dto/package_info.java | 24 +- .../enrichment/EnrichmentSource.java | 18 +- .../enrichment/EnrichmentStatus.java | 36 +- .../enrichment/EnrichmentType.java | 26 +- .../ai/AiEnrichmentOrchestrator.java | 204 +-- .../controller/AdminEnrichmentController.java | 304 ++-- .../enrichment/model/ProductEnrichment.java | 188 +-- .../repo/ProductEnrichmentRepository.java | 186 +-- .../service/CaliberEnrichmentService.java | 170 +- .../taxonomies/CaliberTaxonomy.java | 68 +- .../battlbuilder/model/AuthToken.java | 346 ++--- .../battlbuilder/model/BuildProfile.java | 242 +-- .../battlbuilder/model/EmailRequest.java | 618 ++++---- .../goforward/battlbuilder/model/User.java | 1072 ++++++------- .../repo/AuthTokenRepository.java | 54 +- .../repo/BuildProfileRepository.java | 22 +- .../repo/CanonicalCategoryRepository.java | 34 +- .../repo/CategoryMappingRepository.java | 42 +- .../repo/EmailRequestRepository.java | 32 +- .../repo/EmailTemplateRepository.java | 26 +- .../repo/MerchantCategoryMapRepository.java | 124 +- .../repo/PartCategoryRepository.java | 26 +- .../repo/PartRoleMappingRepository.java | 42 +- .../repo/PartRoleRuleRepository.java | 18 +- .../repo/PlatformRuleRepository.java | 26 +- .../battlbuilder/repo/ProductRepository.java | 1264 +++++++-------- .../battlbuilder/repo/UserRepository.java | 86 +- .../spec/CatalogProductSpecifications.java | 112 +- .../battlbuilder/repo/package-info.java | 22 +- .../repo/projections/CatalogRow.java | 26 +- .../security/CustomUserDetailsService.java | 48 +- .../security/JwtAuthenticationFilter.java | 174 +-- .../battlbuilder/service/BrandService.java | 32 +- .../battlbuilder/service/BuildService.java | 50 +- .../service/CatalogQueryService.java | 44 +- .../CategoryClassificationService.java | 52 +- .../CategoryMappingRecommendationService.java | 142 +- .../service/CurrentUserService.java | 102 +- .../service/ImportStatusAdminService.java | 82 +- .../service/MappingAdminService.java | 454 +++--- .../service/MerchantFeedImportService.java | 26 +- .../service/PartCategoryResolverService.java | 68 +- .../service/PartRoleMappingService.java | 68 +- .../service/ProductQueryService.java | 38 +- .../service/ReclassificationService.java | 20 +- .../service/admin/AdminProductService.java | 34 +- .../service/admin/AdminUserService.java | 108 +- .../service/admin/StatesService.java | 32 +- .../service/admin/UsersService.java | 32 +- .../admin/admin_services_package_info.java | 22 +- .../admin/impl/AdminDashboardService.java | 88 +- .../admin/impl/AdminProductServiceImpl.java | 128 +- .../service/admin/impl/StatesServiceImpl.java | 76 +- .../service/admin/impl/UsersServiceImpl.java | 74 +- .../service/admin/package-info.java | 24 +- .../service/auth/BetaAuthService.java | 56 +- .../auth/impl/BetaAuthServiceImpl.java | 640 ++++---- .../service/auth/impl/BetaInviteService.java | 368 ++--- .../service/impl/BrandServiceImpl.java | 76 +- .../service/impl/BuildServiceImpl.java | 1026 ++++++------- .../service/impl/CatalogQueryServiceImpl.java | 416 ++--- .../CategoryClassificationServiceImpl.java | 268 ++-- .../impl/MerchantCategoryMappingService.java | 74 +- .../impl/MerchantFeedImportServiceImpl.java | 1364 ++++++++--------- .../service/impl/ProductQueryServiceImpl.java | 346 ++--- .../impl/ReclassificationServiceImpl.java | 348 ++--- .../battlbuilder/service/package-info.java | 24 +- .../utils/ImageUrlToMinioMigrator.java | 366 ++--- .../MigrateProductImagesToMinioRunner.java | 56 +- .../service/utils/TemplateRenderer.java | 28 +- .../service/utils/TemplatedEmailService.java | 62 +- .../service/utils/impl/EmailServiceImpl.java | 260 ++-- .../service/utils/impl/package-info.java | 20 +- .../admin/AdminImportStatusController.java | 144 +- .../web/admin/AdminMappingController.java | 238 +-- .../web/admin/AdminMerchantController.java | 68 +- .../web/admin/AdminProductController.java | 88 +- .../web/admin/AdminUserController.java | 72 +- .../admin/CategoryMappingAdminController.java | 130 +- .../battlbuilder/web/admin/package-info.java | 24 +- .../web/dto/BuildFeedCardDto.java | 170 +- .../web/dto/UpdateBuildRequest.java | 136 +- .../dto/admin/AdminDashboardOverviewDto.java | 86 +- .../dto/admin/AdminProductSearchRequest.java | 100 +- .../web/dto/auth/package_info.java | 24 +- .../battlbuilder/web/mapper/package-info.java | 1 + .../battlbuilder/web/package-info.java | 22 +- 118 files changed, 9835 insertions(+), 9761 deletions(-) create mode 100644 src/main/java/group/goforward/battlbuilder/web/mapper/package-info.java diff --git a/src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java b/src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java index 13db265..e9f6acc 100644 --- a/src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java +++ b/src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java @@ -1,87 +1,87 @@ -package group.goforward.battlbuilder.catalog.classification; - -import group.goforward.battlbuilder.model.PartRoleRule; -import group.goforward.battlbuilder.repo.PartRoleRuleRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -import jakarta.annotation.PostConstruct; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.regex.Pattern; - -@Component -public class PartRoleResolver { - - private static final Logger log = LoggerFactory.getLogger(PartRoleResolver.class); - - private final PartRoleRuleRepository repo; - - private final List rules = new ArrayList<>(); - - public PartRoleResolver(PartRoleRuleRepository repo) { - this.repo = repo; - } - - @PostConstruct - public void load() { - rules.clear(); - - List active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc(); - for (PartRoleRule r : active) { - try { - rules.add(new CompiledRule( - r.getId(), - r.getTargetPlatform(), - Pattern.compile(r.getNameRegex(), Pattern.CASE_INSENSITIVE), - normalizeRole(r.getTargetPartRole()) - )); - } catch (Exception e) { - log.warn("Skipping invalid part role rule id={} regex={} err={}", - r.getId(), r.getNameRegex(), e.getMessage()); - } - } - - log.info("Loaded {} part role rules", rules.size()); - } - - public String resolve(String platform, String productName, String rawCategoryKey) { - String p = normalizePlatform(platform); - - // we match primarily on productName; optionally also include rawCategoryKey in the text blob - String text = (productName == null ? "" : productName) + - " " + - (rawCategoryKey == null ? "" : rawCategoryKey); - - for (CompiledRule r : rules) { - if (!r.appliesToPlatform(p)) continue; - if (r.pattern.matcher(text).find()) { - return r.targetPartRole; // already normalized - } - } - return null; - } - - private static String normalizeRole(String role) { - if (role == null) return null; - String t = role.trim(); - if (t.isEmpty()) return null; - return t.toLowerCase(Locale.ROOT).replace('_','-'); - } - - private static String normalizePlatform(String platform) { - if (platform == null) return null; - String t = platform.trim(); - return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT); - } - - private record CompiledRule(Long id, String targetPlatform, Pattern pattern, String targetPartRole) { - boolean appliesToPlatform(String platform) { - if (targetPlatform == null || targetPlatform.isBlank()) return true; - if (platform == null) return false; - return targetPlatform.trim().equalsIgnoreCase(platform); - } - } +package group.goforward.battlbuilder.catalog.classification; + +import group.goforward.battlbuilder.model.PartRoleRule; +import group.goforward.battlbuilder.repo.PartRoleRuleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +@Component +public class PartRoleResolver { + + private static final Logger log = LoggerFactory.getLogger(PartRoleResolver.class); + + private final PartRoleRuleRepository repo; + + private final List rules = new ArrayList<>(); + + public PartRoleResolver(PartRoleRuleRepository repo) { + this.repo = repo; + } + + @PostConstruct + public void load() { + rules.clear(); + + List active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc(); + for (PartRoleRule r : active) { + try { + rules.add(new CompiledRule( + r.getId(), + r.getTargetPlatform(), + Pattern.compile(r.getNameRegex(), Pattern.CASE_INSENSITIVE), + normalizeRole(r.getTargetPartRole()) + )); + } catch (Exception e) { + log.warn("Skipping invalid part role rule id={} regex={} err={}", + r.getId(), r.getNameRegex(), e.getMessage()); + } + } + + log.info("Loaded {} part role rules", rules.size()); + } + + public String resolve(String platform, String productName, String rawCategoryKey) { + String p = normalizePlatform(platform); + + // we match primarily on productName; optionally also include rawCategoryKey in the text blob + String text = (productName == null ? "" : productName) + + " " + + (rawCategoryKey == null ? "" : rawCategoryKey); + + for (CompiledRule r : rules) { + if (!r.appliesToPlatform(p)) continue; + if (r.pattern.matcher(text).find()) { + return r.targetPartRole; // already normalized + } + } + return null; + } + + private static String normalizeRole(String role) { + if (role == null) return null; + String t = role.trim(); + if (t.isEmpty()) return null; + return t.toLowerCase(Locale.ROOT).replace('_','-'); + } + + private static String normalizePlatform(String platform) { + if (platform == null) return null; + String t = platform.trim(); + return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT); + } + + private record CompiledRule(Long id, String targetPlatform, Pattern pattern, String targetPartRole) { + boolean appliesToPlatform(String platform) { + if (targetPlatform == null || targetPlatform.isBlank()) return true; + if (platform == null) return false; + return targetPlatform.trim().equalsIgnoreCase(platform); + } + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolutionResult.java b/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolutionResult.java index 63c5bc2..1a8b398 100644 --- a/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolutionResult.java +++ b/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolutionResult.java @@ -1,25 +1,25 @@ -package group.goforward.battlbuilder.catalog.classification; - -/** - * Result returned by PlatformResolver. - *

- * Any of the fields may be null — the importer will only overwrite - * product.platform, product.partRole, or product.configuration - * when the returned value is non-null AND non-blank. - */ -public record PlatformResolutionResult( - String platform, - String partRole, - String configuration -) { - - public static PlatformResolutionResult empty() { - return new PlatformResolutionResult(null, null, null); - } - - public boolean isEmpty() { - return (platform == null || platform.isBlank()) && - (partRole == null || partRole.isBlank()) && - (configuration == null || configuration.isBlank()); - } +package group.goforward.battlbuilder.catalog.classification; + +/** + * Result returned by PlatformResolver. + *

+ * Any of the fields may be null — the importer will only overwrite + * product.platform, product.partRole, or product.configuration + * when the returned value is non-null AND non-blank. + */ +public record PlatformResolutionResult( + String platform, + String partRole, + String configuration +) { + + public static PlatformResolutionResult empty() { + return new PlatformResolutionResult(null, null, null); + } + + public boolean isEmpty() { + return (platform == null || platform.isBlank()) && + (partRole == null || partRole.isBlank()) && + (configuration == null || configuration.isBlank()); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java b/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java index 7e6d9f7..53ae93a 100644 --- a/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java +++ b/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java @@ -1,139 +1,139 @@ -package group.goforward.battlbuilder.catalog.classification; - -import group.goforward.battlbuilder.model.PlatformRule; -import group.goforward.battlbuilder.repo.PlatformRuleRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -import jakarta.annotation.PostConstruct; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.regex.Pattern; - -/** - * Resolves a product's PLATFORM (e.g. AR-15, AR-10, NOT-SUPPORTED) - * using explicit DB-backed rules. - *

- * Conservative approach: - * - If a rule matches, return its target_platform - * - If nothing matches, return null and let the caller decide fallback behavior - */ -@Component -public class PlatformResolver { - - private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class); - - public static final String NOT_SUPPORTED = "NOT-SUPPORTED"; - - private final PlatformRuleRepository repo; - private final List rules = new ArrayList<>(); - - public PlatformResolver(PlatformRuleRepository repo) { - this.repo = repo; - } - - @PostConstruct - public void load() { - rules.clear(); - - List active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc(); - - for (PlatformRule r : active) { - try { - Pattern rawCat = compileNullable(r.getRawCategoryPattern()); - Pattern name = compileNullable(r.getNameRegex()); - String target = normalizePlatform(r.getTargetPlatform()); - - // If a rule has no matchers, it's useless — skip it. - if (rawCat == null && name == null) { - log.warn("Skipping platform rule id={} because it has no patterns (raw_category_pattern/name_regex both blank)", r.getId()); - continue; - } - - if (target == null || target.isBlank()) { - log.warn("Skipping platform rule id={} because target_platform is blank", r.getId()); - continue; - } - - rules.add(new CompiledRule( - r.getId(), - r.getMerchantId(), - r.getBrandId(), - rawCat, - name, - target - )); - } catch (Exception e) { - log.warn("Skipping invalid platform rule id={} err={}", r.getId(), e.getMessage()); - } - } - - log.info("Loaded {} platform rules", rules.size()); - } - - /** - * @return platform string (e.g. AR-15, AR-10, NOT-SUPPORTED) or null if no rule matches. - */ - public String resolve(Long merchantId, Long brandId, String productName, String rawCategoryKey) { - String text = safe(productName) + " " + safe(rawCategoryKey); - - for (CompiledRule r : rules) { - if (!r.appliesToMerchant(merchantId)) continue; - if (!r.appliesToBrand(brandId)) continue; - - if (r.matches(text)) { - return r.targetPlatform; - } - } - - return null; - } - - // ----------------------------- - // Helpers - // ----------------------------- - - private static Pattern compileNullable(String regex) { - if (regex == null || regex.isBlank()) return null; - return Pattern.compile(regex, Pattern.CASE_INSENSITIVE); - } - - private static String normalizePlatform(String platform) { - if (platform == null) return null; - String t = platform.trim(); - return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT); - } - - private static String safe(String s) { - return s == null ? "" : s; - } - - // ----------------------------- - // Internal model - // ----------------------------- - - private record CompiledRule( - Long id, - Long merchantId, - Long brandId, - Pattern rawCategoryPattern, - Pattern namePattern, - String targetPlatform - ) { - boolean appliesToMerchant(Long merchantId) { - return this.merchantId == null || this.merchantId.equals(merchantId); - } - - boolean appliesToBrand(Long brandId) { - return this.brandId == null || this.brandId.equals(brandId); - } - - boolean matches(String text) { - if (rawCategoryPattern != null && rawCategoryPattern.matcher(text).find()) return true; - if (namePattern != null && namePattern.matcher(text).find()) return true; - return false; - } - } +package group.goforward.battlbuilder.catalog.classification; + +import group.goforward.battlbuilder.model.PlatformRule; +import group.goforward.battlbuilder.repo.PlatformRuleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Resolves a product's PLATFORM (e.g. AR-15, AR-10, NOT-SUPPORTED) + * using explicit DB-backed rules. + *

+ * Conservative approach: + * - If a rule matches, return its target_platform + * - If nothing matches, return null and let the caller decide fallback behavior + */ +@Component +public class PlatformResolver { + + private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class); + + public static final String NOT_SUPPORTED = "NOT-SUPPORTED"; + + private final PlatformRuleRepository repo; + private final List rules = new ArrayList<>(); + + public PlatformResolver(PlatformRuleRepository repo) { + this.repo = repo; + } + + @PostConstruct + public void load() { + rules.clear(); + + List active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc(); + + for (PlatformRule r : active) { + try { + Pattern rawCat = compileNullable(r.getRawCategoryPattern()); + Pattern name = compileNullable(r.getNameRegex()); + String target = normalizePlatform(r.getTargetPlatform()); + + // If a rule has no matchers, it's useless — skip it. + if (rawCat == null && name == null) { + log.warn("Skipping platform rule id={} because it has no patterns (raw_category_pattern/name_regex both blank)", r.getId()); + continue; + } + + if (target == null || target.isBlank()) { + log.warn("Skipping platform rule id={} because target_platform is blank", r.getId()); + continue; + } + + rules.add(new CompiledRule( + r.getId(), + r.getMerchantId(), + r.getBrandId(), + rawCat, + name, + target + )); + } catch (Exception e) { + log.warn("Skipping invalid platform rule id={} err={}", r.getId(), e.getMessage()); + } + } + + log.info("Loaded {} platform rules", rules.size()); + } + + /** + * @return platform string (e.g. AR-15, AR-10, NOT-SUPPORTED) or null if no rule matches. + */ + public String resolve(Long merchantId, Long brandId, String productName, String rawCategoryKey) { + String text = safe(productName) + " " + safe(rawCategoryKey); + + for (CompiledRule r : rules) { + if (!r.appliesToMerchant(merchantId)) continue; + if (!r.appliesToBrand(brandId)) continue; + + if (r.matches(text)) { + return r.targetPlatform; + } + } + + return null; + } + + // ----------------------------- + // Helpers + // ----------------------------- + + private static Pattern compileNullable(String regex) { + if (regex == null || regex.isBlank()) return null; + return Pattern.compile(regex, Pattern.CASE_INSENSITIVE); + } + + private static String normalizePlatform(String platform) { + if (platform == null) return null; + String t = platform.trim(); + return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT); + } + + private static String safe(String s) { + return s == null ? "" : s; + } + + // ----------------------------- + // Internal model + // ----------------------------- + + private record CompiledRule( + Long id, + Long merchantId, + Long brandId, + Pattern rawCategoryPattern, + Pattern namePattern, + String targetPlatform + ) { + boolean appliesToMerchant(Long merchantId) { + return this.merchantId == null || this.merchantId.equals(merchantId); + } + + boolean appliesToBrand(Long brandId) { + return this.brandId == null || this.brandId.equals(brandId); + } + + boolean matches(String text) { + if (rawCategoryPattern != null && rawCategoryPattern.matcher(text).find()) return true; + if (namePattern != null && namePattern.matcher(text).find()) return true; + return false; + } + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/classification/admin/ClassificationReconcileService.java b/src/main/java/group/goforward/battlbuilder/classification/admin/ClassificationReconcileService.java index 7713d86..ce3db2f 100644 --- a/src/main/java/group/goforward/battlbuilder/classification/admin/ClassificationReconcileService.java +++ b/src/main/java/group/goforward/battlbuilder/classification/admin/ClassificationReconcileService.java @@ -1,245 +1,245 @@ -package group.goforward.battlbuilder.classification.admin; - -import group.goforward.battlbuilder.classification.ClassificationResult; -import group.goforward.battlbuilder.classification.ProductClassifier; -import group.goforward.battlbuilder.model.MerchantCategoryMap; -import group.goforward.battlbuilder.model.PartRoleSource; -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.repo.ProductRepository; -import group.goforward.battlbuilder.repo.ProductOfferRepository; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; - -import java.util.*; - -@Service -public class ClassificationReconcileService { - - private final ProductRepository productRepository; - private final ProductClassifier productClassifier; - private final ProductOfferRepository productOfferRepository; - - public ClassificationReconcileService(ProductRepository productRepository, - ProductClassifier productClassifier, - ProductOfferRepository productOfferRepository) { - this.productRepository = productRepository; - this.productClassifier = productClassifier; - this.productOfferRepository = productOfferRepository; - - } - - public ReconcileResponse reconcile(ReconcileRequest req) { - - int limit = req.limit(); - boolean dryRun = req.dryRun(); - - // Page in chunks until we hit limit. - final int pageSize = Math.min(250, limit); - int scanned = 0; - int page = 0; - - // Counts by reconcile outcome. - Map counts = new LinkedHashMap<>(); - counts.put("UNCHANGED", 0); - counts.put("WOULD_UPDATE", 0); - counts.put("LOCKED", 0); - counts.put("IGNORED", 0); - counts.put("UNMAPPED", 0); - counts.put("CONFLICT", 0); - counts.put("RULE_MATCHED", 0); - - // Sample rows to inspect quickly in API response. - List samples = new ArrayList<>(); - - // Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1) - Map> mappingMemo = new HashMap<>(); - - - while (scanned < limit) { - var pageable = PageRequest.of(page, pageSize); - - // Merchant is inferred via offers; this query limits products to those with offers for req.merchantId if provided. - var batch = productRepository.pageActiveProductsByOfferMerchant(req.merchantId(), req.platform(), pageable); - if (batch.isEmpty()) break; - - // Avoid N+1: resolve primary merchant for ALL products in this page in one query - List productIds = batch.getContent().stream() - .map(Product::getId) - .filter(Objects::nonNull) - .toList(); - - Map primaryMerchantByProductId = new HashMap<>(); - if (!productIds.isEmpty()) { - productOfferRepository.findPrimaryMerchantsByFirstSeenForProductIds(productIds) - .forEach(r -> primaryMerchantByProductId.put(r.getProductId(), r.getMerchantId())); - } - - for (Product p : batch.getContent()) { - if (scanned >= limit) break; - - // Optional: skip locked products unless includeLocked=true. - boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked()); - if (!req.includeLocked() && locked) { - counts.compute("LOCKED", (k, v) -> v + 1); - scanned++; - continue; - } - - // Run the classifier (merchant_category_map + other logic inside classifier). - Integer resolvedMerchantId = primaryMerchantByProductId.get(p.getId()); - ClassificationResult resolved = productClassifier.classifyProduct(p, resolvedMerchantId, mappingMemo); - - if (resolved != null && resolved.source() != null && resolved.source().startsWith("rules_")) { - counts.compute("RULE_MATCHED", (k, v) -> v + 1); - } - - // Compute diff status (dry-run only right now). - DiffOutcome outcome = diff(p, resolved); - - counts.compute(outcome.status, (k, v) -> v + 1); - scanned++; - - // Keep a small sample set for inspection. - boolean interestingStatus = - outcome.status.equals("WOULD_UPDATE") - || outcome.status.equals("CONFLICT") - || outcome.status.equals("UNMAPPED") - || outcome.status.equals("IGNORED"); - - boolean ruleHit = - resolved != null - && resolved.source() != null - && resolved.source().startsWith("rules_"); - - // Keep a small sample set for inspection. - // Include rule hits even if the product ends up UNCHANGED, so we can verify rules are working. - if (samples.size() < 50 && (interestingStatus || ruleHit)) { - samples.add(toRow(p, resolved, outcome.status, outcome.meta)); - } - } - - if (!batch.hasNext()) break; - page++; - } - - // Dry-run only right now—no writes. - return new ReconcileResponse(dryRun, scanned, counts, samples); - } - - private static class DiffOutcome { - final String status; - final Map meta; - - DiffOutcome(String status, Map meta) { - this.status = status; - this.meta = meta; - } - } - - /** - * Compute reconcile status by comparing: - * - existing product role/source/locks - * - classifier-resolved role/source/confidence/reason - */ - private DiffOutcome diff(Product p, ClassificationResult resolved) { - - String existingRole = trimToNull(p.getPartRole()); - String resolvedRole = resolved == null ? null : trimToNull(resolved.partRole()); - - // Respect locks (never propose changes). - boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked()); - if (locked) { - return new DiffOutcome("LOCKED", Map.of("note", "partRoleLocked or platformLocked")); - } - - // Treat known "ignored category" decisions as their own outcome bucket. - if (isIgnored(resolved)) { - return new DiffOutcome("IGNORED", Map.of( - "reason", resolved.reason(), - "source", resolved.source() - )); - } - - // No role resolved = still unmapped (needs merchant_category_map or rule-based inference later). - if (resolvedRole == null) { - return new DiffOutcome("UNMAPPED", Map.of( - "reason", resolved == null ? null : resolved.reason(), - "source", resolved == null ? null : resolved.source(), - "confidence", resolved == null ? null : resolved.confidence() - )); - } - - // Existing role missing but we resolved one => would update. - if (existingRole == null) { - return new DiffOutcome("WOULD_UPDATE", Map.of("from", null, "to", resolvedRole)); - } - - // Same role => unchanged. - if (existingRole.equalsIgnoreCase(resolvedRole)) { - return new DiffOutcome("UNCHANGED", Map.of()); - } - - // If existing role came from an override, flag as conflict (don't auto-clobber). - PartRoleSource existingSource = p.getPartRoleSource(); - if (existingSource == PartRoleSource.OVERRIDE) { - return new DiffOutcome( - "CONFLICT", - Map.of("from", existingRole, "to", resolvedRole, "note", "existing source is OVERRIDE") - ); - } - - // Otherwise, it’s a normal drift that we would update in apply-mode. - return new DiffOutcome("WOULD_UPDATE", Map.of("from", existingRole, "to", resolvedRole)); - } - - /** - * Detect the "non-classifying category ignored" decisions from the classifier. - * Right now we key off the reason text prefix (fast + simple). - * Later we can formalize this via a dedicated source or meta flag. - */ - private boolean isIgnored(ClassificationResult r) { - return r != null && "ignored_category".equalsIgnoreCase(r.source()); - } - - private ReconcileDiffRow toRow(Product p, ClassificationResult resolved, String status, Map meta) { - - // Extract the merchant used by classifier (if provided in meta). - Integer resolvedMerchantId = null; - if (resolved != null && resolved.meta() != null) { - Object v = resolved.meta().get("resolvedMerchantId"); - if (v instanceof Integer i) resolvedMerchantId = i; - else if (v instanceof Number n) resolvedMerchantId = n.intValue(); - else if (v instanceof String s) { - try { resolvedMerchantId = Integer.parseInt(s); } catch (Exception ignored) {} - } - } - - return new ReconcileDiffRow( - p.getId(), - p.getName(), - p.getPlatform(), - p.getRawCategoryKey(), - - resolvedMerchantId, - - p.getPartRole(), - p.getPartRoleSource() == null ? null : p.getPartRoleSource().name(), - p.getPartRoleLocked(), - p.getPlatformLocked(), - - resolved == null ? null : resolved.partRole(), - resolved == null ? null : resolved.source(), - resolved == null ? 0.0 : resolved.confidence(), - resolved == null ? null : resolved.reason(), - - status, - meta == null ? Map.of() : meta - ); - } - - private static String trimToNull(String s) { - if (s == null) return null; - String t = s.trim(); - return t.isEmpty() ? null : t; - } +package group.goforward.battlbuilder.classification.admin; + +import group.goforward.battlbuilder.classification.ClassificationResult; +import group.goforward.battlbuilder.classification.ProductClassifier; +import group.goforward.battlbuilder.model.MerchantCategoryMap; +import group.goforward.battlbuilder.model.PartRoleSource; +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.repo.ProductRepository; +import group.goforward.battlbuilder.repo.ProductOfferRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +public class ClassificationReconcileService { + + private final ProductRepository productRepository; + private final ProductClassifier productClassifier; + private final ProductOfferRepository productOfferRepository; + + public ClassificationReconcileService(ProductRepository productRepository, + ProductClassifier productClassifier, + ProductOfferRepository productOfferRepository) { + this.productRepository = productRepository; + this.productClassifier = productClassifier; + this.productOfferRepository = productOfferRepository; + + } + + public ReconcileResponse reconcile(ReconcileRequest req) { + + int limit = req.limit(); + boolean dryRun = req.dryRun(); + + // Page in chunks until we hit limit. + final int pageSize = Math.min(250, limit); + int scanned = 0; + int page = 0; + + // Counts by reconcile outcome. + Map counts = new LinkedHashMap<>(); + counts.put("UNCHANGED", 0); + counts.put("WOULD_UPDATE", 0); + counts.put("LOCKED", 0); + counts.put("IGNORED", 0); + counts.put("UNMAPPED", 0); + counts.put("CONFLICT", 0); + counts.put("RULE_MATCHED", 0); + + // Sample rows to inspect quickly in API response. + List samples = new ArrayList<>(); + + // Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1) + Map> mappingMemo = new HashMap<>(); + + + while (scanned < limit) { + var pageable = PageRequest.of(page, pageSize); + + // Merchant is inferred via offers; this query limits products to those with offers for req.merchantId if provided. + var batch = productRepository.pageActiveProductsByOfferMerchant(req.merchantId(), req.platform(), pageable); + if (batch.isEmpty()) break; + + // Avoid N+1: resolve primary merchant for ALL products in this page in one query + List productIds = batch.getContent().stream() + .map(Product::getId) + .filter(Objects::nonNull) + .toList(); + + Map primaryMerchantByProductId = new HashMap<>(); + if (!productIds.isEmpty()) { + productOfferRepository.findPrimaryMerchantsByFirstSeenForProductIds(productIds) + .forEach(r -> primaryMerchantByProductId.put(r.getProductId(), r.getMerchantId())); + } + + for (Product p : batch.getContent()) { + if (scanned >= limit) break; + + // Optional: skip locked products unless includeLocked=true. + boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked()); + if (!req.includeLocked() && locked) { + counts.compute("LOCKED", (k, v) -> v + 1); + scanned++; + continue; + } + + // Run the classifier (merchant_category_map + other logic inside classifier). + Integer resolvedMerchantId = primaryMerchantByProductId.get(p.getId()); + ClassificationResult resolved = productClassifier.classifyProduct(p, resolvedMerchantId, mappingMemo); + + if (resolved != null && resolved.source() != null && resolved.source().startsWith("rules_")) { + counts.compute("RULE_MATCHED", (k, v) -> v + 1); + } + + // Compute diff status (dry-run only right now). + DiffOutcome outcome = diff(p, resolved); + + counts.compute(outcome.status, (k, v) -> v + 1); + scanned++; + + // Keep a small sample set for inspection. + boolean interestingStatus = + outcome.status.equals("WOULD_UPDATE") + || outcome.status.equals("CONFLICT") + || outcome.status.equals("UNMAPPED") + || outcome.status.equals("IGNORED"); + + boolean ruleHit = + resolved != null + && resolved.source() != null + && resolved.source().startsWith("rules_"); + + // Keep a small sample set for inspection. + // Include rule hits even if the product ends up UNCHANGED, so we can verify rules are working. + if (samples.size() < 50 && (interestingStatus || ruleHit)) { + samples.add(toRow(p, resolved, outcome.status, outcome.meta)); + } + } + + if (!batch.hasNext()) break; + page++; + } + + // Dry-run only right now—no writes. + return new ReconcileResponse(dryRun, scanned, counts, samples); + } + + private static class DiffOutcome { + final String status; + final Map meta; + + DiffOutcome(String status, Map meta) { + this.status = status; + this.meta = meta; + } + } + + /** + * Compute reconcile status by comparing: + * - existing product role/source/locks + * - classifier-resolved role/source/confidence/reason + */ + private DiffOutcome diff(Product p, ClassificationResult resolved) { + + String existingRole = trimToNull(p.getPartRole()); + String resolvedRole = resolved == null ? null : trimToNull(resolved.partRole()); + + // Respect locks (never propose changes). + boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked()); + if (locked) { + return new DiffOutcome("LOCKED", Map.of("note", "partRoleLocked or platformLocked")); + } + + // Treat known "ignored category" decisions as their own outcome bucket. + if (isIgnored(resolved)) { + return new DiffOutcome("IGNORED", Map.of( + "reason", resolved.reason(), + "source", resolved.source() + )); + } + + // No role resolved = still unmapped (needs merchant_category_map or rule-based inference later). + if (resolvedRole == null) { + return new DiffOutcome("UNMAPPED", Map.of( + "reason", resolved == null ? null : resolved.reason(), + "source", resolved == null ? null : resolved.source(), + "confidence", resolved == null ? null : resolved.confidence() + )); + } + + // Existing role missing but we resolved one => would update. + if (existingRole == null) { + return new DiffOutcome("WOULD_UPDATE", Map.of("from", null, "to", resolvedRole)); + } + + // Same role => unchanged. + if (existingRole.equalsIgnoreCase(resolvedRole)) { + return new DiffOutcome("UNCHANGED", Map.of()); + } + + // If existing role came from an override, flag as conflict (don't auto-clobber). + PartRoleSource existingSource = p.getPartRoleSource(); + if (existingSource == PartRoleSource.OVERRIDE) { + return new DiffOutcome( + "CONFLICT", + Map.of("from", existingRole, "to", resolvedRole, "note", "existing source is OVERRIDE") + ); + } + + // Otherwise, it’s a normal drift that we would update in apply-mode. + return new DiffOutcome("WOULD_UPDATE", Map.of("from", existingRole, "to", resolvedRole)); + } + + /** + * Detect the "non-classifying category ignored" decisions from the classifier. + * Right now we key off the reason text prefix (fast + simple). + * Later we can formalize this via a dedicated source or meta flag. + */ + private boolean isIgnored(ClassificationResult r) { + return r != null && "ignored_category".equalsIgnoreCase(r.source()); + } + + private ReconcileDiffRow toRow(Product p, ClassificationResult resolved, String status, Map meta) { + + // Extract the merchant used by classifier (if provided in meta). + Integer resolvedMerchantId = null; + if (resolved != null && resolved.meta() != null) { + Object v = resolved.meta().get("resolvedMerchantId"); + if (v instanceof Integer i) resolvedMerchantId = i; + else if (v instanceof Number n) resolvedMerchantId = n.intValue(); + else if (v instanceof String s) { + try { resolvedMerchantId = Integer.parseInt(s); } catch (Exception ignored) {} + } + } + + return new ReconcileDiffRow( + p.getId(), + p.getName(), + p.getPlatform(), + p.getRawCategoryKey(), + + resolvedMerchantId, + + p.getPartRole(), + p.getPartRoleSource() == null ? null : p.getPartRoleSource().name(), + p.getPartRoleLocked(), + p.getPlatformLocked(), + + resolved == null ? null : resolved.partRole(), + resolved == null ? null : resolved.source(), + resolved == null ? 0.0 : resolved.confidence(), + resolved == null ? null : resolved.reason(), + + status, + meta == null ? Map.of() : meta + ); + } + + private static String trimToNull(String s) { + if (s == null) return null; + String t = s.trim(); + return t.isEmpty() ? null : t; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/classification/impl/ProductClassifierImpl.java b/src/main/java/group/goforward/battlbuilder/classification/impl/ProductClassifierImpl.java index 4664b5a..0746940 100644 --- a/src/main/java/group/goforward/battlbuilder/classification/impl/ProductClassifierImpl.java +++ b/src/main/java/group/goforward/battlbuilder/classification/impl/ProductClassifierImpl.java @@ -1,474 +1,474 @@ -package group.goforward.battlbuilder.classification.impl; - -import group.goforward.battlbuilder.classification.ClassificationResult; -import group.goforward.battlbuilder.classification.ProductClassifier; -import group.goforward.battlbuilder.model.MerchantCategoryMap; -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; -import group.goforward.battlbuilder.repo.ProductOfferRepository; -import org.springframework.stereotype.Component; - -import java.util.LinkedHashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.HashMap; - -@Component -public class ProductClassifierImpl implements ProductClassifier { - - /** - * Bump this whenever you change classification logic. - * Useful for debugging and for "reconcile" runs. - */ - private static final String VERSION = "2025.12.29"; - - /** - * Non-classifying "categories" some merchants use as merchandising labels. - * These are orthogonal to part role (condition/marketing), so we ignore them - * to avoid polluting merchant_category_map. - */ - // IMPORTANT: - // These tokens represent NON-SEMANTIC merchant categories. - // They should NEVER be mapped to part roles. - // If you're tempted to add them to merchant_category_map, add them here instead. - private static final Set NON_CLASSIFYING_TOKENS = Set.of( - // marketing / promos - "sale", - "clearance", - "deal", - "special", - "markdown", - "promo", - - // merchandising buckets - "general", - "apparel", - "accessories", - "parts", - "spare parts", - "shop all", - "lineup", - "collection", - - // bundles / kits / sets - "bundle", - "kit", - "set", - "builder set", - - // color / variant groupings - "colors", - "finish", - "variant", - - // caliber / platform / type filters (not part roles) - "bolt action", - "ar15", - "ar-15", - "rifles", - "creedmoor", - "winchester", - "grendel", - "legend", - - // promo shelves - "savings", - - // brand shelves - "magpul", - - // caliber shelves (not part roles) - "5.56", - "5.56 nato", - "223", - ".223", - "223 wylde", - ".223 wylde", - "wylde", - "nato" - ); - - private final MerchantCategoryMapRepository merchantCategoryMapRepository; - private final ProductOfferRepository productOfferRepository; - - public ProductClassifierImpl(MerchantCategoryMapRepository merchantCategoryMapRepository, - ProductOfferRepository productOfferRepository) { - this.merchantCategoryMapRepository = merchantCategoryMapRepository; - this.productOfferRepository = productOfferRepository; - } - - @Override - public ClassificationResult classifyProduct(Product product) { - // Backwards compatible path (existing callers) - return classifyProduct(product, null); - } - - @Override - public ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId) { - - // ===== Guardrails ===== - if (product == null || product.getId() == null) { - return ClassificationResult.unknown(VERSION, "Missing product or product.id."); - } - - if (isBlank(product.getRawCategoryKey())) { - return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product."); - } - - // Normalize inputs early so we don't accidentally compare mismatched strings. - String rawCategory = normalizeRawCategory(product.getRawCategoryKey()); - String platform = normalizePlatform(product.getPlatform()); // may be null - - // Resolve merchant-of-record: - // - Prefer caller-provided merchantId (batch-resolved in reconcile to avoid N+1) - // - Fall back to DB lookup for normal runtime usage - Integer merchantId = resolvedMerchantId; - if (merchantId == null) { - merchantId = productOfferRepository - .findPrimaryMerchantIdByFirstSeen(product.getId()) - .orElse(null); - } - - // ===== Ignore non-classifying categories (e.g. Clearance/Blemished/Caliber shelves) ===== - if (isNonClassifyingCategory(rawCategory)) { - return new ClassificationResult( - null, - "ignored_category", - "Ignored non-classifying merchant category: " + rawCategory, - VERSION, - 0.0, - meta( - "resolvedMerchantId", merchantId, - "rawCategory", rawCategory, - "platform", platform - ) - ); - } - - // If we need merchant mapping or rules, merchantId is required - if (merchantId == null) { - return ClassificationResult.unknown( - VERSION, - "No offers found for product; cannot determine merchant mapping." - ); - } - - // ===== Rule-based split: broad "Gas System" buckets ===== - if (isGasSystemBucket(rawCategory)) { - String name = (product.getName() == null) ? "" : product.getName(); - String n = name.toLowerCase(Locale.ROOT); - - if (containsAny(n, "gas block + tube", "gas block and tube", "gas block w/ tube", "gas block with tube", "block/tube", "block + tube")) { - return new ClassificationResult( - "gas-block-tube-combo", - "rules_gas_system", - "Gas System bucket: inferred gas-block-tube-combo from name.", - VERSION, - 0.92, - meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:combo") - ); - } - - if (containsAny(n, "roll pin", "gas tube roll pin", "gastube roll pin", "tube roll pin")) { - return new ClassificationResult( - "gas-tube-roll-pin", - "rules_gas_system", - "Gas System bucket: inferred gas-tube-roll-pin from name.", - VERSION, - 0.90, - meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:roll-pin") - ); - } - - if (containsAny(n, "gas tube", "gastube")) { - return new ClassificationResult( - "gas-tube", - "rules_gas_system", - "Gas System bucket: inferred gas-tube from name.", - VERSION, - 0.90, - meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:tube") - ); - } - - if (containsAny(n, "gas block", "gasblock")) { - return new ClassificationResult( - "gas-block", - "rules_gas_system", - "Gas System bucket: inferred gas-block from name.", - VERSION, - 0.90, - meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:block") - ); - } - - return new ClassificationResult( - null, - "rules_gas_system", - "Gas System bucket: no confident keyword match (leave unmapped).", - VERSION, - 0.0, - meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:none") - ); - } - - // ===== Primary classification: merchant_category_map lookup ===== - Optional best = - merchantCategoryMapRepository.findBest(merchantId, rawCategory, platform); - - if (best.isPresent()) { - MerchantCategoryMap map = best.get(); - - String role = trimToNull(map.getCanonicalPartRole()); - if (role == null) { - return new ClassificationResult( - null, - "merchant_mapping", - "Mapping found but canonicalPartRole is empty (needs admin mapping).", - VERSION, - 0.20, - meta( - "resolvedMerchantId", merchantId, - "rawCategory", rawCategory, - "platform", platform, - "mapId", map.getId(), - "mapPlatform", map.getPlatform() - ) - ); - } - - double confidence = - platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 : - "ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 : - map.getPlatform() == null ? 0.90 : 0.90; - - return new ClassificationResult( - role, - "merchant_mapping", - "Mapped via merchant_category_map (canonical_part_role).", - VERSION, - confidence, - meta( - "resolvedMerchantId", merchantId, - "rawCategory", rawCategory, - "platform", platform, - "mapId", map.getId(), - "mapPlatform", map.getPlatform() - ) - ); - } - - return new ClassificationResult( - null, - "merchant_mapping", - "No enabled mapping found for resolved merchant/rawCategory/platform.", - VERSION, - 0.0, - meta( - "resolvedMerchantId", merchantId, - "rawCategory", rawCategory, - "platform", platform - ) - ); - } - - @Override - public ClassificationResult classifyProduct( - Product product, - Integer resolvedMerchantId, - Map> mappingMemo - ) { - // Safety: if caller passes null, fall back cleanly - if (mappingMemo == null) { - mappingMemo = new HashMap<>(); - } - - // ===== Guardrails ===== - if (product == null || product.getId() == null) { - return ClassificationResult.unknown(VERSION, "Missing product or product.id."); - } - - if (isBlank(product.getRawCategoryKey())) { - return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product."); - } - - String rawCategory = normalizeRawCategory(product.getRawCategoryKey()); - String platform = normalizePlatform(product.getPlatform()); - - Integer merchantId = resolvedMerchantId; - if (merchantId == null) { - merchantId = productOfferRepository - .findPrimaryMerchantIdByFirstSeen(product.getId()) - .orElse(null); - } - - // Ignore merchandising/filters - if (isNonClassifyingCategory(rawCategory)) { - return new ClassificationResult( - null, - "ignored_category", - "Ignored non-classifying merchant category: " + rawCategory, - VERSION, - 0.0, - meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform) - ); - } - - if (merchantId == null) { - return ClassificationResult.unknown( - VERSION, - "No offers found for product; cannot determine merchant mapping." - ); - } - - // ===== Gas System rules (unchanged) ===== - if (isGasSystemBucket(rawCategory)) { - // reuse your existing logic by calling the 2-arg version - return classifyProduct(product, merchantId); - } - - // ===== Memoized merchant_category_map lookup ===== - // Key is normalized to maximize cache hits across equivalent strings. - String memoKey = - merchantId + "|" + - (platform == null ? "null" : platform.toUpperCase(Locale.ROOT)) + "|" + - rawCategory.toLowerCase(Locale.ROOT).trim(); - - final Integer mid = merchantId; - final String rc = rawCategory; - final String pl = platform; - - Optional best = mappingMemo.computeIfAbsent( - memoKey, - k -> merchantCategoryMapRepository.findBest(mid, rc, pl) - ); - - if (best.isPresent()) { - MerchantCategoryMap map = best.get(); - - String role = trimToNull(map.getCanonicalPartRole()); - if (role == null) { - return new ClassificationResult( - null, - "merchant_mapping", - "Mapping found but canonicalPartRole is empty (needs admin mapping).", - VERSION, - 0.20, - meta( - "resolvedMerchantId", merchantId, - "rawCategory", rawCategory, - "platform", platform, - "mapId", map.getId(), - "mapPlatform", map.getPlatform() - ) - ); - } - - double confidence = - platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 : - "ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 : - map.getPlatform() == null ? 0.90 : 0.90; - - return new ClassificationResult( - role, - "merchant_mapping", - "Mapped via merchant_category_map (canonical_part_role).", - VERSION, - confidence, - meta( - "resolvedMerchantId", merchantId, - "rawCategory", rawCategory, - "platform", platform, - "mapId", map.getId(), - "mapPlatform", map.getPlatform() - ) - ); - } - - return new ClassificationResult( - null, - "merchant_mapping", - "No enabled mapping found for resolved merchant/rawCategory/platform.", - VERSION, - 0.0, - meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform) - ); - } - - - /** - * Returns true if this raw category is a merchandising/condition label rather than a taxonomy path. - */ - private boolean isNonClassifyingCategory(String rawCategory) { - if (rawCategory == null) return false; - - String v = rawCategory.toLowerCase(Locale.ROOT).trim(); - - // If it's a breadcrumb/taxonomy path, DO NOT treat it as a merchandising label. - // Example: "Gunsmithing > ... > Gun Care & Accessories > Ar-15 Complete Uppers" - if (v.contains(">")) return false; - - // For flat categories, apply token matching. - return NON_CLASSIFYING_TOKENS.stream().anyMatch(v::contains); - } - - /** - * Normalize merchant-provided category strings. - * Keep it light: trim + collapse whitespace. - * (If needed later: unify case or canonicalize separators.) - */ - private String normalizeRawCategory(String raw) { - if (raw == null) return null; - return raw.trim().replaceAll("\\s+", " "); - } - - /** - * Normalize platform values. We keep it consistent with expected inputs like "AR-15". - * Uppercasing is okay because "AR-15" remains "AR-15". - */ - private String normalizePlatform(String p) { - if (p == null) return null; - String t = p.trim(); - return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT); - } - - private boolean isBlank(String s) { - return s == null || s.trim().isEmpty(); - } - - private String trimToNull(String s) { - if (s == null) return null; - String t = s.trim(); - return t.isEmpty() ? null : t; - } - - private boolean isGasSystemBucket(String rawCategory) { - if (rawCategory == null) return false; - String v = rawCategory.toLowerCase(Locale.ROOT); - return v.contains("gas system"); - } - - private boolean containsAny(String haystack, String... needles) { - if (haystack == null) return false; - for (String n : needles) { - if (n != null && !n.isBlank() && haystack.contains(n)) return true; - } - return false; - } - - /** - * Safe metadata builder. - * IMPORTANT: Map.of(...) throws if any key/value is null; this helper skips null entries. - */ - private static Map meta(Object... kv) { - Map m = new LinkedHashMap<>(); - for (int i = 0; i < kv.length; i += 2) { - String k = (String) kv[i]; - Object v = kv[i + 1]; - if (k != null && v != null) m.put(k, v); - } - return m; - } +package group.goforward.battlbuilder.classification.impl; + +import group.goforward.battlbuilder.classification.ClassificationResult; +import group.goforward.battlbuilder.classification.ProductClassifier; +import group.goforward.battlbuilder.model.MerchantCategoryMap; +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; +import group.goforward.battlbuilder.repo.ProductOfferRepository; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.HashMap; + +@Component +public class ProductClassifierImpl implements ProductClassifier { + + /** + * Bump this whenever you change classification logic. + * Useful for debugging and for "reconcile" runs. + */ + private static final String VERSION = "2025.12.29"; + + /** + * Non-classifying "categories" some merchants use as merchandising labels. + * These are orthogonal to part role (condition/marketing), so we ignore them + * to avoid polluting merchant_category_map. + */ + // IMPORTANT: + // These tokens represent NON-SEMANTIC merchant categories. + // They should NEVER be mapped to part roles. + // If you're tempted to add them to merchant_category_map, add them here instead. + private static final Set NON_CLASSIFYING_TOKENS = Set.of( + // marketing / promos + "sale", + "clearance", + "deal", + "special", + "markdown", + "promo", + + // merchandising buckets + "general", + "apparel", + "accessories", + "parts", + "spare parts", + "shop all", + "lineup", + "collection", + + // bundles / kits / sets + "bundle", + "kit", + "set", + "builder set", + + // color / variant groupings + "colors", + "finish", + "variant", + + // caliber / platform / type filters (not part roles) + "bolt action", + "ar15", + "ar-15", + "rifles", + "creedmoor", + "winchester", + "grendel", + "legend", + + // promo shelves + "savings", + + // brand shelves + "magpul", + + // caliber shelves (not part roles) + "5.56", + "5.56 nato", + "223", + ".223", + "223 wylde", + ".223 wylde", + "wylde", + "nato" + ); + + private final MerchantCategoryMapRepository merchantCategoryMapRepository; + private final ProductOfferRepository productOfferRepository; + + public ProductClassifierImpl(MerchantCategoryMapRepository merchantCategoryMapRepository, + ProductOfferRepository productOfferRepository) { + this.merchantCategoryMapRepository = merchantCategoryMapRepository; + this.productOfferRepository = productOfferRepository; + } + + @Override + public ClassificationResult classifyProduct(Product product) { + // Backwards compatible path (existing callers) + return classifyProduct(product, null); + } + + @Override + public ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId) { + + // ===== Guardrails ===== + if (product == null || product.getId() == null) { + return ClassificationResult.unknown(VERSION, "Missing product or product.id."); + } + + if (isBlank(product.getRawCategoryKey())) { + return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product."); + } + + // Normalize inputs early so we don't accidentally compare mismatched strings. + String rawCategory = normalizeRawCategory(product.getRawCategoryKey()); + String platform = normalizePlatform(product.getPlatform()); // may be null + + // Resolve merchant-of-record: + // - Prefer caller-provided merchantId (batch-resolved in reconcile to avoid N+1) + // - Fall back to DB lookup for normal runtime usage + Integer merchantId = resolvedMerchantId; + if (merchantId == null) { + merchantId = productOfferRepository + .findPrimaryMerchantIdByFirstSeen(product.getId()) + .orElse(null); + } + + // ===== Ignore non-classifying categories (e.g. Clearance/Blemished/Caliber shelves) ===== + if (isNonClassifyingCategory(rawCategory)) { + return new ClassificationResult( + null, + "ignored_category", + "Ignored non-classifying merchant category: " + rawCategory, + VERSION, + 0.0, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform + ) + ); + } + + // If we need merchant mapping or rules, merchantId is required + if (merchantId == null) { + return ClassificationResult.unknown( + VERSION, + "No offers found for product; cannot determine merchant mapping." + ); + } + + // ===== Rule-based split: broad "Gas System" buckets ===== + if (isGasSystemBucket(rawCategory)) { + String name = (product.getName() == null) ? "" : product.getName(); + String n = name.toLowerCase(Locale.ROOT); + + if (containsAny(n, "gas block + tube", "gas block and tube", "gas block w/ tube", "gas block with tube", "block/tube", "block + tube")) { + return new ClassificationResult( + "gas-block-tube-combo", + "rules_gas_system", + "Gas System bucket: inferred gas-block-tube-combo from name.", + VERSION, + 0.92, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:combo") + ); + } + + if (containsAny(n, "roll pin", "gas tube roll pin", "gastube roll pin", "tube roll pin")) { + return new ClassificationResult( + "gas-tube-roll-pin", + "rules_gas_system", + "Gas System bucket: inferred gas-tube-roll-pin from name.", + VERSION, + 0.90, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:roll-pin") + ); + } + + if (containsAny(n, "gas tube", "gastube")) { + return new ClassificationResult( + "gas-tube", + "rules_gas_system", + "Gas System bucket: inferred gas-tube from name.", + VERSION, + 0.90, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:tube") + ); + } + + if (containsAny(n, "gas block", "gasblock")) { + return new ClassificationResult( + "gas-block", + "rules_gas_system", + "Gas System bucket: inferred gas-block from name.", + VERSION, + 0.90, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:block") + ); + } + + return new ClassificationResult( + null, + "rules_gas_system", + "Gas System bucket: no confident keyword match (leave unmapped).", + VERSION, + 0.0, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:none") + ); + } + + // ===== Primary classification: merchant_category_map lookup ===== + Optional best = + merchantCategoryMapRepository.findBest(merchantId, rawCategory, platform); + + if (best.isPresent()) { + MerchantCategoryMap map = best.get(); + + String role = trimToNull(map.getCanonicalPartRole()); + if (role == null) { + return new ClassificationResult( + null, + "merchant_mapping", + "Mapping found but canonicalPartRole is empty (needs admin mapping).", + VERSION, + 0.20, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform, + "mapId", map.getId(), + "mapPlatform", map.getPlatform() + ) + ); + } + + double confidence = + platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 : + "ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 : + map.getPlatform() == null ? 0.90 : 0.90; + + return new ClassificationResult( + role, + "merchant_mapping", + "Mapped via merchant_category_map (canonical_part_role).", + VERSION, + confidence, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform, + "mapId", map.getId(), + "mapPlatform", map.getPlatform() + ) + ); + } + + return new ClassificationResult( + null, + "merchant_mapping", + "No enabled mapping found for resolved merchant/rawCategory/platform.", + VERSION, + 0.0, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform + ) + ); + } + + @Override + public ClassificationResult classifyProduct( + Product product, + Integer resolvedMerchantId, + Map> mappingMemo + ) { + // Safety: if caller passes null, fall back cleanly + if (mappingMemo == null) { + mappingMemo = new HashMap<>(); + } + + // ===== Guardrails ===== + if (product == null || product.getId() == null) { + return ClassificationResult.unknown(VERSION, "Missing product or product.id."); + } + + if (isBlank(product.getRawCategoryKey())) { + return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product."); + } + + String rawCategory = normalizeRawCategory(product.getRawCategoryKey()); + String platform = normalizePlatform(product.getPlatform()); + + Integer merchantId = resolvedMerchantId; + if (merchantId == null) { + merchantId = productOfferRepository + .findPrimaryMerchantIdByFirstSeen(product.getId()) + .orElse(null); + } + + // Ignore merchandising/filters + if (isNonClassifyingCategory(rawCategory)) { + return new ClassificationResult( + null, + "ignored_category", + "Ignored non-classifying merchant category: " + rawCategory, + VERSION, + 0.0, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform) + ); + } + + if (merchantId == null) { + return ClassificationResult.unknown( + VERSION, + "No offers found for product; cannot determine merchant mapping." + ); + } + + // ===== Gas System rules (unchanged) ===== + if (isGasSystemBucket(rawCategory)) { + // reuse your existing logic by calling the 2-arg version + return classifyProduct(product, merchantId); + } + + // ===== Memoized merchant_category_map lookup ===== + // Key is normalized to maximize cache hits across equivalent strings. + String memoKey = + merchantId + "|" + + (platform == null ? "null" : platform.toUpperCase(Locale.ROOT)) + "|" + + rawCategory.toLowerCase(Locale.ROOT).trim(); + + final Integer mid = merchantId; + final String rc = rawCategory; + final String pl = platform; + + Optional best = mappingMemo.computeIfAbsent( + memoKey, + k -> merchantCategoryMapRepository.findBest(mid, rc, pl) + ); + + if (best.isPresent()) { + MerchantCategoryMap map = best.get(); + + String role = trimToNull(map.getCanonicalPartRole()); + if (role == null) { + return new ClassificationResult( + null, + "merchant_mapping", + "Mapping found but canonicalPartRole is empty (needs admin mapping).", + VERSION, + 0.20, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform, + "mapId", map.getId(), + "mapPlatform", map.getPlatform() + ) + ); + } + + double confidence = + platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 : + "ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 : + map.getPlatform() == null ? 0.90 : 0.90; + + return new ClassificationResult( + role, + "merchant_mapping", + "Mapped via merchant_category_map (canonical_part_role).", + VERSION, + confidence, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform, + "mapId", map.getId(), + "mapPlatform", map.getPlatform() + ) + ); + } + + return new ClassificationResult( + null, + "merchant_mapping", + "No enabled mapping found for resolved merchant/rawCategory/platform.", + VERSION, + 0.0, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform) + ); + } + + + /** + * Returns true if this raw category is a merchandising/condition label rather than a taxonomy path. + */ + private boolean isNonClassifyingCategory(String rawCategory) { + if (rawCategory == null) return false; + + String v = rawCategory.toLowerCase(Locale.ROOT).trim(); + + // If it's a breadcrumb/taxonomy path, DO NOT treat it as a merchandising label. + // Example: "Gunsmithing > ... > Gun Care & Accessories > Ar-15 Complete Uppers" + if (v.contains(">")) return false; + + // For flat categories, apply token matching. + return NON_CLASSIFYING_TOKENS.stream().anyMatch(v::contains); + } + + /** + * Normalize merchant-provided category strings. + * Keep it light: trim + collapse whitespace. + * (If needed later: unify case or canonicalize separators.) + */ + private String normalizeRawCategory(String raw) { + if (raw == null) return null; + return raw.trim().replaceAll("\\s+", " "); + } + + /** + * Normalize platform values. We keep it consistent with expected inputs like "AR-15". + * Uppercasing is okay because "AR-15" remains "AR-15". + */ + private String normalizePlatform(String p) { + if (p == null) return null; + String t = p.trim(); + return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT); + } + + private boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } + + private String trimToNull(String s) { + if (s == null) return null; + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private boolean isGasSystemBucket(String rawCategory) { + if (rawCategory == null) return false; + String v = rawCategory.toLowerCase(Locale.ROOT); + return v.contains("gas system"); + } + + private boolean containsAny(String haystack, String... needles) { + if (haystack == null) return false; + for (String n : needles) { + if (n != null && !n.isBlank() && haystack.contains(n)) return true; + } + return false; + } + + /** + * Safe metadata builder. + * IMPORTANT: Map.of(...) throws if any key/value is null; this helper skips null entries. + */ + private static Map meta(Object... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + String k = (String) kv[i]; + Object v = kv[i + 1]; + if (k != null && v != null) m.put(k, v); + } + return m; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/cli/BetaInviteCliRunner.java b/src/main/java/group/goforward/battlbuilder/cli/BetaInviteCliRunner.java index 940d908..7b597f6 100644 --- a/src/main/java/group/goforward/battlbuilder/cli/BetaInviteCliRunner.java +++ b/src/main/java/group/goforward/battlbuilder/cli/BetaInviteCliRunner.java @@ -1,45 +1,45 @@ -package group.goforward.battlbuilder.cli; - -import group.goforward.battlbuilder.service.auth.impl.BetaInviteService; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - - -@Component -@Profile("!prod") -@ConditionalOnProperty( - name = "app.beta.invite.run", - havingValue = "true" -) -public class BetaInviteCliRunner implements CommandLineRunner { - - private final BetaInviteService inviteService; - - @Value("${app.beta.invite.limit:0}") - private int limit; - - @Value("${app.beta.invite.dryRun:true}") - private boolean dryRun; - - @Value("${app.beta.invite.tokenMinutes:30}") - private int tokenMinutes; - - public BetaInviteCliRunner(BetaInviteService inviteService) { - this.inviteService = inviteService; - } - - @Override - public void run(String... args) { - int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun); - - System.out.println( - "✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun - ); - - // Exit so it behaves like a CLI command - System.exit(0); - } +package group.goforward.battlbuilder.cli; + +import group.goforward.battlbuilder.service.auth.impl.BetaInviteService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + + +@Component +@Profile("!prod") +@ConditionalOnProperty( + name = "app.beta.invite.run", + havingValue = "true" +) +public class BetaInviteCliRunner implements CommandLineRunner { + + private final BetaInviteService inviteService; + + @Value("${app.beta.invite.limit:0}") + private int limit; + + @Value("${app.beta.invite.dryRun:true}") + private boolean dryRun; + + @Value("${app.beta.invite.tokenMinutes:30}") + private int tokenMinutes; + + public BetaInviteCliRunner(BetaInviteService inviteService) { + this.inviteService = inviteService; + } + + @Override + public void run(String... args) { + int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun); + + System.out.println( + "✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun + ); + + // Exit so it behaves like a CLI command + System.exit(0); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/common/package-info.java b/src/main/java/group/goforward/battlbuilder/common/package-info.java index 121d879..8660b9c 100644 --- a/src/main/java/group/goforward/battlbuilder/common/package-info.java +++ b/src/main/java/group/goforward/battlbuilder/common/package-info.java @@ -1,11 +1,11 @@ -/** - * Utility controller package for the BattlBuilder application. - *

- * Contains utility REST controller for email handling and - * health check operations. - * - * @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.common; +/** + * Utility controller package for the BattlBuilder application. + *

+ * Contains utility REST controller for email handling and + * health check operations. + * + * @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.common; diff --git a/src/main/java/group/goforward/battlbuilder/config/MinioConfig.java b/src/main/java/group/goforward/battlbuilder/config/MinioConfig.java index 0fdbc86..bbf7ffc 100644 --- a/src/main/java/group/goforward/battlbuilder/config/MinioConfig.java +++ b/src/main/java/group/goforward/battlbuilder/config/MinioConfig.java @@ -1,22 +1,22 @@ -package group.goforward.battlbuilder.config; - -import io.minio.MinioClient; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class MinioConfig { - - @Bean - public MinioClient minioClient( - @Value("${minio.endpoint}") String endpoint, - @Value("${minio.access-key}") String accessKey, - @Value("${minio.secret-key}") String secretKey - ) { - return MinioClient.builder() - .endpoint(endpoint) - .credentials(accessKey, secretKey) - .build(); - } -} +package group.goforward.battlbuilder.config; + +import io.minio.MinioClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MinioConfig { + + @Bean + public MinioClient minioClient( + @Value("${minio.endpoint}") String endpoint, + @Value("${minio.access-key}") String accessKey, + @Value("${minio.secret-key}") String secretKey + ) { + return MinioClient.builder() + .endpoint(endpoint) + .credentials(accessKey, secretKey) + .build(); + } +} diff --git a/src/main/java/group/goforward/battlbuilder/config/PasswordConfig.java b/src/main/java/group/goforward/battlbuilder/config/PasswordConfig.java index 7533c48..0e4c84c 100644 --- a/src/main/java/group/goforward/battlbuilder/config/PasswordConfig.java +++ b/src/main/java/group/goforward/battlbuilder/config/PasswordConfig.java @@ -1,17 +1,17 @@ -/* -package group.goforward.battlbuilder.configuration; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; - -@Configuration - public class PasswordConfig { - - @Bean - public PasswordEncoder passwordEncoder() { -// // BCrypt default password - return new BCryptPasswordEncoder(); - } - }*/ +/* +package group.goforward.battlbuilder.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration + public class PasswordConfig { + + @Bean + public PasswordEncoder passwordEncoder() { +// // BCrypt default password + return new BCryptPasswordEncoder(); + } + }*/ diff --git a/src/main/java/group/goforward/battlbuilder/config/SecurityConfig.java b/src/main/java/group/goforward/battlbuilder/config/SecurityConfig.java index 7cfdd54..9261acc 100644 --- a/src/main/java/group/goforward/battlbuilder/config/SecurityConfig.java +++ b/src/main/java/group/goforward/battlbuilder/config/SecurityConfig.java @@ -1,93 +1,93 @@ -package group.goforward.battlbuilder.config; - -import group.goforward.battlbuilder.security.JwtAuthenticationFilter; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.List; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - private final JwtAuthenticationFilter jwtAuthenticationFilter; - - public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { - this.jwtAuthenticationFilter = jwtAuthenticationFilter; - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .cors(c -> c.configurationSource(corsConfigurationSource())) - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - - // ---------------------------- - // Public - // ---------------------------- - .requestMatchers("/api/auth/**").permitAll() - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() - .requestMatchers("/actuator/health", "/actuator/info").permitAll() - .requestMatchers("/api/products/gunbuilder/**").permitAll() - - // Public builds feed + public build detail (1 path segment only) - .requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/builds/*").permitAll() - - // ---------------------------- - // Protected - // ---------------------------- - .requestMatchers("/api/v1/builds/me/**").authenticated() - .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") - - // Everything else (adjust later as you lock down) - .anyRequest().permitAll() - ) - - // run JWT before AnonymousAuth sets principal="anonymousUser" - .addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class); - - return http.build(); - } - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration cfg = new CorsConfiguration(); - cfg.setAllowedOrigins(List.of("http://localhost:3000")); - cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - cfg.setAllowedHeaders(List.of("Authorization", "Content-Type")); - cfg.setExposedHeaders(List.of("Authorization")); - cfg.setAllowCredentials(true); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", cfg); - return source; - } - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) - throws Exception { - return configuration.getAuthenticationManager(); - } +package group.goforward.battlbuilder.config; + +import group.goforward.battlbuilder.security.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(c -> c.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + + // ---------------------------- + // Public + // ---------------------------- + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/actuator/health", "/actuator/info").permitAll() + .requestMatchers("/api/products/gunbuilder/**").permitAll() + + // Public builds feed + public build detail (1 path segment only) + .requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/builds/*").permitAll() + + // ---------------------------- + // Protected + // ---------------------------- + .requestMatchers("/api/v1/builds/me/**").authenticated() + .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + + // Everything else (adjust later as you lock down) + .anyRequest().permitAll() + ) + + // run JWT before AnonymousAuth sets principal="anonymousUser" + .addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration cfg = new CorsConfiguration(); + cfg.setAllowedOrigins(List.of("http://localhost:3000")); + cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + cfg.setAllowedHeaders(List.of("Authorization", "Content-Type")); + cfg.setExposedHeaders(List.of("Authorization")); + cfg.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", cfg); + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) + throws Exception { + return configuration.getAuthenticationManager(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/config/package-info.java b/src/main/java/group/goforward/battlbuilder/config/package-info.java index cd02eb5..af3b138 100644 --- a/src/main/java/group/goforward/battlbuilder/config/package-info.java +++ b/src/main/java/group/goforward/battlbuilder/config/package-info.java @@ -1,11 +1,11 @@ -/** - * Configuration package for the BattlBuilder application. - *

- * Contains Spring configuration classes for security, CORS, JPA, - * caching, and password encoding. - * - * @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.config; +/** + * Configuration package for the BattlBuilder application. + *

+ * Contains Spring configuration classes for security, CORS, JPA, + * caching, and password encoding. + * + * @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.config; diff --git a/src/main/java/group/goforward/battlbuilder/controller/AuthController.java b/src/main/java/group/goforward/battlbuilder/controller/AuthController.java index c10b8a0..9394575 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/AuthController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/AuthController.java @@ -1,227 +1,227 @@ -package group.goforward.battlbuilder.controller; - -import group.goforward.battlbuilder.model.User; -import group.goforward.battlbuilder.repo.UserRepository; -import group.goforward.battlbuilder.security.JwtService; -import group.goforward.battlbuilder.service.auth.BetaAuthService; -import group.goforward.battlbuilder.web.dto.auth.AuthResponse; -import group.goforward.battlbuilder.web.dto.auth.BetaSignupRequest; -import group.goforward.battlbuilder.web.dto.auth.LoginRequest; -import group.goforward.battlbuilder.web.dto.auth.RegisterRequest; -import group.goforward.battlbuilder.web.dto.auth.TokenRequest; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.*; -import jakarta.servlet.http.HttpServletRequest; - - -import java.time.OffsetDateTime; -import java.util.Map; -import java.util.UUID; - -@RestController -@RequestMapping({"/api/auth", "/api/v1/auth"}) -@CrossOrigin -public class AuthController { - - private final UserRepository users; - private final PasswordEncoder passwordEncoder; - private final JwtService jwtService; - private final BetaAuthService betaAuthService; - - public AuthController( - UserRepository users, - PasswordEncoder passwordEncoder, - JwtService jwtService, - BetaAuthService betaAuthService - ) { - this.users = users; - this.passwordEncoder = passwordEncoder; - this.jwtService = jwtService; - this.betaAuthService = betaAuthService; - } - - // --------------------------------------------------------------------- - // Standard Auth - // --------------------------------------------------------------------- - - @PostMapping("/register") - public ResponseEntity register( - @RequestBody RegisterRequest request, - HttpServletRequest httpRequest - ) { - String email = request.getEmail().trim().toLowerCase(); - - // ✅ Enforce acceptance - if (!Boolean.TRUE.equals(request.getAcceptedTos())) { - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body("Terms of Service acceptance is required"); - } - - if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) { - return ResponseEntity - .status(HttpStatus.CONFLICT) - .body("Email is already registered"); - } - - User user = new User(); - user.setUuid(UUID.randomUUID()); - user.setEmail(email); - user.setPasswordHash(passwordEncoder.encode(request.getPassword())); - user.setPasswordSetAt(OffsetDateTime.now()); - user.setDisplayName(request.getDisplayName()); - user.setRole("USER"); - user.setActive(true); - user.setCreatedAt(OffsetDateTime.now()); - user.setUpdatedAt(OffsetDateTime.now()); - - // ✅ Record ToS acceptance evidence - String tosVersion = StringUtils.hasText(request.getTosVersion()) - ? request.getTosVersion().trim() - : "2025-12-27"; // keep in sync with your ToS page - - user.setTosAcceptedAt(OffsetDateTime.now()); - user.setTosVersion(tosVersion); - user.setTosIp(extractClientIp(httpRequest)); - user.setTosUserAgent(httpRequest.getHeader("User-Agent")); - - users.save(user); - - String token = jwtService.generateToken(user); - - return ResponseEntity - .status(HttpStatus.CREATED) - .body(new AuthResponse( - token, - user.getEmail(), - user.getDisplayName(), - user.getRole() - )); - } - - private String extractClientIp(HttpServletRequest request) { - String xff = request.getHeader("X-Forwarded-For"); - if (StringUtils.hasText(xff)) { - // first IP in the list - return xff.split(",")[0].trim(); - } - String realIp = request.getHeader("X-Real-IP"); - if (StringUtils.hasText(realIp)) return realIp.trim(); - - return request.getRemoteAddr(); - } - - @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest request) { - String email = request.getEmail().trim().toLowerCase(); - - User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) - .orElse(null); - - if (user == null || !user.isActive()) { - return ResponseEntity - .status(HttpStatus.UNAUTHORIZED) - .body("Invalid credentials"); - } - - if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { - return ResponseEntity - .status(HttpStatus.UNAUTHORIZED) - .body("Invalid credentials"); - } - - user.setLastLoginAt(OffsetDateTime.now()); - user.incrementLoginCount(); - user.setUpdatedAt(OffsetDateTime.now()); - users.save(user); - - String token = jwtService.generateToken(user); - - return ResponseEntity.ok( - new AuthResponse( - token, - user.getEmail(), - user.getDisplayName(), - user.getRole() - ) - ); - } - - // --------------------------------------------------------------------- - // Beta Flow - // --------------------------------------------------------------------- - - @PostMapping("/beta/signup") - public ResponseEntity> betaSignup(@RequestBody BetaSignupRequest request) { - // Always return OK to prevent email enumeration - try { - betaAuthService.signup(request.getEmail(), request.getUseCase()); - } catch (Exception ignored) { - // Intentionally swallow errors here to avoid leaking state - } - return ResponseEntity.ok(Map.of("ok", true)); - } - - @PostMapping("/beta/confirm") - public ResponseEntity betaConfirm(@RequestBody TokenRequest request) { - try { - return ResponseEntity.ok(betaAuthService.confirmAndExchange(request.getToken())); - } catch (IllegalArgumentException e) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body("Confirm link is invalid or expired. Please request a new one."); - } - } - - @PostMapping("/magic/exchange") - public ResponseEntity magicExchange(@RequestBody TokenRequest request) { - try { - AuthResponse auth = betaAuthService.exchangeMagicToken(request.getToken()); - return ResponseEntity.ok(auth); - } catch (IllegalArgumentException e) { - // token invalid/expired/consumed - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body("Magic link is invalid or expired. Please request a new one."); - } - } - - - - @PostMapping("/password/forgot") - public ResponseEntity forgotPassword(@RequestBody Map body) { - String email = body.getOrDefault("email", "").trim(); - - try { - betaAuthService.sendPasswordReset(email); // name we’ll add below - } catch (Exception ignored) { - // swallow to avoid enumeration - } - - return ResponseEntity.ok().body("{\"ok\":true}"); - } - - @PostMapping("/password/reset") - public ResponseEntity resetPassword(@RequestBody Map body) { - String token = body.getOrDefault("token", "").trim(); - String password = body.getOrDefault("password", "").trim(); - - betaAuthService.resetPassword(token, password); - - return ResponseEntity.ok().body("{\"ok\":true}"); - } - - @PostMapping("/magic") - public ResponseEntity requestMagic(@RequestBody Map body) { - String email = body.getOrDefault("email", "").trim(); - - try { - betaAuthService.sendMagicLoginLink(email); - } catch (Exception ignored) { - // swallow to avoid enumeration - } - - return ResponseEntity.ok(Map.of("ok", true)); - } +package group.goforward.battlbuilder.controller; + +import group.goforward.battlbuilder.model.User; +import group.goforward.battlbuilder.repo.UserRepository; +import group.goforward.battlbuilder.security.JwtService; +import group.goforward.battlbuilder.service.auth.BetaAuthService; +import group.goforward.battlbuilder.web.dto.auth.AuthResponse; +import group.goforward.battlbuilder.web.dto.auth.BetaSignupRequest; +import group.goforward.battlbuilder.web.dto.auth.LoginRequest; +import group.goforward.battlbuilder.web.dto.auth.RegisterRequest; +import group.goforward.battlbuilder.web.dto.auth.TokenRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; +import jakarta.servlet.http.HttpServletRequest; + + +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping({"/api/auth", "/api/v1/auth"}) +@CrossOrigin +public class AuthController { + + private final UserRepository users; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final BetaAuthService betaAuthService; + + public AuthController( + UserRepository users, + PasswordEncoder passwordEncoder, + JwtService jwtService, + BetaAuthService betaAuthService + ) { + this.users = users; + this.passwordEncoder = passwordEncoder; + this.jwtService = jwtService; + this.betaAuthService = betaAuthService; + } + + // --------------------------------------------------------------------- + // Standard Auth + // --------------------------------------------------------------------- + + @PostMapping("/register") + public ResponseEntity register( + @RequestBody RegisterRequest request, + HttpServletRequest httpRequest + ) { + String email = request.getEmail().trim().toLowerCase(); + + // ✅ Enforce acceptance + if (!Boolean.TRUE.equals(request.getAcceptedTos())) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body("Terms of Service acceptance is required"); + } + + if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body("Email is already registered"); + } + + User user = new User(); + user.setUuid(UUID.randomUUID()); + user.setEmail(email); + user.setPasswordHash(passwordEncoder.encode(request.getPassword())); + user.setPasswordSetAt(OffsetDateTime.now()); + user.setDisplayName(request.getDisplayName()); + user.setRole("USER"); + user.setActive(true); + user.setCreatedAt(OffsetDateTime.now()); + user.setUpdatedAt(OffsetDateTime.now()); + + // ✅ Record ToS acceptance evidence + String tosVersion = StringUtils.hasText(request.getTosVersion()) + ? request.getTosVersion().trim() + : "2025-12-27"; // keep in sync with your ToS page + + user.setTosAcceptedAt(OffsetDateTime.now()); + user.setTosVersion(tosVersion); + user.setTosIp(extractClientIp(httpRequest)); + user.setTosUserAgent(httpRequest.getHeader("User-Agent")); + + users.save(user); + + String token = jwtService.generateToken(user); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(new AuthResponse( + token, + user.getEmail(), + user.getDisplayName(), + user.getRole() + )); + } + + private String extractClientIp(HttpServletRequest request) { + String xff = request.getHeader("X-Forwarded-For"); + if (StringUtils.hasText(xff)) { + // first IP in the list + return xff.split(",")[0].trim(); + } + String realIp = request.getHeader("X-Real-IP"); + if (StringUtils.hasText(realIp)) return realIp.trim(); + + return request.getRemoteAddr(); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request) { + String email = request.getEmail().trim().toLowerCase(); + + User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) + .orElse(null); + + if (user == null || !user.isActive()) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body("Invalid credentials"); + } + + if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body("Invalid credentials"); + } + + user.setLastLoginAt(OffsetDateTime.now()); + user.incrementLoginCount(); + user.setUpdatedAt(OffsetDateTime.now()); + users.save(user); + + String token = jwtService.generateToken(user); + + return ResponseEntity.ok( + new AuthResponse( + token, + user.getEmail(), + user.getDisplayName(), + user.getRole() + ) + ); + } + + // --------------------------------------------------------------------- + // Beta Flow + // --------------------------------------------------------------------- + + @PostMapping("/beta/signup") + public ResponseEntity> betaSignup(@RequestBody BetaSignupRequest request) { + // Always return OK to prevent email enumeration + try { + betaAuthService.signup(request.getEmail(), request.getUseCase()); + } catch (Exception ignored) { + // Intentionally swallow errors here to avoid leaking state + } + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/beta/confirm") + public ResponseEntity betaConfirm(@RequestBody TokenRequest request) { + try { + return ResponseEntity.ok(betaAuthService.confirmAndExchange(request.getToken())); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("Confirm link is invalid or expired. Please request a new one."); + } + } + + @PostMapping("/magic/exchange") + public ResponseEntity magicExchange(@RequestBody TokenRequest request) { + try { + AuthResponse auth = betaAuthService.exchangeMagicToken(request.getToken()); + return ResponseEntity.ok(auth); + } catch (IllegalArgumentException e) { + // token invalid/expired/consumed + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("Magic link is invalid or expired. Please request a new one."); + } + } + + + + @PostMapping("/password/forgot") + public ResponseEntity forgotPassword(@RequestBody Map body) { + String email = body.getOrDefault("email", "").trim(); + + try { + betaAuthService.sendPasswordReset(email); // name we’ll add below + } catch (Exception ignored) { + // swallow to avoid enumeration + } + + return ResponseEntity.ok().body("{\"ok\":true}"); + } + + @PostMapping("/password/reset") + public ResponseEntity resetPassword(@RequestBody Map body) { + String token = body.getOrDefault("token", "").trim(); + String password = body.getOrDefault("password", "").trim(); + + betaAuthService.resetPassword(token, password); + + return ResponseEntity.ok().body("{\"ok\":true}"); + } + + @PostMapping("/magic") + public ResponseEntity requestMagic(@RequestBody Map body) { + String email = body.getOrDefault("email", "").trim(); + + try { + betaAuthService.sendMagicLoginLink(email); + } catch (Exception ignored) { + // swallow to avoid enumeration + } + + return ResponseEntity.ok(Map.of("ok", true)); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/BuilderBootstrapController.java b/src/main/java/group/goforward/battlbuilder/controller/BuilderBootstrapController.java index ead24e7..3d1930d 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/BuilderBootstrapController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/BuilderBootstrapController.java @@ -1,102 +1,106 @@ -package group.goforward.battlbuilder.controller; - -import group.goforward.battlbuilder.model.PartRoleMapping; -import group.goforward.battlbuilder.repo.PartCategoryRepository; -import group.goforward.battlbuilder.repo.PartRoleMappingRepository; -import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto; -import org.springframework.web.bind.annotation.*; - -import java.util.*; - -@RestController -@RequestMapping({"/api/builder", "/api/v1/builder"}) -@CrossOrigin -public class BuilderBootstrapController { - - private final PartCategoryRepository partCategoryRepository; - private final PartRoleMappingRepository mappingRepository; - - public BuilderBootstrapController( - PartCategoryRepository partCategoryRepository, - PartRoleMappingRepository mappingRepository - ) { - this.partCategoryRepository = partCategoryRepository; - this.mappingRepository = mappingRepository; - } - - /** - * Builder bootstrap payload. - *

- * Returns: - * - categories: ordered list for UI navigation - * - partRoleMap: normalized partRole -> categorySlug (platform-scoped) - * - categoryRoles: categorySlug -> normalized partRoles (derived) - */ - @GetMapping("/bootstrap") - public BuilderBootstrapDto bootstrap( - @RequestParam(defaultValue = "AR-15") String platform - ) { - final String platformNorm = normalizePlatform(platform); - - // 1) Categories in display order - List categories = partCategoryRepository - .findAllByOrderByGroupNameAscSortOrderAscNameAsc() - .stream() - .map(pc -> new PartCategoryDto( - pc.getId(), - pc.getSlug(), - pc.getName(), - pc.getDescription(), - pc.getGroupName(), - pc.getSortOrder() - )) - .toList(); - - // 2) Role -> CategorySlug mapping (platform-scoped) - // Normalize keys to kebab-case so the UI can treat roles consistently. - Map roleToCategorySlug = new LinkedHashMap<>(); - - List mappings = mappingRepository - .findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(platformNorm); - - for (PartRoleMapping m : mappings) { - String roleKey = normalizePartRole(m.getPartRole()); - if (roleKey == null || roleKey.isBlank()) continue; - - if (m.getPartCategory() == null || m.getPartCategory().getSlug() == null) continue; - - // If duplicates exist, keep first and ignore the rest so bootstrap never 500s. - roleToCategorySlug.putIfAbsent(roleKey, m.getPartCategory().getSlug()); - } - - // 3) CategorySlug -> Roles (derived) - Map> categoryToRoles = new LinkedHashMap<>(); - for (Map.Entry e : roleToCategorySlug.entrySet()) { - categoryToRoles.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey()); - } - - return new BuilderBootstrapDto(platformNorm, categories, roleToCategorySlug, categoryToRoles); - } - - private String normalizePartRole(String role) { - if (role == null) return null; - String r = role.trim(); - if (r.isEmpty()) return null; - return r.toLowerCase(Locale.ROOT).replace('_', '-'); - } - - private String normalizePlatform(String platform) { - if (platform == null) return "AR-15"; - String p = platform.trim(); - if (p.isEmpty()) return "AR-15"; - // normalize to AR-15 / AR-10 style - return p.toUpperCase(Locale.ROOT).replace('_', '-'); - } - - public record BuilderBootstrapDto( - String platform, - List categories, - Map partRoleMap, - Map> categoryRoles - ) {} +package group.goforward.battlbuilder.controller; + +import group.goforward.battlbuilder.model.PartRoleMapping; +import group.goforward.battlbuilder.repo.PartCategoryRepository; +import group.goforward.battlbuilder.repo.PartRoleMappingRepository; +import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +/** + * REST controller responsible for providing bootstrap data for the builder platform. + * This controller aggregates and normalizes data required to initialize builder-related UI components. + */ +@RestController +@RequestMapping({"/api/builder", "/api/v1/builder"}) +@CrossOrigin +public class BuilderBootstrapController { + + private final PartCategoryRepository partCategoryRepository; + private final PartRoleMappingRepository mappingRepository; + + public BuilderBootstrapController( + PartCategoryRepository partCategoryRepository, + PartRoleMappingRepository mappingRepository + ) { + this.partCategoryRepository = partCategoryRepository; + this.mappingRepository = mappingRepository; + } + + /** + * Builder bootstrap payload. + *

+ * Returns: + * - categories: ordered list for UI navigation + * - partRoleMap: normalized partRole -> categorySlug (platform-scoped) + * - categoryRoles: categorySlug -> normalized partRoles (derived) + */ + @GetMapping("/bootstrap") + public BuilderBootstrapDto bootstrap( + @RequestParam(defaultValue = "AR-15") String platform + ) { + final String platformNorm = normalizePlatform(platform); + + // 1) Categories in display order + List categories = partCategoryRepository + .findAllByOrderByGroupNameAscSortOrderAscNameAsc() + .stream() + .map(pc -> new PartCategoryDto( + pc.getId(), + pc.getSlug(), + pc.getName(), + pc.getDescription(), + pc.getGroupName(), + pc.getSortOrder() + )) + .toList(); + + // 2) Role -> CategorySlug mapping (platform-scoped) + // Normalize keys to kebab-case so the UI can treat roles consistently. + Map roleToCategorySlug = new LinkedHashMap<>(); + + List mappings = mappingRepository + .findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(platformNorm); + + for (PartRoleMapping m : mappings) { + String roleKey = normalizePartRole(m.getPartRole()); + if (roleKey == null || roleKey.isBlank()) continue; + + if (m.getPartCategory() == null || m.getPartCategory().getSlug() == null) continue; + + // If duplicates exist, keep first and ignore the rest so bootstrap never 500s. + roleToCategorySlug.putIfAbsent(roleKey, m.getPartCategory().getSlug()); + } + + // 3) CategorySlug -> Roles (derived) + Map> categoryToRoles = new LinkedHashMap<>(); + for (Map.Entry e : roleToCategorySlug.entrySet()) { + categoryToRoles.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey()); + } + + return new BuilderBootstrapDto(platformNorm, categories, roleToCategorySlug, categoryToRoles); + } + + private String normalizePartRole(String role) { + if (role == null) return null; + String r = role.trim(); + if (r.isEmpty()) return null; + return r.toLowerCase(Locale.ROOT).replace('_', '-'); + } + + private String normalizePlatform(String platform) { + if (platform == null) return "AR-15"; + String p = platform.trim(); + if (p.isEmpty()) return "AR-15"; + // normalize to AR-15 / AR-10 style + return p.toUpperCase(Locale.ROOT).replace('_', '-'); + } + + public record BuilderBootstrapDto( + String platform, + List categories, + Map partRoleMap, + Map> categoryRoles + ) {} } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/CategoryController.java b/src/main/java/group/goforward/battlbuilder/controller/CategoryController.java index 1748289..83f25e8 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/CategoryController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/CategoryController.java @@ -1,34 +1,55 @@ -package group.goforward.battlbuilder.controller; - -import group.goforward.battlbuilder.repo.PartCategoryRepository; -import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping({"/api/categories", "/api/v1/categories"}) -@CrossOrigin // you can tighten origins later -public class CategoryController { - - private final PartCategoryRepository partCategories; - - public CategoryController(PartCategoryRepository partCategories) { - this.partCategories = partCategories; - } - - @GetMapping - public List list() { - return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc() - .stream() - .map(pc -> new PartCategoryDto( - pc.getId(), - pc.getSlug(), - pc.getName(), - pc.getDescription(), - pc.getGroupName(), - pc.getSortOrder() - )) - .toList(); - } +package group.goforward.battlbuilder.controller; + +import group.goforward.battlbuilder.repo.PartCategoryRepository; +import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * REST controller for managing part categories. + * + * This controller provides endpoints for retrieving and interacting with + * part category data through its associated repository. Part categories are + * sorted based on their group name, sort order, and name in ascending order. + * + * Annotations: + * - {@code @RestController}: Indicates that this class is a REST controller. + * - {@code @RequestMapping}: Maps HTTP requests to specific endpoints. Supported + * paths are "/api/categories" and "/api/v1/categories". + * - {@code @CrossOrigin}: Enables cross-origin requests. + * + * Constructor: + * - {@code CategoryController(PartCategoryRepository partCategories)}: Initializes + * the controller with the specified repository for accessing part category data. + * + * Methods: + * - {@code List list()}: Retrieves a list of part categories from + * the repository, sorts them, and maps them to DTO objects for output. + */ +@RestController +@RequestMapping({"/api/categories", "/api/v1/categories"}) +@CrossOrigin // you can tighten origins later +public class CategoryController { + + private final PartCategoryRepository partCategories; + + public CategoryController(PartCategoryRepository partCategories) { + this.partCategories = partCategories; + } + + @GetMapping + public List list() { + return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc() + .stream() + .map(pc -> new PartCategoryDto( + pc.getId(), + pc.getSlug(), + pc.getName(), + pc.getDescription(), + pc.getGroupName(), + pc.getSortOrder() + )) + .toList(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/EmailTrackingController.java b/src/main/java/group/goforward/battlbuilder/controller/EmailTrackingController.java index 9bf358b..834ff0b 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/EmailTrackingController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/EmailTrackingController.java @@ -1,48 +1,60 @@ -package group.goforward.battlbuilder.controller; - -import group.goforward.battlbuilder.repo.EmailRequestRepository; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.net.URI; -import java.time.LocalDateTime; - -@RestController -@RequestMapping("/api/email") -public class EmailTrackingController { - - // 1x1 transparent GIF - private static final byte[] PIXEL = new byte[] { - 71,73,70,56,57,97,1,0,1,0,-128,0,0,0,0,0,-1,-1,-1,33,-7,4,1,0,0,0,0,44,0,0,0,0,1,0,1,0,0,2,2,68,1,0,59 - }; - - private final EmailRequestRepository repo; - - public EmailTrackingController(EmailRequestRepository repo) { - this.repo = repo; - } - - @GetMapping(value = "/open/{id}", produces = "image/gif") - public ResponseEntity open(@PathVariable Long id) { - repo.findById(id).ifPresent(r -> { - if (r.getOpenedAt() == null) r.setOpenedAt(LocalDateTime.now()); - r.setOpenCount((r.getOpenCount() == null ? 0 : r.getOpenCount()) + 1); - repo.save(r); - }); - - return ResponseEntity.ok() - .header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") - .body(PIXEL); - } - - @GetMapping("/click/{id}") - public ResponseEntity click(@PathVariable Long id, @RequestParam String url) { - repo.findById(id).ifPresent(r -> { - if (r.getClickedAt() == null) r.setClickedAt(LocalDateTime.now()); - r.setClickCount((r.getClickCount() == null ? 0 : r.getClickCount()) + 1); - repo.save(r); - }); - - return ResponseEntity.status(302).location(URI.create(url)).build(); - } +package group.goforward.battlbuilder.controller; + +import group.goforward.battlbuilder.repo.EmailRequestRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.time.LocalDateTime; + +/** + * The EmailTrackingController handles tracking of email-related events such as + * email opens and link clicks. This controller provides endpoints to record + * these events and return appropriate responses. + * + * The tracking of email opens is achieved through a transparent 1x1 GIF image, + * and link clicks are redirected to the intended URL while capturing relevant metadata. + * + * Request mappings: + * 1. "/api/email/open/{id}" - Tracks email open events. + * 2. "/api/email/click/{id}" - Tracks email link click events. + */ +@RestController +@RequestMapping("/api/email") +public class EmailTrackingController { + + // 1x1 transparent GIF + private static final byte[] PIXEL = new byte[] { + 71,73,70,56,57,97,1,0,1,0,-128,0,0,0,0,0,-1,-1,-1,33,-7,4,1,0,0,0,0,44,0,0,0,0,1,0,1,0,0,2,2,68,1,0,59 + }; + + private final EmailRequestRepository repo; + + public EmailTrackingController(EmailRequestRepository repo) { + this.repo = repo; + } + + @GetMapping(value = "/open/{id}", produces = "image/gif") + public ResponseEntity open(@PathVariable Long id) { + repo.findById(id).ifPresent(r -> { + if (r.getOpenedAt() == null) r.setOpenedAt(LocalDateTime.now()); + r.setOpenCount((r.getOpenCount() == null ? 0 : r.getOpenCount()) + 1); + repo.save(r); + }); + + return ResponseEntity.ok() + .header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + .body(PIXEL); + } + + @GetMapping("/click/{id}") + public ResponseEntity click(@PathVariable Long id, @RequestParam String url) { + repo.findById(id).ifPresent(r -> { + if (r.getClickedAt() == null) r.setClickedAt(LocalDateTime.now()); + r.setClickCount((r.getClickCount() == null ? 0 : r.getClickCount()) + 1); + repo.save(r); + }); + + return ResponseEntity.status(302).location(URI.create(url)).build(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/ImportController.java b/src/main/java/group/goforward/battlbuilder/controller/ImportController.java index e4d98cf..db027d8 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/ImportController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/ImportController.java @@ -4,6 +4,15 @@ import group.goforward.battlbuilder.service.MerchantFeedImportService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +/** + * REST controller responsible for handling import operations for merchants. + * Supports full product and offer imports as well as offers-only synchronization. + * + * Mapped to the following base endpoints: + * /api/admin/imports and /api/v1/admin/imports + * + * Cross-origin requests are permitted from http://localhost:3000. + */ @RestController @RequestMapping({"/api/admin/imports", "/api/v1/admin/imports"}) diff --git a/src/main/java/group/goforward/battlbuilder/controller/MerchantDebugController.java b/src/main/java/group/goforward/battlbuilder/controller/MerchantDebugController.java index 5cb9d5d..a15a7b0 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/MerchantDebugController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/MerchantDebugController.java @@ -8,6 +8,12 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; +/** + * REST controller for debugging merchant-related functionalities in the admin API. + * Provides endpoints for retrieving merchant data for administrative purposes. + * + * Mapped to both "/api/admin" and "/api/v1/admin" base paths. + */ @RestController @RequestMapping({"/api/admin", "/api/v1/admin"}) public class MerchantDebugController { diff --git a/src/main/java/group/goforward/battlbuilder/controller/PartRoleMappingController.java b/src/main/java/group/goforward/battlbuilder/controller/PartRoleMappingController.java index 9e1cbc7..b909101 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/PartRoleMappingController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/PartRoleMappingController.java @@ -1,31 +1,31 @@ -//package group.goforward.battlbuilder.controller; -// -//import group.goforward.battlbuilder.service.PartRoleMappingService; -//import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto; -//import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto; -//import org.springframework.web.bind.annotation.*; -// -//import java.util.List; -// -//@RestController -//@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"}) -//public class PartRoleMappingController { -// -// private final PartRoleMappingService service; -// -// public PartRoleMappingController(PartRoleMappingService service) { -// this.service = service; -// } -// -// // Full view for admin UI -// @GetMapping("/{platform}") -// public List getMappings(@PathVariable String platform) { -// return service.getMappingsForPlatform(platform); -// } -// -// // Thin mapping for the builder -// @GetMapping("/{platform}/map") -// public List getRoleMap(@PathVariable String platform) { -// return service.getRoleToCategoryMap(platform); -// } +//package group.goforward.battlbuilder.controller; +// +//import group.goforward.battlbuilder.service.PartRoleMappingService; +//import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto; +//import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto; +//import org.springframework.web.bind.annotation.*; +// +//import java.util.List; +// +//@RestController +//@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"}) +//public class PartRoleMappingController { +// +// private final PartRoleMappingService service; +// +// public PartRoleMappingController(PartRoleMappingService service) { +// this.service = service; +// } +// +// // Full view for admin UI +// @GetMapping("/{platform}") +// public List getMappings(@PathVariable String platform) { +// return service.getMappingsForPlatform(platform); +// } +// +// // Thin mapping for the builder +// @GetMapping("/{platform}/map") +// public List getRoleMap(@PathVariable String platform) { +// return service.getRoleToCategoryMap(platform); +// } //} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/admin/AdminBetaInviteController.java b/src/main/java/group/goforward/battlbuilder/controller/admin/AdminBetaInviteController.java index 9355d7a..ef073b5 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/admin/AdminBetaInviteController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/admin/AdminBetaInviteController.java @@ -1,51 +1,51 @@ -package group.goforward.battlbuilder.controller.admin; - -import group.goforward.battlbuilder.service.auth.impl.BetaInviteService; -import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto; -import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse; -import org.springframework.data.domain.Page; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/v1/admin/beta") -public class AdminBetaInviteController { - - private final BetaInviteService betaInviteService; - - public AdminBetaInviteController(BetaInviteService betaInviteService) { - this.betaInviteService = betaInviteService; - } - - /** - * //api/v1/admin/beta/invites/send?limit=25&dryRun=true&tokenMinutes=30 - * @param limit - * @param dryRun - * @param tokenMinutes - * @return - */ - - @PostMapping("/invites/send") - public InviteBatchResponse sendInvites( - @RequestParam(defaultValue = "0") int limit, - @RequestParam(defaultValue = "true") boolean dryRun, - @RequestParam(defaultValue = "30") int tokenMinutes - ) { - int processed = betaInviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun); - return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit); - } - - @GetMapping("/requests") - public Page listBetaRequests( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "25") int size - ) { - return betaInviteService.listPendingBetaUsers(page, size); - } - - @PostMapping("/requests/{userId}/invite") - public AdminInviteResponse inviteSingle(@PathVariable Integer userId) { - return betaInviteService.inviteSingleBetaUser(userId); - } - - public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {} +package group.goforward.battlbuilder.controller.admin; + +import group.goforward.battlbuilder.service.auth.impl.BetaInviteService; +import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto; +import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/beta") +public class AdminBetaInviteController { + + private final BetaInviteService betaInviteService; + + public AdminBetaInviteController(BetaInviteService betaInviteService) { + this.betaInviteService = betaInviteService; + } + + /** + * //api/v1/admin/beta/invites/send?limit=25&dryRun=true&tokenMinutes=30 + * @param limit + * @param dryRun + * @param tokenMinutes + * @return + */ + + @PostMapping("/invites/send") + public InviteBatchResponse sendInvites( + @RequestParam(defaultValue = "0") int limit, + @RequestParam(defaultValue = "true") boolean dryRun, + @RequestParam(defaultValue = "30") int tokenMinutes + ) { + int processed = betaInviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun); + return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit); + } + + @GetMapping("/requests") + public Page listBetaRequests( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "25") int size + ) { + return betaInviteService.listPendingBetaUsers(page, size); + } + + @PostMapping("/requests/{userId}/invite") + public AdminInviteResponse inviteSingle(@PathVariable Integer userId) { + return betaInviteService.inviteSingleBetaUser(userId); + } + + public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {} } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/admin/AdminCategoryController.java b/src/main/java/group/goforward/battlbuilder/controller/admin/AdminCategoryController.java index 650fe03..775312c 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/admin/AdminCategoryController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/admin/AdminCategoryController.java @@ -1,40 +1,40 @@ -package group.goforward.battlbuilder.controller.admin; - -import group.goforward.battlbuilder.model.PartCategory; -import group.goforward.battlbuilder.repo.PartCategoryRepository; -import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/admin/categories") -@CrossOrigin -public class AdminCategoryController { - - private final PartCategoryRepository partCategories; - - public AdminCategoryController(PartCategoryRepository partCategories) { - this.partCategories = partCategories; - } - - @GetMapping - public List listCategories() { - return partCategories - .findAllByOrderByGroupNameAscSortOrderAscNameAsc() - .stream() - .map(this::toDto) - .toList(); - } - - private PartCategoryDto toDto(PartCategory entity) { - return new PartCategoryDto( - entity.getId(), - entity.getSlug(), - entity.getName(), - entity.getDescription(), - entity.getGroupName(), - entity.getSortOrder() - ); - } +package group.goforward.battlbuilder.controller.admin; + +import group.goforward.battlbuilder.model.PartCategory; +import group.goforward.battlbuilder.repo.PartCategoryRepository; +import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/categories") +@CrossOrigin +public class AdminCategoryController { + + private final PartCategoryRepository partCategories; + + public AdminCategoryController(PartCategoryRepository partCategories) { + this.partCategories = partCategories; + } + + @GetMapping + public List listCategories() { + return partCategories + .findAllByOrderByGroupNameAscSortOrderAscNameAsc() + .stream() + .map(this::toDto) + .toList(); + } + + private PartCategoryDto toDto(PartCategory entity) { + return new PartCategoryDto( + entity.getId(), + entity.getSlug(), + entity.getName(), + entity.getDescription(), + entity.getGroupName(), + entity.getSortOrder() + ); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/admin/AdminDashboardController.java b/src/main/java/group/goforward/battlbuilder/controller/admin/AdminDashboardController.java index a0a74b4..7892a99 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/admin/AdminDashboardController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/admin/AdminDashboardController.java @@ -1,25 +1,25 @@ -package group.goforward.battlbuilder.controller.admin; - -import group.goforward.battlbuilder.service.admin.impl.AdminDashboardService; -import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/admin/dashboard") -public class AdminDashboardController { - - private final AdminDashboardService adminDashboardService; - - public AdminDashboardController(AdminDashboardService adminDashboardService) { - this.adminDashboardService = adminDashboardService; - } - - @GetMapping("/overview") - public ResponseEntity getOverview() { - AdminDashboardOverviewDto dto = adminDashboardService.getOverview(); - return ResponseEntity.ok(dto); - } +package group.goforward.battlbuilder.controller.admin; + +import group.goforward.battlbuilder.service.admin.impl.AdminDashboardService; +import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/dashboard") +public class AdminDashboardController { + + private final AdminDashboardService adminDashboardService; + + public AdminDashboardController(AdminDashboardService adminDashboardService) { + this.adminDashboardService = adminDashboardService; + } + + @GetMapping("/overview") + public ResponseEntity getOverview() { + AdminDashboardOverviewDto dto = adminDashboardService.getOverview(); + return ResponseEntity.ok(dto); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/admin/AdminPartCategoryController.java b/src/main/java/group/goforward/battlbuilder/controller/admin/AdminPartCategoryController.java index c8bf4f6..714c2d1 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/admin/AdminPartCategoryController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/admin/AdminPartCategoryController.java @@ -1,34 +1,34 @@ -package group.goforward.battlbuilder.controller.admin; - -import group.goforward.battlbuilder.repo.PartCategoryRepository; -import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/admin/part-categories") -@CrossOrigin // keep it loose for now, you can tighten origins later -public class AdminPartCategoryController { - - private final PartCategoryRepository partCategories; - - public AdminPartCategoryController(PartCategoryRepository partCategories) { - this.partCategories = partCategories; - } - - @GetMapping - public List list() { - return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc() - .stream() - .map(pc -> new PartCategoryDto( - pc.getId(), - pc.getSlug(), - pc.getName(), - pc.getDescription(), - pc.getGroupName(), - pc.getSortOrder() - )) - .toList(); - } +package group.goforward.battlbuilder.controller.admin; + +import group.goforward.battlbuilder.repo.PartCategoryRepository; +import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/part-categories") +@CrossOrigin // keep it loose for now, you can tighten origins later +public class AdminPartCategoryController { + + private final PartCategoryRepository partCategories; + + public AdminPartCategoryController(PartCategoryRepository partCategories) { + this.partCategories = partCategories; + } + + @GetMapping + public List list() { + return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc() + .stream() + .map(pc -> new PartCategoryDto( + pc.getId(), + pc.getSlug(), + pc.getName(), + pc.getDescription(), + pc.getGroupName(), + pc.getSortOrder() + )) + .toList(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/admin/AdminPartRoleMappingController.java b/src/main/java/group/goforward/battlbuilder/controller/admin/AdminPartRoleMappingController.java index f3c606d..420c38f 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/admin/AdminPartRoleMappingController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/admin/AdminPartRoleMappingController.java @@ -1,124 +1,124 @@ -package group.goforward.battlbuilder.controller.admin; - -import group.goforward.battlbuilder.model.PartCategory; -import group.goforward.battlbuilder.model.PartRoleMapping; -import group.goforward.battlbuilder.repo.PartCategoryRepository; -import group.goforward.battlbuilder.repo.PartRoleMappingRepository; -import group.goforward.battlbuilder.web.dto.admin.AdminPartRoleMappingDto; -import group.goforward.battlbuilder.web.dto.admin.CreatePartRoleMappingRequest; -import group.goforward.battlbuilder.web.dto.admin.UpdatePartRoleMappingRequest; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; - -import java.util.List; - -@RestController -@RequestMapping("/api/admin/part-role-mappings") -@CrossOrigin -public class AdminPartRoleMappingController { - - private final PartRoleMappingRepository partRoleMappingRepository; - private final PartCategoryRepository partCategoryRepository; - - public AdminPartRoleMappingController( - PartRoleMappingRepository partRoleMappingRepository, - PartCategoryRepository partCategoryRepository - ) { - this.partRoleMappingRepository = partRoleMappingRepository; - this.partCategoryRepository = partCategoryRepository; - } - - // GET /api/admin/part-role-mappings?platform=AR-15 - @GetMapping - public List list( - @RequestParam(name = "platform", required = false) String platform - ) { - List mappings; - - if (platform != null && !platform.isBlank()) { - mappings = partRoleMappingRepository - .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform); - } else { - mappings = partRoleMappingRepository.findAll(); - } - - return mappings.stream() - .map(this::toDto) - .toList(); - } - - // POST /api/admin/part-role-mappings - @PostMapping - public AdminPartRoleMappingDto create( - @RequestBody CreatePartRoleMappingRequest request - ) { - PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "PartCategory not found for slug: " + request.categorySlug() - )); - - PartRoleMapping mapping = new PartRoleMapping(); - mapping.setPlatform(request.platform()); - mapping.setPartRole(request.partRole()); - mapping.setPartCategory(category); - mapping.setNotes(request.notes()); - - mapping = partRoleMappingRepository.save(mapping); - return toDto(mapping); - } - - // PUT /api/admin/part-role-mappings/{id} - @PutMapping("/{id}") - public AdminPartRoleMappingDto update( - @PathVariable Integer id, - @RequestBody UpdatePartRoleMappingRequest request - ) { - PartRoleMapping mapping = partRoleMappingRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found")); - - if (request.platform() != null) { - mapping.setPlatform(request.platform()); - } - if (request.partRole() != null) { - mapping.setPartRole(request.partRole()); - } - if (request.categorySlug() != null) { - PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "PartCategory not found for slug: " + request.categorySlug() - )); - mapping.setPartCategory(category); - } - if (request.notes() != null) { - mapping.setNotes(request.notes()); - } - - mapping = partRoleMappingRepository.save(mapping); - return toDto(mapping); - } - - // DELETE /api/admin/part-role-mappings/{id} - @DeleteMapping("/{id}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(@PathVariable Integer id) { - if (!partRoleMappingRepository.existsById(id)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"); - } - partRoleMappingRepository.deleteById(id); - } - - private AdminPartRoleMappingDto toDto(PartRoleMapping mapping) { - PartCategory cat = mapping.getPartCategory(); - return new AdminPartRoleMappingDto( - mapping.getId(), - mapping.getPlatform(), - mapping.getPartRole(), - cat != null ? cat.getSlug() : null, - cat != null ? cat.getGroupName() : null, - mapping.getNotes() - ); - } +package group.goforward.battlbuilder.controller.admin; + +import group.goforward.battlbuilder.model.PartCategory; +import group.goforward.battlbuilder.model.PartRoleMapping; +import group.goforward.battlbuilder.repo.PartCategoryRepository; +import group.goforward.battlbuilder.repo.PartRoleMappingRepository; +import group.goforward.battlbuilder.web.dto.admin.AdminPartRoleMappingDto; +import group.goforward.battlbuilder.web.dto.admin.CreatePartRoleMappingRequest; +import group.goforward.battlbuilder.web.dto.admin.UpdatePartRoleMappingRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/part-role-mappings") +@CrossOrigin +public class AdminPartRoleMappingController { + + private final PartRoleMappingRepository partRoleMappingRepository; + private final PartCategoryRepository partCategoryRepository; + + public AdminPartRoleMappingController( + PartRoleMappingRepository partRoleMappingRepository, + PartCategoryRepository partCategoryRepository + ) { + this.partRoleMappingRepository = partRoleMappingRepository; + this.partCategoryRepository = partCategoryRepository; + } + + // GET /api/admin/part-role-mappings?platform=AR-15 + @GetMapping + public List list( + @RequestParam(name = "platform", required = false) String platform + ) { + List mappings; + + if (platform != null && !platform.isBlank()) { + mappings = partRoleMappingRepository + .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform); + } else { + mappings = partRoleMappingRepository.findAll(); + } + + return mappings.stream() + .map(this::toDto) + .toList(); + } + + // POST /api/admin/part-role-mappings + @PostMapping + public AdminPartRoleMappingDto create( + @RequestBody CreatePartRoleMappingRequest request + ) { + PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "PartCategory not found for slug: " + request.categorySlug() + )); + + PartRoleMapping mapping = new PartRoleMapping(); + mapping.setPlatform(request.platform()); + mapping.setPartRole(request.partRole()); + mapping.setPartCategory(category); + mapping.setNotes(request.notes()); + + mapping = partRoleMappingRepository.save(mapping); + return toDto(mapping); + } + + // PUT /api/admin/part-role-mappings/{id} + @PutMapping("/{id}") + public AdminPartRoleMappingDto update( + @PathVariable Integer id, + @RequestBody UpdatePartRoleMappingRequest request + ) { + PartRoleMapping mapping = partRoleMappingRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found")); + + if (request.platform() != null) { + mapping.setPlatform(request.platform()); + } + if (request.partRole() != null) { + mapping.setPartRole(request.partRole()); + } + if (request.categorySlug() != null) { + PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "PartCategory not found for slug: " + request.categorySlug() + )); + mapping.setPartCategory(category); + } + if (request.notes() != null) { + mapping.setNotes(request.notes()); + } + + mapping = partRoleMappingRepository.save(mapping); + return toDto(mapping); + } + + // DELETE /api/admin/part-role-mappings/{id} + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Integer id) { + if (!partRoleMappingRepository.existsById(id)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"); + } + partRoleMappingRepository.deleteById(id); + } + + private AdminPartRoleMappingDto toDto(PartRoleMapping mapping) { + PartCategory cat = mapping.getPartCategory(); + return new AdminPartRoleMappingDto( + mapping.getId(), + mapping.getPlatform(), + mapping.getPartRole(), + cat != null ? cat.getSlug() : null, + cat != null ? cat.getGroupName() : null, + mapping.getNotes() + ); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/admin/package-info.java b/src/main/java/group/goforward/battlbuilder/controller/admin/package-info.java index 62a3d30..9f0b346 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/admin/package-info.java +++ b/src/main/java/group/goforward/battlbuilder/controller/admin/package-info.java @@ -1,11 +1,11 @@ -/** - * Admin controller package for the BattlBuilder application. - *

- * Contains REST controller for administrative operations including - * category management, platform configuration, and merchant administration. - * - * @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.controller.admin; +/** + * Admin controller package for the BattlBuilder application. + *

+ * Contains REST controller for administrative operations including + * category management, platform configuration, and merchant administration. + * + * @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.controller.admin; diff --git a/src/main/java/group/goforward/battlbuilder/controller/api/v1/BrandController.java b/src/main/java/group/goforward/battlbuilder/controller/api/v1/BrandController.java index b82cdfe..cc10f34 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/api/v1/BrandController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/api/v1/BrandController.java @@ -11,6 +11,27 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * REST controller for managing brand entities. + * Provides endpoints for performing CRUD operations on brands. + * + * The controller exposes REST APIs to: + * - Retrieve a list of all brands. + * - Fetch a single brand by its ID. + * - Create a new brand. + * - Delete a brand by its ID. + * + * This controller interacts with the persistence layer through `BrandRepository` and with the + * business logic through `BrandService`. + * + * Mapped base endpoints: + * - `/api/v1/brands` + * - `/api/brands` + * + * Dependencies: + * - `BrandRepository` for direct database access. + * - `BrandService` for handling the business logic of brands. + */ @RestController @RequestMapping({"/api/v1/brands", "/api/brands"}) public class BrandController { diff --git a/src/main/java/group/goforward/battlbuilder/controller/api/v1/BuildV1Controller.java b/src/main/java/group/goforward/battlbuilder/controller/api/v1/BuildV1Controller.java index 74f08d2..4518b2e 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/api/v1/BuildV1Controller.java +++ b/src/main/java/group/goforward/battlbuilder/controller/api/v1/BuildV1Controller.java @@ -1,96 +1,96 @@ -package group.goforward.battlbuilder.controller.api.v1; - -import group.goforward.battlbuilder.service.BuildService; -import group.goforward.battlbuilder.web.dto.BuildDto; -import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; -import group.goforward.battlbuilder.web.dto.BuildSummaryDto; -import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.UUID; - -@CrossOrigin -@RestController -@RequestMapping("/api/v1/builds") -public class BuildV1Controller { - - private final BuildService buildService; - - public BuildV1Controller(BuildService buildService) { - this.buildService = buildService; - } - - /** - * Public builds feed for /builds page. - * GET /api/v1/builds?limit=50 - */ - @GetMapping - public ResponseEntity> listPublicBuilds( - @RequestParam(name = "limit", required = false, defaultValue = "50") Integer limit - ) { - return ResponseEntity.ok(buildService.listPublicBuilds(limit == null ? 50 : limit)); - } - - /** - * Public build detail for /builds/{uuid} - * GET /api/v1/builds/{uuid} - */ - @GetMapping("/{uuid}") - public ResponseEntity getPublicBuild(@PathVariable("uuid") UUID uuid) { - return ResponseEntity.ok(buildService.getPublicBuild(uuid)); - } - - /** - * Vault builds (authenticated user). - * GET /api/v1/builds/me?limit=100 - */ - @GetMapping("/me") - public ResponseEntity> listMyBuilds( - @RequestParam(name = "limit", required = false, defaultValue = "100") Integer limit - ) { - return ResponseEntity.ok(buildService.listMyBuilds(limit == null ? 100 : limit)); - } - - /** - * Load a single build (Vault edit + Builder ?load=uuid). - * GET /api/v1/builds/me/{uuid} - */ - @GetMapping("/me/{uuid}") - public ResponseEntity getMyBuild(@PathVariable("uuid") UUID uuid) { - return ResponseEntity.ok(buildService.getMyBuild(uuid)); - } - - /** - * Create a NEW build in Vault (Save As…). - * POST /api/v1/builds/me - */ - @PostMapping("/me") - public ResponseEntity createMyBuild(@RequestBody UpdateBuildRequest req) { - return ResponseEntity.ok(buildService.createMyBuild(req)); - } - - /** - * Update build (authenticated user; must own build eventually). - * PUT /api/v1/builds/me/{uuid} - */ - @PutMapping("/me/{uuid}") - public ResponseEntity updateMyBuild( - @PathVariable("uuid") UUID uuid, - @RequestBody UpdateBuildRequest req - ) { - return ResponseEntity.ok(buildService.updateMyBuild(uuid, req)); - } - - /** - * Delete a build (authenticated user; must own build). - * DELETE /api/v1/builds/me/{uuid} - */ - @DeleteMapping("/me/{uuid}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void deleteMyBuild(@PathVariable("uuid") UUID uuid) { - buildService.deleteMyBuild(uuid); - } +package group.goforward.battlbuilder.controller.api.v1; + +import group.goforward.battlbuilder.service.BuildService; +import group.goforward.battlbuilder.web.dto.BuildDto; +import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; +import group.goforward.battlbuilder.web.dto.BuildSummaryDto; +import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@CrossOrigin +@RestController +@RequestMapping("/api/v1/builds") +public class BuildV1Controller { + + private final BuildService buildService; + + public BuildV1Controller(BuildService buildService) { + this.buildService = buildService; + } + + /** + * Public builds feed for /builds page. + * GET /api/v1/builds?limit=50 + */ + @GetMapping + public ResponseEntity> listPublicBuilds( + @RequestParam(name = "limit", required = false, defaultValue = "50") Integer limit + ) { + return ResponseEntity.ok(buildService.listPublicBuilds(limit == null ? 50 : limit)); + } + + /** + * Public build detail for /builds/{uuid} + * GET /api/v1/builds/{uuid} + */ + @GetMapping("/{uuid}") + public ResponseEntity getPublicBuild(@PathVariable("uuid") UUID uuid) { + return ResponseEntity.ok(buildService.getPublicBuild(uuid)); + } + + /** + * Vault builds (authenticated user). + * GET /api/v1/builds/me?limit=100 + */ + @GetMapping("/me") + public ResponseEntity> listMyBuilds( + @RequestParam(name = "limit", required = false, defaultValue = "100") Integer limit + ) { + return ResponseEntity.ok(buildService.listMyBuilds(limit == null ? 100 : limit)); + } + + /** + * Load a single build (Vault edit + Builder ?load=uuid). + * GET /api/v1/builds/me/{uuid} + */ + @GetMapping("/me/{uuid}") + public ResponseEntity getMyBuild(@PathVariable("uuid") UUID uuid) { + return ResponseEntity.ok(buildService.getMyBuild(uuid)); + } + + /** + * Create a NEW build in Vault (Save As…). + * POST /api/v1/builds/me + */ + @PostMapping("/me") + public ResponseEntity createMyBuild(@RequestBody UpdateBuildRequest req) { + return ResponseEntity.ok(buildService.createMyBuild(req)); + } + + /** + * Update build (authenticated user; must own build eventually). + * PUT /api/v1/builds/me/{uuid} + */ + @PutMapping("/me/{uuid}") + public ResponseEntity updateMyBuild( + @PathVariable("uuid") UUID uuid, + @RequestBody UpdateBuildRequest req + ) { + return ResponseEntity.ok(buildService.updateMyBuild(uuid, req)); + } + + /** + * Delete a build (authenticated user; must own build). + * DELETE /api/v1/builds/me/{uuid} + */ + @DeleteMapping("/me/{uuid}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteMyBuild(@PathVariable("uuid") UUID uuid) { + buildService.deleteMyBuild(uuid); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/api/v1/CatalogController.java b/src/main/java/group/goforward/battlbuilder/controller/api/v1/CatalogController.java index 2514ce6..e2a92fb 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/api/v1/CatalogController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/api/v1/CatalogController.java @@ -1,60 +1,60 @@ -package group.goforward.battlbuilder.controller.api.v1; - -import group.goforward.battlbuilder.service.CatalogQueryService; -import group.goforward.battlbuilder.web.dto.ProductSummaryDto; -import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/v1/catalog") -@CrossOrigin // tighten later -public class CatalogController { - - private final CatalogQueryService catalogQueryService; - - public CatalogController(CatalogQueryService catalogQueryService) { - this.catalogQueryService = catalogQueryService; - } - - @GetMapping("/options") - public Page getOptions( - @RequestParam(required = false) String platform, - @RequestParam(required = false) String partRole, - @RequestParam(required = false) List partRoles, - @RequestParam(required = false, name = "brand") List brands, - @RequestParam(required = false) String q, - Pageable pageable - ) { - Pageable safe = sanitizeCatalogPageable(pageable); - return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe); - } - - private Pageable sanitizeCatalogPageable(Pageable pageable) { - int page = Math.max(0, pageable.getPageNumber()); - - // hard cap to keep UI snappy + protect DB - int requested = pageable.getPageSize(); - int size = Math.min(Math.max(requested, 1), 48); // 48 max - - // default sort if none provided - Sort sort = pageable.getSort().isSorted() - ? pageable.getSort() - : Sort.by(Sort.Direction.DESC, "updatedAt"); - - return PageRequest.of(page, size, sort); - } - - @PostMapping("/products/by-ids") - public List getProductsByIds(@RequestBody CatalogProductIdsRequest request) { - return catalogQueryService.getProductsByIds(request); - } - - +package group.goforward.battlbuilder.controller.api.v1; + +import group.goforward.battlbuilder.service.CatalogQueryService; +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; +import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/catalog") +@CrossOrigin // tighten later +public class CatalogController { + + private final CatalogQueryService catalogQueryService; + + public CatalogController(CatalogQueryService catalogQueryService) { + this.catalogQueryService = catalogQueryService; + } + + @GetMapping("/options") + public Page getOptions( + @RequestParam(required = false) String platform, + @RequestParam(required = false) String partRole, + @RequestParam(required = false) List partRoles, + @RequestParam(required = false, name = "brand") List brands, + @RequestParam(required = false) String q, + Pageable pageable + ) { + Pageable safe = sanitizeCatalogPageable(pageable); + return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe); + } + + private Pageable sanitizeCatalogPageable(Pageable pageable) { + int page = Math.max(0, pageable.getPageNumber()); + + // hard cap to keep UI snappy + protect DB + int requested = pageable.getPageSize(); + int size = Math.min(Math.max(requested, 1), 48); // 48 max + + // default sort if none provided + Sort sort = pageable.getSort().isSorted() + ? pageable.getSort() + : Sort.by(Sort.Direction.DESC, "updatedAt"); + + return PageRequest.of(page, size, sort); + } + + @PostMapping("/products/by-ids") + public List getProductsByIds(@RequestBody CatalogProductIdsRequest request) { + return catalogQueryService.getProductsByIds(request); + } + + } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/api/v1/EmailController.java b/src/main/java/group/goforward/battlbuilder/controller/api/v1/EmailController.java index eaca61a..ab79d13 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/api/v1/EmailController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/api/v1/EmailController.java @@ -1,152 +1,152 @@ -package group.goforward.battlbuilder.controller.api.v1; - -import group.goforward.battlbuilder.common.ApiResponse; -import group.goforward.battlbuilder.dto.EmailRequestDto; -import group.goforward.battlbuilder.model.EmailRequest; -import group.goforward.battlbuilder.model.EmailStatus; -import group.goforward.battlbuilder.repo.EmailRequestRepository; -import group.goforward.battlbuilder.service.utils.EmailService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; - -import java.util.Arrays; -import java.util.List; - -@RestController -@RequestMapping("/api/email") -public class EmailController { - - private static final EmailStatus EMAIL_STATUS_SENT = EmailStatus.SENT; - - private final EmailService emailService; - private final EmailRequestRepository emailRequestRepository; - - @Autowired - public EmailController(EmailService emailService, EmailRequestRepository emailRequestRepository) { - this.emailService = emailService; - this.emailRequestRepository = emailRequestRepository; - } - - @GetMapping("/statuses") - public ResponseEntity>> getEmailStatuses() { - List statuses = Arrays.stream(EmailStatus.values()) - .map(Enum::name) - .toList(); - - return ResponseEntity.ok( - ApiResponse.success(statuses, "Email statuses retrieved successfully") - ); - } - - @GetMapping - public ResponseEntity>> getAllEmailRequests() { - try { - List emailRequests = emailRequestRepository.findAll(); - return ResponseEntity.ok( - ApiResponse.success(emailRequests, "Email requests retrieved successfully") - ); - } catch (Exception e) { - return ResponseEntity.status(500).body( - ApiResponse.error("Error retrieving email requests: " + e.getMessage(), null) - ); - } - } - - @GetMapping("/allSent") - public ResponseEntity>> getNotSentEmailRequests() { - try { - List emailRequests = emailRequestRepository.findByStatus(EmailStatus.SENT); - return ResponseEntity.ok( - ApiResponse.success(emailRequests, "Not sent email requests retrieved successfully") - ); - } catch (Exception e) { - return ResponseEntity.status(500).body( - ApiResponse.error("Error retrieving not sent email requests: " + e.getMessage(), null) - ); - } - } - - @GetMapping("/allFailed") - public ResponseEntity>> getFailedEmailRequests() { - try { - List emailRequests = emailRequestRepository.findByStatus(EmailStatus.FAILED); - return ResponseEntity.ok( - ApiResponse.success(emailRequests, "Failed email requests retrieved successfully") - ); - } catch (Exception e) { - return ResponseEntity.status(500).body( - ApiResponse.error("Error retrieving failed email requests: " + e.getMessage(), null) - ); - } - } - - @GetMapping("/allPending") - public ResponseEntity>> getPendingEmailRequests() { - try { - List emailRequests = emailRequestRepository.findByStatus(EmailStatus.PENDING); - return ResponseEntity.ok( - ApiResponse.success(emailRequests, "Pending email requests retrieved successfully") - ); - } catch (Exception e) { - return ResponseEntity.status(500).body( - ApiResponse.error("Error retrieving Pending email requests: " + e.getMessage(), null) - ); - } - } - - - @PostMapping("/send") - public ResponseEntity> sendEmail(@RequestBody EmailRequestDto emailDto) { - try { - EmailRequest emailRequest = emailService.sendEmail( - emailDto.getRecipient(), - emailDto.getSubject(), - emailDto.getBody() - ); - return buildEmailResponse(emailRequest); - } catch (Exception e) { - return buildErrorResponse(e.getMessage()); - } - } - @DeleteMapping("/delete/{id}") - public ResponseEntity deleteItem(@PathVariable Integer id) { - return emailRequestRepository.findById(Long.valueOf(id)) - .map(item -> { - emailRequestRepository.deleteById(Long.valueOf(id)); - return ResponseEntity.noContent().build(); - }) - .orElse(ResponseEntity.notFound().build()); - } - // Replace /delete/{id} with a RESTful DELETE /{id} - @DeleteMapping("/{id}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void deleteEmailRequest(@PathVariable Long id) { - if (!emailRequestRepository.existsById(id)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email request not found"); - } - emailRequestRepository.deleteById(id); - } - - private ResponseEntity> buildEmailResponse(EmailRequest emailRequest) { - if (EMAIL_STATUS_SENT.equals(emailRequest.getStatus())) { - return ResponseEntity.ok( - ApiResponse.success(emailRequest, "Email sent successfully") - ); - } else { - String errorMessage = "Failed to send email: " + emailRequest.getErrorMessage(); - return ResponseEntity.status(500).body( - ApiResponse.error(errorMessage, emailRequest) - ); - } - } - - private ResponseEntity> buildErrorResponse(String exceptionMessage) { - String errorMessage = "Error processing email request: " + exceptionMessage; - return ResponseEntity.status(500).body( - ApiResponse.error(errorMessage, null) - ); - } +package group.goforward.battlbuilder.controller.api.v1; + +import group.goforward.battlbuilder.common.ApiResponse; +import group.goforward.battlbuilder.dto.EmailRequestDto; +import group.goforward.battlbuilder.model.EmailRequest; +import group.goforward.battlbuilder.model.EmailStatus; +import group.goforward.battlbuilder.repo.EmailRequestRepository; +import group.goforward.battlbuilder.service.utils.EmailService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Arrays; +import java.util.List; + +@RestController +@RequestMapping("/api/email") +public class EmailController { + + private static final EmailStatus EMAIL_STATUS_SENT = EmailStatus.SENT; + + private final EmailService emailService; + private final EmailRequestRepository emailRequestRepository; + + @Autowired + public EmailController(EmailService emailService, EmailRequestRepository emailRequestRepository) { + this.emailService = emailService; + this.emailRequestRepository = emailRequestRepository; + } + + @GetMapping("/statuses") + public ResponseEntity>> getEmailStatuses() { + List statuses = Arrays.stream(EmailStatus.values()) + .map(Enum::name) + .toList(); + + return ResponseEntity.ok( + ApiResponse.success(statuses, "Email statuses retrieved successfully") + ); + } + + @GetMapping + public ResponseEntity>> getAllEmailRequests() { + try { + List emailRequests = emailRequestRepository.findAll(); + return ResponseEntity.ok( + ApiResponse.success(emailRequests, "Email requests retrieved successfully") + ); + } catch (Exception e) { + return ResponseEntity.status(500).body( + ApiResponse.error("Error retrieving email requests: " + e.getMessage(), null) + ); + } + } + + @GetMapping("/allSent") + public ResponseEntity>> getNotSentEmailRequests() { + try { + List emailRequests = emailRequestRepository.findByStatus(EmailStatus.SENT); + return ResponseEntity.ok( + ApiResponse.success(emailRequests, "Not sent email requests retrieved successfully") + ); + } catch (Exception e) { + return ResponseEntity.status(500).body( + ApiResponse.error("Error retrieving not sent email requests: " + e.getMessage(), null) + ); + } + } + + @GetMapping("/allFailed") + public ResponseEntity>> getFailedEmailRequests() { + try { + List emailRequests = emailRequestRepository.findByStatus(EmailStatus.FAILED); + return ResponseEntity.ok( + ApiResponse.success(emailRequests, "Failed email requests retrieved successfully") + ); + } catch (Exception e) { + return ResponseEntity.status(500).body( + ApiResponse.error("Error retrieving failed email requests: " + e.getMessage(), null) + ); + } + } + + @GetMapping("/allPending") + public ResponseEntity>> getPendingEmailRequests() { + try { + List emailRequests = emailRequestRepository.findByStatus(EmailStatus.PENDING); + return ResponseEntity.ok( + ApiResponse.success(emailRequests, "Pending email requests retrieved successfully") + ); + } catch (Exception e) { + return ResponseEntity.status(500).body( + ApiResponse.error("Error retrieving Pending email requests: " + e.getMessage(), null) + ); + } + } + + + @PostMapping("/send") + public ResponseEntity> sendEmail(@RequestBody EmailRequestDto emailDto) { + try { + EmailRequest emailRequest = emailService.sendEmail( + emailDto.getRecipient(), + emailDto.getSubject(), + emailDto.getBody() + ); + return buildEmailResponse(emailRequest); + } catch (Exception e) { + return buildErrorResponse(e.getMessage()); + } + } + @DeleteMapping("/delete/{id}") + public ResponseEntity deleteItem(@PathVariable Integer id) { + return emailRequestRepository.findById(Long.valueOf(id)) + .map(item -> { + emailRequestRepository.deleteById(Long.valueOf(id)); + return ResponseEntity.noContent().build(); + }) + .orElse(ResponseEntity.notFound().build()); + } + // Replace /delete/{id} with a RESTful DELETE /{id} + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteEmailRequest(@PathVariable Long id) { + if (!emailRequestRepository.existsById(id)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email request not found"); + } + emailRequestRepository.deleteById(id); + } + + private ResponseEntity> buildEmailResponse(EmailRequest emailRequest) { + if (EMAIL_STATUS_SENT.equals(emailRequest.getStatus())) { + return ResponseEntity.ok( + ApiResponse.success(emailRequest, "Email sent successfully") + ); + } else { + String errorMessage = "Failed to send email: " + emailRequest.getErrorMessage(); + return ResponseEntity.status(500).body( + ApiResponse.error(errorMessage, emailRequest) + ); + } + } + + private ResponseEntity> buildErrorResponse(String exceptionMessage) { + String errorMessage = "Error processing email request: " + exceptionMessage; + return ResponseEntity.status(500).body( + ApiResponse.error(errorMessage, null) + ); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/api/v1/MeController.java b/src/main/java/group/goforward/battlbuilder/controller/api/v1/MeController.java index 4424a76..f6e56b0 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/api/v1/MeController.java +++ b/src/main/java/group/goforward/battlbuilder/controller/api/v1/MeController.java @@ -1,231 +1,231 @@ -package group.goforward.battlbuilder.controller.api.v1; - -import group.goforward.battlbuilder.model.User; -import group.goforward.battlbuilder.repo.UserRepository; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; - -import java.time.OffsetDateTime; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -import static org.springframework.http.HttpStatus.*; - -@RestController -@RequestMapping({"/api/v1/users/me", "/api/users/me"}) -@CrossOrigin -public class MeController { - - private final UserRepository users; - private final PasswordEncoder passwordEncoder; - - public MeController(UserRepository users, PasswordEncoder passwordEncoder) { - this.users = users; - this.passwordEncoder = passwordEncoder; - } - - // ----------------------------- - // Helpers - // ----------------------------- - - private Authentication requireAuth() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); - } - // Spring may set "anonymousUser" as a principal when not logged in - Object principal = auth.getPrincipal(); - if (principal == null || "anonymousUser".equals(principal)) { - throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); - } - return auth; - } - - private Optional tryParseUuid(String s) { - try { - return Optional.of(UUID.fromString(s)); - } catch (Exception ignored) { - return Optional.empty(); - } - } - - private User requireUser() { - Authentication auth = requireAuth(); - Object principal = auth.getPrincipal(); - - // Case 1: principal is a String (we commonly set this to UUID string) - if (principal instanceof String s) { - // Prefer UUID lookup - Optional uuid = tryParseUuid(s); - if (uuid.isPresent()) { - return users.findByUuidAndDeletedAtIsNull(uuid.get()) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); - } - - // Fallback to email lookup - String email = s.trim().toLowerCase(); - return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); - } - - // Case 2: principal is a UserDetails (often username=email) - if (principal instanceof UserDetails ud) { - String username = ud.getUsername(); - if (username == null) { - throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); - } - - // Try UUID first, then email - Optional uuid = tryParseUuid(username); - if (uuid.isPresent()) { - return users.findByUuidAndDeletedAtIsNull(uuid.get()) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); - } - - String email = username.trim().toLowerCase(); - return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); - } - - // Anything else: unsupported principal type - throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); - } - - private Map toMeResponse(User user) { - Map out = new java.util.HashMap<>(); - out.put("email", user.getEmail()); - out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName()); - out.put("username", user.getUsername() == null ? "" : user.getUsername()); - out.put("role", user.getRole() == null ? "USER" : user.getRole()); - out.put("uuid", String.valueOf(user.getUuid())); - out.put("passwordSetAt", user.getPasswordSetAt() == null ? null : user.getPasswordSetAt().toString()); - return out; - } - - private String normalizeUsername(String raw) { - if (raw == null) return null; - String s = raw.trim().toLowerCase(); - return s.isBlank() ? null : s; - } - - private boolean isReservedUsername(String u) { - return switch (u) { - case "admin", "support", "battl", "battlbuilders", "builder", - "api", "login", "register", "account", "privacy", "tos" -> true; - default -> false; - }; - } - - // ----------------------------- - // Routes - // ----------------------------- - - @GetMapping - public ResponseEntity me() { - User user = requireUser(); - return ResponseEntity.ok(toMeResponse(user)); - } - - @PatchMapping - public ResponseEntity updateMe(@RequestBody Map body) { - User user = requireUser(); - - String displayName = null; - if (body != null && body.get("displayName") != null) { - displayName = String.valueOf(body.get("displayName")).trim(); - } - - String username = null; - if (body != null && body.get("username") != null) { - username = normalizeUsername(String.valueOf(body.get("username"))); - } - - if ((displayName == null || displayName.isBlank()) && (username == null)) { - throw new ResponseStatusException(BAD_REQUEST, "username or displayName is required"); - } - - // display name is flexible - if (displayName != null && !displayName.isBlank()) { - user.setDisplayName(displayName); - } - - // username is strict + unique - if (username != null) { - if (username.length() < 3 || username.length() > 20) { - throw new ResponseStatusException(BAD_REQUEST, "Username must be 3–20 characters"); - } - if (!username.matches("^[a-z0-9_]+$")) { - throw new ResponseStatusException(BAD_REQUEST, "Username can only contain a-z, 0-9, underscore"); - } - if (isReservedUsername(username)) { - throw new ResponseStatusException(BAD_REQUEST, "That username is reserved"); - } - - users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username).ifPresent(existing -> { - if (!existing.getId().equals(user.getId())) { - throw new ResponseStatusException(CONFLICT, "Username already taken"); - } - }); - - user.setUsername(username); - } - - user.setUpdatedAt(OffsetDateTime.now()); - users.save(user); - - return ResponseEntity.ok(toMeResponse(user)); - } - - @PostMapping("/password") - public ResponseEntity setPassword(@RequestBody Map body) { - User user = requireUser(); - - String password = null; - if (body != null && body.get("password") != null) { - password = String.valueOf(body.get("password")); - } - - if (password == null || password.length() < 8) { - throw new ResponseStatusException(BAD_REQUEST, "Password must be at least 8 characters"); - } - - user.setPasswordHash(passwordEncoder.encode(password)); - user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW - user.setUpdatedAt(OffsetDateTime.now()); - users.save(user); - - return ResponseEntity.ok(Map.of("ok", true, "passwordSetAt", user.getPasswordSetAt().toString())); - } - - @GetMapping("/username-available") - public ResponseEntity usernameAvailable(@RequestParam("username") String usernameRaw) { - String username = normalizeUsername(usernameRaw); - - // Soft fail - if (username == null) return ResponseEntity.ok(Map.of("available", false)); - - if (username.length() < 3 || username.length() > 20) { - return ResponseEntity.ok(Map.of("available", false)); - } - if (!username.matches("^[a-z0-9_]+$")) { - return ResponseEntity.ok(Map.of("available", false)); - } - if (isReservedUsername(username)) { - return ResponseEntity.ok(Map.of("available", false)); - } - - User me = requireUser(); - - boolean available = users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username) - .map(existing -> existing.getId().equals(me.getId())) // if it's mine, treat as available - .orElse(true); - - return ResponseEntity.ok(Map.of("available", available)); - } +package group.goforward.battlbuilder.controller.api.v1; + +import group.goforward.battlbuilder.model.User; +import group.goforward.battlbuilder.repo.UserRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.springframework.http.HttpStatus.*; + +@RestController +@RequestMapping({"/api/v1/users/me", "/api/users/me"}) +@CrossOrigin +public class MeController { + + private final UserRepository users; + private final PasswordEncoder passwordEncoder; + + public MeController(UserRepository users, PasswordEncoder passwordEncoder) { + this.users = users; + this.passwordEncoder = passwordEncoder; + } + + // ----------------------------- + // Helpers + // ----------------------------- + + private Authentication requireAuth() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); + } + // Spring may set "anonymousUser" as a principal when not logged in + Object principal = auth.getPrincipal(); + if (principal == null || "anonymousUser".equals(principal)) { + throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); + } + return auth; + } + + private Optional tryParseUuid(String s) { + try { + return Optional.of(UUID.fromString(s)); + } catch (Exception ignored) { + return Optional.empty(); + } + } + + private User requireUser() { + Authentication auth = requireAuth(); + Object principal = auth.getPrincipal(); + + // Case 1: principal is a String (we commonly set this to UUID string) + if (principal instanceof String s) { + // Prefer UUID lookup + Optional uuid = tryParseUuid(s); + if (uuid.isPresent()) { + return users.findByUuidAndDeletedAtIsNull(uuid.get()) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); + } + + // Fallback to email lookup + String email = s.trim().toLowerCase(); + return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); + } + + // Case 2: principal is a UserDetails (often username=email) + if (principal instanceof UserDetails ud) { + String username = ud.getUsername(); + if (username == null) { + throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); + } + + // Try UUID first, then email + Optional uuid = tryParseUuid(username); + if (uuid.isPresent()) { + return users.findByUuidAndDeletedAtIsNull(uuid.get()) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); + } + + String email = username.trim().toLowerCase(); + return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); + } + + // Anything else: unsupported principal type + throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); + } + + private Map toMeResponse(User user) { + Map out = new java.util.HashMap<>(); + out.put("email", user.getEmail()); + out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName()); + out.put("username", user.getUsername() == null ? "" : user.getUsername()); + out.put("role", user.getRole() == null ? "USER" : user.getRole()); + out.put("uuid", String.valueOf(user.getUuid())); + out.put("passwordSetAt", user.getPasswordSetAt() == null ? null : user.getPasswordSetAt().toString()); + return out; + } + + private String normalizeUsername(String raw) { + if (raw == null) return null; + String s = raw.trim().toLowerCase(); + return s.isBlank() ? null : s; + } + + private boolean isReservedUsername(String u) { + return switch (u) { + case "admin", "support", "battl", "battlbuilders", "builder", + "api", "login", "register", "account", "privacy", "tos" -> true; + default -> false; + }; + } + + // ----------------------------- + // Routes + // ----------------------------- + + @GetMapping + public ResponseEntity me() { + User user = requireUser(); + return ResponseEntity.ok(toMeResponse(user)); + } + + @PatchMapping + public ResponseEntity updateMe(@RequestBody Map body) { + User user = requireUser(); + + String displayName = null; + if (body != null && body.get("displayName") != null) { + displayName = String.valueOf(body.get("displayName")).trim(); + } + + String username = null; + if (body != null && body.get("username") != null) { + username = normalizeUsername(String.valueOf(body.get("username"))); + } + + if ((displayName == null || displayName.isBlank()) && (username == null)) { + throw new ResponseStatusException(BAD_REQUEST, "username or displayName is required"); + } + + // display name is flexible + if (displayName != null && !displayName.isBlank()) { + user.setDisplayName(displayName); + } + + // username is strict + unique + if (username != null) { + if (username.length() < 3 || username.length() > 20) { + throw new ResponseStatusException(BAD_REQUEST, "Username must be 3–20 characters"); + } + if (!username.matches("^[a-z0-9_]+$")) { + throw new ResponseStatusException(BAD_REQUEST, "Username can only contain a-z, 0-9, underscore"); + } + if (isReservedUsername(username)) { + throw new ResponseStatusException(BAD_REQUEST, "That username is reserved"); + } + + users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username).ifPresent(existing -> { + if (!existing.getId().equals(user.getId())) { + throw new ResponseStatusException(CONFLICT, "Username already taken"); + } + }); + + user.setUsername(username); + } + + user.setUpdatedAt(OffsetDateTime.now()); + users.save(user); + + return ResponseEntity.ok(toMeResponse(user)); + } + + @PostMapping("/password") + public ResponseEntity setPassword(@RequestBody Map body) { + User user = requireUser(); + + String password = null; + if (body != null && body.get("password") != null) { + password = String.valueOf(body.get("password")); + } + + if (password == null || password.length() < 8) { + throw new ResponseStatusException(BAD_REQUEST, "Password must be at least 8 characters"); + } + + user.setPasswordHash(passwordEncoder.encode(password)); + user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW + user.setUpdatedAt(OffsetDateTime.now()); + users.save(user); + + return ResponseEntity.ok(Map.of("ok", true, "passwordSetAt", user.getPasswordSetAt().toString())); + } + + @GetMapping("/username-available") + public ResponseEntity usernameAvailable(@RequestParam("username") String usernameRaw) { + String username = normalizeUsername(usernameRaw); + + // Soft fail + if (username == null) return ResponseEntity.ok(Map.of("available", false)); + + if (username.length() < 3 || username.length() > 20) { + return ResponseEntity.ok(Map.of("available", false)); + } + if (!username.matches("^[a-z0-9_]+$")) { + return ResponseEntity.ok(Map.of("available", false)); + } + if (isReservedUsername(username)) { + return ResponseEntity.ok(Map.of("available", false)); + } + + User me = requireUser(); + + boolean available = users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username) + .map(existing -> existing.getId().equals(me.getId())) // if it's mine, treat as available + .orElse(true); + + return ResponseEntity.ok(Map.of("available", available)); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/api/v1/ProductV1Controller.java b/src/main/java/group/goforward/battlbuilder/controller/api/v1/ProductV1Controller.java index 020d90e..2887bbc 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/api/v1/ProductV1Controller.java +++ b/src/main/java/group/goforward/battlbuilder/controller/api/v1/ProductV1Controller.java @@ -1,49 +1,49 @@ -package group.goforward.battlbuilder.controller.api.v1; - -import group.goforward.battlbuilder.service.ProductQueryService; -import group.goforward.battlbuilder.web.dto.ProductOfferDto; -import group.goforward.battlbuilder.web.dto.ProductSummaryDto; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; - -import java.util.List; - -@RestController -@RequestMapping("/api/v1/products") -@CrossOrigin -public class ProductV1Controller { - - private final ProductQueryService productQueryService; - - public ProductV1Controller(ProductQueryService productQueryService) { - this.productQueryService = productQueryService; - } - - @GetMapping - @Cacheable( - value = "gunbuilderProductsV1", - key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize" - ) - public Page getProducts( - @RequestParam(defaultValue = "AR-15") String platform, - @RequestParam(required = false, name = "partRoles") List partRoles, - @PageableDefault(size = 50) Pageable pageable - ) { - return productQueryService.getProductsPage(platform, partRoles, pageable); - } - - @GetMapping("/{id}/offers") - public List getOffersForProduct(@PathVariable("id") Integer productId) { - return productQueryService.getOffersForProduct(productId); - } - - @GetMapping("/{id}") - public ResponseEntity getProductById(@PathVariable("id") Integer productId) { - ProductSummaryDto dto = productQueryService.getProductById(productId); - return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build(); - } +package group.goforward.battlbuilder.controller.api.v1; + +import group.goforward.battlbuilder.service.ProductQueryService; +import group.goforward.battlbuilder.web.dto.ProductOfferDto; +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/products") +@CrossOrigin +public class ProductV1Controller { + + private final ProductQueryService productQueryService; + + public ProductV1Controller(ProductQueryService productQueryService) { + this.productQueryService = productQueryService; + } + + @GetMapping + @Cacheable( + value = "gunbuilderProductsV1", + key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize" + ) + public Page getProducts( + @RequestParam(defaultValue = "AR-15") String platform, + @RequestParam(required = false, name = "partRoles") List partRoles, + @PageableDefault(size = 50) Pageable pageable + ) { + return productQueryService.getProductsPage(platform, partRoles, pageable); + } + + @GetMapping("/{id}/offers") + public List getOffersForProduct(@PathVariable("id") Integer productId) { + return productQueryService.getOffersForProduct(productId); + } + + @GetMapping("/{id}") + public ResponseEntity getProductById(@PathVariable("id") Integer productId) { + ProductSummaryDto dto = productQueryService.getProductById(productId); + return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controller/api/v1/package-info.java b/src/main/java/group/goforward/battlbuilder/controller/api/v1/package-info.java index ab6dba3..379fab6 100644 --- a/src/main/java/group/goforward/battlbuilder/controller/api/v1/package-info.java +++ b/src/main/java/group/goforward/battlbuilder/controller/api/v1/package-info.java @@ -1,11 +1,11 @@ -/** - * API controller package for the BattlBuilder application. - *

- * Contains REST API controller for public-facing endpoints including - * brand management, state information, and user operations. - * - * @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.controller.api.v1; +/** + * API controller package for the BattlBuilder application. + *

+ * Contains REST API controller for public-facing endpoints including + * brand management, state information, and user operations. + * + * @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.controller.api.v1; diff --git a/src/main/java/group/goforward/battlbuilder/dto/package_info.java b/src/main/java/group/goforward/battlbuilder/dto/package_info.java index 3dfe2b1..0afbe70 100644 --- a/src/main/java/group/goforward/battlbuilder/dto/package_info.java +++ b/src/main/java/group/goforward/battlbuilder/dto/package_info.java @@ -1,12 +1,12 @@ - -/* - Web admin DTOs package for the BattlBuilder application. -

- Contains Data Transfer Objects specific to administrative - operations including user management, mappings, and platform configuration. - - @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.dto; + +/* + Web admin DTOs package for the BattlBuilder application. +

+ Contains Data Transfer Objects specific to administrative + operations including user management, mappings, and platform configuration. + + @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.dto; diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentSource.java b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentSource.java index 8cbaae5..e988ca9 100644 --- a/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentSource.java +++ b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentSource.java @@ -1,10 +1,10 @@ -package group.goforward.battlbuilder.enrichment; - -/** - * Enum representing the source of an enrichment. - */ -public enum EnrichmentSource { - AI, - RULES, - HUMAN +package group.goforward.battlbuilder.enrichment; + +/** + * Enum representing the source of an enrichment. + */ +public enum EnrichmentSource { + AI, + RULES, + HUMAN } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentStatus.java b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentStatus.java index 7c3ee38..95f479e 100644 --- a/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentStatus.java +++ b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentStatus.java @@ -1,19 +1,19 @@ -package group.goforward.battlbuilder.enrichment; -/** - * Status of an enrichment in the system. - * - *

Possible values: - *

    - *
  • PENDING_REVIEW - awaiting review
  • - *
  • APPROVED - approved to apply
  • - *
  • REJECTED - rejected and will not be applied
  • - *
  • APPLIED - enrichment has been applied
  • - *
- */ - -public enum EnrichmentStatus { - PENDING_REVIEW, - APPROVED, - REJECTED, - APPLIED +package group.goforward.battlbuilder.enrichment; +/** + * Status of an enrichment in the system. + * + *

Possible values: + *

    + *
  • PENDING_REVIEW - awaiting review
  • + *
  • APPROVED - approved to apply
  • + *
  • REJECTED - rejected and will not be applied
  • + *
  • APPLIED - enrichment has been applied
  • + *
+ */ + +public enum EnrichmentStatus { + PENDING_REVIEW, + APPROVED, + REJECTED, + APPLIED } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentType.java b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentType.java index 69203f1..9b94acd 100644 --- a/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentType.java +++ b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentType.java @@ -1,14 +1,14 @@ -package group.goforward.battlbuilder.enrichment; - -/** - * Enum representing different types of enrichment that can be applied to products. - */ -public enum EnrichmentType { - CALIBER, - CALIBER_GROUP, - BARREL_LENGTH, - GAS_SYSTEM, - HANDGUARD_LENGTH, - CONFIGURATION, - PART_ROLE +package group.goforward.battlbuilder.enrichment; + +/** + * Enum representing different types of enrichment that can be applied to products. + */ +public enum EnrichmentType { + CALIBER, + CALIBER_GROUP, + BARREL_LENGTH, + GAS_SYSTEM, + HANDGUARD_LENGTH, + CONFIGURATION, + PART_ROLE } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/ai/AiEnrichmentOrchestrator.java b/src/main/java/group/goforward/battlbuilder/enrichment/ai/AiEnrichmentOrchestrator.java index 9746bdb..6e51af0 100644 --- a/src/main/java/group/goforward/battlbuilder/enrichment/ai/AiEnrichmentOrchestrator.java +++ b/src/main/java/group/goforward/battlbuilder/enrichment/ai/AiEnrichmentOrchestrator.java @@ -1,103 +1,103 @@ -package group.goforward.battlbuilder.enrichment.ai; - -import group.goforward.battlbuilder.enrichment.*; -import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult; -import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; -import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository; -import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy; -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.repo.ProductRepository; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import java.math.BigDecimal; -import java.util.List; -import java.util.Map; - -@Service -public class AiEnrichmentOrchestrator { - - private final EnrichmentModelClient modelClient; - private final ProductRepository productRepository; - private final ProductEnrichmentRepository enrichmentRepository; - - @Value("${ai.minConfidence:0.75}") - private BigDecimal minConfidence; - - public AiEnrichmentOrchestrator( - EnrichmentModelClient modelClient, - ProductRepository productRepository, - ProductEnrichmentRepository enrichmentRepository - ) { - this.modelClient = modelClient; - this.productRepository = productRepository; - this.enrichmentRepository = enrichmentRepository; - } - - public int runCaliber(int limit) { - // pick candidates: caliber missing - List candidates = productRepository.findProductsMissingCaliber(limit); - - int created = 0; - - for (Product p : candidates) { - CaliberExtractionResult r = modelClient.extractCaliber(p); - - if (r == null || !r.isUsable(minConfidence)) { - continue; - } - - // Optional: avoid duplicates for same product/type/status - boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus( - p.getId(), EnrichmentType.CALIBER, EnrichmentStatus.PENDING_REVIEW - ); - if (exists) continue; - - ProductEnrichment pe = new ProductEnrichment(); - pe.setProductId(p.getId()); - pe.setEnrichmentType(EnrichmentType.CALIBER); - pe.setSource(EnrichmentSource.AI); - pe.setStatus(EnrichmentStatus.PENDING_REVIEW); - pe.setSchemaVersion(1); - pe.setAttributes(Map.of("caliber", r.caliber())); - pe.setConfidence(r.confidence()); - pe.setRationale(r.reason()); - pe.setMeta(Map.of("provider", modelClient.providerName())); - - enrichmentRepository.save(pe); - created++; - } - - return created; - } - public int runCaliberGroup(int limit) { - List candidates = productRepository.findProductsMissingCaliberGroup(limit); - int created = 0; - - for (Product p : candidates) { - String group = CaliberTaxonomy.groupForCaliber(p.getCaliber()); - if (group == null || group.isBlank()) continue; - - boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus( - p.getId(), EnrichmentType.CALIBER_GROUP, EnrichmentStatus.PENDING_REVIEW - ); - if (exists) continue; - - ProductEnrichment pe = new ProductEnrichment(); - pe.setProductId(p.getId()); - pe.setEnrichmentType(EnrichmentType.CALIBER_GROUP); - pe.setSource(EnrichmentSource.RULES); // derived rules - pe.setStatus(EnrichmentStatus.PENDING_REVIEW); - pe.setSchemaVersion(1); - pe.setAttributes(java.util.Map.of("caliberGroup", group)); - pe.setConfidence(new java.math.BigDecimal("1.00")); - pe.setRationale("Derived caliberGroup from product.caliber via CaliberTaxonomy"); - pe.setMeta(java.util.Map.of("provider", "TAXONOMY")); - - enrichmentRepository.save(pe); - created++; - } - - return created; - } +package group.goforward.battlbuilder.enrichment.ai; + +import group.goforward.battlbuilder.enrichment.*; +import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult; +import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; +import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository; +import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy; +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.repo.ProductRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +@Service +public class AiEnrichmentOrchestrator { + + private final EnrichmentModelClient modelClient; + private final ProductRepository productRepository; + private final ProductEnrichmentRepository enrichmentRepository; + + @Value("${ai.minConfidence:0.75}") + private BigDecimal minConfidence; + + public AiEnrichmentOrchestrator( + EnrichmentModelClient modelClient, + ProductRepository productRepository, + ProductEnrichmentRepository enrichmentRepository + ) { + this.modelClient = modelClient; + this.productRepository = productRepository; + this.enrichmentRepository = enrichmentRepository; + } + + public int runCaliber(int limit) { + // pick candidates: caliber missing + List candidates = productRepository.findProductsMissingCaliber(limit); + + int created = 0; + + for (Product p : candidates) { + CaliberExtractionResult r = modelClient.extractCaliber(p); + + if (r == null || !r.isUsable(minConfidence)) { + continue; + } + + // Optional: avoid duplicates for same product/type/status + boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus( + p.getId(), EnrichmentType.CALIBER, EnrichmentStatus.PENDING_REVIEW + ); + if (exists) continue; + + ProductEnrichment pe = new ProductEnrichment(); + pe.setProductId(p.getId()); + pe.setEnrichmentType(EnrichmentType.CALIBER); + pe.setSource(EnrichmentSource.AI); + pe.setStatus(EnrichmentStatus.PENDING_REVIEW); + pe.setSchemaVersion(1); + pe.setAttributes(Map.of("caliber", r.caliber())); + pe.setConfidence(r.confidence()); + pe.setRationale(r.reason()); + pe.setMeta(Map.of("provider", modelClient.providerName())); + + enrichmentRepository.save(pe); + created++; + } + + return created; + } + public int runCaliberGroup(int limit) { + List candidates = productRepository.findProductsMissingCaliberGroup(limit); + int created = 0; + + for (Product p : candidates) { + String group = CaliberTaxonomy.groupForCaliber(p.getCaliber()); + if (group == null || group.isBlank()) continue; + + boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus( + p.getId(), EnrichmentType.CALIBER_GROUP, EnrichmentStatus.PENDING_REVIEW + ); + if (exists) continue; + + ProductEnrichment pe = new ProductEnrichment(); + pe.setProductId(p.getId()); + pe.setEnrichmentType(EnrichmentType.CALIBER_GROUP); + pe.setSource(EnrichmentSource.RULES); // derived rules + pe.setStatus(EnrichmentStatus.PENDING_REVIEW); + pe.setSchemaVersion(1); + pe.setAttributes(java.util.Map.of("caliberGroup", group)); + pe.setConfidence(new java.math.BigDecimal("1.00")); + pe.setRationale("Derived caliberGroup from product.caliber via CaliberTaxonomy"); + pe.setMeta(java.util.Map.of("provider", "TAXONOMY")); + + enrichmentRepository.save(pe); + created++; + } + + return created; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/controller/AdminEnrichmentController.java b/src/main/java/group/goforward/battlbuilder/enrichment/controller/AdminEnrichmentController.java index ddd4ff9..c8cd9b8 100644 --- a/src/main/java/group/goforward/battlbuilder/enrichment/controller/AdminEnrichmentController.java +++ b/src/main/java/group/goforward/battlbuilder/enrichment/controller/AdminEnrichmentController.java @@ -1,153 +1,153 @@ -package group.goforward.battlbuilder.enrichment.controller; - -import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy; -import group.goforward.battlbuilder.enrichment.EnrichmentStatus; -import group.goforward.battlbuilder.enrichment.EnrichmentType; -import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; -import group.goforward.battlbuilder.enrichment.ai.AiEnrichmentOrchestrator; -import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository; -import group.goforward.battlbuilder.enrichment.service.CaliberEnrichmentService; -import org.springframework.data.domain.PageRequest; -import org.springframework.http.ResponseEntity; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/admin/enrichment") -public class AdminEnrichmentController { - - private final CaliberEnrichmentService caliberEnrichmentService; - private final ProductEnrichmentRepository enrichmentRepository; - private final AiEnrichmentOrchestrator aiEnrichmentOrchestrator; - - public AdminEnrichmentController( - CaliberEnrichmentService caliberEnrichmentService, - ProductEnrichmentRepository enrichmentRepository, - AiEnrichmentOrchestrator aiEnrichmentOrchestrator - ) { - this.caliberEnrichmentService = caliberEnrichmentService; - this.enrichmentRepository = enrichmentRepository; - this.aiEnrichmentOrchestrator = aiEnrichmentOrchestrator; - } - - @PostMapping("/run") - public ResponseEntity run( - @RequestParam EnrichmentType type, - @RequestParam(defaultValue = "200") int limit - ) { - if (type != EnrichmentType.CALIBER) { - return ResponseEntity.badRequest().body("Only CALIBER is supported in v0."); - } - return ResponseEntity.ok(caliberEnrichmentService.runRules(limit)); - } - - // ✅ NEW: Run AI enrichment - @PostMapping("/ai/run") - public ResponseEntity runAi( - @RequestParam EnrichmentType type, - @RequestParam(defaultValue = "200") int limit - ) { - if (type != EnrichmentType.CALIBER) { - return ResponseEntity.badRequest().body("Only CALIBER is supported in v0."); - } - - // This should create ProductEnrichment rows with source=AI and status=PENDING_REVIEW - return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliber(limit)); - } - - @GetMapping("/queue") - public ResponseEntity> queue( - @RequestParam(defaultValue = "CALIBER") EnrichmentType type, - @RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status, - @RequestParam(defaultValue = "100") int limit - ) { - var items = enrichmentRepository - .findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(type, status, PageRequest.of(0, limit)); - return ResponseEntity.ok(items); - } - - @GetMapping("/queue2") - public ResponseEntity queue2( - @RequestParam(defaultValue = "CALIBER") EnrichmentType type, - @RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status, - @RequestParam(defaultValue = "100") int limit - ) { - return ResponseEntity.ok( - enrichmentRepository.queueWithProduct(type, status, PageRequest.of(0, limit)) - ); - } - - @PostMapping("/{id}/approve") - public ResponseEntity approve(@PathVariable Long id) { - var e = enrichmentRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); - e.setStatus(EnrichmentStatus.APPROVED); - enrichmentRepository.save(e); - return ResponseEntity.ok(e); - } - - @PostMapping("/{id}/reject") - public ResponseEntity reject(@PathVariable Long id) { - var e = enrichmentRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); - e.setStatus(EnrichmentStatus.REJECTED); - enrichmentRepository.save(e); - return ResponseEntity.ok(e); - } - - @PostMapping("/{id}/apply") - @Transactional - public ResponseEntity apply(@PathVariable Long id) { - var e = enrichmentRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); - - if (e.getStatus() != EnrichmentStatus.APPROVED) { - return ResponseEntity.badRequest().body("Enrichment must be APPROVED before applying."); - } - - if (e.getEnrichmentType() == EnrichmentType.CALIBER) { - Object caliberObj = e.getAttributes().get("caliber"); - if (!(caliberObj instanceof String caliber) || caliber.trim().isEmpty()) { - return ResponseEntity.badRequest().body("Missing attributes.caliber"); - } - - String canonical = CaliberTaxonomy.normalizeCaliber(caliber.trim()); - int updated = enrichmentRepository.applyCaliberIfBlank(e.getProductId(), canonical); - - if (updated == 0) { - return ResponseEntity.badRequest().body("Product caliber already set (or product not found). Not applied."); - } - - // Bonus safety: set group if blank - String group = CaliberTaxonomy.groupForCaliber(canonical); - if (group != null && !group.isBlank()) { - enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group); - } - - } else if (e.getEnrichmentType() == EnrichmentType.CALIBER_GROUP) { - Object groupObj = e.getAttributes().get("caliberGroup"); - if (!(groupObj instanceof String group) || group.trim().isEmpty()) { - return ResponseEntity.badRequest().body("Missing attributes.caliberGroup"); - } - - int updated = enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group.trim()); - if (updated == 0) { - return ResponseEntity.badRequest().body("Product caliber_group already set (or product not found). Not applied."); - } - } else { - return ResponseEntity.badRequest().body("Unsupported enrichment type in v0."); - } - - e.setStatus(EnrichmentStatus.APPLIED); - enrichmentRepository.save(e); - - return ResponseEntity.ok(e); - } - - @PostMapping("/groups/run") - public ResponseEntity runGroups(@RequestParam(defaultValue = "200") int limit) { - return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliberGroup(limit)); - } +package group.goforward.battlbuilder.enrichment.controller; + +import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy; +import group.goforward.battlbuilder.enrichment.EnrichmentStatus; +import group.goforward.battlbuilder.enrichment.EnrichmentType; +import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; +import group.goforward.battlbuilder.enrichment.ai.AiEnrichmentOrchestrator; +import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository; +import group.goforward.battlbuilder.enrichment.service.CaliberEnrichmentService; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/enrichment") +public class AdminEnrichmentController { + + private final CaliberEnrichmentService caliberEnrichmentService; + private final ProductEnrichmentRepository enrichmentRepository; + private final AiEnrichmentOrchestrator aiEnrichmentOrchestrator; + + public AdminEnrichmentController( + CaliberEnrichmentService caliberEnrichmentService, + ProductEnrichmentRepository enrichmentRepository, + AiEnrichmentOrchestrator aiEnrichmentOrchestrator + ) { + this.caliberEnrichmentService = caliberEnrichmentService; + this.enrichmentRepository = enrichmentRepository; + this.aiEnrichmentOrchestrator = aiEnrichmentOrchestrator; + } + + @PostMapping("/run") + public ResponseEntity run( + @RequestParam EnrichmentType type, + @RequestParam(defaultValue = "200") int limit + ) { + if (type != EnrichmentType.CALIBER) { + return ResponseEntity.badRequest().body("Only CALIBER is supported in v0."); + } + return ResponseEntity.ok(caliberEnrichmentService.runRules(limit)); + } + + // ✅ NEW: Run AI enrichment + @PostMapping("/ai/run") + public ResponseEntity runAi( + @RequestParam EnrichmentType type, + @RequestParam(defaultValue = "200") int limit + ) { + if (type != EnrichmentType.CALIBER) { + return ResponseEntity.badRequest().body("Only CALIBER is supported in v0."); + } + + // This should create ProductEnrichment rows with source=AI and status=PENDING_REVIEW + return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliber(limit)); + } + + @GetMapping("/queue") + public ResponseEntity> queue( + @RequestParam(defaultValue = "CALIBER") EnrichmentType type, + @RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status, + @RequestParam(defaultValue = "100") int limit + ) { + var items = enrichmentRepository + .findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(type, status, PageRequest.of(0, limit)); + return ResponseEntity.ok(items); + } + + @GetMapping("/queue2") + public ResponseEntity queue2( + @RequestParam(defaultValue = "CALIBER") EnrichmentType type, + @RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status, + @RequestParam(defaultValue = "100") int limit + ) { + return ResponseEntity.ok( + enrichmentRepository.queueWithProduct(type, status, PageRequest.of(0, limit)) + ); + } + + @PostMapping("/{id}/approve") + public ResponseEntity approve(@PathVariable Long id) { + var e = enrichmentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); + e.setStatus(EnrichmentStatus.APPROVED); + enrichmentRepository.save(e); + return ResponseEntity.ok(e); + } + + @PostMapping("/{id}/reject") + public ResponseEntity reject(@PathVariable Long id) { + var e = enrichmentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); + e.setStatus(EnrichmentStatus.REJECTED); + enrichmentRepository.save(e); + return ResponseEntity.ok(e); + } + + @PostMapping("/{id}/apply") + @Transactional + public ResponseEntity apply(@PathVariable Long id) { + var e = enrichmentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); + + if (e.getStatus() != EnrichmentStatus.APPROVED) { + return ResponseEntity.badRequest().body("Enrichment must be APPROVED before applying."); + } + + if (e.getEnrichmentType() == EnrichmentType.CALIBER) { + Object caliberObj = e.getAttributes().get("caliber"); + if (!(caliberObj instanceof String caliber) || caliber.trim().isEmpty()) { + return ResponseEntity.badRequest().body("Missing attributes.caliber"); + } + + String canonical = CaliberTaxonomy.normalizeCaliber(caliber.trim()); + int updated = enrichmentRepository.applyCaliberIfBlank(e.getProductId(), canonical); + + if (updated == 0) { + return ResponseEntity.badRequest().body("Product caliber already set (or product not found). Not applied."); + } + + // Bonus safety: set group if blank + String group = CaliberTaxonomy.groupForCaliber(canonical); + if (group != null && !group.isBlank()) { + enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group); + } + + } else if (e.getEnrichmentType() == EnrichmentType.CALIBER_GROUP) { + Object groupObj = e.getAttributes().get("caliberGroup"); + if (!(groupObj instanceof String group) || group.trim().isEmpty()) { + return ResponseEntity.badRequest().body("Missing attributes.caliberGroup"); + } + + int updated = enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group.trim()); + if (updated == 0) { + return ResponseEntity.badRequest().body("Product caliber_group already set (or product not found). Not applied."); + } + } else { + return ResponseEntity.badRequest().body("Unsupported enrichment type in v0."); + } + + e.setStatus(EnrichmentStatus.APPLIED); + enrichmentRepository.save(e); + + return ResponseEntity.ok(e); + } + + @PostMapping("/groups/run") + public ResponseEntity runGroups(@RequestParam(defaultValue = "200") int limit) { + return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliberGroup(limit)); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/model/ProductEnrichment.java b/src/main/java/group/goforward/battlbuilder/enrichment/model/ProductEnrichment.java index f46e10c..18d2366 100644 --- a/src/main/java/group/goforward/battlbuilder/enrichment/model/ProductEnrichment.java +++ b/src/main/java/group/goforward/battlbuilder/enrichment/model/ProductEnrichment.java @@ -1,95 +1,95 @@ -package group.goforward.battlbuilder.enrichment.model; - -import group.goforward.battlbuilder.enrichment.EnrichmentSource; -import group.goforward.battlbuilder.enrichment.EnrichmentStatus; -import group.goforward.battlbuilder.enrichment.EnrichmentType; -import jakarta.persistence.*; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.math.BigDecimal; -import java.time.OffsetDateTime; -import java.util.HashMap; -import java.util.Map; - -@Entity -@Table(name = "product_enrichments") -public class ProductEnrichment { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "product_id", nullable = false) - private Integer productId; - - @Enumerated(EnumType.STRING) - @Column(name = "enrichment_type", nullable = false) - private EnrichmentType enrichmentType; - - @Enumerated(EnumType.STRING) - @Column(name = "source", nullable = false) - private EnrichmentSource source = EnrichmentSource.AI; - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - private EnrichmentStatus status = EnrichmentStatus.PENDING_REVIEW; - - @Column(name = "schema_version", nullable = false) - private Integer schemaVersion = 1; - - @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "attributes", nullable = false, columnDefinition = "jsonb") - private Map attributes = new HashMap<>(); - - @Column(name = "confidence", precision = 4, scale = 3) - private BigDecimal confidence; - - @Column(name = "rationale") - private String rationale; - - @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "meta", nullable = false, columnDefinition = "jsonb") - private Map meta = new HashMap<>(); - - // DB sets these defaults; we keep them read-only to avoid fighting the trigger/defaults - @Column(name = "created_at", insertable = false, updatable = false) - private OffsetDateTime createdAt; - - @Column(name = "updated_at", insertable = false, updatable = false) - private OffsetDateTime updatedAt; - - // --- getters/setters (generate via IDE) --- - - public Long getId() { return id; } - - public Integer getProductId() { return productId; } - public void setProductId(Integer productId) { this.productId = productId; } - - public EnrichmentType getEnrichmentType() { return enrichmentType; } - public void setEnrichmentType(EnrichmentType enrichmentType) { this.enrichmentType = enrichmentType; } - - public EnrichmentSource getSource() { return source; } - public void setSource(EnrichmentSource source) { this.source = source; } - - public EnrichmentStatus getStatus() { return status; } - public void setStatus(EnrichmentStatus status) { this.status = status; } - - public Integer getSchemaVersion() { return schemaVersion; } - public void setSchemaVersion(Integer schemaVersion) { this.schemaVersion = schemaVersion; } - - public Map getAttributes() { return attributes; } - public void setAttributes(Map attributes) { this.attributes = attributes; } - - public BigDecimal getConfidence() { return confidence; } - public void setConfidence(BigDecimal confidence) { this.confidence = confidence; } - - public String getRationale() { return rationale; } - public void setRationale(String rationale) { this.rationale = rationale; } - - public Map getMeta() { return meta; } - public void setMeta(Map meta) { this.meta = meta; } - - public OffsetDateTime getCreatedAt() { return createdAt; } - public OffsetDateTime getUpdatedAt() { return updatedAt; } +package group.goforward.battlbuilder.enrichment.model; + +import group.goforward.battlbuilder.enrichment.EnrichmentSource; +import group.goforward.battlbuilder.enrichment.EnrichmentStatus; +import group.goforward.battlbuilder.enrichment.EnrichmentType; +import jakarta.persistence.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; + +@Entity +@Table(name = "product_enrichments") +public class ProductEnrichment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Integer productId; + + @Enumerated(EnumType.STRING) + @Column(name = "enrichment_type", nullable = false) + private EnrichmentType enrichmentType; + + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false) + private EnrichmentSource source = EnrichmentSource.AI; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private EnrichmentStatus status = EnrichmentStatus.PENDING_REVIEW; + + @Column(name = "schema_version", nullable = false) + private Integer schemaVersion = 1; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "attributes", nullable = false, columnDefinition = "jsonb") + private Map attributes = new HashMap<>(); + + @Column(name = "confidence", precision = 4, scale = 3) + private BigDecimal confidence; + + @Column(name = "rationale") + private String rationale; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "meta", nullable = false, columnDefinition = "jsonb") + private Map meta = new HashMap<>(); + + // DB sets these defaults; we keep them read-only to avoid fighting the trigger/defaults + @Column(name = "created_at", insertable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", insertable = false, updatable = false) + private OffsetDateTime updatedAt; + + // --- getters/setters (generate via IDE) --- + + public Long getId() { return id; } + + public Integer getProductId() { return productId; } + public void setProductId(Integer productId) { this.productId = productId; } + + public EnrichmentType getEnrichmentType() { return enrichmentType; } + public void setEnrichmentType(EnrichmentType enrichmentType) { this.enrichmentType = enrichmentType; } + + public EnrichmentSource getSource() { return source; } + public void setSource(EnrichmentSource source) { this.source = source; } + + public EnrichmentStatus getStatus() { return status; } + public void setStatus(EnrichmentStatus status) { this.status = status; } + + public Integer getSchemaVersion() { return schemaVersion; } + public void setSchemaVersion(Integer schemaVersion) { this.schemaVersion = schemaVersion; } + + public Map getAttributes() { return attributes; } + public void setAttributes(Map attributes) { this.attributes = attributes; } + + public BigDecimal getConfidence() { return confidence; } + public void setConfidence(BigDecimal confidence) { this.confidence = confidence; } + + public String getRationale() { return rationale; } + public void setRationale(String rationale) { this.rationale = rationale; } + + public Map getMeta() { return meta; } + public void setMeta(Map meta) { this.meta = meta; } + + public OffsetDateTime getCreatedAt() { return createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/repo/ProductEnrichmentRepository.java b/src/main/java/group/goforward/battlbuilder/enrichment/repo/ProductEnrichmentRepository.java index fc95df8..dbdcb0a 100644 --- a/src/main/java/group/goforward/battlbuilder/enrichment/repo/ProductEnrichmentRepository.java +++ b/src/main/java/group/goforward/battlbuilder/enrichment/repo/ProductEnrichmentRepository.java @@ -1,94 +1,94 @@ -package group.goforward.battlbuilder.enrichment.repo; - -import group.goforward.battlbuilder.enrichment.EnrichmentStatus; -import group.goforward.battlbuilder.enrichment.EnrichmentType; -import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.jpa.repository.Modifying; -import group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem; -import org.springframework.data.repository.query.Param; - -import java.util.List; -import java.util.Optional; - -public interface ProductEnrichmentRepository extends JpaRepository { - - boolean existsByProductIdAndEnrichmentTypeAndStatus( - Integer productId, - EnrichmentType enrichmentType, - EnrichmentStatus status - ); - - @Query(""" - select e from ProductEnrichment e - where e.productId = :productId - and e.enrichmentType = :type - and e.status in ('PENDING_REVIEW','APPROVED') - """) - Optional findActive(Integer productId, EnrichmentType type); - - List findByEnrichmentTypeAndStatusOrderByCreatedAtDesc( - EnrichmentType type, - EnrichmentStatus status, - Pageable pageable - ); - - @Query(""" - select new group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem( - e.id, - e.productId, - p.name, - p.slug, - p.mainImageUrl, - b.name, - e.enrichmentType, - e.source, - e.status, - e.schemaVersion, - e.attributes, - e.confidence, - e.rationale, - e.createdAt, - p.caliber, - p.caliberGroup - - ) - from ProductEnrichment e - join Product p on p.id = e.productId - join p.brand b - where e.enrichmentType = :type - and e.status = :status - order by e.createdAt desc - """) - List queueWithProduct( - EnrichmentType type, - EnrichmentStatus status, - Pageable pageable - ); - - @Modifying - @Query(""" - update Product p - set p.caliber = :caliber - where p.id = :productId - and (p.caliber is null or trim(p.caliber) = '') - """) - int applyCaliberIfBlank( - @Param("productId") Integer productId, - @Param("caliber") String caliber - ); - - @Modifying - @Query(""" - update Product p - set p.caliberGroup = :caliberGroup - where p.id = :productId - and (p.caliberGroup is null or trim(p.caliberGroup) = '') -""") - int applyCaliberGroupIfBlank( - @Param("productId") Integer productId, - @Param("caliberGroup") String caliberGroup - ); +package group.goforward.battlbuilder.enrichment.repo; + +import group.goforward.battlbuilder.enrichment.EnrichmentStatus; +import group.goforward.battlbuilder.enrichment.EnrichmentType; +import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.Modifying; +import group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductEnrichmentRepository extends JpaRepository { + + boolean existsByProductIdAndEnrichmentTypeAndStatus( + Integer productId, + EnrichmentType enrichmentType, + EnrichmentStatus status + ); + + @Query(""" + select e from ProductEnrichment e + where e.productId = :productId + and e.enrichmentType = :type + and e.status in ('PENDING_REVIEW','APPROVED') + """) + Optional findActive(Integer productId, EnrichmentType type); + + List findByEnrichmentTypeAndStatusOrderByCreatedAtDesc( + EnrichmentType type, + EnrichmentStatus status, + Pageable pageable + ); + + @Query(""" + select new group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem( + e.id, + e.productId, + p.name, + p.slug, + p.mainImageUrl, + b.name, + e.enrichmentType, + e.source, + e.status, + e.schemaVersion, + e.attributes, + e.confidence, + e.rationale, + e.createdAt, + p.caliber, + p.caliberGroup + + ) + from ProductEnrichment e + join Product p on p.id = e.productId + join p.brand b + where e.enrichmentType = :type + and e.status = :status + order by e.createdAt desc + """) + List queueWithProduct( + EnrichmentType type, + EnrichmentStatus status, + Pageable pageable + ); + + @Modifying + @Query(""" + update Product p + set p.caliber = :caliber + where p.id = :productId + and (p.caliber is null or trim(p.caliber) = '') + """) + int applyCaliberIfBlank( + @Param("productId") Integer productId, + @Param("caliber") String caliber + ); + + @Modifying + @Query(""" + update Product p + set p.caliberGroup = :caliberGroup + where p.id = :productId + and (p.caliberGroup is null or trim(p.caliberGroup) = '') +""") + int applyCaliberGroupIfBlank( + @Param("productId") Integer productId, + @Param("caliberGroup") String caliberGroup + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/service/CaliberEnrichmentService.java b/src/main/java/group/goforward/battlbuilder/enrichment/service/CaliberEnrichmentService.java index 62a2ade..65622ea 100644 --- a/src/main/java/group/goforward/battlbuilder/enrichment/service/CaliberEnrichmentService.java +++ b/src/main/java/group/goforward/battlbuilder/enrichment/service/CaliberEnrichmentService.java @@ -1,86 +1,86 @@ -package group.goforward.battlbuilder.enrichment.service; - -import group.goforward.battlbuilder.enrichment.*; -import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; -import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; -import java.util.HashMap; -import java.util.List; -import java.util.Optional; - -@Service -public class CaliberEnrichmentService { - - private final ProductEnrichmentRepository enrichmentRepository; - - @PersistenceContext - private EntityManager em; - - private final CaliberRuleExtractor extractor = new CaliberRuleExtractor(); - - public CaliberEnrichmentService(ProductEnrichmentRepository enrichmentRepository) { - this.enrichmentRepository = enrichmentRepository; - } - - public record RunResult(int scanned, int created) {} - - /** - * Creates PENDING_REVIEW caliber enrichments for products that don't already have an active one. - */ - @Transactional - public RunResult runRules(int limit) { - // Adjust Product entity package if needed: - // IMPORTANT: Product must be a mapped @Entity named "Product" - List rows = em.createQuery(""" - select p.id, p.name, p.description - from Product p - where p.deletedAt is null - and not exists ( - select 1 from ProductEnrichment e - where e.productId = p.id - and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER - and e.status in ('PENDING_REVIEW','APPROVED') - ) - order by p.id desc - """, Object[].class) - .setMaxResults(limit) - .getResultList(); - - int created = 0; - - for (Object[] r : rows) { - Integer productId = (Integer) r[0]; - String name = (String) r[1]; - String description = (String) r[2]; - - Optional res = extractor.extract(name, description); - if (res.isEmpty()) continue; - - var result = res.get(); - - ProductEnrichment e = new ProductEnrichment(); - e.setProductId(productId); - e.setEnrichmentType(EnrichmentType.CALIBER); - e.setSource(EnrichmentSource.RULES); - e.setStatus(EnrichmentStatus.PENDING_REVIEW); - e.setSchemaVersion(1); - - var attrs = new HashMap(); - attrs.put("caliber", result.caliber()); - e.setAttributes(attrs); - - e.setConfidence(BigDecimal.valueOf(result.confidence())); - e.setRationale(result.rationale()); - - enrichmentRepository.save(e); - created++; - } - - return new RunResult(rows.size(), created); - } +package group.goforward.battlbuilder.enrichment.service; + +import group.goforward.battlbuilder.enrichment.*; +import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; +import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; + +@Service +public class CaliberEnrichmentService { + + private final ProductEnrichmentRepository enrichmentRepository; + + @PersistenceContext + private EntityManager em; + + private final CaliberRuleExtractor extractor = new CaliberRuleExtractor(); + + public CaliberEnrichmentService(ProductEnrichmentRepository enrichmentRepository) { + this.enrichmentRepository = enrichmentRepository; + } + + public record RunResult(int scanned, int created) {} + + /** + * Creates PENDING_REVIEW caliber enrichments for products that don't already have an active one. + */ + @Transactional + public RunResult runRules(int limit) { + // Adjust Product entity package if needed: + // IMPORTANT: Product must be a mapped @Entity named "Product" + List rows = em.createQuery(""" + select p.id, p.name, p.description + from Product p + where p.deletedAt is null + and not exists ( + select 1 from ProductEnrichment e + where e.productId = p.id + and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER + and e.status in ('PENDING_REVIEW','APPROVED') + ) + order by p.id desc + """, Object[].class) + .setMaxResults(limit) + .getResultList(); + + int created = 0; + + for (Object[] r : rows) { + Integer productId = (Integer) r[0]; + String name = (String) r[1]; + String description = (String) r[2]; + + Optional res = extractor.extract(name, description); + if (res.isEmpty()) continue; + + var result = res.get(); + + ProductEnrichment e = new ProductEnrichment(); + e.setProductId(productId); + e.setEnrichmentType(EnrichmentType.CALIBER); + e.setSource(EnrichmentSource.RULES); + e.setStatus(EnrichmentStatus.PENDING_REVIEW); + e.setSchemaVersion(1); + + var attrs = new HashMap(); + attrs.put("caliber", result.caliber()); + e.setAttributes(attrs); + + e.setConfidence(BigDecimal.valueOf(result.confidence())); + e.setRationale(result.rationale()); + + enrichmentRepository.save(e); + created++; + } + + return new RunResult(rows.size(), created); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/taxonomies/CaliberTaxonomy.java b/src/main/java/group/goforward/battlbuilder/enrichment/taxonomies/CaliberTaxonomy.java index eb4d17f..e011396 100644 --- a/src/main/java/group/goforward/battlbuilder/enrichment/taxonomies/CaliberTaxonomy.java +++ b/src/main/java/group/goforward/battlbuilder/enrichment/taxonomies/CaliberTaxonomy.java @@ -1,35 +1,35 @@ -package group.goforward.battlbuilder.enrichment.taxonomies; - -import java.util.Locale; - -public final class CaliberTaxonomy { - private CaliberTaxonomy() {} - - public static String normalizeCaliber(String raw) { - if (raw == null) return null; - String s = raw.trim(); - - // Canonicalize common variants - String l = s.toLowerCase(Locale.ROOT); - - if (l.contains("223 wylde") || l.contains(".223 wylde")) return ".223 Wylde"; - if (l.contains("5.56") || l.contains("5,56") || l.contains("5.56x45") || l.contains("5.56x45mm")) return "5.56 NATO"; - if (l.contains("223") || l.contains(".223") || l.contains("223 rem") || l.contains("223 remington")) return ".223 Remington"; - - if (l.contains("300 blackout") || l.contains("300 blk") || l.contains("300 aac")) return "300 BLK"; - - // fallback: return trimmed original (you can tighten later) - return s; - } - - public static String groupForCaliber(String caliberCanonical) { - if (caliberCanonical == null) return null; - String l = caliberCanonical.toLowerCase(Locale.ROOT); - - if (l.contains("223") || l.contains("5.56") || l.contains("wylde")) return "223/5.56"; - if (l.contains("300 blk") || l.contains("300 blackout") || l.contains("300 aac")) return "300 BLK"; - - // TODO add more buckets: 308/7.62, 6.5 CM, 9mm, etc. - return null; - } +package group.goforward.battlbuilder.enrichment.taxonomies; + +import java.util.Locale; + +public final class CaliberTaxonomy { + private CaliberTaxonomy() {} + + public static String normalizeCaliber(String raw) { + if (raw == null) return null; + String s = raw.trim(); + + // Canonicalize common variants + String l = s.toLowerCase(Locale.ROOT); + + if (l.contains("223 wylde") || l.contains(".223 wylde")) return ".223 Wylde"; + if (l.contains("5.56") || l.contains("5,56") || l.contains("5.56x45") || l.contains("5.56x45mm")) return "5.56 NATO"; + if (l.contains("223") || l.contains(".223") || l.contains("223 rem") || l.contains("223 remington")) return ".223 Remington"; + + if (l.contains("300 blackout") || l.contains("300 blk") || l.contains("300 aac")) return "300 BLK"; + + // fallback: return trimmed original (you can tighten later) + return s; + } + + public static String groupForCaliber(String caliberCanonical) { + if (caliberCanonical == null) return null; + String l = caliberCanonical.toLowerCase(Locale.ROOT); + + if (l.contains("223") || l.contains("5.56") || l.contains("wylde")) return "223/5.56"; + if (l.contains("300 blk") || l.contains("300 blackout") || l.contains("300 aac")) return "300 BLK"; + + // TODO add more buckets: 308/7.62, 6.5 CM, 9mm, etc. + return null; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/AuthToken.java b/src/main/java/group/goforward/battlbuilder/model/AuthToken.java index 72c20e7..a850135 100644 --- a/src/main/java/group/goforward/battlbuilder/model/AuthToken.java +++ b/src/main/java/group/goforward/battlbuilder/model/AuthToken.java @@ -1,174 +1,174 @@ -package group.goforward.battlbuilder.model; - -import jakarta.persistence.*; -import java.time.OffsetDateTime; - -/** - * Entity representing an authentication token. - * Tokens are used for beta verification, magic login links, and password resets. - * Tokens are hashed before storage and can be consumed and expired. - * - * @see jakarta.persistence.Entity - */ -@Entity -@Table( - name = "auth_tokens", - indexes = { - @Index(name = "idx_auth_tokens_email", columnList = "email"), - @Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash") - } -) -public class AuthToken { - - /** - * Enumeration of token types. - */ - public enum TokenType { - /** Token for beta access verification. */ - BETA_VERIFY, - /** Token for magic link login. */ - MAGIC_LOGIN, - /** Token for password reset. */ - PASSWORD_RESET - } - - /** The primary key identifier for the token. */ - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - /** The email address associated with this token. */ - @Column(nullable = false) - private String email; - - /** The type of token. */ - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 32) - private TokenType type; - - /** The hashed token value. */ - @Column(name = "token_hash", nullable = false, length = 64) - private String tokenHash; - - /** The timestamp when this token expires. */ - @Column(name = "expires_at", nullable = false) - private OffsetDateTime expiresAt; - - /** The timestamp when this token was consumed/used. */ - @Column(name = "consumed_at") - private OffsetDateTime consumedAt; - - /** The timestamp when this token was created. */ - @Column(name = "created_at", nullable = false) - private OffsetDateTime createdAt; - - // getters/setters - - /** - * Gets the primary key identifier for the token. - * - * @return the token ID - */ - public Long getId() { return id; } - - /** - * Gets the email address associated with this token. - * - * @return the email address - */ - public String getEmail() { return email; } - - /** - * Sets the email address associated with this token. - * - * @param email the email address to set - */ - public void setEmail(String email) { this.email = email; } - - /** - * Gets the type of token. - * - * @return the token type - */ - public TokenType getType() { return type; } - - /** - * Sets the type of token. - * - * @param type the token type to set - */ - public void setType(TokenType type) { this.type = type; } - - /** - * Gets the hashed token value. - * - * @return the token hash - */ - public String getTokenHash() { return tokenHash; } - - /** - * Sets the hashed token value. - * - * @param tokenHash the token hash to set - */ - public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; } - - /** - * Gets the timestamp when this token expires. - * - * @return the expiration timestamp - */ - public OffsetDateTime getExpiresAt() { return expiresAt; } - - /** - * Sets the timestamp when this token expires. - * - * @param expiresAt the expiration timestamp to set - */ - public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; } - - /** - * Gets the timestamp when this token was consumed/used. - * - * @return the consumed timestamp, or null if not yet consumed - */ - public OffsetDateTime getConsumedAt() { return consumedAt; } - - /** - * Sets the timestamp when this token was consumed/used. - * - * @param consumedAt the consumed timestamp to set - */ - public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; } - - /** - * Gets the timestamp when this token was created. - * - * @return the creation timestamp - */ - public OffsetDateTime getCreatedAt() { return createdAt; } - - /** - * Sets the timestamp when this token was created. - * - * @param createdAt the creation timestamp to set - */ - public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } - - /** - * Checks if this token has been consumed/used. - * - * @return true if the token has been consumed, false otherwise - */ - @Transient - public boolean isConsumed() { return consumedAt != null; } - - /** - * Checks if this token has expired at the given time. - * - * @param now the current time to check against - * @return true if the token has expired, false otherwise - */ - @Transient - public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); } +package group.goforward.battlbuilder.model; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; + +/** + * Entity representing an authentication token. + * Tokens are used for beta verification, magic login links, and password resets. + * Tokens are hashed before storage and can be consumed and expired. + * + * @see jakarta.persistence.Entity + */ +@Entity +@Table( + name = "auth_tokens", + indexes = { + @Index(name = "idx_auth_tokens_email", columnList = "email"), + @Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash") + } +) +public class AuthToken { + + /** + * Enumeration of token types. + */ + public enum TokenType { + /** Token for beta access verification. */ + BETA_VERIFY, + /** Token for magic link login. */ + MAGIC_LOGIN, + /** Token for password reset. */ + PASSWORD_RESET + } + + /** The primary key identifier for the token. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** The email address associated with this token. */ + @Column(nullable = false) + private String email; + + /** The type of token. */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private TokenType type; + + /** The hashed token value. */ + @Column(name = "token_hash", nullable = false, length = 64) + private String tokenHash; + + /** The timestamp when this token expires. */ + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + /** The timestamp when this token was consumed/used. */ + @Column(name = "consumed_at") + private OffsetDateTime consumedAt; + + /** The timestamp when this token was created. */ + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + // getters/setters + + /** + * Gets the primary key identifier for the token. + * + * @return the token ID + */ + public Long getId() { return id; } + + /** + * Gets the email address associated with this token. + * + * @return the email address + */ + public String getEmail() { return email; } + + /** + * Sets the email address associated with this token. + * + * @param email the email address to set + */ + public void setEmail(String email) { this.email = email; } + + /** + * Gets the type of token. + * + * @return the token type + */ + public TokenType getType() { return type; } + + /** + * Sets the type of token. + * + * @param type the token type to set + */ + public void setType(TokenType type) { this.type = type; } + + /** + * Gets the hashed token value. + * + * @return the token hash + */ + public String getTokenHash() { return tokenHash; } + + /** + * Sets the hashed token value. + * + * @param tokenHash the token hash to set + */ + public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; } + + /** + * Gets the timestamp when this token expires. + * + * @return the expiration timestamp + */ + public OffsetDateTime getExpiresAt() { return expiresAt; } + + /** + * Sets the timestamp when this token expires. + * + * @param expiresAt the expiration timestamp to set + */ + public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; } + + /** + * Gets the timestamp when this token was consumed/used. + * + * @return the consumed timestamp, or null if not yet consumed + */ + public OffsetDateTime getConsumedAt() { return consumedAt; } + + /** + * Sets the timestamp when this token was consumed/used. + * + * @param consumedAt the consumed timestamp to set + */ + public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; } + + /** + * Gets the timestamp when this token was created. + * + * @return the creation timestamp + */ + public OffsetDateTime getCreatedAt() { return createdAt; } + + /** + * Sets the timestamp when this token was created. + * + * @param createdAt the creation timestamp to set + */ + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + /** + * Checks if this token has been consumed/used. + * + * @return true if the token has been consumed, false otherwise + */ + @Transient + public boolean isConsumed() { return consumedAt != null; } + + /** + * Checks if this token has expired at the given time. + * + * @param now the current time to check against + * @return true if the token has expired, false otherwise + */ + @Transient + public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/BuildProfile.java b/src/main/java/group/goforward/battlbuilder/model/BuildProfile.java index 2df0a74..d703ede 100644 --- a/src/main/java/group/goforward/battlbuilder/model/BuildProfile.java +++ b/src/main/java/group/goforward/battlbuilder/model/BuildProfile.java @@ -1,122 +1,122 @@ -package group.goforward.battlbuilder.model; - -import jakarta.persistence.*; -import org.hibernate.annotations.ColumnDefault; - -import java.time.OffsetDateTime; - -/** - * build_profiles - * 1:1 with builds (build_id is both PK and FK) - *

- * Dev notes: - * - This is the "feed/meta" table for Option B (caliber, class, cover image, tags, etc.) - * - Keep it lightweight. Anything social (votes/comments/media) lives elsewhere. - */ -@Entity -@Table(name = "build_profiles") -public class BuildProfile { - - // ----------------------------------------------------- - // Primary Key = FK to builds.id - // ----------------------------------------------------- - @Id - @Column(name = "build_id", nullable = false) - private Integer buildId; - - @OneToOne(fetch = FetchType.LAZY, optional = false) - @MapsId - @JoinColumn(name = "build_id", nullable = false) - private Build build; - - // ----------------------------------------------------- - // Feed metadata fields (MVP) - // ----------------------------------------------------- - - /** - * Examples: "AR-15", "AR-10", "AR-9" - * (String for now; we can enum later once stable.) - */ - @Column(name = "platform") - private String platform; - - /** - * Examples: "5.56", "9mm", ".300 BLK" - */ - @Column(name = "caliber") - private String caliber; - - /** - * Expected values for UI: "Rifle" | "Pistol" | "NFA" - * (String for now; UI will default if missing.) - */ - @Column(name = "build_class") - private String buildClass; - - /** - * Optional hero image used by /builds cards. - */ - @Column(name = "cover_image_url") - private String coverImageUrl; - - /** - * MVP tags storage: - * - store as comma-separated string: "Duty,NV-Ready,LPVO" - * - later: switch to jsonb or join table when needed - */ - @Column(name = "tags_csv") - private String tagsCsv; - - // ----------------------------------------------------- - // Timestamps (optional but nice for auditing) - // ----------------------------------------------------- - @ColumnDefault("now()") - @Column(name = "created_at", nullable = false) - private OffsetDateTime createdAt; - - @ColumnDefault("now()") - @Column(name = "updated_at", nullable = false) - private OffsetDateTime updatedAt; - - @PrePersist - public void prePersist() { - OffsetDateTime now = OffsetDateTime.now(); - if (createdAt == null) createdAt = now; - if (updatedAt == null) updatedAt = now; - } - - @PreUpdate - public void preUpdate() { - updatedAt = OffsetDateTime.now(); - } - - // ----------------------------------------------------- - // Getters / Setters - // ----------------------------------------------------- - public Integer getBuildId() { return buildId; } - public void setBuildId(Integer buildId) { this.buildId = buildId; } - - public Build getBuild() { return build; } - public void setBuild(Build build) { this.build = build; } - - public String getPlatform() { return platform; } - public void setPlatform(String platform) { this.platform = platform; } - - public String getCaliber() { return caliber; } - public void setCaliber(String caliber) { this.caliber = caliber; } - - public String getBuildClass() { return buildClass; } - public void setBuildClass(String buildClass) { this.buildClass = buildClass; } - - public String getCoverImageUrl() { return coverImageUrl; } - public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; } - - public String getTagsCsv() { return tagsCsv; } - public void setTagsCsv(String tagsCsv) { this.tagsCsv = tagsCsv; } - - public OffsetDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } - - public OffsetDateTime getUpdatedAt() { return updatedAt; } - public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +package group.goforward.battlbuilder.model; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; + +/** + * build_profiles + * 1:1 with builds (build_id is both PK and FK) + *

+ * Dev notes: + * - This is the "feed/meta" table for Option B (caliber, class, cover image, tags, etc.) + * - Keep it lightweight. Anything social (votes/comments/media) lives elsewhere. + */ +@Entity +@Table(name = "build_profiles") +public class BuildProfile { + + // ----------------------------------------------------- + // Primary Key = FK to builds.id + // ----------------------------------------------------- + @Id + @Column(name = "build_id", nullable = false) + private Integer buildId; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @MapsId + @JoinColumn(name = "build_id", nullable = false) + private Build build; + + // ----------------------------------------------------- + // Feed metadata fields (MVP) + // ----------------------------------------------------- + + /** + * Examples: "AR-15", "AR-10", "AR-9" + * (String for now; we can enum later once stable.) + */ + @Column(name = "platform") + private String platform; + + /** + * Examples: "5.56", "9mm", ".300 BLK" + */ + @Column(name = "caliber") + private String caliber; + + /** + * Expected values for UI: "Rifle" | "Pistol" | "NFA" + * (String for now; UI will default if missing.) + */ + @Column(name = "build_class") + private String buildClass; + + /** + * Optional hero image used by /builds cards. + */ + @Column(name = "cover_image_url") + private String coverImageUrl; + + /** + * MVP tags storage: + * - store as comma-separated string: "Duty,NV-Ready,LPVO" + * - later: switch to jsonb or join table when needed + */ + @Column(name = "tags_csv") + private String tagsCsv; + + // ----------------------------------------------------- + // Timestamps (optional but nice for auditing) + // ----------------------------------------------------- + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + public void prePersist() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + } + + @PreUpdate + public void preUpdate() { + updatedAt = OffsetDateTime.now(); + } + + // ----------------------------------------------------- + // Getters / Setters + // ----------------------------------------------------- + public Integer getBuildId() { return buildId; } + public void setBuildId(Integer buildId) { this.buildId = buildId; } + + public Build getBuild() { return build; } + public void setBuild(Build build) { this.build = build; } + + public String getPlatform() { return platform; } + public void setPlatform(String platform) { this.platform = platform; } + + public String getCaliber() { return caliber; } + public void setCaliber(String caliber) { this.caliber = caliber; } + + public String getBuildClass() { return buildClass; } + public void setBuildClass(String buildClass) { this.buildClass = buildClass; } + + public String getCoverImageUrl() { return coverImageUrl; } + public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; } + + public String getTagsCsv() { return tagsCsv; } + public void setTagsCsv(String tagsCsv) { this.tagsCsv = tagsCsv; } + + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/EmailRequest.java b/src/main/java/group/goforward/battlbuilder/model/EmailRequest.java index e6af124..1927b82 100644 --- a/src/main/java/group/goforward/battlbuilder/model/EmailRequest.java +++ b/src/main/java/group/goforward/battlbuilder/model/EmailRequest.java @@ -1,310 +1,310 @@ -package group.goforward.battlbuilder.model; - -import jakarta.persistence.*; - -import java.time.LocalDateTime; - -/** - * Entity representing an email request/queue entry. - * Tracks email sending status, delivery, and engagement metrics (opens, clicks). - * - * @see jakarta.persistence.Entity - */ -@Entity -@Table(name = "email_requests") -@NamedQueries({ - @NamedQuery( - name = "EmailRequest.findSent", - query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT" - ), - @NamedQuery( - name = "EmailRequest.findFailed", - query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED" - ), - @NamedQuery( - name = "EmailRequest.findPending", - query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING" - ) -}) -public class EmailRequest { - - /** The primary key identifier for the email request. */ - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - /** The email address of the recipient. */ - @Column(nullable = false) - private String recipient; - - /** The email subject line. */ - @Column(nullable = false) - private String subject; - - /** The email body content. */ - @Column(columnDefinition = "TEXT") - private String body; - - /** The template key used to generate this email (if applicable). */ - @Column(name = "template_key", length = 100) - private String templateKey; - - /** The status of the email (PENDING, SENT, FAILED). */ - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private EmailStatus status; // PENDING, SENT, FAILED - - /** The timestamp when the email was sent. */ - @Column(name = "sent_at") - private LocalDateTime sentAt; - - /** The error message if the email failed to send. */ - @Column(name = "error_message") - private String errorMessage; - - /** The timestamp when the email was first opened. */ - @Column(name = "opened_at") - private LocalDateTime openedAt; - - /** The number of times the email has been opened. Defaults to 0. */ - @Column(name = "open_count", nullable = false) - private Integer openCount = 0; - - /** The timestamp when a link in the email was first clicked. */ - @Column(name = "clicked_at") - private LocalDateTime clickedAt; - - /** The number of times links in the email have been clicked. Defaults to 0. */ - @Column(name = "click_count", nullable = false) - private Integer clickCount = 0; - - /** The timestamp when this email request was created. */ - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - /** The timestamp when this email request was last updated. */ - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - /** - * Lifecycle hook called before persisting a new entity. - * Initializes timestamps and default values. - */ - @PrePersist - protected void onCreate() { - LocalDateTime now = LocalDateTime.now(); - createdAt = now; - updatedAt = now; - - if (status == null) status = EmailStatus.PENDING; - if (openCount == null) openCount = 0; - if (clickCount == null) clickCount = 0; - } - - /** - * Lifecycle hook called before updating an existing entity. - * Updates the updatedAt timestamp. - */ - @PreUpdate - protected void onUpdate() { - updatedAt = LocalDateTime.now(); - } - - // ===== Getters / Setters ===== - - /** - * Gets the primary key identifier for the email request. - * - * @return the email request ID - */ - public Long getId() { return id; } - - /** - * Sets the primary key identifier for the email request. - * - * @param id the email request ID to set - */ - public void setId(Long id) { this.id = id; } - - /** - * Gets the email address of the recipient. - * - * @return the recipient email address - */ - public String getRecipient() { return recipient; } - - /** - * Sets the email address of the recipient. - * - * @param recipient the recipient email address to set - */ - public void setRecipient(String recipient) { this.recipient = recipient; } - - /** - * Gets the email subject line. - * - * @return the subject - */ - public String getSubject() { return subject; } - - /** - * Sets the email subject line. - * - * @param subject the subject to set - */ - public void setSubject(String subject) { this.subject = subject; } - - /** - * Gets the email body content. - * - * @return the body, or null if not set - */ - public String getBody() { return body; } - - /** - * Sets the email body content. - * - * @param body the body to set - */ - public void setBody(String body) { this.body = body; } - - /** - * Gets the template key used to generate this email. - * - * @return the template key, or null if not set - */ - public String getTemplateKey() { return templateKey; } - - /** - * Sets the template key used to generate this email. - * - * @param templateKey the template key to set - */ - public void setTemplateKey(String templateKey) { this.templateKey = templateKey; } - - /** - * Gets the status of the email. - * - * @return the email status (PENDING, SENT, FAILED) - */ - public EmailStatus getStatus() { return status; } - - /** - * Sets the status of the email. - * - * @param status the email status to set - */ - public void setStatus(EmailStatus status) { this.status = status; } - - /** - * Gets the timestamp when the email was sent. - * - * @return the sent timestamp, or null if not yet sent - */ - public LocalDateTime getSentAt() { return sentAt; } - - /** - * Sets the timestamp when the email was sent. - * - * @param sentAt the sent timestamp to set - */ - public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; } - - /** - * Gets the error message if the email failed to send. - * - * @return the error message, or null if no error - */ - public String getErrorMessage() { return errorMessage; } - - /** - * Sets the error message if the email failed to send. - * - * @param errorMessage the error message to set - */ - public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } - - /** - * Gets the timestamp when the email was first opened. - * - * @return the opened timestamp, or null if never opened - */ - public LocalDateTime getOpenedAt() { return openedAt; } - - /** - * Sets the timestamp when the email was first opened. - * - * @param openedAt the opened timestamp to set - */ - public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; } - - /** - * Gets the number of times the email has been opened. - * - * @return the open count - */ - public Integer getOpenCount() { return openCount; } - - /** - * Sets the number of times the email has been opened. - * - * @param openCount the open count to set - */ - public void setOpenCount(Integer openCount) { this.openCount = openCount; } - - /** - * Gets the timestamp when a link in the email was first clicked. - * - * @return the clicked timestamp, or null if never clicked - */ - public LocalDateTime getClickedAt() { return clickedAt; } - - /** - * Sets the timestamp when a link in the email was first clicked. - * - * @param clickedAt the clicked timestamp to set - */ - public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; } - - /** - * Gets the number of times links in the email have been clicked. - * - * @return the click count - */ - public Integer getClickCount() { return clickCount; } - - /** - * Sets the number of times links in the email have been clicked. - * - * @param clickCount the click count to set - */ - public void setClickCount(Integer clickCount) { this.clickCount = clickCount; } - - /** - * Gets the timestamp when this email request was created. - * - * @return the creation timestamp - */ - public LocalDateTime getCreatedAt() { return createdAt; } - - /** - * Sets the timestamp when this email request was created. - * - * @param createdAt the creation timestamp to set - */ - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } - - /** - * Gets the timestamp when this email request was last updated. - * - * @return the last update timestamp - */ - public LocalDateTime getUpdatedAt() { return updatedAt; } - - /** - * Sets the timestamp when this email request was last updated. - * - * @param updatedAt the last update timestamp to set - */ - public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +package group.goforward.battlbuilder.model; + +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +/** + * Entity representing an email request/queue entry. + * Tracks email sending status, delivery, and engagement metrics (opens, clicks). + * + * @see jakarta.persistence.Entity + */ +@Entity +@Table(name = "email_requests") +@NamedQueries({ + @NamedQuery( + name = "EmailRequest.findSent", + query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT" + ), + @NamedQuery( + name = "EmailRequest.findFailed", + query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED" + ), + @NamedQuery( + name = "EmailRequest.findPending", + query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING" + ) +}) +public class EmailRequest { + + /** The primary key identifier for the email request. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** The email address of the recipient. */ + @Column(nullable = false) + private String recipient; + + /** The email subject line. */ + @Column(nullable = false) + private String subject; + + /** The email body content. */ + @Column(columnDefinition = "TEXT") + private String body; + + /** The template key used to generate this email (if applicable). */ + @Column(name = "template_key", length = 100) + private String templateKey; + + /** The status of the email (PENDING, SENT, FAILED). */ + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private EmailStatus status; // PENDING, SENT, FAILED + + /** The timestamp when the email was sent. */ + @Column(name = "sent_at") + private LocalDateTime sentAt; + + /** The error message if the email failed to send. */ + @Column(name = "error_message") + private String errorMessage; + + /** The timestamp when the email was first opened. */ + @Column(name = "opened_at") + private LocalDateTime openedAt; + + /** The number of times the email has been opened. Defaults to 0. */ + @Column(name = "open_count", nullable = false) + private Integer openCount = 0; + + /** The timestamp when a link in the email was first clicked. */ + @Column(name = "clicked_at") + private LocalDateTime clickedAt; + + /** The number of times links in the email have been clicked. Defaults to 0. */ + @Column(name = "click_count", nullable = false) + private Integer clickCount = 0; + + /** The timestamp when this email request was created. */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** The timestamp when this email request was last updated. */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * Lifecycle hook called before persisting a new entity. + * Initializes timestamps and default values. + */ + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + createdAt = now; + updatedAt = now; + + if (status == null) status = EmailStatus.PENDING; + if (openCount == null) openCount = 0; + if (clickCount == null) clickCount = 0; + } + + /** + * Lifecycle hook called before updating an existing entity. + * Updates the updatedAt timestamp. + */ + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + // ===== Getters / Setters ===== + + /** + * Gets the primary key identifier for the email request. + * + * @return the email request ID + */ + public Long getId() { return id; } + + /** + * Sets the primary key identifier for the email request. + * + * @param id the email request ID to set + */ + public void setId(Long id) { this.id = id; } + + /** + * Gets the email address of the recipient. + * + * @return the recipient email address + */ + public String getRecipient() { return recipient; } + + /** + * Sets the email address of the recipient. + * + * @param recipient the recipient email address to set + */ + public void setRecipient(String recipient) { this.recipient = recipient; } + + /** + * Gets the email subject line. + * + * @return the subject + */ + public String getSubject() { return subject; } + + /** + * Sets the email subject line. + * + * @param subject the subject to set + */ + public void setSubject(String subject) { this.subject = subject; } + + /** + * Gets the email body content. + * + * @return the body, or null if not set + */ + public String getBody() { return body; } + + /** + * Sets the email body content. + * + * @param body the body to set + */ + public void setBody(String body) { this.body = body; } + + /** + * Gets the template key used to generate this email. + * + * @return the template key, or null if not set + */ + public String getTemplateKey() { return templateKey; } + + /** + * Sets the template key used to generate this email. + * + * @param templateKey the template key to set + */ + public void setTemplateKey(String templateKey) { this.templateKey = templateKey; } + + /** + * Gets the status of the email. + * + * @return the email status (PENDING, SENT, FAILED) + */ + public EmailStatus getStatus() { return status; } + + /** + * Sets the status of the email. + * + * @param status the email status to set + */ + public void setStatus(EmailStatus status) { this.status = status; } + + /** + * Gets the timestamp when the email was sent. + * + * @return the sent timestamp, or null if not yet sent + */ + public LocalDateTime getSentAt() { return sentAt; } + + /** + * Sets the timestamp when the email was sent. + * + * @param sentAt the sent timestamp to set + */ + public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; } + + /** + * Gets the error message if the email failed to send. + * + * @return the error message, or null if no error + */ + public String getErrorMessage() { return errorMessage; } + + /** + * Sets the error message if the email failed to send. + * + * @param errorMessage the error message to set + */ + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + + /** + * Gets the timestamp when the email was first opened. + * + * @return the opened timestamp, or null if never opened + */ + public LocalDateTime getOpenedAt() { return openedAt; } + + /** + * Sets the timestamp when the email was first opened. + * + * @param openedAt the opened timestamp to set + */ + public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; } + + /** + * Gets the number of times the email has been opened. + * + * @return the open count + */ + public Integer getOpenCount() { return openCount; } + + /** + * Sets the number of times the email has been opened. + * + * @param openCount the open count to set + */ + public void setOpenCount(Integer openCount) { this.openCount = openCount; } + + /** + * Gets the timestamp when a link in the email was first clicked. + * + * @return the clicked timestamp, or null if never clicked + */ + public LocalDateTime getClickedAt() { return clickedAt; } + + /** + * Sets the timestamp when a link in the email was first clicked. + * + * @param clickedAt the clicked timestamp to set + */ + public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; } + + /** + * Gets the number of times links in the email have been clicked. + * + * @return the click count + */ + public Integer getClickCount() { return clickCount; } + + /** + * Sets the number of times links in the email have been clicked. + * + * @param clickCount the click count to set + */ + public void setClickCount(Integer clickCount) { this.clickCount = clickCount; } + + /** + * Gets the timestamp when this email request was created. + * + * @return the creation timestamp + */ + public LocalDateTime getCreatedAt() { return createdAt; } + + /** + * Sets the timestamp when this email request was created. + * + * @param createdAt the creation timestamp to set + */ + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + /** + * Gets the timestamp when this email request was last updated. + * + * @return the last update timestamp + */ + public LocalDateTime getUpdatedAt() { return updatedAt; } + + /** + * Sets the timestamp when this email request was last updated. + * + * @param updatedAt the last update timestamp to set + */ + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/User.java b/src/main/java/group/goforward/battlbuilder/model/User.java index 3a32019..754f07a 100644 --- a/src/main/java/group/goforward/battlbuilder/model/User.java +++ b/src/main/java/group/goforward/battlbuilder/model/User.java @@ -1,537 +1,537 @@ -package group.goforward.battlbuilder.model; - -import jakarta.persistence.*; -import jakarta.validation.constraints.NotNull; -import org.hibernate.annotations.ColumnDefault; - -import java.time.OffsetDateTime; -import java.util.UUID; - -/** - * Entity representing a user in the system. - * This class stores user authentication, profile, and account management information - * including email verification, password reset tokens, login tracking, and Terms of Service acceptance. - * - * @see jakarta.persistence.Entity - */ -@Entity -@Table(name = "users") -public class User { - - /** The primary key identifier for the user. */ - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) - private Integer id; - - /** A unique identifier (UUID) for the user. */ - @NotNull - @ColumnDefault("gen_random_uuid()") - @Column(name = "uuid", nullable = false) - private UUID uuid; - - /** The user's email address. */ - @NotNull - @Column(name = "email", nullable = false, length = Integer.MAX_VALUE) - private String email; - - /** The hashed password. Can be null for magic-link or beta users. */ - @Column(name = "password_hash", length = Integer.MAX_VALUE, nullable = true) - private String passwordHash; - - /** The user's display name. */ - @Column(name = "display_name", length = Integer.MAX_VALUE) - private String displayName; - - /** The user's role (e.g., "USER", "ADMIN"). Defaults to "USER". */ - @NotNull - @ColumnDefault("'USER'") - @Column(name = "role", nullable = false, length = Integer.MAX_VALUE) - private String role; - - /** Whether the user account is active. Defaults to true. */ - @NotNull - @ColumnDefault("true") - @Column(name = "is_active", nullable = false) - private boolean isActive = true; - - /** The timestamp when the user account was created. */ - @NotNull - @ColumnDefault("now()") - @Column(name = "created_at", nullable = false) - private OffsetDateTime createdAt; - - /** The timestamp when the user account was last updated. */ - @NotNull - @ColumnDefault("now()") - @Column(name = "updated_at", nullable = false) - private OffsetDateTime updatedAt; - - /** The timestamp when the user account was soft-deleted (null if not deleted). */ - @Column(name = "deleted_at") - private OffsetDateTime deletedAt; - - /** The timestamp when the user's email was verified. */ - @Column(name = "email_verified_at") - private OffsetDateTime emailVerifiedAt; - - /** The token used for email verification. */ - @Column(name = "verification_token", length = Integer.MAX_VALUE) - private String verificationToken; - - /** The token used for password reset. */ - @Column(name = "reset_password_token", length = Integer.MAX_VALUE) - private String resetPasswordToken; - - /** The expiration timestamp for the password reset token. */ - @Column(name = "reset_password_expires_at") - private OffsetDateTime resetPasswordExpiresAt; - - /** The timestamp of the user's last login. */ - @Column(name = "last_login_at") - private OffsetDateTime lastLoginAt; - - /** The total number of times the user has logged in. Defaults to 0. */ - @ColumnDefault("0") - @Column(name = "login_count", nullable = false) - private Integer loginCount = 0; - - /** The timestamp when the user accepted the Terms of Service. */ - @Column(name = "tos_accepted_at") - private OffsetDateTime tosAcceptedAt; - - /** The version of the Terms of Service that was accepted. */ - @Column(name = "tos_version", length = 32) - private String tosVersion; - - /** The IP address from which the Terms of Service were accepted. */ - @Column(name = "tos_ip", length = 64) - private String tosIp; - - /** The user agent string from when the Terms of Service were accepted. */ - @Column(name = "tos_user_agent", columnDefinition = "TEXT") - private String tosUserAgent; - - /** The user's username. */ - @Column(name = "username", length = 32) - private String username; - - /** The timestamp when the user's password was last set. */ - @Column(name = "password_set_at") - private OffsetDateTime passwordSetAt; - - - - // --- Getters / setters --- - - /** - * Gets the primary key identifier for the user. - * - * @return the user ID - */ - public Integer getId() { - return id; - } - - /** - * Sets the primary key identifier for the user. - * - * @param id the user ID to set - */ - public void setId(Integer id) { - this.id = id; - } - - /** - * Gets the unique identifier (UUID) for the user. - * - * @return the user UUID - */ - public UUID getUuid() { - return uuid; - } - - /** - * Sets the unique identifier (UUID) for the user. - * - * @param uuid the user UUID to set - */ - public void setUuid(UUID uuid) { - this.uuid = uuid; - } - - /** - * Gets the user's email address. - * - * @return the email address - */ - public String getEmail() { - return email; - } - - /** - * Sets the user's email address. - * - * @param email the email address to set - */ - public void setEmail(String email) { - this.email = email; - } - - /** - * Gets the hashed password. - * - * @return the password hash, or null if not set (e.g., for magic-link users) - */ - public String getPasswordHash() { - return passwordHash; - } - - /** - * Sets the hashed password. - * - * @param passwordHash the password hash to set - */ - public void setPasswordHash(String passwordHash) { - this.passwordHash = passwordHash; - } - - /** - * Gets the user's display name. - * - * @return the display name, or null if not set - */ - public String getDisplayName() { - return displayName; - } - - /** - * Sets the user's display name. - * - * @param displayName the display name to set - */ - public void setDisplayName(String displayName) { - this.displayName = displayName; - } - - /** - * Gets the user's role. - * - * @return the role (e.g., "USER", "ADMIN") - */ - public String getRole() { - return role; - } - - /** - * Sets the user's role. - * - * @param role the role to set - */ - public void setRole(String role) { - this.role = role; - } - - /** - * Checks if the user account is active. - * - * @return true if the account is active, false otherwise - */ - public boolean isActive() { return isActive; } - - /** - * Sets whether the user account is active. - * - * @param active true to activate the account, false to deactivate - */ - public void setActive(boolean active) { this.isActive = active; } - - /** - * Gets the timestamp when the user account was created. - * - * @return the creation timestamp - */ - public OffsetDateTime getCreatedAt() { - return createdAt; - } - - /** - * Sets the timestamp when the user account was created. - * - * @param createdAt the creation timestamp to set - */ - public void setCreatedAt(OffsetDateTime createdAt) { - this.createdAt = createdAt; - } - - /** - * Gets the timestamp when the user account was last updated. - * - * @return the last update timestamp - */ - public OffsetDateTime getUpdatedAt() { - return updatedAt; - } - - /** - * Sets the timestamp when the user account was last updated. - * - * @param updatedAt the last update timestamp to set - */ - public void setUpdatedAt(OffsetDateTime updatedAt) { - this.updatedAt = updatedAt; - } - - /** - * Gets the timestamp when the user account was soft-deleted. - * - * @return the deletion timestamp, or null if the account is not deleted - */ - public OffsetDateTime getDeletedAt() { - return deletedAt; - } - - /** - * Sets the timestamp when the user account was soft-deleted. - * - * @param deletedAt the deletion timestamp to set (null to mark as not deleted) - */ - public void setDeletedAt(OffsetDateTime deletedAt) { - this.deletedAt = deletedAt; - } - - /** - * Gets the timestamp when the user's email was verified. - * - * @return the email verification timestamp, or null if not verified - */ - public OffsetDateTime getEmailVerifiedAt() { - return emailVerifiedAt; - } - - /** - * Sets the timestamp when the user's email was verified. - * - * @param emailVerifiedAt the email verification timestamp to set - */ - public void setEmailVerifiedAt(OffsetDateTime emailVerifiedAt) { - this.emailVerifiedAt = emailVerifiedAt; - } - - /** - * Gets the token used for email verification. - * - * @return the verification token, or null if not set - */ - public String getVerificationToken() { - return verificationToken; - } - - /** - * Sets the token used for email verification. - * - * @param verificationToken the verification token to set - */ - public void setVerificationToken(String verificationToken) { - this.verificationToken = verificationToken; - } - - /** - * Gets the token used for password reset. - * - * @return the password reset token, or null if not set - */ - public String getResetPasswordToken() { - return resetPasswordToken; - } - - /** - * Sets the token used for password reset. - * - * @param resetPasswordToken the password reset token to set - */ - public void setResetPasswordToken(String resetPasswordToken) { - this.resetPasswordToken = resetPasswordToken; - } - - /** - * Gets the expiration timestamp for the password reset token. - * - * @return the expiration timestamp, or null if not set - */ - public OffsetDateTime getResetPasswordExpiresAt() { - return resetPasswordExpiresAt; - } - - /** - * Sets the expiration timestamp for the password reset token. - * - * @param resetPasswordExpiresAt the expiration timestamp to set - */ - public void setResetPasswordExpiresAt(OffsetDateTime resetPasswordExpiresAt) { - this.resetPasswordExpiresAt = resetPasswordExpiresAt; - } - - /** - * Gets the timestamp of the user's last login. - * - * @return the last login timestamp, or null if the user has never logged in - */ - public OffsetDateTime getLastLoginAt() { - return lastLoginAt; - } - - /** - * Sets the timestamp of the user's last login. - * - * @param lastLoginAt the last login timestamp to set - */ - public void setLastLoginAt(OffsetDateTime lastLoginAt) { - this.lastLoginAt = lastLoginAt; - } - - /** - * Gets the total number of times the user has logged in. - * - * @return the login count - */ - public Integer getLoginCount() { - return loginCount; - } - - /** - * Sets the total number of times the user has logged in. - * - * @param loginCount the login count to set - */ - public void setLoginCount(Integer loginCount) { - this.loginCount = loginCount; - } - - /** - * Gets the user's username. - * - * @return the username, or null if not set - */ - public String getUsername() { return username; } - - /** - * Sets the user's username. - * - * @param username the username to set - */ - public void setUsername(String username) { this.username = username; } - - - // --- ToS acceptance --- - - /** - * Gets the timestamp when the user accepted the Terms of Service. - * - * @return the ToS acceptance timestamp, or null if not accepted - */ - public OffsetDateTime getTosAcceptedAt() { - return tosAcceptedAt; - } - - /** - * Sets the timestamp when the user accepted the Terms of Service. - * - * @param tosAcceptedAt the ToS acceptance timestamp to set - */ - public void setTosAcceptedAt(OffsetDateTime tosAcceptedAt) { - this.tosAcceptedAt = tosAcceptedAt; - } - - /** - * Gets the version of the Terms of Service that was accepted. - * - * @return the ToS version, or null if not set - */ - public String getTosVersion() { - return tosVersion; - } - - /** - * Sets the version of the Terms of Service that was accepted. - * - * @param tosVersion the ToS version to set - */ - public void setTosVersion(String tosVersion) { - this.tosVersion = tosVersion; - } - - /** - * Gets the IP address from which the Terms of Service were accepted. - * - * @return the IP address, or null if not set - */ - public String getTosIp() { - return tosIp; - } - - /** - * Sets the IP address from which the Terms of Service were accepted. - * - * @param tosIp the IP address to set - */ - public void setTosIp(String tosIp) { - this.tosIp = tosIp; - } - - /** - * Gets the user agent string from when the Terms of Service were accepted. - * - * @return the user agent string, or null if not set - */ - public String getTosUserAgent() { - return tosUserAgent; - } - - /** - * Sets the user agent string from when the Terms of Service were accepted. - * - * @param tosUserAgent the user agent string to set - */ - public void setTosUserAgent(String tosUserAgent) { - this.tosUserAgent = tosUserAgent; - } - - /** - * Gets the timestamp when the user's password was last set. - * - * @return the password set timestamp, or null if not set - */ - public OffsetDateTime getPasswordSetAt() { return passwordSetAt; } - - /** - * Sets the timestamp when the user's password was last set. - * - * @param passwordSetAt the password set timestamp to set - */ - public void setPasswordSetAt(OffsetDateTime passwordSetAt) { this.passwordSetAt = passwordSetAt; } - - - // convenience helpers - - /** - * Checks if the user's email has been verified. - * - * @return true if the email is verified (emailVerifiedAt is not null), false otherwise - */ - @Transient - public boolean isEmailVerified() { - return emailVerifiedAt != null; - } - - /** - * Increments the login count by one. - * If the login count is null, it is initialized to 0 before incrementing. - */ - public void incrementLoginCount() { - if (loginCount == null) { - loginCount = 0; - } - loginCount++; - } +package group.goforward.battlbuilder.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Entity representing a user in the system. + * This class stores user authentication, profile, and account management information + * including email verification, password reset tokens, login tracking, and Terms of Service acceptance. + * + * @see jakarta.persistence.Entity + */ +@Entity +@Table(name = "users") +public class User { + + /** The primary key identifier for the user. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Integer id; + + /** A unique identifier (UUID) for the user. */ + @NotNull + @ColumnDefault("gen_random_uuid()") + @Column(name = "uuid", nullable = false) + private UUID uuid; + + /** The user's email address. */ + @NotNull + @Column(name = "email", nullable = false, length = Integer.MAX_VALUE) + private String email; + + /** The hashed password. Can be null for magic-link or beta users. */ + @Column(name = "password_hash", length = Integer.MAX_VALUE, nullable = true) + private String passwordHash; + + /** The user's display name. */ + @Column(name = "display_name", length = Integer.MAX_VALUE) + private String displayName; + + /** The user's role (e.g., "USER", "ADMIN"). Defaults to "USER". */ + @NotNull + @ColumnDefault("'USER'") + @Column(name = "role", nullable = false, length = Integer.MAX_VALUE) + private String role; + + /** Whether the user account is active. Defaults to true. */ + @NotNull + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private boolean isActive = true; + + /** The timestamp when the user account was created. */ + @NotNull + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + /** The timestamp when the user account was last updated. */ + @NotNull + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + /** The timestamp when the user account was soft-deleted (null if not deleted). */ + @Column(name = "deleted_at") + private OffsetDateTime deletedAt; + + /** The timestamp when the user's email was verified. */ + @Column(name = "email_verified_at") + private OffsetDateTime emailVerifiedAt; + + /** The token used for email verification. */ + @Column(name = "verification_token", length = Integer.MAX_VALUE) + private String verificationToken; + + /** The token used for password reset. */ + @Column(name = "reset_password_token", length = Integer.MAX_VALUE) + private String resetPasswordToken; + + /** The expiration timestamp for the password reset token. */ + @Column(name = "reset_password_expires_at") + private OffsetDateTime resetPasswordExpiresAt; + + /** The timestamp of the user's last login. */ + @Column(name = "last_login_at") + private OffsetDateTime lastLoginAt; + + /** The total number of times the user has logged in. Defaults to 0. */ + @ColumnDefault("0") + @Column(name = "login_count", nullable = false) + private Integer loginCount = 0; + + /** The timestamp when the user accepted the Terms of Service. */ + @Column(name = "tos_accepted_at") + private OffsetDateTime tosAcceptedAt; + + /** The version of the Terms of Service that was accepted. */ + @Column(name = "tos_version", length = 32) + private String tosVersion; + + /** The IP address from which the Terms of Service were accepted. */ + @Column(name = "tos_ip", length = 64) + private String tosIp; + + /** The user agent string from when the Terms of Service were accepted. */ + @Column(name = "tos_user_agent", columnDefinition = "TEXT") + private String tosUserAgent; + + /** The user's username. */ + @Column(name = "username", length = 32) + private String username; + + /** The timestamp when the user's password was last set. */ + @Column(name = "password_set_at") + private OffsetDateTime passwordSetAt; + + + + // --- Getters / setters --- + + /** + * Gets the primary key identifier for the user. + * + * @return the user ID + */ + public Integer getId() { + return id; + } + + /** + * Sets the primary key identifier for the user. + * + * @param id the user ID to set + */ + public void setId(Integer id) { + this.id = id; + } + + /** + * Gets the unique identifier (UUID) for the user. + * + * @return the user UUID + */ + public UUID getUuid() { + return uuid; + } + + /** + * Sets the unique identifier (UUID) for the user. + * + * @param uuid the user UUID to set + */ + public void setUuid(UUID uuid) { + this.uuid = uuid; + } + + /** + * Gets the user's email address. + * + * @return the email address + */ + public String getEmail() { + return email; + } + + /** + * Sets the user's email address. + * + * @param email the email address to set + */ + public void setEmail(String email) { + this.email = email; + } + + /** + * Gets the hashed password. + * + * @return the password hash, or null if not set (e.g., for magic-link users) + */ + public String getPasswordHash() { + return passwordHash; + } + + /** + * Sets the hashed password. + * + * @param passwordHash the password hash to set + */ + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + /** + * Gets the user's display name. + * + * @return the display name, or null if not set + */ + public String getDisplayName() { + return displayName; + } + + /** + * Sets the user's display name. + * + * @param displayName the display name to set + */ + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + /** + * Gets the user's role. + * + * @return the role (e.g., "USER", "ADMIN") + */ + public String getRole() { + return role; + } + + /** + * Sets the user's role. + * + * @param role the role to set + */ + public void setRole(String role) { + this.role = role; + } + + /** + * Checks if the user account is active. + * + * @return true if the account is active, false otherwise + */ + public boolean isActive() { return isActive; } + + /** + * Sets whether the user account is active. + * + * @param active true to activate the account, false to deactivate + */ + public void setActive(boolean active) { this.isActive = active; } + + /** + * Gets the timestamp when the user account was created. + * + * @return the creation timestamp + */ + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + /** + * Sets the timestamp when the user account was created. + * + * @param createdAt the creation timestamp to set + */ + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + /** + * Gets the timestamp when the user account was last updated. + * + * @return the last update timestamp + */ + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + /** + * Sets the timestamp when the user account was last updated. + * + * @param updatedAt the last update timestamp to set + */ + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + /** + * Gets the timestamp when the user account was soft-deleted. + * + * @return the deletion timestamp, or null if the account is not deleted + */ + public OffsetDateTime getDeletedAt() { + return deletedAt; + } + + /** + * Sets the timestamp when the user account was soft-deleted. + * + * @param deletedAt the deletion timestamp to set (null to mark as not deleted) + */ + public void setDeletedAt(OffsetDateTime deletedAt) { + this.deletedAt = deletedAt; + } + + /** + * Gets the timestamp when the user's email was verified. + * + * @return the email verification timestamp, or null if not verified + */ + public OffsetDateTime getEmailVerifiedAt() { + return emailVerifiedAt; + } + + /** + * Sets the timestamp when the user's email was verified. + * + * @param emailVerifiedAt the email verification timestamp to set + */ + public void setEmailVerifiedAt(OffsetDateTime emailVerifiedAt) { + this.emailVerifiedAt = emailVerifiedAt; + } + + /** + * Gets the token used for email verification. + * + * @return the verification token, or null if not set + */ + public String getVerificationToken() { + return verificationToken; + } + + /** + * Sets the token used for email verification. + * + * @param verificationToken the verification token to set + */ + public void setVerificationToken(String verificationToken) { + this.verificationToken = verificationToken; + } + + /** + * Gets the token used for password reset. + * + * @return the password reset token, or null if not set + */ + public String getResetPasswordToken() { + return resetPasswordToken; + } + + /** + * Sets the token used for password reset. + * + * @param resetPasswordToken the password reset token to set + */ + public void setResetPasswordToken(String resetPasswordToken) { + this.resetPasswordToken = resetPasswordToken; + } + + /** + * Gets the expiration timestamp for the password reset token. + * + * @return the expiration timestamp, or null if not set + */ + public OffsetDateTime getResetPasswordExpiresAt() { + return resetPasswordExpiresAt; + } + + /** + * Sets the expiration timestamp for the password reset token. + * + * @param resetPasswordExpiresAt the expiration timestamp to set + */ + public void setResetPasswordExpiresAt(OffsetDateTime resetPasswordExpiresAt) { + this.resetPasswordExpiresAt = resetPasswordExpiresAt; + } + + /** + * Gets the timestamp of the user's last login. + * + * @return the last login timestamp, or null if the user has never logged in + */ + public OffsetDateTime getLastLoginAt() { + return lastLoginAt; + } + + /** + * Sets the timestamp of the user's last login. + * + * @param lastLoginAt the last login timestamp to set + */ + public void setLastLoginAt(OffsetDateTime lastLoginAt) { + this.lastLoginAt = lastLoginAt; + } + + /** + * Gets the total number of times the user has logged in. + * + * @return the login count + */ + public Integer getLoginCount() { + return loginCount; + } + + /** + * Sets the total number of times the user has logged in. + * + * @param loginCount the login count to set + */ + public void setLoginCount(Integer loginCount) { + this.loginCount = loginCount; + } + + /** + * Gets the user's username. + * + * @return the username, or null if not set + */ + public String getUsername() { return username; } + + /** + * Sets the user's username. + * + * @param username the username to set + */ + public void setUsername(String username) { this.username = username; } + + + // --- ToS acceptance --- + + /** + * Gets the timestamp when the user accepted the Terms of Service. + * + * @return the ToS acceptance timestamp, or null if not accepted + */ + public OffsetDateTime getTosAcceptedAt() { + return tosAcceptedAt; + } + + /** + * Sets the timestamp when the user accepted the Terms of Service. + * + * @param tosAcceptedAt the ToS acceptance timestamp to set + */ + public void setTosAcceptedAt(OffsetDateTime tosAcceptedAt) { + this.tosAcceptedAt = tosAcceptedAt; + } + + /** + * Gets the version of the Terms of Service that was accepted. + * + * @return the ToS version, or null if not set + */ + public String getTosVersion() { + return tosVersion; + } + + /** + * Sets the version of the Terms of Service that was accepted. + * + * @param tosVersion the ToS version to set + */ + public void setTosVersion(String tosVersion) { + this.tosVersion = tosVersion; + } + + /** + * Gets the IP address from which the Terms of Service were accepted. + * + * @return the IP address, or null if not set + */ + public String getTosIp() { + return tosIp; + } + + /** + * Sets the IP address from which the Terms of Service were accepted. + * + * @param tosIp the IP address to set + */ + public void setTosIp(String tosIp) { + this.tosIp = tosIp; + } + + /** + * Gets the user agent string from when the Terms of Service were accepted. + * + * @return the user agent string, or null if not set + */ + public String getTosUserAgent() { + return tosUserAgent; + } + + /** + * Sets the user agent string from when the Terms of Service were accepted. + * + * @param tosUserAgent the user agent string to set + */ + public void setTosUserAgent(String tosUserAgent) { + this.tosUserAgent = tosUserAgent; + } + + /** + * Gets the timestamp when the user's password was last set. + * + * @return the password set timestamp, or null if not set + */ + public OffsetDateTime getPasswordSetAt() { return passwordSetAt; } + + /** + * Sets the timestamp when the user's password was last set. + * + * @param passwordSetAt the password set timestamp to set + */ + public void setPasswordSetAt(OffsetDateTime passwordSetAt) { this.passwordSetAt = passwordSetAt; } + + + // convenience helpers + + /** + * Checks if the user's email has been verified. + * + * @return true if the email is verified (emailVerifiedAt is not null), false otherwise + */ + @Transient + public boolean isEmailVerified() { + return emailVerifiedAt != null; + } + + /** + * Increments the login count by one. + * If the login count is null, it is initialized to 0 before incrementing. + */ + public void incrementLoginCount() { + if (loginCount == null) { + loginCount = 0; + } + loginCount++; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/AuthTokenRepository.java b/src/main/java/group/goforward/battlbuilder/repo/AuthTokenRepository.java index 5efb9e5..6ac8ef7 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/AuthTokenRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/AuthTokenRepository.java @@ -1,28 +1,28 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.AuthToken; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.time.OffsetDateTime; -import java.util.Optional; - -public interface AuthTokenRepository extends JpaRepository { - - Optional findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash); - - // ✅ Used for "Invited" badge: active (not expired, not consumed) magic token exists - @Query(""" - select (count(t) > 0) from AuthToken t - where lower(t.email) = lower(:email) - and t.type = :type - and t.expiresAt > :now - and t.consumedAt is null - """) - boolean hasActiveToken( - @Param("email") String email, - @Param("type") AuthToken.TokenType type, - @Param("now") OffsetDateTime now - ); +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.AuthToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.Optional; + +public interface AuthTokenRepository extends JpaRepository { + + Optional findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash); + + // ✅ Used for "Invited" badge: active (not expired, not consumed) magic token exists + @Query(""" + select (count(t) > 0) from AuthToken t + where lower(t.email) = lower(:email) + and t.type = :type + and t.expiresAt > :now + and t.consumedAt is null + """) + boolean hasActiveToken( + @Param("email") String email, + @Param("type") AuthToken.TokenType type, + @Param("now") OffsetDateTime now + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/BuildProfileRepository.java b/src/main/java/group/goforward/battlbuilder/repo/BuildProfileRepository.java index c251553..837e26d 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/BuildProfileRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/BuildProfileRepository.java @@ -1,12 +1,12 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.BuildProfile; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Collection; -import java.util.List; - -public interface BuildProfileRepository extends JpaRepository { - - List findByBuildIdIn(Collection buildIds); +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.BuildProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; + +public interface BuildProfileRepository extends JpaRepository { + + List findByBuildIdIn(Collection buildIds); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/CanonicalCategoryRepository.java b/src/main/java/group/goforward/battlbuilder/repo/CanonicalCategoryRepository.java index 1d10a97..db26107 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/CanonicalCategoryRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/CanonicalCategoryRepository.java @@ -1,18 +1,18 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.CanonicalCategory; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -import java.util.List; - -public interface CanonicalCategoryRepository extends JpaRepository { - - @Query(""" - select c - from CanonicalCategory c - where c.deletedAt is null - order by c.name asc - """) - List findAllActive(); +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.CanonicalCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface CanonicalCategoryRepository extends JpaRepository { + + @Query(""" + select c + from CanonicalCategory c + where c.deletedAt is null + order by c.name asc + """) + List findAllActive(); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/CategoryMappingRepository.java b/src/main/java/group/goforward/battlbuilder/repo/CategoryMappingRepository.java index 0f6a198..c685e8f 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/CategoryMappingRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/CategoryMappingRepository.java @@ -1,22 +1,22 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.CategoryMapping; -import group.goforward.battlbuilder.model.Merchant; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -import java.util.List; - -public interface CategoryMappingRepository extends JpaRepository { - - // All mappings for a merchant, ordered nicely - List findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId); - - // Merchants that actually have mappings (for the dropdown) - @Query(""" - select distinct cm.merchant - from CategoryMapping cm - order by cm.merchant.name asc - """) - List findDistinctMerchantsWithMappings(); +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.CategoryMapping; +import group.goforward.battlbuilder.model.Merchant; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface CategoryMappingRepository extends JpaRepository { + + // All mappings for a merchant, ordered nicely + List findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId); + + // Merchants that actually have mappings (for the dropdown) + @Query(""" + select distinct cm.merchant + from CategoryMapping cm + order by cm.merchant.name asc + """) + List findDistinctMerchantsWithMappings(); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/EmailRequestRepository.java b/src/main/java/group/goforward/battlbuilder/repo/EmailRequestRepository.java index 6242a79..cceee62 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/EmailRequestRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/EmailRequestRepository.java @@ -1,16 +1,16 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.EmailRequest; -import group.goforward.battlbuilder.model.EmailStatus; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface EmailRequestRepository extends JpaRepository { - - List findByStatus(EmailStatus status); - List findByStatusOrderByCreatedAtDesc(EmailStatus status); - -} +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.EmailRequest; +import group.goforward.battlbuilder.model.EmailStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface EmailRequestRepository extends JpaRepository { + + List findByStatus(EmailStatus status); + List findByStatusOrderByCreatedAtDesc(EmailStatus status); + +} diff --git a/src/main/java/group/goforward/battlbuilder/repo/EmailTemplateRepository.java b/src/main/java/group/goforward/battlbuilder/repo/EmailTemplateRepository.java index 4e41fb5..aa6087c 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/EmailTemplateRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/EmailTemplateRepository.java @@ -1,14 +1,14 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.EmailTemplate; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface EmailTemplateRepository extends JpaRepository { - - Optional findByTemplateKeyAndEnabledTrue(String templateKey); - +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.EmailTemplate; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface EmailTemplateRepository extends JpaRepository { + + Optional findByTemplateKeyAndEnabledTrue(String templateKey); + } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/MerchantCategoryMapRepository.java b/src/main/java/group/goforward/battlbuilder/repo/MerchantCategoryMapRepository.java index 9523dcd..5e0df1a 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/MerchantCategoryMapRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/MerchantCategoryMapRepository.java @@ -1,63 +1,63 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.MerchantCategoryMap; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -public interface MerchantCategoryMapRepository extends JpaRepository { - - // Pull candidates ordered by platform specificity: exact match first, then ANY/null. - @Query(""" - select m - from MerchantCategoryMap m - where m.merchant.id = :merchantId - and lower(m.rawCategory) = lower(:rawCategory) and m.enabled = true - and m.deletedAt is null - and (m.platform is null or m.platform = 'ANY' or m.platform = :platform) - order by - case - when m.platform = :platform then 0 - when m.platform = 'ANY' then 1 - when m.platform is null then 2 - else 3 - end, - m.updatedAt desc - """) - List findCandidates( - @Param("merchantId") Integer merchantId, - @Param("rawCategory") String rawCategory, - @Param("platform") String platform - ); - - default Optional findBest(Integer merchantId, String rawCategory, String platform) { - List candidates = findCandidates(merchantId, rawCategory, platform); - return candidates.stream().findFirst(); - } - - // Optional helper if you want a quick "latest mapping regardless of platform" - Optional findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc( - Integer merchantId, - String rawCategory - ); - - // Optional: if you still want a role-only lookup list for debugging - @Query(""" - select mcm.canonicalPartRole - from MerchantCategoryMap mcm - where mcm.merchant.id = :merchantId - and mcm.rawCategory = :rawCategory - and mcm.enabled = true - and mcm.deletedAt is null - order by mcm.updatedAt desc - """) - List findCanonicalPartRoles( - @Param("merchantId") Integer merchantId, - @Param("rawCategory") String rawCategory - ); +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.MerchantCategoryMap; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface MerchantCategoryMapRepository extends JpaRepository { + + // Pull candidates ordered by platform specificity: exact match first, then ANY/null. + @Query(""" + select m + from MerchantCategoryMap m + where m.merchant.id = :merchantId + and lower(m.rawCategory) = lower(:rawCategory) and m.enabled = true + and m.deletedAt is null + and (m.platform is null or m.platform = 'ANY' or m.platform = :platform) + order by + case + when m.platform = :platform then 0 + when m.platform = 'ANY' then 1 + when m.platform is null then 2 + else 3 + end, + m.updatedAt desc + """) + List findCandidates( + @Param("merchantId") Integer merchantId, + @Param("rawCategory") String rawCategory, + @Param("platform") String platform + ); + + default Optional findBest(Integer merchantId, String rawCategory, String platform) { + List candidates = findCandidates(merchantId, rawCategory, platform); + return candidates.stream().findFirst(); + } + + // Optional helper if you want a quick "latest mapping regardless of platform" + Optional findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc( + Integer merchantId, + String rawCategory + ); + + // Optional: if you still want a role-only lookup list for debugging + @Query(""" + select mcm.canonicalPartRole + from MerchantCategoryMap mcm + where mcm.merchant.id = :merchantId + and mcm.rawCategory = :rawCategory + and mcm.enabled = true + and mcm.deletedAt is null + order by mcm.updatedAt desc + """) + List findCanonicalPartRoles( + @Param("merchantId") Integer merchantId, + @Param("rawCategory") String rawCategory + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/PartCategoryRepository.java b/src/main/java/group/goforward/battlbuilder/repo/PartCategoryRepository.java index 27043c7..790e4af 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/PartCategoryRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/PartCategoryRepository.java @@ -1,14 +1,14 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.PartCategory; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.Optional; - -public interface PartCategoryRepository extends JpaRepository { - - Optional findBySlug(String slug); - - List findAllByOrderByGroupNameAscSortOrderAscNameAsc(); +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.PartCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PartCategoryRepository extends JpaRepository { + + Optional findBySlug(String slug); + + List findAllByOrderByGroupNameAscSortOrderAscNameAsc(); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/PartRoleMappingRepository.java b/src/main/java/group/goforward/battlbuilder/repo/PartRoleMappingRepository.java index 6a17f17..7d4339d 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/PartRoleMappingRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/PartRoleMappingRepository.java @@ -1,22 +1,22 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.PartRoleMapping; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.Optional; - -public interface PartRoleMappingRepository extends JpaRepository { - - // Used by admin screens / lists (case-sensitive, no platform normalization) - List findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform); - - // Used by builder/bootstrap flows (case-insensitive) - List findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform); - - // Used by resolvers when mapping a single role (case-insensitive) - Optional findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull( - String platform, - String partRole - ); +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.PartRoleMapping; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PartRoleMappingRepository extends JpaRepository { + + // Used by admin screens / lists (case-sensitive, no platform normalization) + List findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform); + + // Used by builder/bootstrap flows (case-insensitive) + List findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform); + + // Used by resolvers when mapping a single role (case-insensitive) + Optional findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull( + String platform, + String partRole + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/PartRoleRuleRepository.java b/src/main/java/group/goforward/battlbuilder/repo/PartRoleRuleRepository.java index a42e87d..9025358 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/PartRoleRuleRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/PartRoleRuleRepository.java @@ -1,10 +1,10 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.PartRoleRule; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface PartRoleRuleRepository extends JpaRepository { - List findAllByActiveTrueOrderByPriorityDescIdAsc(); +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.PartRoleRule; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PartRoleRuleRepository extends JpaRepository { + List findAllByActiveTrueOrderByPriorityDescIdAsc(); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/PlatformRuleRepository.java b/src/main/java/group/goforward/battlbuilder/repo/PlatformRuleRepository.java index 77edb15..748f583 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/PlatformRuleRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/PlatformRuleRepository.java @@ -1,14 +1,14 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.PlatformRule; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface PlatformRuleRepository extends JpaRepository { - - // Active rules, highest priority first (tie-breaker: id asc for stability) - List findAllByActiveTrueOrderByPriorityDescIdAsc(); +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.PlatformRule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PlatformRuleRepository extends JpaRepository { + + // Active rules, highest priority first (tie-breaker: id asc for stability) + List findAllByActiveTrueOrderByPriorityDescIdAsc(); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/ProductRepository.java b/src/main/java/group/goforward/battlbuilder/repo/ProductRepository.java index fadf572..d2db888 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/ProductRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/ProductRepository.java @@ -1,632 +1,632 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.ImportStatus; -import group.goforward.battlbuilder.model.Brand; -import group.goforward.battlbuilder.model.Product; - -import group.goforward.battlbuilder.repo.projections.CatalogRow; -import group.goforward.battlbuilder.web.dto.catalog.CatalogOptionRow; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.domain.Specification; -import org.springframework.data.jpa.repository.*; -import org.springframework.data.repository.query.Param; -import org.springframework.data.jpa.repository.EntityGraph; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -public interface ProductRepository - extends JpaRepository, - JpaSpecificationExecutor { - - @EntityGraph(attributePaths = {"brand"}) - List findByIdIn(Collection ids); - - @Override - @EntityGraph(attributePaths = {"brand"}) - Page findAll(Specification spec, Pageable pageable); - - /** - * Catalog mapping UI: - * Returns raw categories for a given merchant + optional platform/q filter, - * along with current merchant_category_map state and canonical category name. - *

- * Row shape MUST match MappingAdminService mapping: - * r[0] merchantId (Integer) - * r[1] merchantName (String) - * r[2] platform (String) - * r[3] rawCategoryKey (String) - * r[4] productCount (Number) - * r[5] mcmId (Number -> Long) - * r[6] enabled (Boolean) - * r[7] canonicalPartRole (String) - * r[8] canonicalCategoryId (Number -> Long) - * r[9] canonicalCategoryName (String) - */ - @Query(value = """ - with primary_offer as ( - select * - from ( - select - po.product_id, - po.merchant_id, - row_number() over ( - partition by po.product_id - order by po.first_seen_at asc nulls last, po.id asc - ) as rn - from product_offers po - where po.merchant_id = :merchantId - ) x - where x.rn = 1 - ), - buckets as ( - select - po.merchant_id, - p.platform, - p.raw_category_key, - count(*) as product_count - from products p - join primary_offer po on po.product_id = p.id - where p.deleted_at is null - and p.raw_category_key is not null - and (:platform is null or p.platform = :platform) - and (:q is null or lower(p.raw_category_key) like concat('%', lower(:q), '%')) - group by po.merchant_id, p.platform, p.raw_category_key - ) - select - b.merchant_id as merchant_id, - m.name as merchant_name, - b.platform as platform, - b.raw_category_key as raw_category_key, - b.product_count as product_count, - - mcm.id as mcm_id, - coalesce(mcm.enabled, false) as enabled, - mcm.canonical_part_role as canonical_part_role, - - mcm.canonical_category_id as canonical_category_id, - cc.name as canonical_category_name - - from buckets b - join merchants m on m.id = b.merchant_id - left join merchant_category_map mcm - on mcm.merchant_id = b.merchant_id - and mcm.deleted_at is null - and mcm.enabled = true - and lower(mcm.raw_category) = lower(b.raw_category_key) - -- platform-aware mapping preference: - and (mcm.platform is null or mcm.platform = 'ANY' or mcm.platform = b.platform) - - left join canonical_categories cc - on cc.id = mcm.canonical_category_id - and cc.deleted_at is null - - order by b.product_count desc, b.raw_category_key asc - limit :limit - """, nativeQuery = true) - List findRawCategoryMappingRows( - @Param("merchantId") Integer merchantId, - @Param("platform") String platform, - @Param("q") String q, - @Param("limit") int limit - ); - - @Modifying - @Query(value = """ -with primary_offer as ( - select * - from ( - select - po.product_id, - po.merchant_id, - row_number() over ( - partition by po.product_id - order by po.first_seen_at asc nulls last, po.id asc - ) as rn - from product_offers po - ) x - where x.rn = 1 -) -update products p -set canonical_category_id = :canonicalCategoryId, - updated_at = now() -from primary_offer po -where po.product_id = p.id - and po.merchant_id = :merchantId - and p.deleted_at is null - and p.raw_category_key = :rawCategoryKey -""", nativeQuery = true) - int applyCanonicalCategoryByPrimaryMerchantAndRawCategory( - @Param("merchantId") Integer merchantId, - @Param("rawCategoryKey") String rawCategoryKey, - @Param("canonicalCategoryId") Integer canonicalCategoryId - ); - - @Query( - value = """ - SELECT - p.id AS id, - p.name AS name, - b.name AS brand, - p.platform AS platform, - p.part_role AS partRole, - p.raw_category_key AS categoryKey, - p.main_image_url AS imageUrl, - bo.price AS price, - bo.buy_url AS buyUrl, - bo.in_stock AS inStock - FROM products p - LEFT JOIN brands b ON b.id = p.brand_id - LEFT JOIN ( - SELECT x.product_id, x.price, x.buy_url, x.in_stock - FROM ( - SELECT - po.product_id, - COALESCE( - CASE WHEN po.original_price IS NOT NULL AND po.price < po.original_price THEN po.price ELSE po.price END, - po.original_price - ) AS price, - po.buy_url, - po.in_stock, - ROW_NUMBER() OVER ( - PARTITION BY po.product_id - ORDER BY - po.in_stock DESC, - COALESCE( - CASE WHEN po.original_price IS NOT NULL AND po.price < po.original_price THEN po.price ELSE po.price END, - po.original_price - ) ASC NULLS LAST, - po.last_seen_at DESC - ) AS rn - FROM product_offers po - ) x - WHERE x.rn = 1 - ) bo ON bo.product_id = p.id - WHERE - p.deleted_at IS NULL - AND p.status = 'ACTIVE' - AND p.visibility = 'PUBLIC' - AND p.builder_eligible = true - AND (:platform IS NULL OR :platform = '' OR p.platform = :platform) - AND (COALESCE(:partRoles) IS NULL OR p.part_role = ANY(:partRoles)) - AND (:q IS NULL OR :q = '' OR LOWER(p.name) LIKE LOWER(CONCAT('%', :q, '%'))) - AND (COALESCE(:brands) IS NULL OR b.name = ANY(:brands)) - """, - countQuery = """ - SELECT COUNT(*) - FROM products p - LEFT JOIN brands b ON b.id = p.brand_id - WHERE - p.deleted_at IS NULL - AND p.status = 'ACTIVE' - AND p.visibility = 'PUBLIC' - AND p.builder_eligible = true - AND (:platform IS NULL OR :platform = '' OR p.platform = :platform) - AND (COALESCE(:partRoles) IS NULL OR p.part_role = ANY(:partRoles)) - AND (:q IS NULL OR :q = '' OR LOWER(p.name) LIKE LOWER(CONCAT('%', :q, '%'))) - AND (COALESCE(:brands) IS NULL OR b.name = ANY(:brands)) - """, - nativeQuery = true - ) - Page searchCatalogOptions( - @Param("platform") String platform, - @Param("partRoles") String[] partRoles, - @Param("q") String q, - @Param("brands") String[] brands, - Pageable pageable - ); - - // ------------------------------------------------- - // Used by MerchantFeedImportServiceImpl - // ------------------------------------------------- - - List findAllByBrandAndMpn(Brand brand, String mpn); - - List findAllByBrandAndUpc(Brand brand, String upc); - - long countByImportStatus(ImportStatus importStatus); - - boolean existsBySlug(String slug); - - // ------------------------------------------------- - // Used by ProductController for platform views - // ------------------------------------------------- - - @Query(""" - SELECT p - FROM Product p - JOIN FETCH p.brand b - WHERE p.platform = :platform - AND p.deletedAt IS NULL - """) - List findByPlatformWithBrand(@Param("platform") String platform); - - @Query(name = "Products.findByPlatformWithBrand") - List findByPlatformWithBrandNQ(@Param("platform") String platform); - - @Query(""" - SELECT p - FROM Product p - JOIN FETCH p.brand b - WHERE p.platform = :platform - AND p.partRole IN :roles - AND p.deletedAt IS NULL - """) - List findByPlatformAndPartRoleInWithBrand( - @Param("platform") String platform, - @Param("roles") List roles - ); - - @Query(""" - SELECT p - FROM Product p - JOIN FETCH p.brand b - WHERE p.deletedAt IS NULL - """) - List findAllWithBrand(); - - @Query(""" - SELECT p - FROM Product p - JOIN FETCH p.brand b - WHERE p.partRole IN :roles - AND p.deletedAt IS NULL - """) - List findByPartRoleInWithBrand(@Param("roles") List roles); - - // ------------------------------------------------- - // 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 findByPlatformWithBrandOrdered(@Param("platform") String platform); - - // ------------------------------------------------- - // Used by GunbuilderProductService (builder UI) - // Only returns MAPPED products - // ------------------------------------------------- - - @Query(""" - SELECT DISTINCT p - FROM Product p - LEFT JOIN FETCH p.brand b - LEFT JOIN FETCH p.offers o - WHERE p.platform = :platform - AND p.partRole IN :partRoles - AND p.importStatus = :status - AND p.deletedAt IS NULL - """) - List findForGunbuilderByPlatformAndPartRoles( - @Param("platform") String platform, - @Param("partRoles") Collection partRoles, - @Param("status") ImportStatus status - ); - - @Query(value = """ - select distinct p.* - from products p - join product_offers po on po.product_id = p.id - where po.merchant_id = :merchantId - and p.raw_category_key = :rawCategoryKey - and p.deleted_at is null - """, nativeQuery = true) - List findByMerchantIdAndRawCategoryKey(@Param("merchantId") Integer merchantId, - @Param("rawCategoryKey") String rawCategoryKey); - - - @Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.deletedAt IS NULL") - Page findAllWithBrand(Pageable pageable); - - @Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.platform = :platform AND p.deletedAt IS NULL") - Page findByPlatformWithBrand(String platform, Pageable pageable); - - @Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.partRole IN :roles AND p.deletedAt IS NULL") - Page findByPartRoleInWithBrand(List roles, Pageable pageable); - - @Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.platform = :platform AND p.partRole IN :roles AND p.deletedAt IS NULL") - Page findByPlatformAndPartRoleInWithBrand(String platform, List roles, Pageable pageable); - - - // ------------------------------------------------- - // Admin import-status dashboard (summary) - // ------------------------------------------------- - @Query(""" - SELECT p.importStatus AS status, COUNT(p) AS count - FROM Product p - WHERE p.deletedAt IS NULL - GROUP BY p.importStatus - """) - List> aggregateByImportStatus(); - - // ------------------------------------------------- - // Admin import-status dashboard (by merchant) - // ------------------------------------------------- - @Query(""" - SELECT m.id AS merchantId, - m.name AS merchantName, - p.platform AS platform, - p.importStatus AS status, - COUNT(p) AS count - FROM Product p - JOIN p.offers o - JOIN o.merchant m - WHERE p.deletedAt IS NULL - GROUP BY m.id, m.name, p.platform, p.importStatus - ORDER BY m.name ASC, p.platform ASC, p.importStatus ASC - """) - List> aggregateByMerchantAndStatus(); - - // ------------------------------------------------- - // Admin: Unmapped category clusters - // ------------------------------------------------- - @Query(""" - SELECT p.rawCategoryKey AS rawCategoryKey, - m.name AS merchantName, - COUNT(DISTINCT p.id) AS productCount - FROM Product p - JOIN p.offers o - JOIN o.merchant m - WHERE p.deletedAt IS NULL - AND (p.importStatus = group.goforward.battlbuilder.model.ImportStatus.PENDING_MAPPING - OR p.partRole IS NULL - OR LOWER(p.partRole) = 'unknown') - AND p.rawCategoryKey IS NOT NULL - GROUP BY p.rawCategoryKey, m.name - ORDER BY productCount DESC - """) - List> findUnmappedCategoryGroups(); - - @Query(""" - SELECT p - FROM Product p - JOIN p.offers o - JOIN o.merchant m - WHERE p.deletedAt IS NULL - AND m.name = :merchantName - AND p.rawCategoryKey = :rawCategoryKey - ORDER BY p.id - """) - List findExamplesForCategoryGroup( - @Param("merchantName") String merchantName, - @Param("rawCategoryKey") String rawCategoryKey - ); - - // ------------------------------------------------- -// Mapping admin – pending buckets (all merchants) -// Pending = no MerchantCategoryMap row exists -// ------------------------------------------------- - @Query(""" -SELECT m.id AS merchantId, - m.name AS merchantName, - p.rawCategoryKey AS rawCategoryKey, - COUNT(DISTINCT p.id) AS productCount -FROM Product p -JOIN p.offers o -JOIN o.merchant m -LEFT JOIN MerchantCategoryMap mcm - ON mcm.merchant.id = m.id - AND mcm.rawCategory = p.rawCategoryKey - AND mcm.deletedAt IS NULL -WHERE p.importStatus = :status - AND p.rawCategoryKey IS NOT NULL -GROUP BY m.id, m.name, p.rawCategoryKey -HAVING COUNT(mcm.id) = 0 -ORDER BY productCount DESC -""") - List findPendingMappingBuckets(@Param("status") ImportStatus status); - - // ------------------------------------------------- -// Mapping admin – pending buckets for a single merchant -// Pending = no MerchantCategoryMap row exists -// ------------------------------------------------- - @Query(""" -SELECT m.id AS merchantId, - m.name AS merchantName, - p.rawCategoryKey AS rawCategoryKey, - COUNT(DISTINCT p.id) AS productCount -FROM Product p -JOIN p.offers o -JOIN o.merchant m -LEFT JOIN MerchantCategoryMap mcm - ON mcm.merchant.id = m.id - AND mcm.rawCategory = p.rawCategoryKey - AND mcm.deletedAt IS NULL -WHERE p.importStatus = :status - AND m.id = :merchantId - AND p.rawCategoryKey IS NOT NULL -GROUP BY m.id, m.name, p.rawCategoryKey -HAVING COUNT(mcm.id) = 0 -ORDER BY productCount DESC -""") - List findPendingMappingBucketsForMerchant( - @Param("merchantId") Integer merchantId, - @Param("status") ImportStatus status - ); - - @Query(""" - SELECT DISTINCT p - FROM Product p - JOIN p.offers o - WHERE o.merchant.id = :merchantId - AND p.importStatus = group.goforward.battlbuilder.model.ImportStatus.PENDING_MAPPING - AND p.deletedAt IS NULL - """) - List findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId); - - Page findByDeletedAtIsNullAndMainImageUrlIsNotNullAndBattlImageUrlIsNull(Pageable pageable); - - @Query(""" - SELECT p - FROM Product p - WHERE p.deletedAt IS NULL - AND p.mainImageUrl IS NOT NULL - AND ( - p.battlImageUrl IS NULL - OR TRIM(p.battlImageUrl) = '' - ) - """) - Page findNeedingBattlImageUrlMigration(Pageable pageable); - - - @Query(""" - select distinct p - from Product p - join ProductOffer po on po.product.id = p.id - where p.deletedAt is null - and (:platform is null or p.platform = :platform) - and (:merchantId is null or po.merchant.id = :merchantId) - order by p.id asc - """) - Page pageActiveProductsByOfferMerchant( - @Param("merchantId") Integer merchantId, - @Param("platform") String platform, - Pageable pageable - ); - - // ------------------------------------------------- - // Enrichment: find products missing caliber and NOT already queued - // ------------------------------------------------- - @Query(value = """ - select p.* - from products p - where p.deleted_at is null - and (p.caliber is null or btrim(p.caliber) = '') - and not exists ( - select 1 - from product_enrichments pe - where pe.product_id = p.id - and pe.enrichment_type = 'CALIBER' - and pe.status in ('PENDING_REVIEW', 'APPROVED', 'APPLIED') - ) - order by p.id asc - limit :limit - """, nativeQuery = true) - List findProductsMissingCaliberNotQueued(@Param("limit") int limit); - - - // ------------------------------------------------- - // Enrichment: find products missing caliber - // ------------------------------------------------- - @Query(value = """ - select p.* - from products p - where p.deleted_at is null - and (p.caliber is null or btrim(p.caliber) = '') - order by p.id asc - limit :limit - """, nativeQuery = true) - List findProductsMissingCaliber(@Param("limit") int limit); - - // ------------------------------------------------- - // Enrichment: find products missing caliber group - // ------------------------------------------------- - @Query(value = """ - select * - from products p - where p.deleted_at is null - and (p.caliber_group is null or trim(p.caliber_group) = '') - and p.caliber is not null and trim(p.caliber) <> '' - limit :limit -""", nativeQuery = true) - List findProductsMissingCaliberGroup(@Param("limit") int limit); - - - - // ------------------------------------------------- - // Deliver 1 Product and 1 Best Offer - // ------------------------------------------------- - - @Query( - value = """ - select - p.id as id, - p.name as name, - p.platform as platform, - p.part_role as partRole, - coalesce(p.battl_image_url, p.main_image_url) as imageUrl, - b.name as brand, - - o.price as price, - o.buy_url as buyUrl, - o.in_stock as inStock - from products p - join brands b on b.id = p.brand_id - left join lateral ( - select po.price, po.buy_url, po.in_stock - from product_offers po - where po.product_id = p.id - order by - case when po.in_stock then 0 else 1 end, - po.price asc nulls last, - po.last_seen_at desc - limit 1 - ) o on true - where p.deleted_at is null - and p.status = 'ACTIVE' - and p.visibility = 'PUBLIC' - and p.builder_eligible = true - and (:platform is null or p.platform = :platform) - and ( - (:partRole is null and (:partRoles is null or cardinality(:partRoles) = 0)) - or (:partRole is not null and p.part_role = :partRole) - or (:partRoles is not null and p.part_role = any(:partRoles)) - ) - and ( - (:brands is null or cardinality(:brands) = 0) - or b.name = any(:brands) - ) - and ( - :q is null - or lower(p.name) like lower(concat('%', :q, '%')) - or lower(b.name) like lower(concat('%', :q, '%')) - ) - """, - countQuery = """ - select count(*) - from products p - join brands b on b.id = p.brand_id - where p.deleted_at is null - and p.status = 'ACTIVE' - and p.visibility = 'PUBLIC' - and p.builder_eligible = true - and (:platform is null or p.platform = :platform) - and ( - (:partRole is null and (:partRoles is null or cardinality(:partRoles) = 0)) - or (:partRole is not null and p.part_role = :partRole) - or (:partRoles is not null and p.part_role = any(:partRoles)) - ) - and ( - (:brands is null or cardinality(:brands) = 0) - or b.name = any(:brands) - ) - and ( - :q is null - or lower(p.name) like lower(concat('%', :q, '%')) - or lower(b.name) like lower(concat('%', :q, '%')) - ) - """, - nativeQuery = true - ) - Page searchCatalog( - @Param("platform") String platform, - @Param("partRole") String partRole, - @Param("partRoles") String[] partRoles, - @Param("brands") String[] brands, - @Param("q") String q, - Pageable pageable - ); - - -} - - - +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.ImportStatus; +import group.goforward.battlbuilder.model.Brand; +import group.goforward.battlbuilder.model.Product; + +import group.goforward.battlbuilder.repo.projections.CatalogRow; +import group.goforward.battlbuilder.web.dto.catalog.CatalogOptionRow; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.*; +import org.springframework.data.repository.query.Param; +import org.springframework.data.jpa.repository.EntityGraph; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public interface ProductRepository + extends JpaRepository, + JpaSpecificationExecutor { + + @EntityGraph(attributePaths = {"brand"}) + List findByIdIn(Collection ids); + + @Override + @EntityGraph(attributePaths = {"brand"}) + Page findAll(Specification spec, Pageable pageable); + + /** + * Catalog mapping UI: + * Returns raw categories for a given merchant + optional platform/q filter, + * along with current merchant_category_map state and canonical category name. + *

+ * Row shape MUST match MappingAdminService mapping: + * r[0] merchantId (Integer) + * r[1] merchantName (String) + * r[2] platform (String) + * r[3] rawCategoryKey (String) + * r[4] productCount (Number) + * r[5] mcmId (Number -> Long) + * r[6] enabled (Boolean) + * r[7] canonicalPartRole (String) + * r[8] canonicalCategoryId (Number -> Long) + * r[9] canonicalCategoryName (String) + */ + @Query(value = """ + with primary_offer as ( + select * + from ( + select + po.product_id, + po.merchant_id, + row_number() over ( + partition by po.product_id + order by po.first_seen_at asc nulls last, po.id asc + ) as rn + from product_offers po + where po.merchant_id = :merchantId + ) x + where x.rn = 1 + ), + buckets as ( + select + po.merchant_id, + p.platform, + p.raw_category_key, + count(*) as product_count + from products p + join primary_offer po on po.product_id = p.id + where p.deleted_at is null + and p.raw_category_key is not null + and (:platform is null or p.platform = :platform) + and (:q is null or lower(p.raw_category_key) like concat('%', lower(:q), '%')) + group by po.merchant_id, p.platform, p.raw_category_key + ) + select + b.merchant_id as merchant_id, + m.name as merchant_name, + b.platform as platform, + b.raw_category_key as raw_category_key, + b.product_count as product_count, + + mcm.id as mcm_id, + coalesce(mcm.enabled, false) as enabled, + mcm.canonical_part_role as canonical_part_role, + + mcm.canonical_category_id as canonical_category_id, + cc.name as canonical_category_name + + from buckets b + join merchants m on m.id = b.merchant_id + left join merchant_category_map mcm + on mcm.merchant_id = b.merchant_id + and mcm.deleted_at is null + and mcm.enabled = true + and lower(mcm.raw_category) = lower(b.raw_category_key) + -- platform-aware mapping preference: + and (mcm.platform is null or mcm.platform = 'ANY' or mcm.platform = b.platform) + + left join canonical_categories cc + on cc.id = mcm.canonical_category_id + and cc.deleted_at is null + + order by b.product_count desc, b.raw_category_key asc + limit :limit + """, nativeQuery = true) + List findRawCategoryMappingRows( + @Param("merchantId") Integer merchantId, + @Param("platform") String platform, + @Param("q") String q, + @Param("limit") int limit + ); + + @Modifying + @Query(value = """ +with primary_offer as ( + select * + from ( + select + po.product_id, + po.merchant_id, + row_number() over ( + partition by po.product_id + order by po.first_seen_at asc nulls last, po.id asc + ) as rn + from product_offers po + ) x + where x.rn = 1 +) +update products p +set canonical_category_id = :canonicalCategoryId, + updated_at = now() +from primary_offer po +where po.product_id = p.id + and po.merchant_id = :merchantId + and p.deleted_at is null + and p.raw_category_key = :rawCategoryKey +""", nativeQuery = true) + int applyCanonicalCategoryByPrimaryMerchantAndRawCategory( + @Param("merchantId") Integer merchantId, + @Param("rawCategoryKey") String rawCategoryKey, + @Param("canonicalCategoryId") Integer canonicalCategoryId + ); + + @Query( + value = """ + SELECT + p.id AS id, + p.name AS name, + b.name AS brand, + p.platform AS platform, + p.part_role AS partRole, + p.raw_category_key AS categoryKey, + p.main_image_url AS imageUrl, + bo.price AS price, + bo.buy_url AS buyUrl, + bo.in_stock AS inStock + FROM products p + LEFT JOIN brands b ON b.id = p.brand_id + LEFT JOIN ( + SELECT x.product_id, x.price, x.buy_url, x.in_stock + FROM ( + SELECT + po.product_id, + COALESCE( + CASE WHEN po.original_price IS NOT NULL AND po.price < po.original_price THEN po.price ELSE po.price END, + po.original_price + ) AS price, + po.buy_url, + po.in_stock, + ROW_NUMBER() OVER ( + PARTITION BY po.product_id + ORDER BY + po.in_stock DESC, + COALESCE( + CASE WHEN po.original_price IS NOT NULL AND po.price < po.original_price THEN po.price ELSE po.price END, + po.original_price + ) ASC NULLS LAST, + po.last_seen_at DESC + ) AS rn + FROM product_offers po + ) x + WHERE x.rn = 1 + ) bo ON bo.product_id = p.id + WHERE + p.deleted_at IS NULL + AND p.status = 'ACTIVE' + AND p.visibility = 'PUBLIC' + AND p.builder_eligible = true + AND (:platform IS NULL OR :platform = '' OR p.platform = :platform) + AND (COALESCE(:partRoles) IS NULL OR p.part_role = ANY(:partRoles)) + AND (:q IS NULL OR :q = '' OR LOWER(p.name) LIKE LOWER(CONCAT('%', :q, '%'))) + AND (COALESCE(:brands) IS NULL OR b.name = ANY(:brands)) + """, + countQuery = """ + SELECT COUNT(*) + FROM products p + LEFT JOIN brands b ON b.id = p.brand_id + WHERE + p.deleted_at IS NULL + AND p.status = 'ACTIVE' + AND p.visibility = 'PUBLIC' + AND p.builder_eligible = true + AND (:platform IS NULL OR :platform = '' OR p.platform = :platform) + AND (COALESCE(:partRoles) IS NULL OR p.part_role = ANY(:partRoles)) + AND (:q IS NULL OR :q = '' OR LOWER(p.name) LIKE LOWER(CONCAT('%', :q, '%'))) + AND (COALESCE(:brands) IS NULL OR b.name = ANY(:brands)) + """, + nativeQuery = true + ) + Page searchCatalogOptions( + @Param("platform") String platform, + @Param("partRoles") String[] partRoles, + @Param("q") String q, + @Param("brands") String[] brands, + Pageable pageable + ); + + // ------------------------------------------------- + // Used by MerchantFeedImportServiceImpl + // ------------------------------------------------- + + List findAllByBrandAndMpn(Brand brand, String mpn); + + List findAllByBrandAndUpc(Brand brand, String upc); + + long countByImportStatus(ImportStatus importStatus); + + boolean existsBySlug(String slug); + + // ------------------------------------------------- + // Used by ProductController for platform views + // ------------------------------------------------- + + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.platform = :platform + AND p.deletedAt IS NULL + """) + List findByPlatformWithBrand(@Param("platform") String platform); + + @Query(name = "Products.findByPlatformWithBrand") + List findByPlatformWithBrandNQ(@Param("platform") String platform); + + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.platform = :platform + AND p.partRole IN :roles + AND p.deletedAt IS NULL + """) + List findByPlatformAndPartRoleInWithBrand( + @Param("platform") String platform, + @Param("roles") List roles + ); + + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.deletedAt IS NULL + """) + List findAllWithBrand(); + + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.partRole IN :roles + AND p.deletedAt IS NULL + """) + List findByPartRoleInWithBrand(@Param("roles") List roles); + + // ------------------------------------------------- + // 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 findByPlatformWithBrandOrdered(@Param("platform") String platform); + + // ------------------------------------------------- + // Used by GunbuilderProductService (builder UI) + // Only returns MAPPED products + // ------------------------------------------------- + + @Query(""" + SELECT DISTINCT p + FROM Product p + LEFT JOIN FETCH p.brand b + LEFT JOIN FETCH p.offers o + WHERE p.platform = :platform + AND p.partRole IN :partRoles + AND p.importStatus = :status + AND p.deletedAt IS NULL + """) + List findForGunbuilderByPlatformAndPartRoles( + @Param("platform") String platform, + @Param("partRoles") Collection partRoles, + @Param("status") ImportStatus status + ); + + @Query(value = """ + select distinct p.* + from products p + join product_offers po on po.product_id = p.id + where po.merchant_id = :merchantId + and p.raw_category_key = :rawCategoryKey + and p.deleted_at is null + """, nativeQuery = true) + List findByMerchantIdAndRawCategoryKey(@Param("merchantId") Integer merchantId, + @Param("rawCategoryKey") String rawCategoryKey); + + + @Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.deletedAt IS NULL") + Page findAllWithBrand(Pageable pageable); + + @Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.platform = :platform AND p.deletedAt IS NULL") + Page findByPlatformWithBrand(String platform, Pageable pageable); + + @Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.partRole IN :roles AND p.deletedAt IS NULL") + Page findByPartRoleInWithBrand(List roles, Pageable pageable); + + @Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.platform = :platform AND p.partRole IN :roles AND p.deletedAt IS NULL") + Page findByPlatformAndPartRoleInWithBrand(String platform, List roles, Pageable pageable); + + + // ------------------------------------------------- + // Admin import-status dashboard (summary) + // ------------------------------------------------- + @Query(""" + SELECT p.importStatus AS status, COUNT(p) AS count + FROM Product p + WHERE p.deletedAt IS NULL + GROUP BY p.importStatus + """) + List> aggregateByImportStatus(); + + // ------------------------------------------------- + // Admin import-status dashboard (by merchant) + // ------------------------------------------------- + @Query(""" + SELECT m.id AS merchantId, + m.name AS merchantName, + p.platform AS platform, + p.importStatus AS status, + COUNT(p) AS count + FROM Product p + JOIN p.offers o + JOIN o.merchant m + WHERE p.deletedAt IS NULL + GROUP BY m.id, m.name, p.platform, p.importStatus + ORDER BY m.name ASC, p.platform ASC, p.importStatus ASC + """) + List> aggregateByMerchantAndStatus(); + + // ------------------------------------------------- + // Admin: Unmapped category clusters + // ------------------------------------------------- + @Query(""" + SELECT p.rawCategoryKey AS rawCategoryKey, + m.name AS merchantName, + COUNT(DISTINCT p.id) AS productCount + FROM Product p + JOIN p.offers o + JOIN o.merchant m + WHERE p.deletedAt IS NULL + AND (p.importStatus = group.goforward.battlbuilder.model.ImportStatus.PENDING_MAPPING + OR p.partRole IS NULL + OR LOWER(p.partRole) = 'unknown') + AND p.rawCategoryKey IS NOT NULL + GROUP BY p.rawCategoryKey, m.name + ORDER BY productCount DESC + """) + List> findUnmappedCategoryGroups(); + + @Query(""" + SELECT p + FROM Product p + JOIN p.offers o + JOIN o.merchant m + WHERE p.deletedAt IS NULL + AND m.name = :merchantName + AND p.rawCategoryKey = :rawCategoryKey + ORDER BY p.id + """) + List findExamplesForCategoryGroup( + @Param("merchantName") String merchantName, + @Param("rawCategoryKey") String rawCategoryKey + ); + + // ------------------------------------------------- +// Mapping admin – pending buckets (all merchants) +// Pending = no MerchantCategoryMap row exists +// ------------------------------------------------- + @Query(""" +SELECT m.id AS merchantId, + m.name AS merchantName, + p.rawCategoryKey AS rawCategoryKey, + COUNT(DISTINCT p.id) AS productCount +FROM Product p +JOIN p.offers o +JOIN o.merchant m +LEFT JOIN MerchantCategoryMap mcm + ON mcm.merchant.id = m.id + AND mcm.rawCategory = p.rawCategoryKey + AND mcm.deletedAt IS NULL +WHERE p.importStatus = :status + AND p.rawCategoryKey IS NOT NULL +GROUP BY m.id, m.name, p.rawCategoryKey +HAVING COUNT(mcm.id) = 0 +ORDER BY productCount DESC +""") + List findPendingMappingBuckets(@Param("status") ImportStatus status); + + // ------------------------------------------------- +// Mapping admin – pending buckets for a single merchant +// Pending = no MerchantCategoryMap row exists +// ------------------------------------------------- + @Query(""" +SELECT m.id AS merchantId, + m.name AS merchantName, + p.rawCategoryKey AS rawCategoryKey, + COUNT(DISTINCT p.id) AS productCount +FROM Product p +JOIN p.offers o +JOIN o.merchant m +LEFT JOIN MerchantCategoryMap mcm + ON mcm.merchant.id = m.id + AND mcm.rawCategory = p.rawCategoryKey + AND mcm.deletedAt IS NULL +WHERE p.importStatus = :status + AND m.id = :merchantId + AND p.rawCategoryKey IS NOT NULL +GROUP BY m.id, m.name, p.rawCategoryKey +HAVING COUNT(mcm.id) = 0 +ORDER BY productCount DESC +""") + List findPendingMappingBucketsForMerchant( + @Param("merchantId") Integer merchantId, + @Param("status") ImportStatus status + ); + + @Query(""" + SELECT DISTINCT p + FROM Product p + JOIN p.offers o + WHERE o.merchant.id = :merchantId + AND p.importStatus = group.goforward.battlbuilder.model.ImportStatus.PENDING_MAPPING + AND p.deletedAt IS NULL + """) + List findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId); + + Page findByDeletedAtIsNullAndMainImageUrlIsNotNullAndBattlImageUrlIsNull(Pageable pageable); + + @Query(""" + SELECT p + FROM Product p + WHERE p.deletedAt IS NULL + AND p.mainImageUrl IS NOT NULL + AND ( + p.battlImageUrl IS NULL + OR TRIM(p.battlImageUrl) = '' + ) + """) + Page findNeedingBattlImageUrlMigration(Pageable pageable); + + + @Query(""" + select distinct p + from Product p + join ProductOffer po on po.product.id = p.id + where p.deletedAt is null + and (:platform is null or p.platform = :platform) + and (:merchantId is null or po.merchant.id = :merchantId) + order by p.id asc + """) + Page pageActiveProductsByOfferMerchant( + @Param("merchantId") Integer merchantId, + @Param("platform") String platform, + Pageable pageable + ); + + // ------------------------------------------------- + // Enrichment: find products missing caliber and NOT already queued + // ------------------------------------------------- + @Query(value = """ + select p.* + from products p + where p.deleted_at is null + and (p.caliber is null or btrim(p.caliber) = '') + and not exists ( + select 1 + from product_enrichments pe + where pe.product_id = p.id + and pe.enrichment_type = 'CALIBER' + and pe.status in ('PENDING_REVIEW', 'APPROVED', 'APPLIED') + ) + order by p.id asc + limit :limit + """, nativeQuery = true) + List findProductsMissingCaliberNotQueued(@Param("limit") int limit); + + + // ------------------------------------------------- + // Enrichment: find products missing caliber + // ------------------------------------------------- + @Query(value = """ + select p.* + from products p + where p.deleted_at is null + and (p.caliber is null or btrim(p.caliber) = '') + order by p.id asc + limit :limit + """, nativeQuery = true) + List findProductsMissingCaliber(@Param("limit") int limit); + + // ------------------------------------------------- + // Enrichment: find products missing caliber group + // ------------------------------------------------- + @Query(value = """ + select * + from products p + where p.deleted_at is null + and (p.caliber_group is null or trim(p.caliber_group) = '') + and p.caliber is not null and trim(p.caliber) <> '' + limit :limit +""", nativeQuery = true) + List findProductsMissingCaliberGroup(@Param("limit") int limit); + + + + // ------------------------------------------------- + // Deliver 1 Product and 1 Best Offer + // ------------------------------------------------- + + @Query( + value = """ + select + p.id as id, + p.name as name, + p.platform as platform, + p.part_role as partRole, + coalesce(p.battl_image_url, p.main_image_url) as imageUrl, + b.name as brand, + + o.price as price, + o.buy_url as buyUrl, + o.in_stock as inStock + from products p + join brands b on b.id = p.brand_id + left join lateral ( + select po.price, po.buy_url, po.in_stock + from product_offers po + where po.product_id = p.id + order by + case when po.in_stock then 0 else 1 end, + po.price asc nulls last, + po.last_seen_at desc + limit 1 + ) o on true + where p.deleted_at is null + and p.status = 'ACTIVE' + and p.visibility = 'PUBLIC' + and p.builder_eligible = true + and (:platform is null or p.platform = :platform) + and ( + (:partRole is null and (:partRoles is null or cardinality(:partRoles) = 0)) + or (:partRole is not null and p.part_role = :partRole) + or (:partRoles is not null and p.part_role = any(:partRoles)) + ) + and ( + (:brands is null or cardinality(:brands) = 0) + or b.name = any(:brands) + ) + and ( + :q is null + or lower(p.name) like lower(concat('%', :q, '%')) + or lower(b.name) like lower(concat('%', :q, '%')) + ) + """, + countQuery = """ + select count(*) + from products p + join brands b on b.id = p.brand_id + where p.deleted_at is null + and p.status = 'ACTIVE' + and p.visibility = 'PUBLIC' + and p.builder_eligible = true + and (:platform is null or p.platform = :platform) + and ( + (:partRole is null and (:partRoles is null or cardinality(:partRoles) = 0)) + or (:partRole is not null and p.part_role = :partRole) + or (:partRoles is not null and p.part_role = any(:partRoles)) + ) + and ( + (:brands is null or cardinality(:brands) = 0) + or b.name = any(:brands) + ) + and ( + :q is null + or lower(p.name) like lower(concat('%', :q, '%')) + or lower(b.name) like lower(concat('%', :q, '%')) + ) + """, + nativeQuery = true + ) + Page searchCatalog( + @Param("platform") String platform, + @Param("partRole") String partRole, + @Param("partRoles") String[] partRoles, + @Param("brands") String[] brands, + @Param("q") String q, + Pageable pageable + ); + + +} + + + diff --git a/src/main/java/group/goforward/battlbuilder/repo/UserRepository.java b/src/main/java/group/goforward/battlbuilder/repo/UserRepository.java index afb1402..d0802a6 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/UserRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repo/UserRepository.java @@ -1,44 +1,44 @@ -package group.goforward.battlbuilder.repo; - -import group.goforward.battlbuilder.model.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import java.util.List; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import java.util.Optional; -import java.util.UUID; - -public interface UserRepository extends JpaRepository { - - Optional findByEmailIgnoreCaseAndDeletedAtIsNull(String email); - - boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email); - - Optional findByUuid(UUID uuid); - - boolean existsByRole(String role); - - Optional findByUuidAndDeletedAtIsNull(UUID uuid); - - List findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role); - - // ✅ Pending beta requests (what you described) - Page findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc( - String role, - Pageable pageable - ); - - // ✅ Optional: find user by verification token for confirm flow (if you don’t already have it) - Optional findByVerificationTokenAndDeletedAtIsNull(String verificationToken); - - // Set Username - Optional findByUsernameIgnoreCaseAndDeletedAtIsNull(String username); - - boolean existsByUsernameIgnoreCaseAndDeletedAtIsNull(String username); - - @Query(value = "select * from users where role = :role and is_active = false and deleted_at is null order by created_at asc limit :limit", nativeQuery = true) - List findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit); +package group.goforward.battlbuilder.repo; + +import group.goforward.battlbuilder.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.util.List; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository extends JpaRepository { + + Optional findByEmailIgnoreCaseAndDeletedAtIsNull(String email); + + boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email); + + Optional findByUuid(UUID uuid); + + boolean existsByRole(String role); + + Optional findByUuidAndDeletedAtIsNull(UUID uuid); + + List findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role); + + // ✅ Pending beta requests (what you described) + Page findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc( + String role, + Pageable pageable + ); + + // ✅ Optional: find user by verification token for confirm flow (if you don’t already have it) + Optional findByVerificationTokenAndDeletedAtIsNull(String verificationToken); + + // Set Username + Optional findByUsernameIgnoreCaseAndDeletedAtIsNull(String username); + + boolean existsByUsernameIgnoreCaseAndDeletedAtIsNull(String username); + + @Query(value = "select * from users where role = :role and is_active = false and deleted_at is null order by created_at asc limit :limit", nativeQuery = true) + List findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/catalog/spec/CatalogProductSpecifications.java b/src/main/java/group/goforward/battlbuilder/repo/catalog/spec/CatalogProductSpecifications.java index eba79f3..86e879c 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/catalog/spec/CatalogProductSpecifications.java +++ b/src/main/java/group/goforward/battlbuilder/repo/catalog/spec/CatalogProductSpecifications.java @@ -1,57 +1,57 @@ -package group.goforward.battlbuilder.repo.catalog.spec; - -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.model.ProductStatus; -import group.goforward.battlbuilder.model.ProductVisibility; - -import org.springframework.data.jpa.domain.Specification; - -import jakarta.persistence.criteria.JoinType; -import java.util.List; - -public class CatalogProductSpecifications { - - private CatalogProductSpecifications() {} - - // Default public catalog rules - public static Specification isCatalogVisible() { - return (root, query, cb) -> cb.and( - cb.isNull(root.get("deletedAt")), - cb.equal(root.get("status"), ProductStatus.ACTIVE), - cb.equal(root.get("visibility"), ProductVisibility.PUBLIC), - cb.isTrue(root.get("builderEligible")) - ); - } - - public static Specification platformEquals(String platform) { - return (root, query, cb) -> cb.equal(root.get("platform"), platform); - } - - public static Specification partRoleIn(List roles) { - return (root, query, cb) -> root.get("partRole").in(roles); - } - - public static Specification brandNameIn(List brandNames) { - return (root, query, cb) -> { - root.fetch("brand", JoinType.LEFT); - query.distinct(true); - return root.join("brand", JoinType.LEFT).get("name").in(brandNames); - }; - } - - public static Specification queryLike(String q) { - final String like = "%" + q.toLowerCase().trim() + "%"; - return (root, query, cb) -> { - root.fetch("brand", JoinType.LEFT); - query.distinct(true); - - var brandJoin = root.join("brand", JoinType.LEFT); - return cb.or( - cb.like(cb.lower(root.get("name")), like), - cb.like(cb.lower(brandJoin.get("name")), like), - cb.like(cb.lower(root.get("mpn")), like), - cb.like(cb.lower(root.get("upc")), like) - ); - }; - } +package group.goforward.battlbuilder.repo.catalog.spec; + +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.model.ProductStatus; +import group.goforward.battlbuilder.model.ProductVisibility; + +import org.springframework.data.jpa.domain.Specification; + +import jakarta.persistence.criteria.JoinType; +import java.util.List; + +public class CatalogProductSpecifications { + + private CatalogProductSpecifications() {} + + // Default public catalog rules + public static Specification isCatalogVisible() { + return (root, query, cb) -> cb.and( + cb.isNull(root.get("deletedAt")), + cb.equal(root.get("status"), ProductStatus.ACTIVE), + cb.equal(root.get("visibility"), ProductVisibility.PUBLIC), + cb.isTrue(root.get("builderEligible")) + ); + } + + public static Specification platformEquals(String platform) { + return (root, query, cb) -> cb.equal(root.get("platform"), platform); + } + + public static Specification partRoleIn(List roles) { + return (root, query, cb) -> root.get("partRole").in(roles); + } + + public static Specification brandNameIn(List brandNames) { + return (root, query, cb) -> { + root.fetch("brand", JoinType.LEFT); + query.distinct(true); + return root.join("brand", JoinType.LEFT).get("name").in(brandNames); + }; + } + + public static Specification queryLike(String q) { + final String like = "%" + q.toLowerCase().trim() + "%"; + return (root, query, cb) -> { + root.fetch("brand", JoinType.LEFT); + query.distinct(true); + + var brandJoin = root.join("brand", JoinType.LEFT); + return cb.or( + cb.like(cb.lower(root.get("name")), like), + cb.like(cb.lower(brandJoin.get("name")), like), + cb.like(cb.lower(root.get("mpn")), like), + cb.like(cb.lower(root.get("upc")), like) + ); + }; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repo/package-info.java b/src/main/java/group/goforward/battlbuilder/repo/package-info.java index bb3e8b5..174b010 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/package-info.java +++ b/src/main/java/group/goforward/battlbuilder/repo/package-info.java @@ -1,11 +1,11 @@ -/** - * Repositories package for the BattlBuilder application. - *

- * Contains Spring Data JPA repository interfaces for database - * access and persistence operations. - * - * @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.repo; +/** + * Repositories package for the BattlBuilder application. + *

+ * Contains Spring Data JPA repository interfaces for database + * access and persistence operations. + * + * @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.repo; diff --git a/src/main/java/group/goforward/battlbuilder/repo/projections/CatalogRow.java b/src/main/java/group/goforward/battlbuilder/repo/projections/CatalogRow.java index 45bc5bf..8ae3512 100644 --- a/src/main/java/group/goforward/battlbuilder/repo/projections/CatalogRow.java +++ b/src/main/java/group/goforward/battlbuilder/repo/projections/CatalogRow.java @@ -1,14 +1,14 @@ -package group.goforward.battlbuilder.repo.projections; - -public interface CatalogRow { - Long getId(); - String getName(); - String getPlatform(); - String getPartRole(); - String getImageUrl(); // or mainImageUrl depending on your schema - String getBrand(); - - Double getPrice(); - String getBuyUrl(); - Boolean getInStock(); +package group.goforward.battlbuilder.repo.projections; + +public interface CatalogRow { + Long getId(); + String getName(); + String getPlatform(); + String getPartRole(); + String getImageUrl(); // or mainImageUrl depending on your schema + String getBrand(); + + Double getPrice(); + String getBuyUrl(); + Boolean getInStock(); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/security/CustomUserDetailsService.java b/src/main/java/group/goforward/battlbuilder/security/CustomUserDetailsService.java index ddfff7a..a8a6f75 100644 --- a/src/main/java/group/goforward/battlbuilder/security/CustomUserDetailsService.java +++ b/src/main/java/group/goforward/battlbuilder/security/CustomUserDetailsService.java @@ -1,25 +1,25 @@ -package group.goforward.battlbuilder.security; - -import group.goforward.battlbuilder.model.User; -import group.goforward.battlbuilder.repo.UserRepository; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; - -@Service -public class CustomUserDetailsService implements UserDetailsService { - - private final UserRepository users; - - public CustomUserDetailsService(UserRepository users) { - this.users = users; - } - - @Override - public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) - .orElseThrow(() -> new UsernameNotFoundException("User not found")); - return new CustomUserDetails(user); - } +package group.goforward.battlbuilder.security; + +import group.goforward.battlbuilder.model.User; +import group.goforward.battlbuilder.repo.UserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository users; + + public CustomUserDetailsService(UserRepository users) { + this.users = users; + } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + return new CustomUserDetails(user); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java b/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java index 103c23a..19f3fc0 100644 --- a/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java +++ b/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java @@ -1,88 +1,88 @@ -package group.goforward.battlbuilder.security; - -import group.goforward.battlbuilder.model.User; -import group.goforward.battlbuilder.repo.UserRepository; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.UUID; - -@Component -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private final JwtService jwtService; - private final UserRepository userRepository; - - public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) { - this.jwtService = jwtService; - this.userRepository = userRepository; - } - - @Override - protected void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain - ) throws ServletException, IOException { - - String authHeader = request.getHeader("Authorization"); - - if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) { - filterChain.doFilter(request, response); - return; - } - - // ✅ If already authenticated with a REAL user, skip. - // ✅ If it's anonymous, we should continue and replace it. - var existing = SecurityContextHolder.getContext().getAuthentication(); - if (existing != null - && existing.isAuthenticated() - && !(existing instanceof AnonymousAuthenticationToken)) { - filterChain.doFilter(request, response); - return; - } - - String token = authHeader.substring(7); - - if (!jwtService.isTokenValid(token)) { - filterChain.doFilter(request, response); - return; - } - - UUID userUuid = jwtService.extractUserUuid(token); - if (userUuid == null) { - filterChain.doFilter(request, response); - return; - } - - User user = userRepository.findByUuid(userUuid).orElse(null); - if (user == null || !Boolean.TRUE.equals(user.isActive())) { - filterChain.doFilter(request, response); - return; - } - - CustomUserDetails userDetails = new CustomUserDetails(user); - - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken( - user.getUuid().toString(), // principal = UUID string - null, - userDetails.getAuthorities() - ); - - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authToken); - - filterChain.doFilter(request, response); - } +package group.goforward.battlbuilder.security; + +import group.goforward.battlbuilder.model.User; +import group.goforward.battlbuilder.repo.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UserRepository userRepository; + + public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) { + this.jwtService = jwtService; + this.userRepository = userRepository; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + // ✅ If already authenticated with a REAL user, skip. + // ✅ If it's anonymous, we should continue and replace it. + var existing = SecurityContextHolder.getContext().getAuthentication(); + if (existing != null + && existing.isAuthenticated() + && !(existing instanceof AnonymousAuthenticationToken)) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(7); + + if (!jwtService.isTokenValid(token)) { + filterChain.doFilter(request, response); + return; + } + + UUID userUuid = jwtService.extractUserUuid(token); + if (userUuid == null) { + filterChain.doFilter(request, response); + return; + } + + User user = userRepository.findByUuid(userUuid).orElse(null); + if (user == null || !Boolean.TRUE.equals(user.isActive())) { + filterChain.doFilter(request, response); + return; + } + + CustomUserDetails userDetails = new CustomUserDetails(user); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + user.getUuid().toString(), // principal = UUID string + null, + userDetails.getAuthorities() + ); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/BrandService.java b/src/main/java/group/goforward/battlbuilder/service/BrandService.java index 66681b1..e0da8ba 100644 --- a/src/main/java/group/goforward/battlbuilder/service/BrandService.java +++ b/src/main/java/group/goforward/battlbuilder/service/BrandService.java @@ -1,16 +1,16 @@ -package group.goforward.battlbuilder.service; - -import group.goforward.battlbuilder.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.battlbuilder.service; + +import group.goforward.battlbuilder.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/battlbuilder/service/BuildService.java b/src/main/java/group/goforward/battlbuilder/service/BuildService.java index b1e663c..2fe464f 100644 --- a/src/main/java/group/goforward/battlbuilder/service/BuildService.java +++ b/src/main/java/group/goforward/battlbuilder/service/BuildService.java @@ -1,26 +1,26 @@ -package group.goforward.battlbuilder.service; - -import group.goforward.battlbuilder.web.dto.BuildDto; -import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; -import group.goforward.battlbuilder.web.dto.BuildSummaryDto; -import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; - -import java.util.List; -import java.util.UUID; - -public interface BuildService { - - List listPublicBuilds(int limit); - - List listMyBuilds(int limit); - - BuildDto getMyBuild(UUID uuid); - - BuildDto createMyBuild(UpdateBuildRequest req); - - BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req); - - BuildDto getPublicBuild(UUID uuid); - - void deleteMyBuild(UUID uuid); +package group.goforward.battlbuilder.service; + +import group.goforward.battlbuilder.web.dto.BuildDto; +import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; +import group.goforward.battlbuilder.web.dto.BuildSummaryDto; +import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; + +import java.util.List; +import java.util.UUID; + +public interface BuildService { + + List listPublicBuilds(int limit); + + List listMyBuilds(int limit); + + BuildDto getMyBuild(UUID uuid); + + BuildDto createMyBuild(UpdateBuildRequest req); + + BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req); + + BuildDto getPublicBuild(UUID uuid); + + void deleteMyBuild(UUID uuid); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/CatalogQueryService.java b/src/main/java/group/goforward/battlbuilder/service/CatalogQueryService.java index 6a768b0..18e8f36 100644 --- a/src/main/java/group/goforward/battlbuilder/service/CatalogQueryService.java +++ b/src/main/java/group/goforward/battlbuilder/service/CatalogQueryService.java @@ -1,23 +1,23 @@ -package group.goforward.battlbuilder.service; - -import group.goforward.battlbuilder.web.dto.ProductSummaryDto; -import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.List; - -public interface CatalogQueryService { - - Page getOptions( - String platform, - String partRole, - List partRoles, - List brands, - String q, - Pageable pageable - ); - - List getProductsByIds(CatalogProductIdsRequest request); +package group.goforward.battlbuilder.service; + +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; +import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface CatalogQueryService { + + Page getOptions( + String platform, + String partRole, + List partRoles, + List brands, + String q, + Pageable pageable + ); + + List getProductsByIds(CatalogProductIdsRequest request); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/CategoryClassificationService.java b/src/main/java/group/goforward/battlbuilder/service/CategoryClassificationService.java index 36d2f72..b9952e2 100644 --- a/src/main/java/group/goforward/battlbuilder/service/CategoryClassificationService.java +++ b/src/main/java/group/goforward/battlbuilder/service/CategoryClassificationService.java @@ -1,27 +1,27 @@ -package group.goforward.battlbuilder.service; - -import group.goforward.battlbuilder.imports.MerchantFeedRow; -import group.goforward.battlbuilder.model.Merchant; -import group.goforward.battlbuilder.model.PartRoleSource; - -public interface CategoryClassificationService { - - record Result( - String platform, - String partRole, - String rawCategoryKey, - PartRoleSource source, - String reason - ) {} - - /** - * Legacy convenience: derives rawCategoryKey + platform from row. - */ - Result classify(Merchant merchant, MerchantFeedRow row); - - /** - * Preferred for ETL: caller already computed platform + rawCategoryKey. - * This prevents platformResolver overrides from drifting vs mapping selection. - */ - Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey); +package group.goforward.battlbuilder.service; + +import group.goforward.battlbuilder.imports.MerchantFeedRow; +import group.goforward.battlbuilder.model.Merchant; +import group.goforward.battlbuilder.model.PartRoleSource; + +public interface CategoryClassificationService { + + record Result( + String platform, + String partRole, + String rawCategoryKey, + PartRoleSource source, + String reason + ) {} + + /** + * Legacy convenience: derives rawCategoryKey + platform from row. + */ + Result classify(Merchant merchant, MerchantFeedRow row); + + /** + * Preferred for ETL: caller already computed platform + rawCategoryKey. + * This prevents platformResolver overrides from drifting vs mapping selection. + */ + Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/CategoryMappingRecommendationService.java b/src/main/java/group/goforward/battlbuilder/service/CategoryMappingRecommendationService.java index 9a0871f..4c69eb5 100644 --- a/src/main/java/group/goforward/battlbuilder/service/CategoryMappingRecommendationService.java +++ b/src/main/java/group/goforward/battlbuilder/service/CategoryMappingRecommendationService.java @@ -1,72 +1,72 @@ -// src/main/java/group/goforward/ballistic/service/CategoryMappingRecommendationService.java -package group.goforward.battlbuilder.service; - -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.repo.ProductRepository; -import group.goforward.battlbuilder.web.dto.CategoryMappingRecommendationDto; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -public class CategoryMappingRecommendationService { - - private final ProductRepository productRepository; - - public CategoryMappingRecommendationService(ProductRepository productRepository) { - this.productRepository = productRepository; - } - - public List listRecommendations() { - var groups = productRepository.findUnmappedCategoryGroups(); - - return groups.stream() - .map(row -> { - String merchantName = (String) row.get("merchantName"); - String rawCategoryKey = (String) row.get("rawCategoryKey"); - long count = (long) row.get("productCount"); - - // Pull one sample product name - List examples = productRepository - .findExamplesForCategoryGroup(merchantName, rawCategoryKey); - String sampleName = examples.isEmpty() - ? null - : examples.get(0).getName(); - - String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName); - - return new CategoryMappingRecommendationDto( - merchantName, - rawCategoryKey, - count, - recommendedRole, - sampleName - ); - }) - .toList(); - } - - private String inferPartRoleFromRawKey(String rawKey, String sampleName) { - String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase(); - - if (blob.contains("handguard") || blob.contains("rail")) return "handguard"; - if (blob.contains("barrel")) return "barrel"; - if (blob.contains("upper")) return "upper-receiver"; - if (blob.contains("lower")) return "lower-receiver"; - if (blob.contains("mag") || blob.contains("magazine")) return "magazine"; - if (blob.contains("stock") || blob.contains("buttstock")) return "stock"; - if (blob.contains("grip")) return "grip"; - if (blob.contains("trigger")) return "trigger"; - if (blob.contains("sight") || blob.contains("iron sights")) return "sights"; - if (blob.contains("optic") || blob.contains("scope") || blob.contains("red dot")) return "optic"; - if (blob.contains("buffer")) return "buffer"; - if (blob.contains("gas block")) return "gas-block"; - if (blob.contains("gas tube")) return "gas-tube"; - if (blob.contains("muzzle")) return "muzzle-device"; - if (blob.contains("sling")) return "sling"; - if (blob.contains("bipod")) return "bipod"; - if (blob.contains("tool")) return "tools"; - - return "UNKNOWN"; - } +// src/main/java/group/goforward/ballistic/service/CategoryMappingRecommendationService.java +package group.goforward.battlbuilder.service; + +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.repo.ProductRepository; +import group.goforward.battlbuilder.web.dto.CategoryMappingRecommendationDto; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class CategoryMappingRecommendationService { + + private final ProductRepository productRepository; + + public CategoryMappingRecommendationService(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + public List listRecommendations() { + var groups = productRepository.findUnmappedCategoryGroups(); + + return groups.stream() + .map(row -> { + String merchantName = (String) row.get("merchantName"); + String rawCategoryKey = (String) row.get("rawCategoryKey"); + long count = (long) row.get("productCount"); + + // Pull one sample product name + List examples = productRepository + .findExamplesForCategoryGroup(merchantName, rawCategoryKey); + String sampleName = examples.isEmpty() + ? null + : examples.get(0).getName(); + + String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName); + + return new CategoryMappingRecommendationDto( + merchantName, + rawCategoryKey, + count, + recommendedRole, + sampleName + ); + }) + .toList(); + } + + private String inferPartRoleFromRawKey(String rawKey, String sampleName) { + String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase(); + + if (blob.contains("handguard") || blob.contains("rail")) return "handguard"; + if (blob.contains("barrel")) return "barrel"; + if (blob.contains("upper")) return "upper-receiver"; + if (blob.contains("lower")) return "lower-receiver"; + if (blob.contains("mag") || blob.contains("magazine")) return "magazine"; + if (blob.contains("stock") || blob.contains("buttstock")) return "stock"; + if (blob.contains("grip")) return "grip"; + if (blob.contains("trigger")) return "trigger"; + if (blob.contains("sight") || blob.contains("iron sights")) return "sights"; + if (blob.contains("optic") || blob.contains("scope") || blob.contains("red dot")) return "optic"; + if (blob.contains("buffer")) return "buffer"; + if (blob.contains("gas block")) return "gas-block"; + if (blob.contains("gas tube")) return "gas-tube"; + if (blob.contains("muzzle")) return "muzzle-device"; + if (blob.contains("sling")) return "sling"; + if (blob.contains("bipod")) return "bipod"; + if (blob.contains("tool")) return "tools"; + + return "UNKNOWN"; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/CurrentUserService.java b/src/main/java/group/goforward/battlbuilder/service/CurrentUserService.java index 1d5d342..e6fcb4f 100644 --- a/src/main/java/group/goforward/battlbuilder/service/CurrentUserService.java +++ b/src/main/java/group/goforward/battlbuilder/service/CurrentUserService.java @@ -1,52 +1,52 @@ -package group.goforward.battlbuilder.service; - -import group.goforward.battlbuilder.model.User; -import group.goforward.battlbuilder.repo.UserRepository; -import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; - -import java.util.UUID; - -@Service -public class CurrentUserService { - - private final UserRepository userRepository; - - public CurrentUserService(UserRepository userRepository) { - this.userRepository = userRepository; - } - - /** Returns the authenticated User (401 if missing/invalid). */ - public User requireUser() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - - // No auth, or anonymous auth => 401 - if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); - } - - // In your setup, JwtAuthenticationFilter sets auth name to UUID string - String principal = auth.getName(); - if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); - } - - final UUID userUuid; - try { - userUuid = UUID.fromString(principal); - } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e); - } - - return userRepository.findByUuid(userUuid) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); - } - - public Integer requireUserId() { - return requireUser().getId(); - } +package group.goforward.battlbuilder.service; + +import group.goforward.battlbuilder.model.User; +import group.goforward.battlbuilder.repo.UserRepository; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; + +@Service +public class CurrentUserService { + + private final UserRepository userRepository; + + public CurrentUserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** Returns the authenticated User (401 if missing/invalid). */ + public User requireUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + // No auth, or anonymous auth => 401 + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); + } + + // In your setup, JwtAuthenticationFilter sets auth name to UUID string + String principal = auth.getName(); + if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); + } + + final UUID userUuid; + try { + userUuid = UUID.fromString(principal); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e); + } + + return userRepository.findByUuid(userUuid) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); + } + + public Integer requireUserId() { + return requireUser().getId(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/ImportStatusAdminService.java b/src/main/java/group/goforward/battlbuilder/service/ImportStatusAdminService.java index 01090e2..b0252e4 100644 --- a/src/main/java/group/goforward/battlbuilder/service/ImportStatusAdminService.java +++ b/src/main/java/group/goforward/battlbuilder/service/ImportStatusAdminService.java @@ -1,42 +1,42 @@ -// src/main/java/group/goforward/ballistic/service/ImportStatusAdminService.java -package group.goforward.battlbuilder.service; - -import group.goforward.battlbuilder.model.ImportStatus; -import group.goforward.battlbuilder.repo.ProductRepository; -import group.goforward.battlbuilder.web.dto.ImportStatusByMerchantDto; -import group.goforward.battlbuilder.web.dto.ImportStatusSummaryDto; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -public class ImportStatusAdminService { - - private final ProductRepository productRepository; - - public ImportStatusAdminService(ProductRepository productRepository) { - this.productRepository = productRepository; - } - - public List summarizeByStatus() { - return productRepository.aggregateByImportStatus() - .stream() - .map(row -> new ImportStatusSummaryDto( - (ImportStatus) row.get("status"), - (long) row.get("count") - )) - .toList(); - } - - public List summarizeByMerchant() { - return productRepository.aggregateByMerchantAndStatus() - .stream() - .map(row -> new ImportStatusByMerchantDto( - (String) row.get("merchantName"), - (String) row.get("platform"), - (ImportStatus) row.get("status"), - (long) row.get("count") - )) - .toList(); - } +// src/main/java/group/goforward/ballistic/service/ImportStatusAdminService.java +package group.goforward.battlbuilder.service; + +import group.goforward.battlbuilder.model.ImportStatus; +import group.goforward.battlbuilder.repo.ProductRepository; +import group.goforward.battlbuilder.web.dto.ImportStatusByMerchantDto; +import group.goforward.battlbuilder.web.dto.ImportStatusSummaryDto; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class ImportStatusAdminService { + + private final ProductRepository productRepository; + + public ImportStatusAdminService(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + public List summarizeByStatus() { + return productRepository.aggregateByImportStatus() + .stream() + .map(row -> new ImportStatusSummaryDto( + (ImportStatus) row.get("status"), + (long) row.get("count") + )) + .toList(); + } + + public List summarizeByMerchant() { + return productRepository.aggregateByMerchantAndStatus() + .stream() + .map(row -> new ImportStatusByMerchantDto( + (String) row.get("merchantName"), + (String) row.get("platform"), + (ImportStatus) row.get("status"), + (long) row.get("count") + )) + .toList(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/MappingAdminService.java b/src/main/java/group/goforward/battlbuilder/service/MappingAdminService.java index dae7c57..caa4406 100644 --- a/src/main/java/group/goforward/battlbuilder/service/MappingAdminService.java +++ b/src/main/java/group/goforward/battlbuilder/service/MappingAdminService.java @@ -1,228 +1,228 @@ -package group.goforward.battlbuilder.service; - -import group.goforward.battlbuilder.model.ImportStatus; -import group.goforward.battlbuilder.model.Merchant; -import group.goforward.battlbuilder.model.MerchantCategoryMap; -import group.goforward.battlbuilder.model.CanonicalCategory; -import group.goforward.battlbuilder.repo.CanonicalCategoryRepository; -import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; -import group.goforward.battlbuilder.repo.MerchantRepository; -import group.goforward.battlbuilder.repo.ProductRepository; -import group.goforward.battlbuilder.web.dto.MappingOptionsDto; -import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto; -import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Locale; -import java.util.Optional; - -@Service -public class MappingAdminService { - - private final ProductRepository productRepository; - private final MerchantCategoryMapRepository merchantCategoryMapRepository; - private final MerchantRepository merchantRepository; - private final CanonicalCategoryRepository canonicalCategoryRepository; - private final ReclassificationService reclassificationService; - - public MappingAdminService( - ProductRepository productRepository, - MerchantCategoryMapRepository merchantCategoryMapRepository, - MerchantRepository merchantRepository, - CanonicalCategoryRepository canonicalCategoryRepository, - ReclassificationService reclassificationService - ) { - this.productRepository = productRepository; - this.merchantCategoryMapRepository = merchantCategoryMapRepository; - this.merchantRepository = merchantRepository; - this.canonicalCategoryRepository = canonicalCategoryRepository; - this.reclassificationService = reclassificationService; - } - - // ========================= - // 1) EXISTING: Role buckets - // ========================= - - @Transactional(readOnly = true) - public List listPendingBuckets() { - List rows = - productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING); - - return rows.stream() - .map(row -> { - Integer merchantId = (Integer) row[0]; - String merchantName = (String) row[1]; - String rawCategoryKey = (String) row[2]; - Long count = (Long) row[3]; - - return new PendingMappingBucketDto( - merchantId, - merchantName, - rawCategoryKey, - count != null ? count : 0L - ); - }) - .toList(); - } - - /** - * Part Role mapping: - * Writes merchant_category_map.canonical_part_role and applies to products. - */ - @Transactional - public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) { - if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank() - || 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)); - - // NOTE: this creates a new row every time. If you want "upsert", use findBest() like we do below. - MerchantCategoryMap mapping = new MerchantCategoryMap(); - mapping.setMerchant(merchant); - mapping.setRawCategory(rawCategoryKey.trim()); - mapping.setEnabled(true); - - // SOURCE OF TRUTH (builder slot mapping) - mapping.setCanonicalPartRole(mappedPartRole.trim()); - - merchantCategoryMapRepository.save(mapping); - - return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey); - } - - @Transactional - public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) { - if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); - if (rawCategoryKey == null || rawCategoryKey.isBlank()) - throw new IllegalArgumentException("rawCategoryKey is required"); - - return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey); - } - - // ========================================== - // 2) NEW: Options endpoint for Catalog UI - // ========================================== - - @Transactional(readOnly = true) - public MappingOptionsDto getOptions() { - var merchants = merchantRepository.findAll().stream() - .map(m -> new MappingOptionsDto.MerchantOptionDto(m.getId(), m.getName())) - .toList(); - - var categories = canonicalCategoryRepository.findAllActive().stream() - .map(c -> new MappingOptionsDto.CanonicalCategoryOptionDto( - c.getId(), - c.getName(), - c.getSlug() - )) - .toList(); - - return new MappingOptionsDto(merchants, categories); - } - - // ===================================================== - // 3) NEW: Raw categories list for Catalog mapping table - // ===================================================== - - @Transactional(readOnly = true) - public List listRawCategories(Integer merchantId, String platform, String q, Integer limit) { - if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); - - String plat = normalizePlatform(platform); - String query = (q == null || q.isBlank()) ? null : q.trim(); - int lim = (limit == null || limit <= 0) ? 500 : Math.min(limit, 2000); - - List rows = productRepository.findRawCategoryMappingRows(merchantId, plat, query, lim); - - return rows.stream().map(r -> new RawCategoryMappingRowDto( - (Integer) r[0], // merchantId - (String) r[1], // merchantName - (String) r[2], // platform - (String) r[3], // rawCategoryKey - ((Number) r[4]).longValue(), // productCount - (r[5] == null ? null : ((Number) r[5]).longValue()), // mcmId - (Boolean) r[6], // enabled - (String) r[7], // canonicalPartRole - - // IMPORTANT: canonicalCategoryId should be Integer, not Long. - (r[8] == null ? null : ((Number) r[8]).intValue()), // canonicalCategoryId (Integer) - (String) r[9] // canonicalCategoryName - )).toList(); - } - - // ========================================================== - // 4) NEW: Upsert catalog mapping - // ========================================================== - - public record UpsertCatalogMappingResult(Integer merchantCategoryMapId, int updatedProducts) {} - - @Transactional - public UpsertCatalogMappingResult upsertCatalogMapping( - Integer merchantId, - String platform, - String rawCategory, - Boolean enabled, - Integer canonicalCategoryId // <-- Integer (NOT Long) - ) { - if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); - if (rawCategory == null || rawCategory.isBlank()) throw new IllegalArgumentException("rawCategory is required"); - - String plat = normalizePlatform(platform); - String raw = rawCategory.trim(); - boolean en = (enabled == null) ? true : enabled; - - Merchant merchant = merchantRepository.findById(merchantId) - .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); - - CanonicalCategory cat = null; - if (canonicalCategoryId != null) { - cat = canonicalCategoryRepository.findById(canonicalCategoryId) - .orElseThrow(() -> new IllegalArgumentException( - "CanonicalCategory not found: " + canonicalCategoryId - )); - } - - // Find mapping row (platform-specific first; then ANY/null via your findBest ordering) - Optional existing = merchantCategoryMapRepository.findBest(merchantId, raw, plat); - MerchantCategoryMap mcm = existing.orElseGet(MerchantCategoryMap::new); - - // Always ensure required fields are set - mcm.setMerchant(merchant); - mcm.setRawCategory(raw); - mcm.setPlatform(plat); - mcm.setEnabled(en); - - // Catalog mapping fields (FK + legacy mirror) - mcm.setCanonicalCategory(cat); // FK (preferred) - mcm.setCanonicalCategoryText(cat == null ? null : cat.getName()); // legacy mirror - - // IMPORTANT: DO NOT clobber canonicalPartRole here - - merchantCategoryMapRepository.save(mcm); - - // Push category FK to products - int updated = reclassificationService.applyCatalogCategoryMappingToProducts( - merchantId, - raw, - canonicalCategoryId // can be null to clear - ); - - return new UpsertCatalogMappingResult(mcm.getId(), updated); - } - - // ----------------- - // Helpers - // ----------------- - - private String normalizePlatform(String p) { - if (p == null) return null; - String t = p.trim(); - if (t.isEmpty()) return null; - return t.toUpperCase(Locale.ROOT); - } +package group.goforward.battlbuilder.service; + +import group.goforward.battlbuilder.model.ImportStatus; +import group.goforward.battlbuilder.model.Merchant; +import group.goforward.battlbuilder.model.MerchantCategoryMap; +import group.goforward.battlbuilder.model.CanonicalCategory; +import group.goforward.battlbuilder.repo.CanonicalCategoryRepository; +import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; +import group.goforward.battlbuilder.repo.MerchantRepository; +import group.goforward.battlbuilder.repo.ProductRepository; +import group.goforward.battlbuilder.web.dto.MappingOptionsDto; +import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto; +import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +@Service +public class MappingAdminService { + + private final ProductRepository productRepository; + private final MerchantCategoryMapRepository merchantCategoryMapRepository; + private final MerchantRepository merchantRepository; + private final CanonicalCategoryRepository canonicalCategoryRepository; + private final ReclassificationService reclassificationService; + + public MappingAdminService( + ProductRepository productRepository, + MerchantCategoryMapRepository merchantCategoryMapRepository, + MerchantRepository merchantRepository, + CanonicalCategoryRepository canonicalCategoryRepository, + ReclassificationService reclassificationService + ) { + this.productRepository = productRepository; + this.merchantCategoryMapRepository = merchantCategoryMapRepository; + this.merchantRepository = merchantRepository; + this.canonicalCategoryRepository = canonicalCategoryRepository; + this.reclassificationService = reclassificationService; + } + + // ========================= + // 1) EXISTING: Role buckets + // ========================= + + @Transactional(readOnly = true) + public List listPendingBuckets() { + List rows = + productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING); + + return rows.stream() + .map(row -> { + Integer merchantId = (Integer) row[0]; + String merchantName = (String) row[1]; + String rawCategoryKey = (String) row[2]; + Long count = (Long) row[3]; + + return new PendingMappingBucketDto( + merchantId, + merchantName, + rawCategoryKey, + count != null ? count : 0L + ); + }) + .toList(); + } + + /** + * Part Role mapping: + * Writes merchant_category_map.canonical_part_role and applies to products. + */ + @Transactional + public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) { + if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank() + || 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)); + + // NOTE: this creates a new row every time. If you want "upsert", use findBest() like we do below. + MerchantCategoryMap mapping = new MerchantCategoryMap(); + mapping.setMerchant(merchant); + mapping.setRawCategory(rawCategoryKey.trim()); + mapping.setEnabled(true); + + // SOURCE OF TRUTH (builder slot mapping) + mapping.setCanonicalPartRole(mappedPartRole.trim()); + + merchantCategoryMapRepository.save(mapping); + + return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey); + } + + @Transactional + public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) { + if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); + if (rawCategoryKey == null || rawCategoryKey.isBlank()) + throw new IllegalArgumentException("rawCategoryKey is required"); + + return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey); + } + + // ========================================== + // 2) NEW: Options endpoint for Catalog UI + // ========================================== + + @Transactional(readOnly = true) + public MappingOptionsDto getOptions() { + var merchants = merchantRepository.findAll().stream() + .map(m -> new MappingOptionsDto.MerchantOptionDto(m.getId(), m.getName())) + .toList(); + + var categories = canonicalCategoryRepository.findAllActive().stream() + .map(c -> new MappingOptionsDto.CanonicalCategoryOptionDto( + c.getId(), + c.getName(), + c.getSlug() + )) + .toList(); + + return new MappingOptionsDto(merchants, categories); + } + + // ===================================================== + // 3) NEW: Raw categories list for Catalog mapping table + // ===================================================== + + @Transactional(readOnly = true) + public List listRawCategories(Integer merchantId, String platform, String q, Integer limit) { + if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); + + String plat = normalizePlatform(platform); + String query = (q == null || q.isBlank()) ? null : q.trim(); + int lim = (limit == null || limit <= 0) ? 500 : Math.min(limit, 2000); + + List rows = productRepository.findRawCategoryMappingRows(merchantId, plat, query, lim); + + return rows.stream().map(r -> new RawCategoryMappingRowDto( + (Integer) r[0], // merchantId + (String) r[1], // merchantName + (String) r[2], // platform + (String) r[3], // rawCategoryKey + ((Number) r[4]).longValue(), // productCount + (r[5] == null ? null : ((Number) r[5]).longValue()), // mcmId + (Boolean) r[6], // enabled + (String) r[7], // canonicalPartRole + + // IMPORTANT: canonicalCategoryId should be Integer, not Long. + (r[8] == null ? null : ((Number) r[8]).intValue()), // canonicalCategoryId (Integer) + (String) r[9] // canonicalCategoryName + )).toList(); + } + + // ========================================================== + // 4) NEW: Upsert catalog mapping + // ========================================================== + + public record UpsertCatalogMappingResult(Integer merchantCategoryMapId, int updatedProducts) {} + + @Transactional + public UpsertCatalogMappingResult upsertCatalogMapping( + Integer merchantId, + String platform, + String rawCategory, + Boolean enabled, + Integer canonicalCategoryId // <-- Integer (NOT Long) + ) { + if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); + if (rawCategory == null || rawCategory.isBlank()) throw new IllegalArgumentException("rawCategory is required"); + + String plat = normalizePlatform(platform); + String raw = rawCategory.trim(); + boolean en = (enabled == null) ? true : enabled; + + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); + + CanonicalCategory cat = null; + if (canonicalCategoryId != null) { + cat = canonicalCategoryRepository.findById(canonicalCategoryId) + .orElseThrow(() -> new IllegalArgumentException( + "CanonicalCategory not found: " + canonicalCategoryId + )); + } + + // Find mapping row (platform-specific first; then ANY/null via your findBest ordering) + Optional existing = merchantCategoryMapRepository.findBest(merchantId, raw, plat); + MerchantCategoryMap mcm = existing.orElseGet(MerchantCategoryMap::new); + + // Always ensure required fields are set + mcm.setMerchant(merchant); + mcm.setRawCategory(raw); + mcm.setPlatform(plat); + mcm.setEnabled(en); + + // Catalog mapping fields (FK + legacy mirror) + mcm.setCanonicalCategory(cat); // FK (preferred) + mcm.setCanonicalCategoryText(cat == null ? null : cat.getName()); // legacy mirror + + // IMPORTANT: DO NOT clobber canonicalPartRole here + + merchantCategoryMapRepository.save(mcm); + + // Push category FK to products + int updated = reclassificationService.applyCatalogCategoryMappingToProducts( + merchantId, + raw, + canonicalCategoryId // can be null to clear + ); + + return new UpsertCatalogMappingResult(mcm.getId(), updated); + } + + // ----------------- + // Helpers + // ----------------- + + private String normalizePlatform(String p) { + if (p == null) return null; + String t = p.trim(); + if (t.isEmpty()) return null; + return t.toUpperCase(Locale.ROOT); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/MerchantFeedImportService.java b/src/main/java/group/goforward/battlbuilder/service/MerchantFeedImportService.java index 40d72fe..dc64c46 100644 --- a/src/main/java/group/goforward/battlbuilder/service/MerchantFeedImportService.java +++ b/src/main/java/group/goforward/battlbuilder/service/MerchantFeedImportService.java @@ -1,14 +1,14 @@ -package group.goforward.battlbuilder.service; - -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.battlbuilder.service; + +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/battlbuilder/service/PartCategoryResolverService.java b/src/main/java/group/goforward/battlbuilder/service/PartCategoryResolverService.java index d63ae04..306f96d 100644 --- a/src/main/java/group/goforward/battlbuilder/service/PartCategoryResolverService.java +++ b/src/main/java/group/goforward/battlbuilder/service/PartCategoryResolverService.java @@ -1,35 +1,35 @@ -package group.goforward.battlbuilder.service; - -import group.goforward.battlbuilder.model.PartCategory; -import group.goforward.battlbuilder.model.PartRoleMapping; -import group.goforward.battlbuilder.repo.PartRoleMappingRepository; -import org.springframework.stereotype.Service; - -import java.util.Optional; - -@Service -public class PartCategoryResolverService { - - private final PartRoleMappingRepository partRoleMappingRepository; - - public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) { - this.partRoleMappingRepository = partRoleMappingRepository; - } - - /** - * Resolve a PartCategory for a given platform + partRole. - * Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower" - */ - public Optional resolveForPlatformAndPartRole(String platform, String partRole) { - if (platform == null || partRole == null) return Optional.empty(); - - String p = platform.trim(); - String r = partRole.trim(); - - if (p.isEmpty() || r.isEmpty()) return Optional.empty(); - - return partRoleMappingRepository - .findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r) - .map(PartRoleMapping::getPartCategory); - } +package group.goforward.battlbuilder.service; + +import group.goforward.battlbuilder.model.PartCategory; +import group.goforward.battlbuilder.model.PartRoleMapping; +import group.goforward.battlbuilder.repo.PartRoleMappingRepository; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class PartCategoryResolverService { + + private final PartRoleMappingRepository partRoleMappingRepository; + + public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) { + this.partRoleMappingRepository = partRoleMappingRepository; + } + + /** + * Resolve a PartCategory for a given platform + partRole. + * Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower" + */ + public Optional resolveForPlatformAndPartRole(String platform, String partRole) { + if (platform == null || partRole == null) return Optional.empty(); + + String p = platform.trim(); + String r = partRole.trim(); + + if (p.isEmpty() || r.isEmpty()) return Optional.empty(); + + return partRoleMappingRepository + .findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r) + .map(PartRoleMapping::getPartCategory); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/PartRoleMappingService.java b/src/main/java/group/goforward/battlbuilder/service/PartRoleMappingService.java index b7a0575..d8d4700 100644 --- a/src/main/java/group/goforward/battlbuilder/service/PartRoleMappingService.java +++ b/src/main/java/group/goforward/battlbuilder/service/PartRoleMappingService.java @@ -1,35 +1,35 @@ -package group.goforward.battlbuilder.service; - -import group.goforward.battlbuilder.repo.PartRoleMappingRepository; -import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto; -import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto; -import group.goforward.battlbuilder.web.mapper.PartRoleMappingMapper; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -public class PartRoleMappingService { - - private final PartRoleMappingRepository repository; - - public PartRoleMappingService(PartRoleMappingRepository repository) { - this.repository = repository; - } - - public List getMappingsForPlatform(String platform) { - return repository - .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform) - .stream() - .map(PartRoleMappingMapper::toDto) - .toList(); - } - - public List getRoleToCategoryMap(String platform) { - return repository - .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform) - .stream() - .map(PartRoleMappingMapper::toRoleMapDto) - .toList(); - } +package group.goforward.battlbuilder.service; + +import group.goforward.battlbuilder.repo.PartRoleMappingRepository; +import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto; +import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto; +import group.goforward.battlbuilder.web.mapper.PartRoleMappingMapper; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PartRoleMappingService { + + private final PartRoleMappingRepository repository; + + public PartRoleMappingService(PartRoleMappingRepository repository) { + this.repository = repository; + } + + public List getMappingsForPlatform(String platform) { + return repository + .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform) + .stream() + .map(PartRoleMappingMapper::toDto) + .toList(); + } + + public List getRoleToCategoryMap(String platform) { + return repository + .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform) + .stream() + .map(PartRoleMappingMapper::toRoleMapDto) + .toList(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/ProductQueryService.java b/src/main/java/group/goforward/battlbuilder/service/ProductQueryService.java index 7f1d859..21d8cc7 100644 --- a/src/main/java/group/goforward/battlbuilder/service/ProductQueryService.java +++ b/src/main/java/group/goforward/battlbuilder/service/ProductQueryService.java @@ -1,20 +1,20 @@ -package group.goforward.battlbuilder.service; - -import group.goforward.battlbuilder.web.dto.ProductOfferDto; -import group.goforward.battlbuilder.web.dto.ProductSummaryDto; - -import java.util.List; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -public interface ProductQueryService { - - List getProducts(String platform, List partRoles); - - List getOffersForProduct(Integer productId); - - ProductSummaryDto getProductById(Integer productId); - - Page getProductsPage(String platform, List partRoles, Pageable pageable); +package group.goforward.battlbuilder.service; + +import group.goforward.battlbuilder.web.dto.ProductOfferDto; +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductQueryService { + + List getProducts(String platform, List partRoles); + + List getOffersForProduct(Integer productId); + + ProductSummaryDto getProductById(Integer productId); + + Page getProductsPage(String platform, List partRoles, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/ReclassificationService.java b/src/main/java/group/goforward/battlbuilder/service/ReclassificationService.java index 44c18b9..e42a373 100644 --- a/src/main/java/group/goforward/battlbuilder/service/ReclassificationService.java +++ b/src/main/java/group/goforward/battlbuilder/service/ReclassificationService.java @@ -1,11 +1,11 @@ -package group.goforward.battlbuilder.service; - -public interface ReclassificationService { - int reclassifyPendingForMerchant(Integer merchantId); - - // Existing: apply canonical_part_role mapping to products - int applyMappingToProducts(Integer merchantId, String rawCategoryKey); - - // NEW: apply canonical_category_id mapping to products - int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId); +package group.goforward.battlbuilder.service; + +public interface ReclassificationService { + int reclassifyPendingForMerchant(Integer merchantId); + + // Existing: apply canonical_part_role mapping to products + int applyMappingToProducts(Integer merchantId, String rawCategoryKey); + + // NEW: apply canonical_category_id mapping to products + int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/admin/AdminProductService.java b/src/main/java/group/goforward/battlbuilder/service/admin/AdminProductService.java index e26baaf..fc63193 100644 --- a/src/main/java/group/goforward/battlbuilder/service/admin/AdminProductService.java +++ b/src/main/java/group/goforward/battlbuilder/service/admin/AdminProductService.java @@ -1,18 +1,18 @@ -package group.goforward.battlbuilder.service.admin; - -import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest; -import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto; -import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -public interface AdminProductService { - - Page search( - AdminProductSearchRequest request, - Pageable pageable - ); - - int bulkUpdate(ProductBulkUpdateRequest request); +package group.goforward.battlbuilder.service.admin; + +import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest; +import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto; +import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface AdminProductService { + + Page search( + AdminProductSearchRequest request, + Pageable pageable + ); + + int bulkUpdate(ProductBulkUpdateRequest request); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/admin/AdminUserService.java b/src/main/java/group/goforward/battlbuilder/service/admin/AdminUserService.java index 69981c3..0674b51 100644 --- a/src/main/java/group/goforward/battlbuilder/service/admin/AdminUserService.java +++ b/src/main/java/group/goforward/battlbuilder/service/admin/AdminUserService.java @@ -1,55 +1,55 @@ -package group.goforward.battlbuilder.service.admin; - -import group.goforward.battlbuilder.model.User; -import group.goforward.battlbuilder.repo.UserRepository; -import group.goforward.battlbuilder.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); - } +package group.goforward.battlbuilder.service.admin; + +import group.goforward.battlbuilder.model.User; +import group.goforward.battlbuilder.repo.UserRepository; +import group.goforward.battlbuilder.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/battlbuilder/service/admin/StatesService.java b/src/main/java/group/goforward/battlbuilder/service/admin/StatesService.java index f22a5b2..08726c0 100644 --- a/src/main/java/group/goforward/battlbuilder/service/admin/StatesService.java +++ b/src/main/java/group/goforward/battlbuilder/service/admin/StatesService.java @@ -1,16 +1,16 @@ -package group.goforward.battlbuilder.service.admin; - -import group.goforward.battlbuilder.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.battlbuilder.service.admin; + +import group.goforward.battlbuilder.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/battlbuilder/service/admin/UsersService.java b/src/main/java/group/goforward/battlbuilder/service/admin/UsersService.java index 936affa..c22993b 100644 --- a/src/main/java/group/goforward/battlbuilder/service/admin/UsersService.java +++ b/src/main/java/group/goforward/battlbuilder/service/admin/UsersService.java @@ -1,16 +1,16 @@ -package group.goforward.battlbuilder.service.admin; - -import group.goforward.battlbuilder.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.battlbuilder.service.admin; + +import group.goforward.battlbuilder.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/battlbuilder/service/admin/admin_services_package_info.java b/src/main/java/group/goforward/battlbuilder/service/admin/admin_services_package_info.java index ac2e07d..a110b9c 100644 --- a/src/main/java/group/goforward/battlbuilder/service/admin/admin_services_package_info.java +++ b/src/main/java/group/goforward/battlbuilder/service/admin/admin_services_package_info.java @@ -1,11 +1,11 @@ -/** - * Admin service package for the BattlBuilder application. - *

- * Contains service classes for administrative business logic - * and operations. - * - * @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.service.admin; +/** + * Admin service package for the BattlBuilder application. + *

+ * Contains service classes for administrative business logic + * and operations. + * + * @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.service.admin; diff --git a/src/main/java/group/goforward/battlbuilder/service/admin/impl/AdminDashboardService.java b/src/main/java/group/goforward/battlbuilder/service/admin/impl/AdminDashboardService.java index 6b97dab..9e5353d 100644 --- a/src/main/java/group/goforward/battlbuilder/service/admin/impl/AdminDashboardService.java +++ b/src/main/java/group/goforward/battlbuilder/service/admin/impl/AdminDashboardService.java @@ -1,45 +1,45 @@ -package group.goforward.battlbuilder.service.admin.impl; - -import group.goforward.battlbuilder.model.ImportStatus; -import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; -import group.goforward.battlbuilder.repo.MerchantRepository; -import group.goforward.battlbuilder.repo.ProductRepository; -import group.goforward.battlbuilder.web.dto.admin.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 MerchantCategoryMapRepository merchantCategoryMapRepository; - - public AdminDashboardService( - ProductRepository productRepository, - MerchantRepository merchantRepository, - MerchantCategoryMapRepository merchantCategoryMapRepository - ) { - this.productRepository = productRepository; - this.merchantRepository = merchantRepository; - this.merchantCategoryMapRepository = merchantCategoryMapRepository; - } - - @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 = merchantCategoryMapRepository.count(); - - return new AdminDashboardOverviewDto( - totalProducts, - mappedProducts, - unmappedProducts, - merchantCount, - categoryMappings - ); - } +package group.goforward.battlbuilder.service.admin.impl; + +import group.goforward.battlbuilder.model.ImportStatus; +import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; +import group.goforward.battlbuilder.repo.MerchantRepository; +import group.goforward.battlbuilder.repo.ProductRepository; +import group.goforward.battlbuilder.web.dto.admin.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 MerchantCategoryMapRepository merchantCategoryMapRepository; + + public AdminDashboardService( + ProductRepository productRepository, + MerchantRepository merchantRepository, + MerchantCategoryMapRepository merchantCategoryMapRepository + ) { + this.productRepository = productRepository; + this.merchantRepository = merchantRepository; + this.merchantCategoryMapRepository = merchantCategoryMapRepository; + } + + @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 = merchantCategoryMapRepository.count(); + + return new AdminDashboardOverviewDto( + totalProducts, + mappedProducts, + unmappedProducts, + merchantCount, + categoryMappings + ); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/admin/impl/AdminProductServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/admin/impl/AdminProductServiceImpl.java index a34997e..b8c421c 100644 --- a/src/main/java/group/goforward/battlbuilder/service/admin/impl/AdminProductServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/admin/impl/AdminProductServiceImpl.java @@ -1,65 +1,65 @@ -package group.goforward.battlbuilder.service.admin.impl; - -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.repo.ProductRepository; -import group.goforward.battlbuilder.service.admin.AdminProductService; -import group.goforward.battlbuilder.specs.ProductSpecifications; -import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest; -import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto; -import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.domain.Specification; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional -public class AdminProductServiceImpl implements AdminProductService { - - private final ProductRepository productRepository; - - public AdminProductServiceImpl(ProductRepository productRepository) { - this.productRepository = productRepository; - } - - @Override - public Page search( - AdminProductSearchRequest request, - Pageable pageable - ) { - Specification spec = - ProductSpecifications.adminSearch(request); - - return productRepository - .findAll(spec, pageable) - .map(ProductAdminRowDto::fromEntity); - } - - @Override - public int bulkUpdate(ProductBulkUpdateRequest request) { - var products = productRepository.findAllById(request.getProductIds()); - - products.forEach(p -> { - if (request.getVisibility() != null) { - p.setVisibility(request.getVisibility()); - } - if (request.getStatus() != null) { - p.setStatus(request.getStatus()); - } - if (request.getBuilderEligible() != null) { - p.setBuilderEligible(request.getBuilderEligible()); - } - if (request.getAdminLocked() != null) { - p.setAdminLocked(request.getAdminLocked()); - } - if (request.getAdminNote() != null) { - p.setAdminNote(request.getAdminNote()); - } - }); - - productRepository.saveAll(products); - return products.size(); - } +package group.goforward.battlbuilder.service.admin.impl; + +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.repo.ProductRepository; +import group.goforward.battlbuilder.service.admin.AdminProductService; +import group.goforward.battlbuilder.specs.ProductSpecifications; +import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest; +import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto; +import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class AdminProductServiceImpl implements AdminProductService { + + private final ProductRepository productRepository; + + public AdminProductServiceImpl(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + @Override + public Page search( + AdminProductSearchRequest request, + Pageable pageable + ) { + Specification spec = + ProductSpecifications.adminSearch(request); + + return productRepository + .findAll(spec, pageable) + .map(ProductAdminRowDto::fromEntity); + } + + @Override + public int bulkUpdate(ProductBulkUpdateRequest request) { + var products = productRepository.findAllById(request.getProductIds()); + + products.forEach(p -> { + if (request.getVisibility() != null) { + p.setVisibility(request.getVisibility()); + } + if (request.getStatus() != null) { + p.setStatus(request.getStatus()); + } + if (request.getBuilderEligible() != null) { + p.setBuilderEligible(request.getBuilderEligible()); + } + if (request.getAdminLocked() != null) { + p.setAdminLocked(request.getAdminLocked()); + } + if (request.getAdminNote() != null) { + p.setAdminNote(request.getAdminNote()); + } + }); + + productRepository.saveAll(products); + return products.size(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/admin/impl/StatesServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/admin/impl/StatesServiceImpl.java index f8f2173..9dae0e8 100644 --- a/src/main/java/group/goforward/battlbuilder/service/admin/impl/StatesServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/admin/impl/StatesServiceImpl.java @@ -1,38 +1,38 @@ -package group.goforward.battlbuilder.service.admin.impl; - - -import group.goforward.battlbuilder.model.State; -import group.goforward.battlbuilder.repo.StateRepository; -import group.goforward.battlbuilder.service.admin.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.battlbuilder.service.admin.impl; + + +import group.goforward.battlbuilder.model.State; +import group.goforward.battlbuilder.repo.StateRepository; +import group.goforward.battlbuilder.service.admin.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/battlbuilder/service/admin/impl/UsersServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/admin/impl/UsersServiceImpl.java index 36c6a0f..a648a74 100644 --- a/src/main/java/group/goforward/battlbuilder/service/admin/impl/UsersServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/admin/impl/UsersServiceImpl.java @@ -1,37 +1,37 @@ -package group.goforward.battlbuilder.service.admin.impl; - -import group.goforward.battlbuilder.model.User; -import group.goforward.battlbuilder.repo.UserRepository; -import group.goforward.battlbuilder.service.admin.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.battlbuilder.service.admin.impl; + +import group.goforward.battlbuilder.model.User; +import group.goforward.battlbuilder.repo.UserRepository; +import group.goforward.battlbuilder.service.admin.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/battlbuilder/service/admin/package-info.java b/src/main/java/group/goforward/battlbuilder/service/admin/package-info.java index 40dd0c3..3f56e2a 100644 --- a/src/main/java/group/goforward/battlbuilder/service/admin/package-info.java +++ b/src/main/java/group/goforward/battlbuilder/service/admin/package-info.java @@ -1,13 +1,13 @@ -/** - * Provides the classes necessary for the Spring Services implementations for the Battl.Builder application. - * This package includes Services implementations for Spring-Boot application - * - * - *

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

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

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

+ * + * @since 1.0 + * @author Don Strawsburg + * @version 1.1 + */ package group.goforward.battlbuilder.service.admin; \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/auth/BetaAuthService.java b/src/main/java/group/goforward/battlbuilder/service/auth/BetaAuthService.java index 7bc85cc..8a57bc9 100644 --- a/src/main/java/group/goforward/battlbuilder/service/auth/BetaAuthService.java +++ b/src/main/java/group/goforward/battlbuilder/service/auth/BetaAuthService.java @@ -1,29 +1,29 @@ -package group.goforward.battlbuilder.service.auth; - -import group.goforward.battlbuilder.web.dto.auth.AuthResponse; - -public interface BetaAuthService { - - /** - * Upsert a beta signup lead and send a confirmation email with a verify token/link. - * Should NOT throw to the caller for common cases (e.g. already exists). - */ - void signup(String email, String useCase); - - /** - * Exchanges a "confirm" token for a real JWT session. - * This confirms the email (one-time) AND logs the user in immediately. - */ - AuthResponse confirmAndExchange(String token); - - /** - * Exchanges a "magic link" token for a real JWT session. - * Used for returning users ("email me a sign-in link"). - */ - AuthResponse exchangeMagicToken(String token); - - void sendPasswordReset(String email); - void resetPassword(String token, String newPassword); - void sendMagicLoginLink(String email); - +package group.goforward.battlbuilder.service.auth; + +import group.goforward.battlbuilder.web.dto.auth.AuthResponse; + +public interface BetaAuthService { + + /** + * Upsert a beta signup lead and send a confirmation email with a verify token/link. + * Should NOT throw to the caller for common cases (e.g. already exists). + */ + void signup(String email, String useCase); + + /** + * Exchanges a "confirm" token for a real JWT session. + * This confirms the email (one-time) AND logs the user in immediately. + */ + AuthResponse confirmAndExchange(String token); + + /** + * Exchanges a "magic link" token for a real JWT session. + * Used for returning users ("email me a sign-in link"). + */ + AuthResponse exchangeMagicToken(String token); + + void sendPasswordReset(String email); + void resetPassword(String token, String newPassword); + void sendMagicLoginLink(String email); + } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/auth/impl/BetaAuthServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/auth/impl/BetaAuthServiceImpl.java index ccd8a29..d68bac2 100644 --- a/src/main/java/group/goforward/battlbuilder/service/auth/impl/BetaAuthServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/auth/impl/BetaAuthServiceImpl.java @@ -1,321 +1,321 @@ -package group.goforward.battlbuilder.service.auth.impl; - -import group.goforward.battlbuilder.model.AuthToken; -import group.goforward.battlbuilder.model.User; -import group.goforward.battlbuilder.repo.AuthTokenRepository; -import group.goforward.battlbuilder.repo.UserRepository; -import group.goforward.battlbuilder.security.JwtService; -import group.goforward.battlbuilder.service.auth.BetaAuthService; -import group.goforward.battlbuilder.service.utils.EmailService; -import group.goforward.battlbuilder.web.dto.auth.AuthResponse; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.time.OffsetDateTime; -import java.util.HexFormat; -import java.util.UUID; - -@Service -public class BetaAuthServiceImpl implements BetaAuthService { - - private final AuthTokenRepository tokens; - private final UserRepository users; - private final JwtService jwtService; - private final EmailService emailService; - private final PasswordEncoder passwordEncoder; - - @Value("${app.publicBaseUrl:http://localhost:3000}") - private String publicBaseUrl; - - @Value("${app.authTokenPepper:change-me}") - private String tokenPepper; - - /** - * When true: - * - Signup captures users (role=BETA, inactive) - * - NO tokens are generated - * - NO emails are sent - */ - @Value("${app.beta.captureOnly:true}") - private boolean betaCaptureOnly; - - private final SecureRandom secureRandom = new SecureRandom(); - - public BetaAuthServiceImpl( - AuthTokenRepository tokens, - UserRepository users, - JwtService jwtService, - EmailService emailService, - PasswordEncoder passwordEncoder - ) { - this.tokens = tokens; - this.users = users; - this.jwtService = jwtService; - this.emailService = emailService; - this.passwordEncoder = passwordEncoder; - } - - /** - * A: Beta signup (capture lead + optionally email confirm+login token). - * The Next page will call /api/auth/beta/confirm and receive AuthResponse. - */ - @Override - public void signup(String rawEmail, String useCase) { - String email = normalizeEmail(rawEmail); - - // ✅ Create or update a "beta lead" user record - User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); - - if (user == null) { - user = new User(); - user.setUuid(UUID.randomUUID()); - user.setEmail(email); - - // Treat beta signups as users, but not active / not verified yet - user.setRole("BETA"); - user.setActive(false); - user.setDisplayName(null); - - user.setCreatedAt(OffsetDateTime.now()); - } - - // Optional: stash useCase somewhere if desired - // user.setPreferences(mergeUseCase(user.getPreferences(), useCase)); - - user.setUpdatedAt(OffsetDateTime.now()); - users.save(user); - - // 🚫 Capture-only mode: do not create tokens, do not send email - if (betaCaptureOnly) return; - - // --- Invite mode (later) --- - // 24h confirm token - String verifyToken = generateToken(); - saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24)); - - String confirmUrl = publicBaseUrl + "/beta/confirm?token=" + verifyToken; - - String subject = "Your Battl Builders sign-in link"; - String body = """ - You're on the list. - - Sign in (and confirm your email) here: - %s - - If you didn’t request this, you can ignore this email. - """.formatted(confirmUrl); - - emailService.sendEmail(email, subject, body); - } - - /** - * B: Existing users only — request a magic login link (no signup/confirm). - * Caller must always return OK to avoid email enumeration. - */ - @Override - public void sendMagicLoginLink(String rawEmail) { - String email = normalizeEmail(rawEmail); - - // Only send if user exists (but do NOT reveal that) - User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); - if (user == null) return; - - boolean isBeta = "BETA".equalsIgnoreCase(user.getRole()); - - // If capture-only mode is enabled, do not generate tokens or send email - if (betaCaptureOnly) return; - - // Allow magic link requests for: - // - active USERs, OR - // - BETA users (even if inactive), since they may not be activated yet - if (!Boolean.TRUE.equals(user.isActive()) && !isBeta) return; - - // Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired) - if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return; - - // 30 minute magic token - String magicToken = generateToken(); - saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(30)); - - String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken; - - String subject = "Your Battl Builders sign-in link"; - String body = """ - Here’s your secure sign-in link (expires in 30 minutes): - %s - - If you didn’t request this, you can ignore this email. - """.formatted(magicUrl); - - emailService.sendEmail(email, subject, body); - } - - /** - * Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately. - */ - @Override - public AuthResponse confirmAndExchange(String token) { - AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token); - String email = authToken.getEmail(); - - User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); - OffsetDateTime now = OffsetDateTime.now(); - - if (user == null) { - user = new User(); - user.setUuid(UUID.randomUUID()); - user.setEmail(email); - user.setDisplayName(null); - user.setRole("USER"); - user.setActive(true); - user.setCreatedAt(now); - } else { - // Promote BETA -> USER on first successful confirm - if ("BETA".equalsIgnoreCase(user.getRole())) { - user.setRole("USER"); - } - user.setActive(true); - } - - user.setLastLoginAt(now); - user.incrementLoginCount(); - user.setUpdatedAt(now); - users.save(user); - - String jwt = jwtService.generateToken(user); - return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole()); - } - - /** - * Consumes MAGIC_LOGIN token and returns JWT (returning users). - * Also promotes BETA->USER and activates the account on first successful login. - */ - @Override - public AuthResponse exchangeMagicToken(String token) { - AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token); - - User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail()) - .orElseThrow(() -> new IllegalStateException("User not found for magic token")); - - OffsetDateTime now = OffsetDateTime.now(); - - // Promote/activate beta users on first successful magic login - if ("BETA".equalsIgnoreCase(user.getRole())) { - user.setRole("USER"); - } - if (!Boolean.TRUE.equals(user.isActive())) { - user.setActive(true); - } - - user.setLastLoginAt(now); - user.incrementLoginCount(); - user.setUpdatedAt(now); - users.save(user); - - String jwt = jwtService.generateToken(user); - return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole()); - } - - // --------------------------------------------------------------------- - // Password Reset - // --------------------------------------------------------------------- - - @Override - public void sendPasswordReset(String rawEmail) { - String email = normalizeEmail(rawEmail); - - User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); - if (user == null) return; - - // If capture-only mode is enabled, do not generate tokens or send email - if (betaCaptureOnly) return; - - String resetToken = generateToken(); - saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30)); - - String resetUrl = publicBaseUrl + "/reset-password?token=" + resetToken; - - String subject = "Reset your Battl Builders password"; - String body = """ - Reset your password using this link (expires in 30 minutes): - %s - - If you didn’t request this, you can ignore this email. - """.formatted(resetUrl); - - emailService.sendEmail(email, subject, body); - } - - @Override - public void resetPassword(String token, String newPassword) { - if (newPassword == null || newPassword.trim().length() < 8) { - throw new IllegalArgumentException("Password must be at least 8 characters"); - } - - AuthToken t = consumeToken(AuthToken.TokenType.PASSWORD_RESET, token); - String email = t.getEmail(); - - User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - - user.setPasswordHash(passwordEncoder.encode(newPassword.trim())); - user.setPasswordSetAt(OffsetDateTime.now()); - user.setUpdatedAt(OffsetDateTime.now()); - users.save(user); - } - - // --------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------- - - private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) { - AuthToken t = new AuthToken(); - t.setEmail(email); - t.setType(type); - t.setTokenHash(hashToken(token)); - t.setExpiresAt(expiresAt); - t.setCreatedAt(OffsetDateTime.now()); - tokens.save(t); - } - - private AuthToken consumeToken(AuthToken.TokenType type, String token) { - String hash = hashToken(token); - - AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash) - .orElseThrow(() -> new IllegalArgumentException("Invalid token")); - - OffsetDateTime now = OffsetDateTime.now(); - - if (t.isConsumed()) throw new IllegalArgumentException("Token already used"); - if (t.isExpired(now)) throw new IllegalArgumentException("Token expired"); - - t.setConsumedAt(now); - tokens.save(t); - return t; - } - - private String normalizeEmail(String email) { - if (email == null) throw new IllegalArgumentException("Email required"); - return email.trim().toLowerCase(); - } - - private String generateToken() { - byte[] bytes = new byte[32]; - secureRandom.nextBytes(bytes); - return HexFormat.of().formatHex(bytes); - } - - private String hashToken(String token) { - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)); - return HexFormat.of().formatHex(hashed); - } catch (Exception e) { - throw new RuntimeException("Failed to hash token", e); - } - } +package group.goforward.battlbuilder.service.auth.impl; + +import group.goforward.battlbuilder.model.AuthToken; +import group.goforward.battlbuilder.model.User; +import group.goforward.battlbuilder.repo.AuthTokenRepository; +import group.goforward.battlbuilder.repo.UserRepository; +import group.goforward.battlbuilder.security.JwtService; +import group.goforward.battlbuilder.service.auth.BetaAuthService; +import group.goforward.battlbuilder.service.utils.EmailService; +import group.goforward.battlbuilder.web.dto.auth.AuthResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.OffsetDateTime; +import java.util.HexFormat; +import java.util.UUID; + +@Service +public class BetaAuthServiceImpl implements BetaAuthService { + + private final AuthTokenRepository tokens; + private final UserRepository users; + private final JwtService jwtService; + private final EmailService emailService; + private final PasswordEncoder passwordEncoder; + + @Value("${app.publicBaseUrl:http://localhost:3000}") + private String publicBaseUrl; + + @Value("${app.authTokenPepper:change-me}") + private String tokenPepper; + + /** + * When true: + * - Signup captures users (role=BETA, inactive) + * - NO tokens are generated + * - NO emails are sent + */ + @Value("${app.beta.captureOnly:true}") + private boolean betaCaptureOnly; + + private final SecureRandom secureRandom = new SecureRandom(); + + public BetaAuthServiceImpl( + AuthTokenRepository tokens, + UserRepository users, + JwtService jwtService, + EmailService emailService, + PasswordEncoder passwordEncoder + ) { + this.tokens = tokens; + this.users = users; + this.jwtService = jwtService; + this.emailService = emailService; + this.passwordEncoder = passwordEncoder; + } + + /** + * A: Beta signup (capture lead + optionally email confirm+login token). + * The Next page will call /api/auth/beta/confirm and receive AuthResponse. + */ + @Override + public void signup(String rawEmail, String useCase) { + String email = normalizeEmail(rawEmail); + + // ✅ Create or update a "beta lead" user record + User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); + + if (user == null) { + user = new User(); + user.setUuid(UUID.randomUUID()); + user.setEmail(email); + + // Treat beta signups as users, but not active / not verified yet + user.setRole("BETA"); + user.setActive(false); + user.setDisplayName(null); + + user.setCreatedAt(OffsetDateTime.now()); + } + + // Optional: stash useCase somewhere if desired + // user.setPreferences(mergeUseCase(user.getPreferences(), useCase)); + + user.setUpdatedAt(OffsetDateTime.now()); + users.save(user); + + // 🚫 Capture-only mode: do not create tokens, do not send email + if (betaCaptureOnly) return; + + // --- Invite mode (later) --- + // 24h confirm token + String verifyToken = generateToken(); + saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24)); + + String confirmUrl = publicBaseUrl + "/beta/confirm?token=" + verifyToken; + + String subject = "Your Battl Builders sign-in link"; + String body = """ + You're on the list. + + Sign in (and confirm your email) here: + %s + + If you didn’t request this, you can ignore this email. + """.formatted(confirmUrl); + + emailService.sendEmail(email, subject, body); + } + + /** + * B: Existing users only — request a magic login link (no signup/confirm). + * Caller must always return OK to avoid email enumeration. + */ + @Override + public void sendMagicLoginLink(String rawEmail) { + String email = normalizeEmail(rawEmail); + + // Only send if user exists (but do NOT reveal that) + User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); + if (user == null) return; + + boolean isBeta = "BETA".equalsIgnoreCase(user.getRole()); + + // If capture-only mode is enabled, do not generate tokens or send email + if (betaCaptureOnly) return; + + // Allow magic link requests for: + // - active USERs, OR + // - BETA users (even if inactive), since they may not be activated yet + if (!Boolean.TRUE.equals(user.isActive()) && !isBeta) return; + + // Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired) + if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return; + + // 30 minute magic token + String magicToken = generateToken(); + saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(30)); + + String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken; + + String subject = "Your Battl Builders sign-in link"; + String body = """ + Here’s your secure sign-in link (expires in 30 minutes): + %s + + If you didn’t request this, you can ignore this email. + """.formatted(magicUrl); + + emailService.sendEmail(email, subject, body); + } + + /** + * Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately. + */ + @Override + public AuthResponse confirmAndExchange(String token) { + AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token); + String email = authToken.getEmail(); + + User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); + OffsetDateTime now = OffsetDateTime.now(); + + if (user == null) { + user = new User(); + user.setUuid(UUID.randomUUID()); + user.setEmail(email); + user.setDisplayName(null); + user.setRole("USER"); + user.setActive(true); + user.setCreatedAt(now); + } else { + // Promote BETA -> USER on first successful confirm + if ("BETA".equalsIgnoreCase(user.getRole())) { + user.setRole("USER"); + } + user.setActive(true); + } + + user.setLastLoginAt(now); + user.incrementLoginCount(); + user.setUpdatedAt(now); + users.save(user); + + String jwt = jwtService.generateToken(user); + return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole()); + } + + /** + * Consumes MAGIC_LOGIN token and returns JWT (returning users). + * Also promotes BETA->USER and activates the account on first successful login. + */ + @Override + public AuthResponse exchangeMagicToken(String token) { + AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token); + + User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail()) + .orElseThrow(() -> new IllegalStateException("User not found for magic token")); + + OffsetDateTime now = OffsetDateTime.now(); + + // Promote/activate beta users on first successful magic login + if ("BETA".equalsIgnoreCase(user.getRole())) { + user.setRole("USER"); + } + if (!Boolean.TRUE.equals(user.isActive())) { + user.setActive(true); + } + + user.setLastLoginAt(now); + user.incrementLoginCount(); + user.setUpdatedAt(now); + users.save(user); + + String jwt = jwtService.generateToken(user); + return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole()); + } + + // --------------------------------------------------------------------- + // Password Reset + // --------------------------------------------------------------------- + + @Override + public void sendPasswordReset(String rawEmail) { + String email = normalizeEmail(rawEmail); + + User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); + if (user == null) return; + + // If capture-only mode is enabled, do not generate tokens or send email + if (betaCaptureOnly) return; + + String resetToken = generateToken(); + saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30)); + + String resetUrl = publicBaseUrl + "/reset-password?token=" + resetToken; + + String subject = "Reset your Battl Builders password"; + String body = """ + Reset your password using this link (expires in 30 minutes): + %s + + If you didn’t request this, you can ignore this email. + """.formatted(resetUrl); + + emailService.sendEmail(email, subject, body); + } + + @Override + public void resetPassword(String token, String newPassword) { + if (newPassword == null || newPassword.trim().length() < 8) { + throw new IllegalArgumentException("Password must be at least 8 characters"); + } + + AuthToken t = consumeToken(AuthToken.TokenType.PASSWORD_RESET, token); + String email = t.getEmail(); + + User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + user.setPasswordHash(passwordEncoder.encode(newPassword.trim())); + user.setPasswordSetAt(OffsetDateTime.now()); + user.setUpdatedAt(OffsetDateTime.now()); + users.save(user); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) { + AuthToken t = new AuthToken(); + t.setEmail(email); + t.setType(type); + t.setTokenHash(hashToken(token)); + t.setExpiresAt(expiresAt); + t.setCreatedAt(OffsetDateTime.now()); + tokens.save(t); + } + + private AuthToken consumeToken(AuthToken.TokenType type, String token) { + String hash = hashToken(token); + + AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash) + .orElseThrow(() -> new IllegalArgumentException("Invalid token")); + + OffsetDateTime now = OffsetDateTime.now(); + + if (t.isConsumed()) throw new IllegalArgumentException("Token already used"); + if (t.isExpired(now)) throw new IllegalArgumentException("Token expired"); + + t.setConsumedAt(now); + tokens.save(t); + return t; + } + + private String normalizeEmail(String email) { + if (email == null) throw new IllegalArgumentException("Email required"); + return email.trim().toLowerCase(); + } + + private String generateToken() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + private String hashToken(String token) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hashed); + } catch (Exception e) { + throw new RuntimeException("Failed to hash token", e); + } + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/auth/impl/BetaInviteService.java b/src/main/java/group/goforward/battlbuilder/service/auth/impl/BetaInviteService.java index c91cbf0..1fa1148 100644 --- a/src/main/java/group/goforward/battlbuilder/service/auth/impl/BetaInviteService.java +++ b/src/main/java/group/goforward/battlbuilder/service/auth/impl/BetaInviteService.java @@ -1,185 +1,185 @@ -package group.goforward.battlbuilder.service.auth.impl; - -import group.goforward.battlbuilder.model.AuthToken; -import group.goforward.battlbuilder.model.User; -import group.goforward.battlbuilder.repo.AuthTokenRepository; -import group.goforward.battlbuilder.repo.UserRepository; -import group.goforward.battlbuilder.service.utils.TemplatedEmailService; -import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto; -import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.time.OffsetDateTime; -import java.util.HexFormat; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Service -public class BetaInviteService { - - private final UserRepository users; - private final AuthTokenRepository tokens; - private final TemplatedEmailService templatedEmailService; - - @Value("${app.publicBaseUrl:http://localhost:3000}") - private String publicBaseUrl; - - @Value("${app.authTokenPepper:change-me}") - private String tokenPepper; - - private final SecureRandom secureRandom = new SecureRandom(); - - public BetaInviteService( - UserRepository users, - AuthTokenRepository tokens, - TemplatedEmailService templatedEmailService - ) { - this.users = users; - this.tokens = tokens; - this.templatedEmailService = templatedEmailService; - } - - /** - * Batch invite for all pending BETA users (role=BETA, is_active=false, deleted_at is null). - */ - public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) { - - List betaUsers = (limit > 0) - ? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit) - : users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA"); - - int sent = 0; - - for (User user : betaUsers) { - inviteUser(user, tokenMinutes, dryRun); - sent++; - } - - return sent; - } - - /** - * Admin UI list: all pending beta requests (role=BETA, is_active=false). - * Controller expects Page. - */ - public Page listPendingBetaUsers(int page, int size) { - int safePage = Math.max(0, page); - int safeSize = Math.min(Math.max(1, size), 100); - - List pending = users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA"); - - int from = Math.min(safePage * safeSize, pending.size()); - int to = Math.min(from + safeSize, pending.size()); - - OffsetDateTime now = OffsetDateTime.now(); - - List dtos = pending.subList(from, to).stream() - .map(u -> { - AdminBetaRequestDto dto = AdminBetaRequestDto.from(u); - dto.invited = tokens.hasActiveToken( - u.getEmail(), - AuthToken.TokenType.MAGIC_LOGIN, - now - ); - return dto; - }) - .collect(Collectors.toList()); - - return new PageImpl<>(dtos, PageRequest.of(safePage, safeSize), pending.size()); - } - - /** - * Invite a single beta request by userId. - */ - public AdminInviteResponse inviteSingleBetaUser(Integer userId) { - if (userId == null) { - return new AdminInviteResponse(false, null, "userId is required"); - } - - User user = users.findById(userId).orElse(null); - if (user == null || user.getDeletedAt() != null) { - return new AdminInviteResponse(false, null, "User not found"); - } - - if (!"BETA".equalsIgnoreCase(user.getRole()) || user.isActive()) { - return new AdminInviteResponse(false, user.getEmail(), "User is not a pending beta request"); - } - - int tokenMinutes = 30; // default for single-invite; feel free to parametrize later - String magicUrl = inviteUser(user, tokenMinutes, false); - - return new AdminInviteResponse(true, user.getEmail(), magicUrl); - } - - /** - * Creates token, persists hash, and (optionally) sends email. - * Returns the magicUrl for logging / admin response. - */ - private String inviteUser(User user, int tokenMinutes, boolean dryRun) { - String email = user.getEmail(); - - String magicToken = generateToken(); - saveToken( - email, - AuthToken.TokenType.MAGIC_LOGIN, - magicToken, - OffsetDateTime.now().plusMinutes(tokenMinutes) - ); - - String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken; - - if (!dryRun) { - templatedEmailService.send( - "beta_invite", - email, - Map.of( - "minutes", String.valueOf(tokenMinutes), - "magicUrl", magicUrl - ) - ); - } - - return magicUrl; - } - - private void saveToken( - String email, - AuthToken.TokenType type, - String token, - OffsetDateTime expiresAt - ) { - AuthToken t = new AuthToken(); - t.setEmail(email); - t.setType(type); - t.setTokenHash(hashToken(token)); - t.setExpiresAt(expiresAt); - t.setCreatedAt(OffsetDateTime.now()); - tokens.save(t); - } - - private String generateToken() { - byte[] bytes = new byte[32]; - secureRandom.nextBytes(bytes); - return HexFormat.of().formatHex(bytes); - } - - private String hashToken(String token) { - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] hashed = md.digest( - (tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8) - ); - return HexFormat.of().formatHex(hashed); - } catch (Exception e) { - throw new RuntimeException("Failed to hash token", e); - } - } +package group.goforward.battlbuilder.service.auth.impl; + +import group.goforward.battlbuilder.model.AuthToken; +import group.goforward.battlbuilder.model.User; +import group.goforward.battlbuilder.repo.AuthTokenRepository; +import group.goforward.battlbuilder.repo.UserRepository; +import group.goforward.battlbuilder.service.utils.TemplatedEmailService; +import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto; +import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.OffsetDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class BetaInviteService { + + private final UserRepository users; + private final AuthTokenRepository tokens; + private final TemplatedEmailService templatedEmailService; + + @Value("${app.publicBaseUrl:http://localhost:3000}") + private String publicBaseUrl; + + @Value("${app.authTokenPepper:change-me}") + private String tokenPepper; + + private final SecureRandom secureRandom = new SecureRandom(); + + public BetaInviteService( + UserRepository users, + AuthTokenRepository tokens, + TemplatedEmailService templatedEmailService + ) { + this.users = users; + this.tokens = tokens; + this.templatedEmailService = templatedEmailService; + } + + /** + * Batch invite for all pending BETA users (role=BETA, is_active=false, deleted_at is null). + */ + public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) { + + List betaUsers = (limit > 0) + ? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit) + : users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA"); + + int sent = 0; + + for (User user : betaUsers) { + inviteUser(user, tokenMinutes, dryRun); + sent++; + } + + return sent; + } + + /** + * Admin UI list: all pending beta requests (role=BETA, is_active=false). + * Controller expects Page. + */ + public Page listPendingBetaUsers(int page, int size) { + int safePage = Math.max(0, page); + int safeSize = Math.min(Math.max(1, size), 100); + + List pending = users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA"); + + int from = Math.min(safePage * safeSize, pending.size()); + int to = Math.min(from + safeSize, pending.size()); + + OffsetDateTime now = OffsetDateTime.now(); + + List dtos = pending.subList(from, to).stream() + .map(u -> { + AdminBetaRequestDto dto = AdminBetaRequestDto.from(u); + dto.invited = tokens.hasActiveToken( + u.getEmail(), + AuthToken.TokenType.MAGIC_LOGIN, + now + ); + return dto; + }) + .collect(Collectors.toList()); + + return new PageImpl<>(dtos, PageRequest.of(safePage, safeSize), pending.size()); + } + + /** + * Invite a single beta request by userId. + */ + public AdminInviteResponse inviteSingleBetaUser(Integer userId) { + if (userId == null) { + return new AdminInviteResponse(false, null, "userId is required"); + } + + User user = users.findById(userId).orElse(null); + if (user == null || user.getDeletedAt() != null) { + return new AdminInviteResponse(false, null, "User not found"); + } + + if (!"BETA".equalsIgnoreCase(user.getRole()) || user.isActive()) { + return new AdminInviteResponse(false, user.getEmail(), "User is not a pending beta request"); + } + + int tokenMinutes = 30; // default for single-invite; feel free to parametrize later + String magicUrl = inviteUser(user, tokenMinutes, false); + + return new AdminInviteResponse(true, user.getEmail(), magicUrl); + } + + /** + * Creates token, persists hash, and (optionally) sends email. + * Returns the magicUrl for logging / admin response. + */ + private String inviteUser(User user, int tokenMinutes, boolean dryRun) { + String email = user.getEmail(); + + String magicToken = generateToken(); + saveToken( + email, + AuthToken.TokenType.MAGIC_LOGIN, + magicToken, + OffsetDateTime.now().plusMinutes(tokenMinutes) + ); + + String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken; + + if (!dryRun) { + templatedEmailService.send( + "beta_invite", + email, + Map.of( + "minutes", String.valueOf(tokenMinutes), + "magicUrl", magicUrl + ) + ); + } + + return magicUrl; + } + + private void saveToken( + String email, + AuthToken.TokenType type, + String token, + OffsetDateTime expiresAt + ) { + AuthToken t = new AuthToken(); + t.setEmail(email); + t.setType(type); + t.setTokenHash(hashToken(token)); + t.setExpiresAt(expiresAt); + t.setCreatedAt(OffsetDateTime.now()); + tokens.save(t); + } + + private String generateToken() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + private String hashToken(String token) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hashed = md.digest( + (tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8) + ); + return HexFormat.of().formatHex(hashed); + } catch (Exception e) { + throw new RuntimeException("Failed to hash token", e); + } + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/impl/BrandServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/impl/BrandServiceImpl.java index cc69b30..e7f723c 100644 --- a/src/main/java/group/goforward/battlbuilder/service/impl/BrandServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/impl/BrandServiceImpl.java @@ -1,38 +1,38 @@ -package group.goforward.battlbuilder.service.impl; - - -import group.goforward.battlbuilder.model.Brand; -import group.goforward.battlbuilder.repo.BrandRepository; -import group.goforward.battlbuilder.service.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 repo.save(item); - } - - @Override - public void deleteById(Integer id) { - deleteById(id); - } -} +package group.goforward.battlbuilder.service.impl; + + +import group.goforward.battlbuilder.model.Brand; +import group.goforward.battlbuilder.repo.BrandRepository; +import group.goforward.battlbuilder.service.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 repo.save(item); + } + + @Override + public void deleteById(Integer id) { + deleteById(id); + } +} diff --git a/src/main/java/group/goforward/battlbuilder/service/impl/BuildServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/impl/BuildServiceImpl.java index f3a98ca..f1a3e87 100644 --- a/src/main/java/group/goforward/battlbuilder/service/impl/BuildServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/impl/BuildServiceImpl.java @@ -1,514 +1,514 @@ -package group.goforward.battlbuilder.service.impl; - -import group.goforward.battlbuilder.model.Build; -import group.goforward.battlbuilder.model.BuildItem; -import group.goforward.battlbuilder.model.BuildProfile; -import group.goforward.battlbuilder.model.ProductOffer; -import group.goforward.battlbuilder.repo.BuildItemRepository; -import group.goforward.battlbuilder.repo.BuildProfileRepository; -import group.goforward.battlbuilder.repo.BuildRepository; -import group.goforward.battlbuilder.repo.ProductOfferRepository; -import group.goforward.battlbuilder.service.CurrentUserService; -import group.goforward.battlbuilder.service.BuildService; -import group.goforward.battlbuilder.web.dto.BuildDto; -import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; -import group.goforward.battlbuilder.web.dto.BuildItemDto; -import group.goforward.battlbuilder.web.dto.BuildSummaryDto; -import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; -import group.goforward.battlbuilder.repo.ProductRepository; - -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; - -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.util.UUID; - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.*; -import java.util.stream.Collectors; - -@Service -public class BuildServiceImpl implements BuildService { - - private final BuildRepository buildRepository; - private final BuildProfileRepository buildProfileRepository; - private final BuildItemRepository buildItemRepository; - private final ProductOfferRepository productOfferRepository; - private final CurrentUserService currentUserService; - private final ProductRepository productRepository; - - public BuildServiceImpl( - BuildRepository buildRepository, - BuildProfileRepository buildProfileRepository, - BuildItemRepository buildItemRepository, - ProductRepository productRepository, - ProductOfferRepository productOfferRepository, - CurrentUserService currentUserService - ) { - this.buildRepository = buildRepository; - this.buildProfileRepository = buildProfileRepository; - this.buildItemRepository = buildItemRepository; - this.productOfferRepository = productOfferRepository; - this.currentUserService = currentUserService; - this.productRepository = productRepository; - } - - // --------------------------- - // Public feed (/builds) - // --------------------------- - - @Override - public List listPublicBuilds(int limit) { - int safeLimit = clamp(limit, 1, 100); - - List builds = buildRepository - .findByIsPublicTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(PageRequest.of(0, safeLimit)) - .getContent(); - - if (builds.isEmpty()) return List.of(); - - List buildIds = builds.stream() - .map(Build::getId) - .filter(Objects::nonNull) - .toList(); - - Map profileByBuildId = buildProfileRepository.findByBuildIdIn(buildIds) - .stream() - .filter(Objects::nonNull) - .collect(Collectors.toMap(BuildProfile::getBuildId, p -> p)); - - List items = buildItemRepository.findByBuild_IdIn(buildIds); - - Map> itemsByBuildId = items.stream() - .filter(Objects::nonNull) - .filter(bi -> bi.getBuild() != null && bi.getBuild().getId() != null) - .collect(Collectors.groupingBy(bi -> bi.getBuild().getId())); - - Set productIds = items.stream() - .map(bi -> bi.getProduct() != null ? bi.getProduct().getId() : null) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - - Map bestPriceByProductId = loadBestPrices(productIds); - - return builds.stream() - .map(b -> { - BuildProfile p = profileByBuildId.get(b.getId()); - List its = itemsByBuildId.getOrDefault(b.getId(), List.of()); - - BigDecimal estDollars = computeEstimatedPriceDollars(its, bestPriceByProductId); - int estCents = dollarsToCents(estDollars); - - return toFeedCardDto(b, p, estCents); - }) - .toList(); - } - - // --------------------------- -// Public build detail (/builds/{uuid}) -// GET /api/v1/builds/public/{uuid} -// --------------------------- - @Override - public BuildDto getPublicBuild(UUID uuid) { - if (uuid == null) throw new IllegalArgumentException("uuid is required"); - - Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Build not found")); - - // Only allow public builds here (and not deleted) - if (!Boolean.TRUE.equals(build.getIsPublic())) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Build not found"); - } - - List items = buildItemRepository.findByBuild_Id(build.getId()); - - BuildDto dto = toBuildDto(build, items); - hydrateBuildDtoItems(dto); // keep consistent with getMyBuild - - return dto; - } - - - // --------------------------- - // Vault list (/builds/me) - // --------------------------- - - @Override - public List listMyBuilds(int limit) { - int safeLimit = clamp(limit, 1, 200); - Integer userId = currentUserService.requireUserId(); - - List builds = buildRepository - .findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(userId, PageRequest.of(0, safeLimit)) - .getContent(); - - if (builds.isEmpty()) return List.of(); - - return builds.stream().map(BuildSummaryDto::from).toList(); - } - - // --------------------------- - // Load one build (Vault edit / builder load) - // GET /api/v1/builds/me/{uuid} - // --------------------------- - - @Override - public BuildDto getMyBuild(UUID uuid) { - if (uuid == null) throw new IllegalArgumentException("uuid is required"); - - Integer userId = currentUserService.requireUserId(); - - Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) - .orElseThrow(() -> new IllegalArgumentException("Build not found")); - - // prevent leaking other users' builds - if (!Objects.equals(build.getUserId(), userId)) { - throw new IllegalArgumentException("Build not found"); - } - - List items = buildItemRepository.findByBuild_Id(build.getId()); - - BuildDto dto = toBuildDto(build, items); - hydrateBuildDtoItems(dto); // 👈 STEP 2: call it here - - return dto; - } - - // --------------------------- - // Create new build (Save As…) - // POST /api/v1/builds/me - // --------------------------- - - @Override - @Transactional - public BuildDto createMyBuild(UpdateBuildRequest req) { - if (req == null) throw new IllegalArgumentException("request body is required"); - - Integer userId = currentUserService.requireUserId(); - - String title = (req.getTitle() == null || req.getTitle().isBlank()) - ? "Untitled Build" - : req.getTitle().trim(); - - Build build = new Build(); - build.setUserId(userId); // ✅ IMPORTANT: satisfies NOT NULL constraint - build.setTitle(title); - build.setDescription(req.getDescription()); - build.setIsPublic(req.getIsPublic() != null ? req.getIsPublic() : Boolean.FALSE); - - if (build.getUuid() == null) build.setUuid(UUID.randomUUID()); - - Build saved = buildRepository.save(build); - - if (req.getItems() != null) { - List newItems = buildItemsFromRequest(saved, req.getItems()); - if (!newItems.isEmpty()) buildItemRepository.saveAll(newItems); - } - - List items = buildItemRepository.findByBuild_Id(saved.getId()); - return toBuildDto(saved, items); - } - - private void hydrateBuildDtoItems(BuildDto dto) { - if (dto == null || dto.getItems() == null || dto.getItems().isEmpty()) return; - - Set productIds = dto.getItems().stream() - .map(BuildItemDto::getProductId) - .filter(Objects::nonNull) - .map(Integer::valueOf) - .collect(Collectors.toSet()); - - if (productIds.isEmpty()) return; - - Map productsById = - productRepository.findAllById(productIds).stream() - .collect(Collectors.toMap( - group.goforward.battlbuilder.model.Product::getId, - p -> p - )); - - Map bestPriceByProductId = loadBestPrices(productIds); - - for (BuildItemDto item : dto.getItems()) { - if (item == null || item.getProductId() == null) continue; - - Integer pid = Integer.valueOf(item.getProductId()); - var p = productsById.get(pid); - - if (p != null) { - item.setProductName(p.getName()); - item.setProductBrand( - p.getBrand() != null ? p.getBrand().getName() : null - ); - - String img = p.getBattlImageUrl() != null - ? p.getBattlImageUrl() - : p.getMainImageUrl(); - - item.setProductImageUrl(img); - } - - item.setBestPrice(bestPriceByProductId.get(pid)); - } - } - - - - // --------------------------- - // Update build (Vault edit save) - // PUT /api/v1/builds/me/{uuid} - // --------------------------- - - @Override - @Transactional - public BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req) { - if (uuid == null) throw new IllegalArgumentException("uuid is required"); - if (req == null) throw new IllegalArgumentException("request body is required"); - - Integer userId = currentUserService.requireUserId(); - - Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) - .orElseThrow(() -> new IllegalArgumentException("Build not found")); - - if (!Objects.equals(build.getUserId(), userId)) { - throw new IllegalArgumentException("Build not found"); - } - - if (req.getTitle() != null) build.setTitle(req.getTitle().trim()); - if (req.getDescription() != null) build.setDescription(req.getDescription()); - if (req.getIsPublic() != null) build.setIsPublic(req.getIsPublic()); - - Build saved = buildRepository.save(build); - - if (req.getItems() != null) { - buildItemRepository.deleteByBuild_Id(saved.getId()); - List newItems = buildItemsFromRequest(saved, req.getItems()); - if (!newItems.isEmpty()) buildItemRepository.saveAll(newItems); - } - - List items = buildItemRepository.findByBuild_Id(saved.getId()); - return toBuildDto(saved, items); - } - - // --------------------------- - // Delete My build (Vault edit Delete) - // DELETE /api/v1/builds/me/{uuid} - // --------------------------- - @Override - @Transactional - public void deleteMyBuild(UUID uuid) { - Integer userId = currentUserService.requireUserId(); - - Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Build not found")); - - // Ownership check - Integer currentUserId = currentUserService.requireUserId(); - - if (!currentUserId.equals(build.getUserId())) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your build"); - } - - build.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC)); - build.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); // optional - buildRepository.save(build); - } - - // --------------------------- - // BuildItem helper - // --------------------------- - - private List buildItemsFromRequest(Build build, List incoming) { - List out = new ArrayList<>(); - if (incoming == null || incoming.isEmpty()) return out; - - for (UpdateBuildRequest.Item it : incoming) { - if (it == null) continue; - if (it.getProductId() == null) continue; - if (it.getSlot() == null || it.getSlot().isBlank()) continue; - - BuildItem bi = new BuildItem(); - bi.setBuild(build); - - // Product proxy by ID only (no DB fetch) - var product = new group.goforward.battlbuilder.model.Product(); - product.setId(it.getProductId()); - bi.setProduct(product); - - bi.setSlot(it.getSlot()); - bi.setPosition(it.getPosition() != null ? it.getPosition() : 0); - bi.setQuantity(it.getQuantity() != null && it.getQuantity() > 0 ? it.getQuantity() : 1); - - out.add(bi); - } - - return out; - } - - // --------------------------- - // DTO mapping - // --------------------------- - - private BuildDto toBuildDto(Build build, List items) { - BuildDto dto = new BuildDto(); - dto.setId(build.getId() != null ? String.valueOf(build.getId()) : null); - dto.setUuid(build.getUuid()); - dto.setTitle(build.getTitle()); - dto.setDescription(build.getDescription()); - dto.setIsPublic(build.getIsPublic()); - dto.setCreatedAt(build.getCreatedAt()); - dto.setUpdatedAt(build.getUpdatedAt()); - - List itemDtos = (items == null ? List.of() : items).stream() - .filter(Objects::nonNull) - .filter(bi -> bi.getDeletedAt() == null) - .map(bi -> { - BuildItemDto it = new BuildItemDto(); - - it.setId(bi.getId() != null ? String.valueOf(bi.getId()) : null); - it.setUuid(bi.getUuid()); - it.setSlot(bi.getSlot()); - it.setPosition(bi.getPosition()); - it.setQuantity(bi.getQuantity()); - - it.setProductId( - (bi.getProduct() != null && bi.getProduct().getId() != null) - ? String.valueOf(bi.getProduct().getId()) - : null - ); - - return it; - }) - .toList(); - - dto.setItems(itemDtos); - return dto; - } - - // --------------------------- - // Price helpers (feed) - // --------------------------- - - private Map loadBestPrices(Set productIds) { - if (productIds == null || productIds.isEmpty()) return Map.of(); - - List offers = productOfferRepository.findByProduct_IdIn(productIds); - - Map> offersByProductId = offers.stream() - .filter(Objects::nonNull) - .filter(o -> o.getProduct() != null && o.getProduct().getId() != null) - .collect(Collectors.groupingBy(o -> o.getProduct().getId())); - - Map best = new HashMap<>(); - - for (Map.Entry> e : offersByProductId.entrySet()) { - ProductOffer winner = pickBestOffer(e.getValue()); - if (winner != null && winner.getEffectivePrice() != null) { - best.put(e.getKey(), winner.getEffectivePrice()); - } - } - - return best; - } - - private BigDecimal computeEstimatedPriceDollars( - List items, - Map bestPriceByProductId - ) { - if (items == null || items.isEmpty()) return BigDecimal.ZERO; - - BigDecimal sum = BigDecimal.ZERO; - - for (BuildItem bi : items) { - if (bi == null) continue; - if (bi.getDeletedAt() != null) continue; - if (bi.getProduct() == null || bi.getProduct().getId() == null) continue; - - Integer pid = bi.getProduct().getId(); - int qty = (bi.getQuantity() != null && bi.getQuantity() > 0) ? bi.getQuantity() : 1; - - BigDecimal each = bestPriceByProductId.get(pid); - if (each == null) continue; - - sum = sum.add(each.multiply(BigDecimal.valueOf(qty))); - } - - return sum.signum() < 0 ? BigDecimal.ZERO : sum; - } - - private static int dollarsToCents(BigDecimal dollars) { - if (dollars == null) return 0; - - BigDecimal cents = dollars - .multiply(BigDecimal.valueOf(100)) - .setScale(0, RoundingMode.HALF_UP); - - if (cents.signum() < 0) return 0; - - long asLong = cents.longValue(); - if (asLong > Integer.MAX_VALUE) return Integer.MAX_VALUE; - return (int) asLong; - } - - private ProductOffer pickBestOffer(List offers) { - if (offers == null || offers.isEmpty()) return null; - - return offers.stream() - .filter(o -> o.getEffectivePrice() != null) - .min(Comparator.comparing(ProductOffer::getEffectivePrice)) - .orElse(null); - } - - private BuildFeedCardDto toFeedCardDto(Build b, BuildProfile p, int estPriceCents) { - BuildFeedCardDto dto = new BuildFeedCardDto(); - - dto.setUuid(b.getUuid()); - dto.setTitle(b.getTitle()); - dto.setSlug(b.getUuid() != null ? b.getUuid().toString() : null); - dto.setCreator("anonymous"); - - if (p != null) { - dto.setCaliber(blankToNull(p.getCaliber())); - dto.setBuildClass(blankToNull(p.getBuildClass())); - dto.setCoverImageUrl(blankToNull(p.getCoverImageUrl())); - dto.setTags(parseTags(p.getTagsCsv())); - } else { - dto.setCaliber(null); - dto.setBuildClass(null); - dto.setCoverImageUrl(null); - dto.setTags(List.of()); - } - - dto.setPrice(estPriceCents); - dto.setVotes(0); - - return dto; - } - - private static List parseTags(String tagsCsv) { - if (tagsCsv == null || tagsCsv.isBlank()) return List.of(); - - return Arrays.stream(tagsCsv.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .distinct() - .limit(12) - .toList(); - } - - private static String blankToNull(String s) { - if (s == null) return null; - String t = s.trim(); - return t.isEmpty() ? null : t; - } - - private static int clamp(int n, int min, int max) { - return Math.max(min, Math.min(max, n)); - } +package group.goforward.battlbuilder.service.impl; + +import group.goforward.battlbuilder.model.Build; +import group.goforward.battlbuilder.model.BuildItem; +import group.goforward.battlbuilder.model.BuildProfile; +import group.goforward.battlbuilder.model.ProductOffer; +import group.goforward.battlbuilder.repo.BuildItemRepository; +import group.goforward.battlbuilder.repo.BuildProfileRepository; +import group.goforward.battlbuilder.repo.BuildRepository; +import group.goforward.battlbuilder.repo.ProductOfferRepository; +import group.goforward.battlbuilder.service.CurrentUserService; +import group.goforward.battlbuilder.service.BuildService; +import group.goforward.battlbuilder.web.dto.BuildDto; +import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; +import group.goforward.battlbuilder.web.dto.BuildItemDto; +import group.goforward.battlbuilder.web.dto.BuildSummaryDto; +import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; +import group.goforward.battlbuilder.repo.ProductRepository; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.UUID; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class BuildServiceImpl implements BuildService { + + private final BuildRepository buildRepository; + private final BuildProfileRepository buildProfileRepository; + private final BuildItemRepository buildItemRepository; + private final ProductOfferRepository productOfferRepository; + private final CurrentUserService currentUserService; + private final ProductRepository productRepository; + + public BuildServiceImpl( + BuildRepository buildRepository, + BuildProfileRepository buildProfileRepository, + BuildItemRepository buildItemRepository, + ProductRepository productRepository, + ProductOfferRepository productOfferRepository, + CurrentUserService currentUserService + ) { + this.buildRepository = buildRepository; + this.buildProfileRepository = buildProfileRepository; + this.buildItemRepository = buildItemRepository; + this.productOfferRepository = productOfferRepository; + this.currentUserService = currentUserService; + this.productRepository = productRepository; + } + + // --------------------------- + // Public feed (/builds) + // --------------------------- + + @Override + public List listPublicBuilds(int limit) { + int safeLimit = clamp(limit, 1, 100); + + List builds = buildRepository + .findByIsPublicTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(PageRequest.of(0, safeLimit)) + .getContent(); + + if (builds.isEmpty()) return List.of(); + + List buildIds = builds.stream() + .map(Build::getId) + .filter(Objects::nonNull) + .toList(); + + Map profileByBuildId = buildProfileRepository.findByBuildIdIn(buildIds) + .stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap(BuildProfile::getBuildId, p -> p)); + + List items = buildItemRepository.findByBuild_IdIn(buildIds); + + Map> itemsByBuildId = items.stream() + .filter(Objects::nonNull) + .filter(bi -> bi.getBuild() != null && bi.getBuild().getId() != null) + .collect(Collectors.groupingBy(bi -> bi.getBuild().getId())); + + Set productIds = items.stream() + .map(bi -> bi.getProduct() != null ? bi.getProduct().getId() : null) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Map bestPriceByProductId = loadBestPrices(productIds); + + return builds.stream() + .map(b -> { + BuildProfile p = profileByBuildId.get(b.getId()); + List its = itemsByBuildId.getOrDefault(b.getId(), List.of()); + + BigDecimal estDollars = computeEstimatedPriceDollars(its, bestPriceByProductId); + int estCents = dollarsToCents(estDollars); + + return toFeedCardDto(b, p, estCents); + }) + .toList(); + } + + // --------------------------- +// Public build detail (/builds/{uuid}) +// GET /api/v1/builds/public/{uuid} +// --------------------------- + @Override + public BuildDto getPublicBuild(UUID uuid) { + if (uuid == null) throw new IllegalArgumentException("uuid is required"); + + Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Build not found")); + + // Only allow public builds here (and not deleted) + if (!Boolean.TRUE.equals(build.getIsPublic())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Build not found"); + } + + List items = buildItemRepository.findByBuild_Id(build.getId()); + + BuildDto dto = toBuildDto(build, items); + hydrateBuildDtoItems(dto); // keep consistent with getMyBuild + + return dto; + } + + + // --------------------------- + // Vault list (/builds/me) + // --------------------------- + + @Override + public List listMyBuilds(int limit) { + int safeLimit = clamp(limit, 1, 200); + Integer userId = currentUserService.requireUserId(); + + List builds = buildRepository + .findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(userId, PageRequest.of(0, safeLimit)) + .getContent(); + + if (builds.isEmpty()) return List.of(); + + return builds.stream().map(BuildSummaryDto::from).toList(); + } + + // --------------------------- + // Load one build (Vault edit / builder load) + // GET /api/v1/builds/me/{uuid} + // --------------------------- + + @Override + public BuildDto getMyBuild(UUID uuid) { + if (uuid == null) throw new IllegalArgumentException("uuid is required"); + + Integer userId = currentUserService.requireUserId(); + + Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) + .orElseThrow(() -> new IllegalArgumentException("Build not found")); + + // prevent leaking other users' builds + if (!Objects.equals(build.getUserId(), userId)) { + throw new IllegalArgumentException("Build not found"); + } + + List items = buildItemRepository.findByBuild_Id(build.getId()); + + BuildDto dto = toBuildDto(build, items); + hydrateBuildDtoItems(dto); // 👈 STEP 2: call it here + + return dto; + } + + // --------------------------- + // Create new build (Save As…) + // POST /api/v1/builds/me + // --------------------------- + + @Override + @Transactional + public BuildDto createMyBuild(UpdateBuildRequest req) { + if (req == null) throw new IllegalArgumentException("request body is required"); + + Integer userId = currentUserService.requireUserId(); + + String title = (req.getTitle() == null || req.getTitle().isBlank()) + ? "Untitled Build" + : req.getTitle().trim(); + + Build build = new Build(); + build.setUserId(userId); // ✅ IMPORTANT: satisfies NOT NULL constraint + build.setTitle(title); + build.setDescription(req.getDescription()); + build.setIsPublic(req.getIsPublic() != null ? req.getIsPublic() : Boolean.FALSE); + + if (build.getUuid() == null) build.setUuid(UUID.randomUUID()); + + Build saved = buildRepository.save(build); + + if (req.getItems() != null) { + List newItems = buildItemsFromRequest(saved, req.getItems()); + if (!newItems.isEmpty()) buildItemRepository.saveAll(newItems); + } + + List items = buildItemRepository.findByBuild_Id(saved.getId()); + return toBuildDto(saved, items); + } + + private void hydrateBuildDtoItems(BuildDto dto) { + if (dto == null || dto.getItems() == null || dto.getItems().isEmpty()) return; + + Set productIds = dto.getItems().stream() + .map(BuildItemDto::getProductId) + .filter(Objects::nonNull) + .map(Integer::valueOf) + .collect(Collectors.toSet()); + + if (productIds.isEmpty()) return; + + Map productsById = + productRepository.findAllById(productIds).stream() + .collect(Collectors.toMap( + group.goforward.battlbuilder.model.Product::getId, + p -> p + )); + + Map bestPriceByProductId = loadBestPrices(productIds); + + for (BuildItemDto item : dto.getItems()) { + if (item == null || item.getProductId() == null) continue; + + Integer pid = Integer.valueOf(item.getProductId()); + var p = productsById.get(pid); + + if (p != null) { + item.setProductName(p.getName()); + item.setProductBrand( + p.getBrand() != null ? p.getBrand().getName() : null + ); + + String img = p.getBattlImageUrl() != null + ? p.getBattlImageUrl() + : p.getMainImageUrl(); + + item.setProductImageUrl(img); + } + + item.setBestPrice(bestPriceByProductId.get(pid)); + } + } + + + + // --------------------------- + // Update build (Vault edit save) + // PUT /api/v1/builds/me/{uuid} + // --------------------------- + + @Override + @Transactional + public BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req) { + if (uuid == null) throw new IllegalArgumentException("uuid is required"); + if (req == null) throw new IllegalArgumentException("request body is required"); + + Integer userId = currentUserService.requireUserId(); + + Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) + .orElseThrow(() -> new IllegalArgumentException("Build not found")); + + if (!Objects.equals(build.getUserId(), userId)) { + throw new IllegalArgumentException("Build not found"); + } + + if (req.getTitle() != null) build.setTitle(req.getTitle().trim()); + if (req.getDescription() != null) build.setDescription(req.getDescription()); + if (req.getIsPublic() != null) build.setIsPublic(req.getIsPublic()); + + Build saved = buildRepository.save(build); + + if (req.getItems() != null) { + buildItemRepository.deleteByBuild_Id(saved.getId()); + List newItems = buildItemsFromRequest(saved, req.getItems()); + if (!newItems.isEmpty()) buildItemRepository.saveAll(newItems); + } + + List items = buildItemRepository.findByBuild_Id(saved.getId()); + return toBuildDto(saved, items); + } + + // --------------------------- + // Delete My build (Vault edit Delete) + // DELETE /api/v1/builds/me/{uuid} + // --------------------------- + @Override + @Transactional + public void deleteMyBuild(UUID uuid) { + Integer userId = currentUserService.requireUserId(); + + Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Build not found")); + + // Ownership check + Integer currentUserId = currentUserService.requireUserId(); + + if (!currentUserId.equals(build.getUserId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your build"); + } + + build.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC)); + build.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); // optional + buildRepository.save(build); + } + + // --------------------------- + // BuildItem helper + // --------------------------- + + private List buildItemsFromRequest(Build build, List incoming) { + List out = new ArrayList<>(); + if (incoming == null || incoming.isEmpty()) return out; + + for (UpdateBuildRequest.Item it : incoming) { + if (it == null) continue; + if (it.getProductId() == null) continue; + if (it.getSlot() == null || it.getSlot().isBlank()) continue; + + BuildItem bi = new BuildItem(); + bi.setBuild(build); + + // Product proxy by ID only (no DB fetch) + var product = new group.goforward.battlbuilder.model.Product(); + product.setId(it.getProductId()); + bi.setProduct(product); + + bi.setSlot(it.getSlot()); + bi.setPosition(it.getPosition() != null ? it.getPosition() : 0); + bi.setQuantity(it.getQuantity() != null && it.getQuantity() > 0 ? it.getQuantity() : 1); + + out.add(bi); + } + + return out; + } + + // --------------------------- + // DTO mapping + // --------------------------- + + private BuildDto toBuildDto(Build build, List items) { + BuildDto dto = new BuildDto(); + dto.setId(build.getId() != null ? String.valueOf(build.getId()) : null); + dto.setUuid(build.getUuid()); + dto.setTitle(build.getTitle()); + dto.setDescription(build.getDescription()); + dto.setIsPublic(build.getIsPublic()); + dto.setCreatedAt(build.getCreatedAt()); + dto.setUpdatedAt(build.getUpdatedAt()); + + List itemDtos = (items == null ? List.of() : items).stream() + .filter(Objects::nonNull) + .filter(bi -> bi.getDeletedAt() == null) + .map(bi -> { + BuildItemDto it = new BuildItemDto(); + + it.setId(bi.getId() != null ? String.valueOf(bi.getId()) : null); + it.setUuid(bi.getUuid()); + it.setSlot(bi.getSlot()); + it.setPosition(bi.getPosition()); + it.setQuantity(bi.getQuantity()); + + it.setProductId( + (bi.getProduct() != null && bi.getProduct().getId() != null) + ? String.valueOf(bi.getProduct().getId()) + : null + ); + + return it; + }) + .toList(); + + dto.setItems(itemDtos); + return dto; + } + + // --------------------------- + // Price helpers (feed) + // --------------------------- + + private Map loadBestPrices(Set productIds) { + if (productIds == null || productIds.isEmpty()) return Map.of(); + + List offers = productOfferRepository.findByProduct_IdIn(productIds); + + Map> offersByProductId = offers.stream() + .filter(Objects::nonNull) + .filter(o -> o.getProduct() != null && o.getProduct().getId() != null) + .collect(Collectors.groupingBy(o -> o.getProduct().getId())); + + Map best = new HashMap<>(); + + for (Map.Entry> e : offersByProductId.entrySet()) { + ProductOffer winner = pickBestOffer(e.getValue()); + if (winner != null && winner.getEffectivePrice() != null) { + best.put(e.getKey(), winner.getEffectivePrice()); + } + } + + return best; + } + + private BigDecimal computeEstimatedPriceDollars( + List items, + Map bestPriceByProductId + ) { + if (items == null || items.isEmpty()) return BigDecimal.ZERO; + + BigDecimal sum = BigDecimal.ZERO; + + for (BuildItem bi : items) { + if (bi == null) continue; + if (bi.getDeletedAt() != null) continue; + if (bi.getProduct() == null || bi.getProduct().getId() == null) continue; + + Integer pid = bi.getProduct().getId(); + int qty = (bi.getQuantity() != null && bi.getQuantity() > 0) ? bi.getQuantity() : 1; + + BigDecimal each = bestPriceByProductId.get(pid); + if (each == null) continue; + + sum = sum.add(each.multiply(BigDecimal.valueOf(qty))); + } + + return sum.signum() < 0 ? BigDecimal.ZERO : sum; + } + + private static int dollarsToCents(BigDecimal dollars) { + if (dollars == null) return 0; + + BigDecimal cents = dollars + .multiply(BigDecimal.valueOf(100)) + .setScale(0, RoundingMode.HALF_UP); + + if (cents.signum() < 0) return 0; + + long asLong = cents.longValue(); + if (asLong > Integer.MAX_VALUE) return Integer.MAX_VALUE; + return (int) asLong; + } + + private ProductOffer pickBestOffer(List offers) { + if (offers == null || offers.isEmpty()) return null; + + return offers.stream() + .filter(o -> o.getEffectivePrice() != null) + .min(Comparator.comparing(ProductOffer::getEffectivePrice)) + .orElse(null); + } + + private BuildFeedCardDto toFeedCardDto(Build b, BuildProfile p, int estPriceCents) { + BuildFeedCardDto dto = new BuildFeedCardDto(); + + dto.setUuid(b.getUuid()); + dto.setTitle(b.getTitle()); + dto.setSlug(b.getUuid() != null ? b.getUuid().toString() : null); + dto.setCreator("anonymous"); + + if (p != null) { + dto.setCaliber(blankToNull(p.getCaliber())); + dto.setBuildClass(blankToNull(p.getBuildClass())); + dto.setCoverImageUrl(blankToNull(p.getCoverImageUrl())); + dto.setTags(parseTags(p.getTagsCsv())); + } else { + dto.setCaliber(null); + dto.setBuildClass(null); + dto.setCoverImageUrl(null); + dto.setTags(List.of()); + } + + dto.setPrice(estPriceCents); + dto.setVotes(0); + + return dto; + } + + private static List parseTags(String tagsCsv) { + if (tagsCsv == null || tagsCsv.isBlank()) return List.of(); + + return Arrays.stream(tagsCsv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .distinct() + .limit(12) + .toList(); + } + + private static String blankToNull(String s) { + if (s == null) return null; + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static int clamp(int n, int min, int max) { + return Math.max(min, Math.min(max, n)); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/impl/CatalogQueryServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/impl/CatalogQueryServiceImpl.java index 4574053..52e445e 100644 --- a/src/main/java/group/goforward/battlbuilder/service/impl/CatalogQueryServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/impl/CatalogQueryServiceImpl.java @@ -1,209 +1,209 @@ -package group.goforward.battlbuilder.service.impl; - -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.model.ProductOffer; -import group.goforward.battlbuilder.repo.ProductOfferRepository; -import group.goforward.battlbuilder.repo.ProductRepository; -import group.goforward.battlbuilder.repo.catalog.spec.CatalogProductSpecifications; -import group.goforward.battlbuilder.service.CatalogQueryService; -import group.goforward.battlbuilder.web.dto.ProductSummaryDto; -import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; -import group.goforward.battlbuilder.web.mapper.ProductMapper; - -import org.springframework.data.domain.*; -import org.springframework.data.jpa.domain.Specification; -import org.springframework.stereotype.Service; - -import java.math.BigDecimal; -import java.time.OffsetDateTime; -import java.util.*; -import java.util.stream.Collectors; - -@Service -public class CatalogQueryServiceImpl implements CatalogQueryService { - - private final ProductRepository productRepository; - private final ProductOfferRepository productOfferRepository; - - public CatalogQueryServiceImpl(ProductRepository productRepository, - ProductOfferRepository productOfferRepository) { - this.productRepository = productRepository; - this.productOfferRepository = productOfferRepository; - } - - @Override - public Page getOptions( - String platform, - String partRole, - List partRoles, - List brands, - String q, - Pageable pageable - ) { - pageable = sanitizeCatalogPageable(pageable); - - // Normalize roles: accept partRole OR partRoles - List roleList = new ArrayList<>(); - if (partRole != null && !partRole.isBlank()) roleList.add(partRole); - if (partRoles != null && !partRoles.isEmpty()) roleList.addAll(partRoles); - roleList = roleList.stream().filter(s -> s != null && !s.isBlank()).distinct().toList(); - - Specification spec = Specification.where(CatalogProductSpecifications.isCatalogVisible()); - - // platform optional: omit/blank/ALL => universal - if (platform != null && !platform.isBlank() && !"ALL".equalsIgnoreCase(platform)) { - spec = spec.and(CatalogProductSpecifications.platformEquals(platform)); - } - - if (!roleList.isEmpty()) { - spec = spec.and(CatalogProductSpecifications.partRoleIn(roleList)); - } - - if (brands != null && !brands.isEmpty()) { - spec = spec.and(CatalogProductSpecifications.brandNameIn(brands)); - } - - if (q != null && !q.isBlank()) { - spec = spec.and(CatalogProductSpecifications.queryLike(q)); - } - - Page page = productRepository.findAll(spec, pageable); - if (page.isEmpty()) { - return new PageImpl<>(List.of(), pageable, 0); - } - - // Bulk offers for this page (no N+1) - List productIds = page.getContent().stream().map(Product::getId).toList(); - List offers = productOfferRepository.findByProduct_IdIn(productIds); - Map bestOfferByProductId = pickBestOffers(offers); - - List dtos = page.getContent().stream().map(p -> { - ProductOffer best = bestOfferByProductId.get(p.getId()); - BigDecimal price = best != null ? best.getEffectivePrice() : null; - String buyUrl = best != null ? best.getBuyUrl() : null; - return ProductMapper.toSummary(p, price, buyUrl); - }).toList(); - - return new PageImpl<>(dtos, pageable, page.getTotalElements()); - } - - @Override - public List getProductsByIds(CatalogProductIdsRequest request) { - List ids = request != null ? request.getIds() : null; - if (ids == null || ids.isEmpty()) return List.of(); - - ids = ids.stream().filter(Objects::nonNull).distinct().toList(); - - List products = productRepository.findByIdIn(ids); - if (products.isEmpty()) return List.of(); - - List offers = productOfferRepository.findByProduct_IdIn(ids); - Map bestOfferByProductId = pickBestOffers(offers); - - Map productById = products.stream() - .collect(Collectors.toMap(Product::getId, p -> p)); - - List out = new ArrayList<>(); - for (Integer id : ids) { - Product p = productById.get(id); - if (p == null) continue; - - ProductOffer best = bestOfferByProductId.get(id); - BigDecimal price = best != null ? best.getEffectivePrice() : null; - String buyUrl = best != null ? best.getBuyUrl() : null; - - out.add(ProductMapper.toSummary(p, price, buyUrl)); - } - - return out; - } - - private Map pickBestOffers(List offers) { - Map best = new HashMap<>(); - if (offers == null || offers.isEmpty()) return best; - - for (ProductOffer o : offers) { - if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue; - - Integer pid = o.getProduct().getId(); - BigDecimal price = o.getEffectivePrice(); - if (price == null) continue; - - ProductOffer current = best.get(pid); - if (current == null) { - best.put(pid, o); - continue; - } - - // ---- ranking rules (in order) ---- - // 1) prefer in-stock - boolean oStock = Boolean.TRUE.equals(o.getInStock()); - boolean cStock = Boolean.TRUE.equals(current.getInStock()); - if (oStock != cStock) { - if (oStock) best.put(pid, o); - continue; - } - - // 2) prefer cheaper price - BigDecimal currentPrice = current.getEffectivePrice(); - if (currentPrice == null || price.compareTo(currentPrice) < 0) { - best.put(pid, o); - continue; - } - if (price.compareTo(currentPrice) > 0) continue; - - // 3) tie-break: most recently seen - OffsetDateTime oSeen = o.getLastSeenAt(); - OffsetDateTime cSeen = current.getLastSeenAt(); - - if (oSeen != null && cSeen != null && oSeen.isAfter(cSeen)) { - best.put(pid, o); - continue; - } - if (oSeen != null && cSeen == null) { - best.put(pid, o); - } - - // 4) tie-break: prefer offer with buyUrl - String oUrl = o.getBuyUrl(); - String cUrl = current.getBuyUrl(); - if ((oUrl != null && !oUrl.isBlank()) && (cUrl == null || cUrl.isBlank())) { - best.put(pid, o); - } - } - - return best; - } - - private Pageable sanitizeCatalogPageable(Pageable pageable) { - if (pageable == null) { - return PageRequest.of(0, 24, Sort.by(Sort.Direction.DESC, "updatedAt")); - } - - int page = pageable.getPageNumber(); - int requested = pageable.getPageSize(); - int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap - - // Default sort if none provided - if (pageable.getSort() == null || pageable.getSort().isUnsorted()) { - return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); - } - - // Only allow safe sorts (for now) - Sort.Order first = pageable.getSort().stream().findFirst().orElse(null); - if (first == null) { - return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); - } - - String prop = first.getProperty(); - Sort.Direction dir = first.getDirection(); - - // IMPORTANT: - // If you're still using JPA Specifications (Product entity), you can only sort by Product fields. - // Once you switch to the native "best offer" query, you can allow "price" and "brand" sorts. - return switch (prop) { - case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop)); - default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); - }; - } +package group.goforward.battlbuilder.service.impl; + +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.model.ProductOffer; +import group.goforward.battlbuilder.repo.ProductOfferRepository; +import group.goforward.battlbuilder.repo.ProductRepository; +import group.goforward.battlbuilder.repo.catalog.spec.CatalogProductSpecifications; +import group.goforward.battlbuilder.service.CatalogQueryService; +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; +import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; +import group.goforward.battlbuilder.web.mapper.ProductMapper; + +import org.springframework.data.domain.*; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class CatalogQueryServiceImpl implements CatalogQueryService { + + private final ProductRepository productRepository; + private final ProductOfferRepository productOfferRepository; + + public CatalogQueryServiceImpl(ProductRepository productRepository, + ProductOfferRepository productOfferRepository) { + this.productRepository = productRepository; + this.productOfferRepository = productOfferRepository; + } + + @Override + public Page getOptions( + String platform, + String partRole, + List partRoles, + List brands, + String q, + Pageable pageable + ) { + pageable = sanitizeCatalogPageable(pageable); + + // Normalize roles: accept partRole OR partRoles + List roleList = new ArrayList<>(); + if (partRole != null && !partRole.isBlank()) roleList.add(partRole); + if (partRoles != null && !partRoles.isEmpty()) roleList.addAll(partRoles); + roleList = roleList.stream().filter(s -> s != null && !s.isBlank()).distinct().toList(); + + Specification spec = Specification.where(CatalogProductSpecifications.isCatalogVisible()); + + // platform optional: omit/blank/ALL => universal + if (platform != null && !platform.isBlank() && !"ALL".equalsIgnoreCase(platform)) { + spec = spec.and(CatalogProductSpecifications.platformEquals(platform)); + } + + if (!roleList.isEmpty()) { + spec = spec.and(CatalogProductSpecifications.partRoleIn(roleList)); + } + + if (brands != null && !brands.isEmpty()) { + spec = spec.and(CatalogProductSpecifications.brandNameIn(brands)); + } + + if (q != null && !q.isBlank()) { + spec = spec.and(CatalogProductSpecifications.queryLike(q)); + } + + Page page = productRepository.findAll(spec, pageable); + if (page.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0); + } + + // Bulk offers for this page (no N+1) + List productIds = page.getContent().stream().map(Product::getId).toList(); + List offers = productOfferRepository.findByProduct_IdIn(productIds); + Map bestOfferByProductId = pickBestOffers(offers); + + List dtos = page.getContent().stream().map(p -> { + ProductOffer best = bestOfferByProductId.get(p.getId()); + BigDecimal price = best != null ? best.getEffectivePrice() : null; + String buyUrl = best != null ? best.getBuyUrl() : null; + return ProductMapper.toSummary(p, price, buyUrl); + }).toList(); + + return new PageImpl<>(dtos, pageable, page.getTotalElements()); + } + + @Override + public List getProductsByIds(CatalogProductIdsRequest request) { + List ids = request != null ? request.getIds() : null; + if (ids == null || ids.isEmpty()) return List.of(); + + ids = ids.stream().filter(Objects::nonNull).distinct().toList(); + + List products = productRepository.findByIdIn(ids); + if (products.isEmpty()) return List.of(); + + List offers = productOfferRepository.findByProduct_IdIn(ids); + Map bestOfferByProductId = pickBestOffers(offers); + + Map productById = products.stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + List out = new ArrayList<>(); + for (Integer id : ids) { + Product p = productById.get(id); + if (p == null) continue; + + ProductOffer best = bestOfferByProductId.get(id); + BigDecimal price = best != null ? best.getEffectivePrice() : null; + String buyUrl = best != null ? best.getBuyUrl() : null; + + out.add(ProductMapper.toSummary(p, price, buyUrl)); + } + + return out; + } + + private Map pickBestOffers(List offers) { + Map best = new HashMap<>(); + if (offers == null || offers.isEmpty()) return best; + + for (ProductOffer o : offers) { + if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue; + + Integer pid = o.getProduct().getId(); + BigDecimal price = o.getEffectivePrice(); + if (price == null) continue; + + ProductOffer current = best.get(pid); + if (current == null) { + best.put(pid, o); + continue; + } + + // ---- ranking rules (in order) ---- + // 1) prefer in-stock + boolean oStock = Boolean.TRUE.equals(o.getInStock()); + boolean cStock = Boolean.TRUE.equals(current.getInStock()); + if (oStock != cStock) { + if (oStock) best.put(pid, o); + continue; + } + + // 2) prefer cheaper price + BigDecimal currentPrice = current.getEffectivePrice(); + if (currentPrice == null || price.compareTo(currentPrice) < 0) { + best.put(pid, o); + continue; + } + if (price.compareTo(currentPrice) > 0) continue; + + // 3) tie-break: most recently seen + OffsetDateTime oSeen = o.getLastSeenAt(); + OffsetDateTime cSeen = current.getLastSeenAt(); + + if (oSeen != null && cSeen != null && oSeen.isAfter(cSeen)) { + best.put(pid, o); + continue; + } + if (oSeen != null && cSeen == null) { + best.put(pid, o); + } + + // 4) tie-break: prefer offer with buyUrl + String oUrl = o.getBuyUrl(); + String cUrl = current.getBuyUrl(); + if ((oUrl != null && !oUrl.isBlank()) && (cUrl == null || cUrl.isBlank())) { + best.put(pid, o); + } + } + + return best; + } + + private Pageable sanitizeCatalogPageable(Pageable pageable) { + if (pageable == null) { + return PageRequest.of(0, 24, Sort.by(Sort.Direction.DESC, "updatedAt")); + } + + int page = pageable.getPageNumber(); + int requested = pageable.getPageSize(); + int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap + + // Default sort if none provided + if (pageable.getSort() == null || pageable.getSort().isUnsorted()) { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); + } + + // Only allow safe sorts (for now) + Sort.Order first = pageable.getSort().stream().findFirst().orElse(null); + if (first == null) { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); + } + + String prop = first.getProperty(); + Sort.Direction dir = first.getDirection(); + + // IMPORTANT: + // If you're still using JPA Specifications (Product entity), you can only sort by Product fields. + // Once you switch to the native "best offer" query, you can allow "price" and "brand" sorts. + return switch (prop) { + case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop)); + default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); + }; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/impl/CategoryClassificationServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/impl/CategoryClassificationServiceImpl.java index 38beb3d..6e478a8 100644 --- a/src/main/java/group/goforward/battlbuilder/service/impl/CategoryClassificationServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/impl/CategoryClassificationServiceImpl.java @@ -1,135 +1,135 @@ -package group.goforward.battlbuilder.service.impl; - -import group.goforward.battlbuilder.catalog.classification.PartRoleResolver; -import group.goforward.battlbuilder.imports.MerchantFeedRow; -import group.goforward.battlbuilder.model.Merchant; -import group.goforward.battlbuilder.model.PartRoleSource; -import group.goforward.battlbuilder.service.CategoryClassificationService; -import org.springframework.stereotype.Service; - -import java.util.Locale; -import java.util.Optional; - -@Service -public class CategoryClassificationServiceImpl implements CategoryClassificationService { - - private final MerchantCategoryMappingService merchantCategoryMappingService; - private final PartRoleResolver partRoleResolver; - - public CategoryClassificationServiceImpl( - MerchantCategoryMappingService merchantCategoryMappingService, - PartRoleResolver partRoleResolver - ) { - this.merchantCategoryMappingService = merchantCategoryMappingService; - this.partRoleResolver = partRoleResolver; - } - - @Override - public Result classify(Merchant merchant, MerchantFeedRow row) { - String rawCategoryKey = buildRawCategoryKey(row); - String platformFinal = inferPlatform(row); - if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15"; - return classify(merchant, row, platformFinal, rawCategoryKey); - } - - @Override - public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) { - if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15"; - - // 1) merchant map (authoritative if present) - Optional mapped = merchantCategoryMappingService.resolveMappedPartRole( - merchant != null ? merchant.getId() : null, - rawCategoryKey, - platformFinal - ); - - if (mapped.isPresent()) { - String role = normalizePartRole(mapped.get()); - return new Result( - platformFinal, - role, - rawCategoryKey, - PartRoleSource.MERCHANT_MAP, - "merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")" - ); - } - - // 2) rules - String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey); - if (resolved != null && !resolved.isBlank()) { - String role = normalizePartRole(resolved); - return new Result( - platformFinal, - role, - rawCategoryKey, - PartRoleSource.RULES, - "PartRoleResolver matched" - ); - } - - // 3) no inference: leave unknown and let it flow to PENDING_MAPPING - return new Result( - platformFinal, - "unknown", - rawCategoryKey, - PartRoleSource.UNKNOWN, - "no mapping or rules match" - ); - } - - private String buildRawCategoryKey(MerchantFeedRow row) { - String dept = trimOrNull(row.department()); - String cat = trimOrNull(row.category()); - String sub = trimOrNull(row.subCategory()); - - StringBuilder sb = new StringBuilder(); - if (dept != null) sb.append(dept); - if (cat != null) { - if (!sb.isEmpty()) sb.append(" > "); - sb.append(cat); - } - if (sub != null) { - if (!sb.isEmpty()) sb.append(" > "); - sb.append(sub); - } - - String result = sb.toString(); - return result.isBlank() ? null : result; - } - - private String inferPlatform(MerchantFeedRow row) { - String blob = String.join(" ", - coalesce(trimOrNull(row.department()), ""), - coalesce(trimOrNull(row.category()), ""), - coalesce(trimOrNull(row.subCategory()), "") - ).toLowerCase(Locale.ROOT); - - if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15"; - if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10"; - if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9"; - if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47"; - - return "AR-15"; - } - - - private String normalizePartRole(String partRole) { - if (partRole == null) return "unknown"; - String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-'); - return t.isBlank() ? "unknown" : t; - } - - private String trimOrNull(String v) { - if (v == null) return null; - String t = v.trim(); - return t.isEmpty() ? null : t; - } - - private String coalesce(String... values) { - if (values == null) return null; - for (String v : values) { - if (v != null && !v.isBlank()) return v; - } - return null; - } +package group.goforward.battlbuilder.service.impl; + +import group.goforward.battlbuilder.catalog.classification.PartRoleResolver; +import group.goforward.battlbuilder.imports.MerchantFeedRow; +import group.goforward.battlbuilder.model.Merchant; +import group.goforward.battlbuilder.model.PartRoleSource; +import group.goforward.battlbuilder.service.CategoryClassificationService; +import org.springframework.stereotype.Service; + +import java.util.Locale; +import java.util.Optional; + +@Service +public class CategoryClassificationServiceImpl implements CategoryClassificationService { + + private final MerchantCategoryMappingService merchantCategoryMappingService; + private final PartRoleResolver partRoleResolver; + + public CategoryClassificationServiceImpl( + MerchantCategoryMappingService merchantCategoryMappingService, + PartRoleResolver partRoleResolver + ) { + this.merchantCategoryMappingService = merchantCategoryMappingService; + this.partRoleResolver = partRoleResolver; + } + + @Override + public Result classify(Merchant merchant, MerchantFeedRow row) { + String rawCategoryKey = buildRawCategoryKey(row); + String platformFinal = inferPlatform(row); + if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15"; + return classify(merchant, row, platformFinal, rawCategoryKey); + } + + @Override + public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) { + if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15"; + + // 1) merchant map (authoritative if present) + Optional mapped = merchantCategoryMappingService.resolveMappedPartRole( + merchant != null ? merchant.getId() : null, + rawCategoryKey, + platformFinal + ); + + if (mapped.isPresent()) { + String role = normalizePartRole(mapped.get()); + return new Result( + platformFinal, + role, + rawCategoryKey, + PartRoleSource.MERCHANT_MAP, + "merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")" + ); + } + + // 2) rules + String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey); + if (resolved != null && !resolved.isBlank()) { + String role = normalizePartRole(resolved); + return new Result( + platformFinal, + role, + rawCategoryKey, + PartRoleSource.RULES, + "PartRoleResolver matched" + ); + } + + // 3) no inference: leave unknown and let it flow to PENDING_MAPPING + return new Result( + platformFinal, + "unknown", + rawCategoryKey, + PartRoleSource.UNKNOWN, + "no mapping or rules match" + ); + } + + private String buildRawCategoryKey(MerchantFeedRow row) { + String dept = trimOrNull(row.department()); + String cat = trimOrNull(row.category()); + String sub = trimOrNull(row.subCategory()); + + StringBuilder sb = new StringBuilder(); + if (dept != null) sb.append(dept); + if (cat != null) { + if (!sb.isEmpty()) sb.append(" > "); + sb.append(cat); + } + if (sub != null) { + if (!sb.isEmpty()) sb.append(" > "); + sb.append(sub); + } + + String result = sb.toString(); + return result.isBlank() ? null : result; + } + + private String inferPlatform(MerchantFeedRow row) { + String blob = String.join(" ", + coalesce(trimOrNull(row.department()), ""), + coalesce(trimOrNull(row.category()), ""), + coalesce(trimOrNull(row.subCategory()), "") + ).toLowerCase(Locale.ROOT); + + if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15"; + if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10"; + if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9"; + if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47"; + + return "AR-15"; + } + + + private String normalizePartRole(String partRole) { + if (partRole == null) return "unknown"; + String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-'); + return t.isBlank() ? "unknown" : t; + } + + private String trimOrNull(String v) { + if (v == null) return null; + String t = v.trim(); + return t.isEmpty() ? null : t; + } + + private String coalesce(String... values) { + if (values == null) return null; + for (String v : values) { + if (v != null && !v.isBlank()) return v; + } + return null; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/impl/MerchantCategoryMappingService.java b/src/main/java/group/goforward/battlbuilder/service/impl/MerchantCategoryMappingService.java index 4ba5679..44f4f26 100644 --- a/src/main/java/group/goforward/battlbuilder/service/impl/MerchantCategoryMappingService.java +++ b/src/main/java/group/goforward/battlbuilder/service/impl/MerchantCategoryMappingService.java @@ -1,38 +1,38 @@ -package group.goforward.battlbuilder.service.impl; - -import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; - -@Service -public class MerchantCategoryMappingService { - - private final MerchantCategoryMapRepository merchantCategoryMapRepository; - - public MerchantCategoryMappingService(MerchantCategoryMapRepository merchantCategoryMapRepository) { - this.merchantCategoryMapRepository = merchantCategoryMapRepository; - } - - public Optional resolveMappedPartRole( - Integer merchantId, - String rawCategoryKey, - String platformFinal - ) { - if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) { - return Optional.empty(); - } - - List canonicalRoles = - merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey); - - if (canonicalRoles == null || canonicalRoles.isEmpty()) { - return Optional.empty(); - } - - return canonicalRoles.stream() - .filter(v -> v != null && !v.isBlank()) - .findFirst(); - } +package group.goforward.battlbuilder.service.impl; + +import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class MerchantCategoryMappingService { + + private final MerchantCategoryMapRepository merchantCategoryMapRepository; + + public MerchantCategoryMappingService(MerchantCategoryMapRepository merchantCategoryMapRepository) { + this.merchantCategoryMapRepository = merchantCategoryMapRepository; + } + + public Optional resolveMappedPartRole( + Integer merchantId, + String rawCategoryKey, + String platformFinal + ) { + if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) { + return Optional.empty(); + } + + List canonicalRoles = + merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey); + + if (canonicalRoles == null || canonicalRoles.isEmpty()) { + return Optional.empty(); + } + + return canonicalRoles.stream() + .filter(v -> v != null && !v.isBlank()) + .findFirst(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/impl/MerchantFeedImportServiceImpl.java index a54f0dd..f30eb1a 100644 --- a/src/main/java/group/goforward/battlbuilder/service/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/impl/MerchantFeedImportServiceImpl.java @@ -1,683 +1,683 @@ -/** - * @deprecated Legacy import flow. Prefer the newer import/reclassification pipeline that relies on: - * - merchant_category_map.canonical_part_role (authoritative) - * - PartRoleResolver rules - * - ImportStatus.PENDING_MAPPING for anything unresolved - */ -package group.goforward.battlbuilder.service.impl; - -import group.goforward.battlbuilder.imports.MerchantFeedRow; -import group.goforward.battlbuilder.model.Brand; -import group.goforward.battlbuilder.model.ImportStatus; -import group.goforward.battlbuilder.model.Merchant; -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.model.ProductOffer; -import group.goforward.battlbuilder.repo.BrandRepository; -import group.goforward.battlbuilder.repo.MerchantRepository; -import group.goforward.battlbuilder.repo.ProductOfferRepository; -import group.goforward.battlbuilder.repo.ProductRepository; -import group.goforward.battlbuilder.catalog.classification.PlatformResolver; -import group.goforward.battlbuilder.service.MerchantFeedImportService; -import group.goforward.battlbuilder.service.CategoryClassificationService; - -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 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.*; -import java.time.Instant; - -/** - * MerchantFeedImportServiceImpl - *

- * RESPONSIBILITIES: - * - Read merchant product feeds (CSV/TSV/etc) - * - Normalize product data into Product entities - * - Resolve PLATFORM (AR-15, AR-10, NOT-SUPPORTED) - * - Infer part roles (temporary heuristic) - * - Upsert Product + ProductOffer rows - *

- * NON-GOALS: - * - Perfect classification (that’s iterative) - * - UI-level filtering (handled later) - */ -@Deprecated(forRemoval = false, since = "2025-12-28") -@Service -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; - - // --- Classification --- - // DB-backed rule engine for platform (AR-15 / AR-10 / NOT-SUPPORTED) - private final CategoryClassificationService categoryClassificationService; - private final PlatformResolver platformResolver; - private final ProductOfferRepository productOfferRepository; - - - // ========================================================================= - // FULL PRODUCT + OFFER IMPORT - // ========================================================================= - // - // This is the main ETL entry point. - // Triggered via: - // POST /api/admin/imports/{merchantId} - // - // Cache eviction ensures builder/API reads see fresh data. - // - - @Override - @CacheEvict(value = "gunbuilderProducts", allEntries = true) - public void importMerchantFeed(Integer merchantId) { - log.info("Starting full import for merchantId={}", merchantId); - - Merchant merchant = merchantRepository.findById(merchantId) - .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); - - // Read & parse CSV feed into structured rows - List rows = readFeedRowsForMerchant(merchant); - log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName()); - - int processed = 0; - int notSupported = 0; - - // Main ETL loop - for (MerchantFeedRow row : rows) { - - // 1) Resolve brand (create if missing) - Brand brand = resolveBrand(row); - - // 2) Upsert product + offer - Product p = upsertProduct(merchant, brand, row); - - processed++; - - // Metrics: how much we are filtering out - if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) { - notSupported++; - } - - // Periodic progress logging - if (processed % 500 == 0) { - log.info("Import progress merchantId={} processed={}/{} notSupportedSoFar={}", - merchantId, processed, rows.size(), notSupported); - } - } - - merchant.setLastFullImportAt(OffsetDateTime.now()); - merchantRepository.save(merchant); - - log.info("✅ Completed full import for merchantId={} rows={} processed={} notSupported={}", - merchantId, rows.size(), processed, notSupported); - } - - // ========================================================================= - // PRODUCT UPSERT - // ========================================================================= - // - // Strategy: - // - Match existing products by Brand + MPN (preferred) - // - Fallback to Brand + UPC (temporary) - // - Create new product if no match - // - - private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { - 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); - } - - // Offers are merchant-specific → always upsert - updateProductFromRow(p, merchant, row, isNew); - - Product saved = productRepository.save(p); - - upsertOfferFromRow(saved, merchant, row); - - return saved; - } - - // ========================================================================= - // PRODUCT NORMALIZATION + CLASSIFICATION - // ========================================================================= - // - // This is the MOST IMPORTANT method in the file. - // If data looks wrong in the UI, 90% of the time the bug is here. - // - private void updateProductFromRow(Product p, - Merchant merchant, - MerchantFeedRow row, - boolean isNew) { - - // ---------- NAME ---------- - // Prefer productName, fallback to descriptions or SKU - // - 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 ---------- - // Only generate once (unless missing) - 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(); - - p.setSlug(generateUniqueSlug(slug)); - } - - // ---------- 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 - - // ---------- RAW CATEGORY KEY ---------- - String rawCategoryKey = buildRawCategoryKey(row); - p.setRawCategoryKey(rawCategoryKey); - - // ---------- PLATFORM RESOLUTION ---------- - // - // ORDER OF OPERATIONS: - // 1) Base heuristic (string contains AR-15, AR-10, etc) - // 2) PlatformResolver DB rules (can override to NOT-SUPPORTED) - // - if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) { - String basePlatform = inferPlatform(row); - - Long mId = merchant.getId() == null ? null : merchant.getId().longValue(); - Long bId = (p.getBrand() != null && p.getBrand().getId() != null) ? p.getBrand().getId().longValue() : null; - - // DB rules can force NOT-SUPPORTED (or AR-10, etc.) - String resolvedPlatform = platformResolver.resolve( - mId, - bId, - p.getName(), - rawCategoryKey - ); - - String finalPlatform = resolvedPlatform != null - ? resolvedPlatform - : (basePlatform != null ? basePlatform : "AR-15"); - - p.setPlatform(finalPlatform); - } - // ---------- PART ROLE (AUTHORITATIVE) ---------- -// Single source of truth: merchant map -> rules -> inference - CategoryClassificationService.Result classification = - categoryClassificationService.classify(merchant, row, p.getPlatform(), rawCategoryKey); - -// Apply results - p.setPartRole(classification.partRole()); - p.setPartRoleSource(classification.source()); - p.setClassifierVersion("v2025-12-28.1"); - p.setClassifiedAt(Instant.now()); - p.setClassificationReason(classification.reason()); - -// ---------- IMPORT STATUS ---------- - if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) { - p.setImportStatus(ImportStatus.PENDING_MAPPING); - return; - } - - if ("unknown".equalsIgnoreCase(classification.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 - .findByMerchant_IdAndAvantlinkProductId(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 - .findByMerchant_IdAndAvantlinkProductId(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"); - - 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 {} with headers {}", - (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), - feedUrl, - headerMap.keySet()); - - return CSVFormat.DEFAULT.builder() - .setDelimiter(delimiter) - .setHeader() - .setSkipHeaderRecord(true) - .setIgnoreSurroundingSpaces(true) - .setTrim(true) - .build(); - } - } catch (Exception ex) { - lastException = ex; - log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage()); - } - } - - log.warn("Could not auto-detect delimiter for feed {}. Falling back to comma delimiter.", feedUrl); - return CSVFormat.DEFAULT.builder() - .setDelimiter(',') - .setHeader() - .setSkipHeaderRecord(true) - .setIgnoreSurroundingSpaces(true) - .setTrim(true) - .build(); - } - - 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); - - return parts.isEmpty() ? null : String.join(" > ", parts); - } - - private String inferPlatform(MerchantFeedRow row) { - // Use *all* category signals. Many feeds put AR-10/AR-15 in SubCategory. - String blob = String.join(" ", - coalesce(trimOrNull(row.department()), ""), - coalesce(trimOrNull(row.category()), ""), - coalesce(trimOrNull(row.subCategory()), "") - ).toLowerCase(Locale.ROOT); - - if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15"; - if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10"; - if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9"; - if (blob.contains("ak-47") || blob.contains("ak47") || blob.contains("ak ")) return "AK-47"; - - return "AR-15"; // safe default - } - - public MerchantFeedImportServiceImpl( - MerchantRepository merchantRepository, - BrandRepository brandRepository, - ProductRepository productRepository, - PlatformResolver platformResolver, - ProductOfferRepository productOfferRepository, - CategoryClassificationService categoryClassificationService - ) { - this.merchantRepository = merchantRepository; - this.brandRepository = brandRepository; - this.productRepository = productRepository; - this.platformResolver = platformResolver; - this.productOfferRepository = productOfferRepository; - this.categoryClassificationService = categoryClassificationService; - } +/** + * @deprecated Legacy import flow. Prefer the newer import/reclassification pipeline that relies on: + * - merchant_category_map.canonical_part_role (authoritative) + * - PartRoleResolver rules + * - ImportStatus.PENDING_MAPPING for anything unresolved + */ +package group.goforward.battlbuilder.service.impl; + +import group.goforward.battlbuilder.imports.MerchantFeedRow; +import group.goforward.battlbuilder.model.Brand; +import group.goforward.battlbuilder.model.ImportStatus; +import group.goforward.battlbuilder.model.Merchant; +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.model.ProductOffer; +import group.goforward.battlbuilder.repo.BrandRepository; +import group.goforward.battlbuilder.repo.MerchantRepository; +import group.goforward.battlbuilder.repo.ProductOfferRepository; +import group.goforward.battlbuilder.repo.ProductRepository; +import group.goforward.battlbuilder.catalog.classification.PlatformResolver; +import group.goforward.battlbuilder.service.MerchantFeedImportService; +import group.goforward.battlbuilder.service.CategoryClassificationService; + +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 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.*; +import java.time.Instant; + +/** + * MerchantFeedImportServiceImpl + *

+ * RESPONSIBILITIES: + * - Read merchant product feeds (CSV/TSV/etc) + * - Normalize product data into Product entities + * - Resolve PLATFORM (AR-15, AR-10, NOT-SUPPORTED) + * - Infer part roles (temporary heuristic) + * - Upsert Product + ProductOffer rows + *

+ * NON-GOALS: + * - Perfect classification (that’s iterative) + * - UI-level filtering (handled later) + */ +@Deprecated(forRemoval = false, since = "2025-12-28") +@Service +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; + + // --- Classification --- + // DB-backed rule engine for platform (AR-15 / AR-10 / NOT-SUPPORTED) + private final CategoryClassificationService categoryClassificationService; + private final PlatformResolver platformResolver; + private final ProductOfferRepository productOfferRepository; + + + // ========================================================================= + // FULL PRODUCT + OFFER IMPORT + // ========================================================================= + // + // This is the main ETL entry point. + // Triggered via: + // POST /api/admin/imports/{merchantId} + // + // Cache eviction ensures builder/API reads see fresh data. + // + + @Override + @CacheEvict(value = "gunbuilderProducts", allEntries = true) + public void importMerchantFeed(Integer merchantId) { + log.info("Starting full import for merchantId={}", merchantId); + + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); + + // Read & parse CSV feed into structured rows + List rows = readFeedRowsForMerchant(merchant); + log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName()); + + int processed = 0; + int notSupported = 0; + + // Main ETL loop + for (MerchantFeedRow row : rows) { + + // 1) Resolve brand (create if missing) + Brand brand = resolveBrand(row); + + // 2) Upsert product + offer + Product p = upsertProduct(merchant, brand, row); + + processed++; + + // Metrics: how much we are filtering out + if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) { + notSupported++; + } + + // Periodic progress logging + if (processed % 500 == 0) { + log.info("Import progress merchantId={} processed={}/{} notSupportedSoFar={}", + merchantId, processed, rows.size(), notSupported); + } + } + + merchant.setLastFullImportAt(OffsetDateTime.now()); + merchantRepository.save(merchant); + + log.info("✅ Completed full import for merchantId={} rows={} processed={} notSupported={}", + merchantId, rows.size(), processed, notSupported); + } + + // ========================================================================= + // PRODUCT UPSERT + // ========================================================================= + // + // Strategy: + // - Match existing products by Brand + MPN (preferred) + // - Fallback to Brand + UPC (temporary) + // - Create new product if no match + // + + private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { + 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); + } + + // Offers are merchant-specific → always upsert + updateProductFromRow(p, merchant, row, isNew); + + Product saved = productRepository.save(p); + + upsertOfferFromRow(saved, merchant, row); + + return saved; + } + + // ========================================================================= + // PRODUCT NORMALIZATION + CLASSIFICATION + // ========================================================================= + // + // This is the MOST IMPORTANT method in the file. + // If data looks wrong in the UI, 90% of the time the bug is here. + // + private void updateProductFromRow(Product p, + Merchant merchant, + MerchantFeedRow row, + boolean isNew) { + + // ---------- NAME ---------- + // Prefer productName, fallback to descriptions or SKU + // + 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 ---------- + // Only generate once (unless missing) + 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(); + + p.setSlug(generateUniqueSlug(slug)); + } + + // ---------- 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 + + // ---------- RAW CATEGORY KEY ---------- + String rawCategoryKey = buildRawCategoryKey(row); + p.setRawCategoryKey(rawCategoryKey); + + // ---------- PLATFORM RESOLUTION ---------- + // + // ORDER OF OPERATIONS: + // 1) Base heuristic (string contains AR-15, AR-10, etc) + // 2) PlatformResolver DB rules (can override to NOT-SUPPORTED) + // + if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) { + String basePlatform = inferPlatform(row); + + Long mId = merchant.getId() == null ? null : merchant.getId().longValue(); + Long bId = (p.getBrand() != null && p.getBrand().getId() != null) ? p.getBrand().getId().longValue() : null; + + // DB rules can force NOT-SUPPORTED (or AR-10, etc.) + String resolvedPlatform = platformResolver.resolve( + mId, + bId, + p.getName(), + rawCategoryKey + ); + + String finalPlatform = resolvedPlatform != null + ? resolvedPlatform + : (basePlatform != null ? basePlatform : "AR-15"); + + p.setPlatform(finalPlatform); + } + // ---------- PART ROLE (AUTHORITATIVE) ---------- +// Single source of truth: merchant map -> rules -> inference + CategoryClassificationService.Result classification = + categoryClassificationService.classify(merchant, row, p.getPlatform(), rawCategoryKey); + +// Apply results + p.setPartRole(classification.partRole()); + p.setPartRoleSource(classification.source()); + p.setClassifierVersion("v2025-12-28.1"); + p.setClassifiedAt(Instant.now()); + p.setClassificationReason(classification.reason()); + +// ---------- IMPORT STATUS ---------- + if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) { + p.setImportStatus(ImportStatus.PENDING_MAPPING); + return; + } + + if ("unknown".equalsIgnoreCase(classification.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 + .findByMerchant_IdAndAvantlinkProductId(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 + .findByMerchant_IdAndAvantlinkProductId(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"); + + 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 {} with headers {}", + (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), + feedUrl, + headerMap.keySet()); + + return CSVFormat.DEFAULT.builder() + .setDelimiter(delimiter) + .setHeader() + .setSkipHeaderRecord(true) + .setIgnoreSurroundingSpaces(true) + .setTrim(true) + .build(); + } + } catch (Exception ex) { + lastException = ex; + log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage()); + } + } + + log.warn("Could not auto-detect delimiter for feed {}. Falling back to comma delimiter.", feedUrl); + return CSVFormat.DEFAULT.builder() + .setDelimiter(',') + .setHeader() + .setSkipHeaderRecord(true) + .setIgnoreSurroundingSpaces(true) + .setTrim(true) + .build(); + } + + 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); + + return parts.isEmpty() ? null : String.join(" > ", parts); + } + + private String inferPlatform(MerchantFeedRow row) { + // Use *all* category signals. Many feeds put AR-10/AR-15 in SubCategory. + String blob = String.join(" ", + coalesce(trimOrNull(row.department()), ""), + coalesce(trimOrNull(row.category()), ""), + coalesce(trimOrNull(row.subCategory()), "") + ).toLowerCase(Locale.ROOT); + + if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15"; + if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10"; + if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9"; + if (blob.contains("ak-47") || blob.contains("ak47") || blob.contains("ak ")) return "AK-47"; + + return "AR-15"; // safe default + } + + public MerchantFeedImportServiceImpl( + MerchantRepository merchantRepository, + BrandRepository brandRepository, + ProductRepository productRepository, + PlatformResolver platformResolver, + ProductOfferRepository productOfferRepository, + CategoryClassificationService categoryClassificationService + ) { + this.merchantRepository = merchantRepository; + this.brandRepository = brandRepository; + this.productRepository = productRepository; + this.platformResolver = platformResolver; + this.productOfferRepository = productOfferRepository; + this.categoryClassificationService = categoryClassificationService; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/impl/ProductQueryServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/impl/ProductQueryServiceImpl.java index 59f18d6..452dcef 100644 --- a/src/main/java/group/goforward/battlbuilder/service/impl/ProductQueryServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/impl/ProductQueryServiceImpl.java @@ -1,174 +1,174 @@ -package group.goforward.battlbuilder.service.impl; - -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.model.ProductOffer; -import group.goforward.battlbuilder.repo.ProductOfferRepository; -import group.goforward.battlbuilder.repo.ProductRepository; -import group.goforward.battlbuilder.service.ProductQueryService; -import group.goforward.battlbuilder.web.dto.ProductOfferDto; -import group.goforward.battlbuilder.web.dto.ProductSummaryDto; -import group.goforward.battlbuilder.web.mapper.ProductMapper; -import org.springframework.stereotype.Service; - -import java.math.BigDecimal; -import java.util.*; -import java.util.stream.Collectors; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; - -import java.util.Collections; -import java.util.Map; -import java.util.List; - -@Service -public class ProductQueryServiceImpl implements ProductQueryService { - - private final ProductRepository productRepository; - private final ProductOfferRepository productOfferRepository; - - public ProductQueryServiceImpl( - ProductRepository productRepository, - ProductOfferRepository productOfferRepository - ) { - this.productRepository = productRepository; - this.productOfferRepository = productOfferRepository; - } - - @Override - public List getProducts(String platform, List partRoles) { - final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL"); - - List products; - if (partRoles == null || partRoles.isEmpty()) { - products = allPlatforms - ? productRepository.findAllWithBrand() - : productRepository.findByPlatformWithBrand(platform); - } else { - products = allPlatforms - ? productRepository.findByPartRoleInWithBrand(partRoles) - : productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles); - } - - if (products.isEmpty()) return List.of(); - - List productIds = products.stream().map(Product::getId).toList(); - - // ✅ canonical repo method - List allOffers = productOfferRepository.findByProduct_IdIn(productIds); - - Map> offersByProductId = allOffers.stream() - .filter(o -> o.getProduct() != null && o.getProduct().getId() != null) - .collect(Collectors.groupingBy(o -> o.getProduct().getId())); - - return products.stream() - .map(p -> { - List offersForProduct = - offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); - - ProductOffer bestOffer = pickBestOffer(offersForProduct); - - BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; - String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; - - return ProductMapper.toSummary(p, price, buyUrl); - }) - .toList(); - } - - @Override - public Page getProductsPage(String platform, List partRoles, Pageable pageable) { - final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL"); - - Page productPage; - - if (partRoles == null || partRoles.isEmpty()) { - productPage = allPlatforms - ? productRepository.findAllWithBrand(pageable) - : productRepository.findByPlatformWithBrand(platform, pageable); - } else { - productPage = allPlatforms - ? productRepository.findByPartRoleInWithBrand(partRoles, pageable) - : productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable); - } - - List products = productPage.getContent(); - if (products.isEmpty()) { - return Page.empty(pageable); - } - - List productIds = products.stream().map(Product::getId).toList(); - - // Only fetch offers for THIS PAGE of products - List allOffers = productOfferRepository.findByProduct_IdIn(productIds); - - Map> offersByProductId = allOffers.stream() - .filter(o -> o.getProduct() != null && o.getProduct().getId() != null) - .collect(Collectors.groupingBy(o -> o.getProduct().getId())); - - List dtos = products.stream() - .map(p -> { - List offersForProduct = - offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); - - ProductOffer bestOffer = pickBestOffer(offersForProduct); - - BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; - String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; - - return ProductMapper.toSummary(p, price, buyUrl); - }) - .toList(); - - return new PageImpl<>(dtos, pageable, productPage.getTotalElements()); - } - - // - // Product Offers - // - - @Override - public List getOffersForProduct(Integer productId) { - // ✅ canonical repo method - List offers = productOfferRepository.findByProduct_Id(productId); - - return offers.stream() - .map(offer -> { - ProductOfferDto dto = new ProductOfferDto(); - dto.setId(offer.getId().toString()); - dto.setMerchantName(offer.getMerchant().getName()); - dto.setPrice(offer.getEffectivePrice()); - dto.setOriginalPrice(offer.getOriginalPrice()); - dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); - dto.setBuyUrl(offer.getBuyUrl()); - dto.setLastUpdated(offer.getLastSeenAt()); - return dto; - }) - .toList(); - } - - @Override - public ProductSummaryDto getProductById(Integer productId) { - Product product = productRepository.findById(productId).orElse(null); - if (product == null) return null; - - // ✅ canonical repo method - List offers = productOfferRepository.findByProduct_Id(productId); - ProductOffer bestOffer = pickBestOffer(offers); - - BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; - String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; - - return ProductMapper.toSummary(product, price, buyUrl); - } - - private ProductOffer pickBestOffer(List offers) { - if (offers == null || offers.isEmpty()) return null; - - // MVP: lowest effective price wins. (Later: prefer in-stock, etc.) - return offers.stream() - .filter(o -> o.getEffectivePrice() != null) - .min(Comparator.comparing(ProductOffer::getEffectivePrice)) - .orElse(null); - } +package group.goforward.battlbuilder.service.impl; + +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.model.ProductOffer; +import group.goforward.battlbuilder.repo.ProductOfferRepository; +import group.goforward.battlbuilder.repo.ProductRepository; +import group.goforward.battlbuilder.service.ProductQueryService; +import group.goforward.battlbuilder.web.dto.ProductOfferDto; +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; +import group.goforward.battlbuilder.web.mapper.ProductMapper; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.Collections; +import java.util.Map; +import java.util.List; + +@Service +public class ProductQueryServiceImpl implements ProductQueryService { + + private final ProductRepository productRepository; + private final ProductOfferRepository productOfferRepository; + + public ProductQueryServiceImpl( + ProductRepository productRepository, + ProductOfferRepository productOfferRepository + ) { + this.productRepository = productRepository; + this.productOfferRepository = productOfferRepository; + } + + @Override + public List getProducts(String platform, List partRoles) { + final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL"); + + List products; + if (partRoles == null || partRoles.isEmpty()) { + products = allPlatforms + ? productRepository.findAllWithBrand() + : productRepository.findByPlatformWithBrand(platform); + } else { + products = allPlatforms + ? productRepository.findByPartRoleInWithBrand(partRoles) + : productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles); + } + + if (products.isEmpty()) return List.of(); + + List productIds = products.stream().map(Product::getId).toList(); + + // ✅ canonical repo method + List allOffers = productOfferRepository.findByProduct_IdIn(productIds); + + Map> offersByProductId = allOffers.stream() + .filter(o -> o.getProduct() != null && o.getProduct().getId() != null) + .collect(Collectors.groupingBy(o -> o.getProduct().getId())); + + return products.stream() + .map(p -> { + List offersForProduct = + offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); + + ProductOffer bestOffer = pickBestOffer(offersForProduct); + + BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; + String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; + + return ProductMapper.toSummary(p, price, buyUrl); + }) + .toList(); + } + + @Override + public Page getProductsPage(String platform, List partRoles, Pageable pageable) { + final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL"); + + Page productPage; + + if (partRoles == null || partRoles.isEmpty()) { + productPage = allPlatforms + ? productRepository.findAllWithBrand(pageable) + : productRepository.findByPlatformWithBrand(platform, pageable); + } else { + productPage = allPlatforms + ? productRepository.findByPartRoleInWithBrand(partRoles, pageable) + : productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable); + } + + List products = productPage.getContent(); + if (products.isEmpty()) { + return Page.empty(pageable); + } + + List productIds = products.stream().map(Product::getId).toList(); + + // Only fetch offers for THIS PAGE of products + List allOffers = productOfferRepository.findByProduct_IdIn(productIds); + + Map> offersByProductId = allOffers.stream() + .filter(o -> o.getProduct() != null && o.getProduct().getId() != null) + .collect(Collectors.groupingBy(o -> o.getProduct().getId())); + + List dtos = products.stream() + .map(p -> { + List offersForProduct = + offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); + + ProductOffer bestOffer = pickBestOffer(offersForProduct); + + BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; + String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; + + return ProductMapper.toSummary(p, price, buyUrl); + }) + .toList(); + + return new PageImpl<>(dtos, pageable, productPage.getTotalElements()); + } + + // + // Product Offers + // + + @Override + public List getOffersForProduct(Integer productId) { + // ✅ canonical repo method + List offers = productOfferRepository.findByProduct_Id(productId); + + return offers.stream() + .map(offer -> { + ProductOfferDto dto = new ProductOfferDto(); + dto.setId(offer.getId().toString()); + dto.setMerchantName(offer.getMerchant().getName()); + dto.setPrice(offer.getEffectivePrice()); + dto.setOriginalPrice(offer.getOriginalPrice()); + dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); + dto.setBuyUrl(offer.getBuyUrl()); + dto.setLastUpdated(offer.getLastSeenAt()); + return dto; + }) + .toList(); + } + + @Override + public ProductSummaryDto getProductById(Integer productId) { + Product product = productRepository.findById(productId).orElse(null); + if (product == null) return null; + + // ✅ canonical repo method + List offers = productOfferRepository.findByProduct_Id(productId); + ProductOffer bestOffer = pickBestOffer(offers); + + BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; + String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; + + return ProductMapper.toSummary(product, price, buyUrl); + } + + private ProductOffer pickBestOffer(List offers) { + if (offers == null || offers.isEmpty()) return null; + + // MVP: lowest effective price wins. (Later: prefer in-stock, etc.) + return offers.stream() + .filter(o -> o.getEffectivePrice() != null) + .min(Comparator.comparing(ProductOffer::getEffectivePrice)) + .orElse(null); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/impl/ReclassificationServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/impl/ReclassificationServiceImpl.java index d389efb..07f62ba 100644 --- a/src/main/java/group/goforward/battlbuilder/service/impl/ReclassificationServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/impl/ReclassificationServiceImpl.java @@ -1,175 +1,175 @@ -package group.goforward.battlbuilder.service.impl; - -import group.goforward.battlbuilder.model.ImportStatus; -import group.goforward.battlbuilder.model.PartRoleSource; -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.repo.ProductRepository; -import group.goforward.battlbuilder.service.ReclassificationService; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Optional; - -@Service -public class ReclassificationServiceImpl implements ReclassificationService { - - private static final String CLASSIFIER_VERSION = "v2025-12-28.1"; - - private final ProductRepository productRepository; - private final MerchantCategoryMappingService merchantCategoryMappingService; - - // ✅ Keep ONE constructor. Spring will inject both deps. - public ReclassificationServiceImpl( - ProductRepository productRepository, - MerchantCategoryMappingService merchantCategoryMappingService - ) { - this.productRepository = productRepository; - this.merchantCategoryMappingService = merchantCategoryMappingService; - } - - // ============================ - // Catalog category FK backfill - // ============================ - - @Override - @Transactional - public int applyCatalogCategoryMappingToProducts( - Integer merchantId, - String rawCategoryKey, - Integer canonicalCategoryId - ) { - if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); - if (rawCategoryKey == null || rawCategoryKey.isBlank()) - throw new IllegalArgumentException("rawCategoryKey is required"); - - return productRepository.applyCanonicalCategoryByPrimaryMerchantAndRawCategory( - merchantId, - rawCategoryKey.trim(), - canonicalCategoryId - ); - } - - /** - * Optional helper: bulk reclassify only PENDING_MAPPING for a merchant, - * using ONLY merchant_category_map (no rules, no inference). - */ - @Override - @Transactional - public int reclassifyPendingForMerchant(Integer merchantId) { - if (merchantId == null) throw new IllegalArgumentException("merchantId required"); - - List pending = productRepository.findPendingMappingByMerchantId(merchantId); - if (pending == null || pending.isEmpty()) return 0; - - Instant now = Instant.now(); - List toSave = new ArrayList<>(); - int updated = 0; - - for (Product p : pending) { - if (p.getDeletedAt() != null) continue; - if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue; - - String rawCategoryKey = p.getRawCategoryKey(); - if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue; - - String platformFinal = normalizePlatformOrNull(p.getPlatform()); - - Optional mappedRole = merchantCategoryMappingService.resolveMappedPartRole( - merchantId, rawCategoryKey, platformFinal - ); - if (mappedRole.isEmpty()) continue; - - String normalized = normalizePartRole(mappedRole.get()); - if ("unknown".equals(normalized)) continue; - - String current = normalizePartRole(p.getPartRole()); - if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue; - - p.setPartRole(normalized); - p.setImportStatus(ImportStatus.MAPPED); - - p.setPartRoleSource(PartRoleSource.MERCHANT_MAP); - p.setClassifierVersion(CLASSIFIER_VERSION); - p.setClassifiedAt(now); - p.setClassificationReason("merchant_category_map: " + rawCategoryKey + - (platformFinal != null ? (" (" + platformFinal + ")") : "")); - - toSave.add(p); - updated++; - } - - if (!toSave.isEmpty()) productRepository.saveAll(toSave); - return updated; - } - - /** - * Called by MappingAdminService after creating/updating a mapping. - * Applies mapping to all products for merchant+rawCategoryKey. - */ - @Override - @Transactional - public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) { - if (merchantId == null) throw new IllegalArgumentException("merchantId required"); - if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required"); - - List products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey); - if (products == null || products.isEmpty()) return 0; - - Instant now = Instant.now(); - List toSave = new ArrayList<>(); - int updated = 0; - - for (Product p : products) { - if (p.getDeletedAt() != null) continue; - if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue; - - String platformFinal = normalizePlatformOrNull(p.getPlatform()); - - Optional mappedRole = merchantCategoryMappingService.resolveMappedPartRole( - merchantId, rawCategoryKey, platformFinal - ); - if (mappedRole.isEmpty()) continue; - - String normalized = normalizePartRole(mappedRole.get()); - if ("unknown".equals(normalized)) continue; - - String current = normalizePartRole(p.getPartRole()); - if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue; - - p.setPartRole(normalized); - p.setImportStatus(ImportStatus.MAPPED); - - p.setPartRoleSource(PartRoleSource.MERCHANT_MAP); - p.setClassifierVersion(CLASSIFIER_VERSION); - p.setClassifiedAt(now); - p.setClassificationReason("merchant_category_map: " + rawCategoryKey + - (platformFinal != null ? (" (" + platformFinal + ")") : "")); - - toSave.add(p); - updated++; - } - - if (!toSave.isEmpty()) productRepository.saveAll(toSave); - return updated; - } - - // ----------------- - // Helpers - // ----------------- - - private String normalizePlatformOrNull(String platform) { - if (platform == null) return null; - String t = platform.trim(); - return t.isEmpty() ? null : t; - } - - private String normalizePartRole(String partRole) { - if (partRole == null) return "unknown"; - String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-'); - return t.isBlank() ? "unknown" : t; - } +package group.goforward.battlbuilder.service.impl; + +import group.goforward.battlbuilder.model.ImportStatus; +import group.goforward.battlbuilder.model.PartRoleSource; +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.repo.ProductRepository; +import group.goforward.battlbuilder.service.ReclassificationService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +@Service +public class ReclassificationServiceImpl implements ReclassificationService { + + private static final String CLASSIFIER_VERSION = "v2025-12-28.1"; + + private final ProductRepository productRepository; + private final MerchantCategoryMappingService merchantCategoryMappingService; + + // ✅ Keep ONE constructor. Spring will inject both deps. + public ReclassificationServiceImpl( + ProductRepository productRepository, + MerchantCategoryMappingService merchantCategoryMappingService + ) { + this.productRepository = productRepository; + this.merchantCategoryMappingService = merchantCategoryMappingService; + } + + // ============================ + // Catalog category FK backfill + // ============================ + + @Override + @Transactional + public int applyCatalogCategoryMappingToProducts( + Integer merchantId, + String rawCategoryKey, + Integer canonicalCategoryId + ) { + if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); + if (rawCategoryKey == null || rawCategoryKey.isBlank()) + throw new IllegalArgumentException("rawCategoryKey is required"); + + return productRepository.applyCanonicalCategoryByPrimaryMerchantAndRawCategory( + merchantId, + rawCategoryKey.trim(), + canonicalCategoryId + ); + } + + /** + * Optional helper: bulk reclassify only PENDING_MAPPING for a merchant, + * using ONLY merchant_category_map (no rules, no inference). + */ + @Override + @Transactional + public int reclassifyPendingForMerchant(Integer merchantId) { + if (merchantId == null) throw new IllegalArgumentException("merchantId required"); + + List pending = productRepository.findPendingMappingByMerchantId(merchantId); + if (pending == null || pending.isEmpty()) return 0; + + Instant now = Instant.now(); + List toSave = new ArrayList<>(); + int updated = 0; + + for (Product p : pending) { + if (p.getDeletedAt() != null) continue; + if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue; + + String rawCategoryKey = p.getRawCategoryKey(); + if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue; + + String platformFinal = normalizePlatformOrNull(p.getPlatform()); + + Optional mappedRole = merchantCategoryMappingService.resolveMappedPartRole( + merchantId, rawCategoryKey, platformFinal + ); + if (mappedRole.isEmpty()) continue; + + String normalized = normalizePartRole(mappedRole.get()); + if ("unknown".equals(normalized)) continue; + + String current = normalizePartRole(p.getPartRole()); + if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue; + + p.setPartRole(normalized); + p.setImportStatus(ImportStatus.MAPPED); + + p.setPartRoleSource(PartRoleSource.MERCHANT_MAP); + p.setClassifierVersion(CLASSIFIER_VERSION); + p.setClassifiedAt(now); + p.setClassificationReason("merchant_category_map: " + rawCategoryKey + + (platformFinal != null ? (" (" + platformFinal + ")") : "")); + + toSave.add(p); + updated++; + } + + if (!toSave.isEmpty()) productRepository.saveAll(toSave); + return updated; + } + + /** + * Called by MappingAdminService after creating/updating a mapping. + * Applies mapping to all products for merchant+rawCategoryKey. + */ + @Override + @Transactional + public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) { + if (merchantId == null) throw new IllegalArgumentException("merchantId required"); + if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required"); + + List products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey); + if (products == null || products.isEmpty()) return 0; + + Instant now = Instant.now(); + List toSave = new ArrayList<>(); + int updated = 0; + + for (Product p : products) { + if (p.getDeletedAt() != null) continue; + if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue; + + String platformFinal = normalizePlatformOrNull(p.getPlatform()); + + Optional mappedRole = merchantCategoryMappingService.resolveMappedPartRole( + merchantId, rawCategoryKey, platformFinal + ); + if (mappedRole.isEmpty()) continue; + + String normalized = normalizePartRole(mappedRole.get()); + if ("unknown".equals(normalized)) continue; + + String current = normalizePartRole(p.getPartRole()); + if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue; + + p.setPartRole(normalized); + p.setImportStatus(ImportStatus.MAPPED); + + p.setPartRoleSource(PartRoleSource.MERCHANT_MAP); + p.setClassifierVersion(CLASSIFIER_VERSION); + p.setClassifiedAt(now); + p.setClassificationReason("merchant_category_map: " + rawCategoryKey + + (platformFinal != null ? (" (" + platformFinal + ")") : "")); + + toSave.add(p); + updated++; + } + + if (!toSave.isEmpty()) productRepository.saveAll(toSave); + return updated; + } + + // ----------------- + // Helpers + // ----------------- + + private String normalizePlatformOrNull(String platform) { + if (platform == null) return null; + String t = platform.trim(); + return t.isEmpty() ? null : t; + } + + private String normalizePartRole(String partRole) { + if (partRole == null) return "unknown"; + String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-'); + return t.isBlank() ? "unknown" : t; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/package-info.java b/src/main/java/group/goforward/battlbuilder/service/package-info.java index c91c9ec..fbb3d2f 100644 --- a/src/main/java/group/goforward/battlbuilder/service/package-info.java +++ b/src/main/java/group/goforward/battlbuilder/service/package-info.java @@ -1,12 +1,12 @@ - -/** - * Services package for the BattlBuilder application. - *

- * Contains business logic service classes for product management, - * category classification, mapping recommendations, and merchant operations. - * - * @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.service; + +/** + * Services package for the BattlBuilder application. + *

+ * Contains business logic service classes for product management, + * category classification, mapping recommendations, and merchant operations. + * + * @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.service; diff --git a/src/main/java/group/goforward/battlbuilder/service/utils/ImageUrlToMinioMigrator.java b/src/main/java/group/goforward/battlbuilder/service/utils/ImageUrlToMinioMigrator.java index 2dc1307..f299fcf 100644 --- a/src/main/java/group/goforward/battlbuilder/service/utils/ImageUrlToMinioMigrator.java +++ b/src/main/java/group/goforward/battlbuilder/service/utils/ImageUrlToMinioMigrator.java @@ -1,183 +1,183 @@ -package group.goforward.battlbuilder.service.utils; - -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.repo.ProductRepository; -import io.minio.BucketExistsArgs; -import io.minio.MakeBucketArgs; -import io.minio.MinioClient; -import io.minio.PutObjectArgs; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.io.InputStream; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.Locale; -import java.util.Optional; - -@Service -public class ImageUrlToMinioMigrator { - - private final ProductRepository productRepository; - private final MinioClient minioClient; - - private final String bucket; - private final String publicBaseUrl; - - private final HttpClient httpClient = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(Duration.ofSeconds(15)) - .build(); - - public ImageUrlToMinioMigrator(ProductRepository productRepository, - MinioClient minioClient, - @Value("${minio.bucket}") String bucket, - @Value("${minio.public-base-url}") String publicBaseUrl) { - this.productRepository = productRepository; - this.minioClient = minioClient; - this.bucket = bucket; - this.publicBaseUrl = trimTrailingSlash(publicBaseUrl); - } - - /** - * Migrates Product.mainImageUrl URLs to MinIO and rewrites mainImageUrl to the new location. - * - * @param pageSize batch size for DB paging - * @param dryRun if true: download+upload is skipped and DB is not updated - * @param maxItems optional cap for safety (null = no cap) - * @return count of successfully migrated products - */ - @Transactional - public int migrateMainImages(int pageSize, boolean dryRun, Integer maxItems) { - ensureBucketExists(); - - int migrated = 0; - int page = 0; - - while (true) { - if (maxItems != null && migrated >= maxItems) break; - - Page batch = productRepository - .findNeedingBattlImageUrlMigration(PageRequest.of(page, pageSize)); - if (batch.isEmpty()) break; - - for (Product p : batch.getContent()) { - if (maxItems != null && migrated >= maxItems) break; - - String sourceUrl = p.getMainImageUrl(); - if (sourceUrl == null || sourceUrl.isBlank()) continue; - - // Extra safety: skip if already set (covers any edge cases outside the query) - if (p.getBattlImageUrl() != null && !p.getBattlImageUrl().trim().isEmpty()) continue; - - try { - if (!dryRun) { - String newUrl = uploadFromUrlToMinio(p, sourceUrl); - p.setBattlImageUrl(newUrl); - productRepository.save(p); - } - migrated++; - } catch (Exception ex) { - // fail-soft: continue migrating other products - } - } - - if (!batch.hasNext()) break; - page++; - } - - return migrated; - } - - private String uploadFromUrlToMinio(Product p, String sourceUrl) throws Exception { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(sourceUrl)) - .timeout(Duration.ofSeconds(60)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - - int status = response.statusCode(); - if (status < 200 || status >= 300) { - throw new IllegalStateException("Failed to download image. HTTP " + status + " url=" + sourceUrl); - } - - String contentType = response.headers() - .firstValue("content-type") - .map(v -> v.split(";", 2)[0].trim()) - .orElse("application/octet-stream"); - - long contentLength = response.headers() - .firstValue("content-length") - .flatMap(ImageUrlToMinioMigrator::parseLongSafe) - .orElse(-1L); - - String ext = extensionForContentType(contentType); - - // Store under a stable key; adjust if you want per-merchant, hashed names, etc. - String objectName = "products/" + p.getId() + "/main" + ext; - - try (InputStream in = response.body()) { - PutObjectArgs.Builder put = PutObjectArgs.builder() - .bucket(bucket) - .object(objectName) - .contentType(contentType); - - if (contentLength >= 0) { - put.stream(in, contentLength, -1); - } else { - put.stream(in, -1, 10L * 1024 * 1024); - } - - minioClient.putObject(put.build()); - } - - return publicBaseUrl + "/" + bucket + "/" + objectName; - } - - private void ensureBucketExists() { - try { - boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); - if (!exists) { - minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); - } - } catch (Exception e) { - throw new IllegalStateException("Unable to ensure MinIO bucket exists: " + bucket, e); - } - } - - private boolean looksAlreadyMigrated(String url) { - String prefix = publicBaseUrl + "/" + bucket + "/"; - return url.startsWith(prefix); - } - - private static Optional parseLongSafe(String v) { - try { - return Optional.of(Long.parseLong(v)); - } catch (Exception e) { - return Optional.empty(); - } - } - - private static String extensionForContentType(String contentType) { - String ct = contentType.toLowerCase(Locale.ROOT); - if (ct.equals("image/jpeg") || ct.equals("image/jpg")) return ".jpg"; - if (ct.equals("image/png")) return ".png"; - if (ct.equals("image/webp")) return ".webp"; - if (ct.equals("image/gif")) return ".gif"; - if (ct.equals("image/svg+xml")) return ".svg"; - return ".bin"; - } - - private static String trimTrailingSlash(String s) { - if (s == null) return ""; - return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; - } -} +package group.goforward.battlbuilder.service.utils; + +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.repo.ProductRepository; +import io.minio.BucketExistsArgs; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Locale; +import java.util.Optional; + +@Service +public class ImageUrlToMinioMigrator { + + private final ProductRepository productRepository; + private final MinioClient minioClient; + + private final String bucket; + private final String publicBaseUrl; + + private final HttpClient httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(15)) + .build(); + + public ImageUrlToMinioMigrator(ProductRepository productRepository, + MinioClient minioClient, + @Value("${minio.bucket}") String bucket, + @Value("${minio.public-base-url}") String publicBaseUrl) { + this.productRepository = productRepository; + this.minioClient = minioClient; + this.bucket = bucket; + this.publicBaseUrl = trimTrailingSlash(publicBaseUrl); + } + + /** + * Migrates Product.mainImageUrl URLs to MinIO and rewrites mainImageUrl to the new location. + * + * @param pageSize batch size for DB paging + * @param dryRun if true: download+upload is skipped and DB is not updated + * @param maxItems optional cap for safety (null = no cap) + * @return count of successfully migrated products + */ + @Transactional + public int migrateMainImages(int pageSize, boolean dryRun, Integer maxItems) { + ensureBucketExists(); + + int migrated = 0; + int page = 0; + + while (true) { + if (maxItems != null && migrated >= maxItems) break; + + Page batch = productRepository + .findNeedingBattlImageUrlMigration(PageRequest.of(page, pageSize)); + if (batch.isEmpty()) break; + + for (Product p : batch.getContent()) { + if (maxItems != null && migrated >= maxItems) break; + + String sourceUrl = p.getMainImageUrl(); + if (sourceUrl == null || sourceUrl.isBlank()) continue; + + // Extra safety: skip if already set (covers any edge cases outside the query) + if (p.getBattlImageUrl() != null && !p.getBattlImageUrl().trim().isEmpty()) continue; + + try { + if (!dryRun) { + String newUrl = uploadFromUrlToMinio(p, sourceUrl); + p.setBattlImageUrl(newUrl); + productRepository.save(p); + } + migrated++; + } catch (Exception ex) { + // fail-soft: continue migrating other products + } + } + + if (!batch.hasNext()) break; + page++; + } + + return migrated; + } + + private String uploadFromUrlToMinio(Product p, String sourceUrl) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(sourceUrl)) + .timeout(Duration.ofSeconds(60)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + int status = response.statusCode(); + if (status < 200 || status >= 300) { + throw new IllegalStateException("Failed to download image. HTTP " + status + " url=" + sourceUrl); + } + + String contentType = response.headers() + .firstValue("content-type") + .map(v -> v.split(";", 2)[0].trim()) + .orElse("application/octet-stream"); + + long contentLength = response.headers() + .firstValue("content-length") + .flatMap(ImageUrlToMinioMigrator::parseLongSafe) + .orElse(-1L); + + String ext = extensionForContentType(contentType); + + // Store under a stable key; adjust if you want per-merchant, hashed names, etc. + String objectName = "products/" + p.getId() + "/main" + ext; + + try (InputStream in = response.body()) { + PutObjectArgs.Builder put = PutObjectArgs.builder() + .bucket(bucket) + .object(objectName) + .contentType(contentType); + + if (contentLength >= 0) { + put.stream(in, contentLength, -1); + } else { + put.stream(in, -1, 10L * 1024 * 1024); + } + + minioClient.putObject(put.build()); + } + + return publicBaseUrl + "/" + bucket + "/" + objectName; + } + + private void ensureBucketExists() { + try { + boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); + if (!exists) { + minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); + } + } catch (Exception e) { + throw new IllegalStateException("Unable to ensure MinIO bucket exists: " + bucket, e); + } + } + + private boolean looksAlreadyMigrated(String url) { + String prefix = publicBaseUrl + "/" + bucket + "/"; + return url.startsWith(prefix); + } + + private static Optional parseLongSafe(String v) { + try { + return Optional.of(Long.parseLong(v)); + } catch (Exception e) { + return Optional.empty(); + } + } + + private static String extensionForContentType(String contentType) { + String ct = contentType.toLowerCase(Locale.ROOT); + if (ct.equals("image/jpeg") || ct.equals("image/jpg")) return ".jpg"; + if (ct.equals("image/png")) return ".png"; + if (ct.equals("image/webp")) return ".webp"; + if (ct.equals("image/gif")) return ".gif"; + if (ct.equals("image/svg+xml")) return ".svg"; + return ".bin"; + } + + private static String trimTrailingSlash(String s) { + if (s == null) return ""; + return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; + } +} diff --git a/src/main/java/group/goforward/battlbuilder/service/utils/MigrateProductImagesToMinioRunner.java b/src/main/java/group/goforward/battlbuilder/service/utils/MigrateProductImagesToMinioRunner.java index 0e13645..9977d3c 100644 --- a/src/main/java/group/goforward/battlbuilder/service/utils/MigrateProductImagesToMinioRunner.java +++ b/src/main/java/group/goforward/battlbuilder/service/utils/MigrateProductImagesToMinioRunner.java @@ -1,28 +1,28 @@ -package group.goforward.battlbuilder.service.utils; - -import org.springframework.boot.CommandLineRunner; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -@Component -@Profile("migrate-images-to-minio") -public class MigrateProductImagesToMinioRunner implements CommandLineRunner { - - private final ImageUrlToMinioMigrator migrator; - - public MigrateProductImagesToMinioRunner(ImageUrlToMinioMigrator migrator) { - this.migrator = migrator; - } - - @Override - public void run(String... args) { - // Tune as needed. Start small; you can remove maxItems once you're confident. - int migrated = migrator.migrateMainImages( - 200, // pageSize - false, // dryRun - 1000 // maxItems safety cap - ); - - System.out.println("Migrated product images: " + migrated); - } -} +package group.goforward.battlbuilder.service.utils; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Profile("migrate-images-to-minio") +public class MigrateProductImagesToMinioRunner implements CommandLineRunner { + + private final ImageUrlToMinioMigrator migrator; + + public MigrateProductImagesToMinioRunner(ImageUrlToMinioMigrator migrator) { + this.migrator = migrator; + } + + @Override + public void run(String... args) { + // Tune as needed. Start small; you can remove maxItems once you're confident. + int migrated = migrator.migrateMainImages( + 200, // pageSize + false, // dryRun + 1000 // maxItems safety cap + ); + + System.out.println("Migrated product images: " + migrated); + } +} diff --git a/src/main/java/group/goforward/battlbuilder/service/utils/TemplateRenderer.java b/src/main/java/group/goforward/battlbuilder/service/utils/TemplateRenderer.java index 0732a56..a75c103 100644 --- a/src/main/java/group/goforward/battlbuilder/service/utils/TemplateRenderer.java +++ b/src/main/java/group/goforward/battlbuilder/service/utils/TemplateRenderer.java @@ -1,15 +1,15 @@ -package group.goforward.battlbuilder.service.utils; - -import java.util.Map; - -public final class TemplateRenderer { - private TemplateRenderer() {} - - public static String render(String template, Map vars) { - String out = template; - for (var e : vars.entrySet()) { - out = out.replace("{{" + e.getKey() + "}}", e.getValue() == null ? "" : e.getValue()); - } - return out; - } +package group.goforward.battlbuilder.service.utils; + +import java.util.Map; + +public final class TemplateRenderer { + private TemplateRenderer() {} + + public static String render(String template, Map vars) { + String out = template; + for (var e : vars.entrySet()) { + out = out.replace("{{" + e.getKey() + "}}", e.getValue() == null ? "" : e.getValue()); + } + return out; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/utils/TemplatedEmailService.java b/src/main/java/group/goforward/battlbuilder/service/utils/TemplatedEmailService.java index 52c70f2..17fc4ed 100644 --- a/src/main/java/group/goforward/battlbuilder/service/utils/TemplatedEmailService.java +++ b/src/main/java/group/goforward/battlbuilder/service/utils/TemplatedEmailService.java @@ -1,32 +1,32 @@ -package group.goforward.battlbuilder.service.utils; - -import group.goforward.battlbuilder.model.EmailRequest; -import group.goforward.battlbuilder.model.EmailTemplate; -import group.goforward.battlbuilder.repo.EmailTemplateRepository; -import org.springframework.stereotype.Service; - -import java.util.Map; - -@Service -public class TemplatedEmailService { - - private final EmailTemplateRepository templates; - private final EmailService emailService; - - public TemplatedEmailService(EmailTemplateRepository templates, EmailService emailService) { - this.templates = templates; - this.emailService = emailService; - } - - public EmailRequest send(String templateKey, String to, Map vars) { - EmailTemplate t = templates.findByTemplateKeyAndEnabledTrue(templateKey) - .orElseThrow(() -> new IllegalArgumentException("Missing/disabled email template: " + templateKey)); - - String subject = TemplateRenderer.render(t.getSubject(), vars); - String html = TemplateRenderer.render(t.getHtmlBody(), vars); - String text = t.getTextBody() == null ? null : TemplateRenderer.render(t.getTextBody(), vars); - - // ✅ template_key persisted inside EmailService (no double-save) - return emailService.sendEmailHtml(to, subject, html, text, templateKey); - } +package group.goforward.battlbuilder.service.utils; + +import group.goforward.battlbuilder.model.EmailRequest; +import group.goforward.battlbuilder.model.EmailTemplate; +import group.goforward.battlbuilder.repo.EmailTemplateRepository; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +public class TemplatedEmailService { + + private final EmailTemplateRepository templates; + private final EmailService emailService; + + public TemplatedEmailService(EmailTemplateRepository templates, EmailService emailService) { + this.templates = templates; + this.emailService = emailService; + } + + public EmailRequest send(String templateKey, String to, Map vars) { + EmailTemplate t = templates.findByTemplateKeyAndEnabledTrue(templateKey) + .orElseThrow(() -> new IllegalArgumentException("Missing/disabled email template: " + templateKey)); + + String subject = TemplateRenderer.render(t.getSubject(), vars); + String html = TemplateRenderer.render(t.getHtmlBody(), vars); + String text = t.getTextBody() == null ? null : TemplateRenderer.render(t.getTextBody(), vars); + + // ✅ template_key persisted inside EmailService (no double-save) + return emailService.sendEmailHtml(to, subject, html, text, templateKey); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/utils/impl/EmailServiceImpl.java b/src/main/java/group/goforward/battlbuilder/service/utils/impl/EmailServiceImpl.java index a955cff..3e2561c 100644 --- a/src/main/java/group/goforward/battlbuilder/service/utils/impl/EmailServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/service/utils/impl/EmailServiceImpl.java @@ -1,131 +1,131 @@ -package group.goforward.battlbuilder.service.utils.impl; - -import group.goforward.battlbuilder.model.EmailRequest; -import group.goforward.battlbuilder.model.EmailStatus; -import group.goforward.battlbuilder.repo.EmailRequestRepository; -import group.goforward.battlbuilder.service.utils.EmailService; -import jakarta.mail.internet.MimeMessage; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; - -@Service -public class EmailServiceImpl implements EmailService { - - @Autowired - private JavaMailSender mailSender; - - @Autowired - private EmailRequestRepository emailRequestRepository; - - @Value("${spring.mail.username}") - private String fromEmail; - - // Kill switch for beta sign up(default true so dev + future prod work normally) - @Value("${app.email.outbound-enabled:true}") - private boolean outboundEnabled; - - @Override - @Transactional - public EmailRequest sendEmail(String recipient, String subject, String body) { - EmailRequest req = new EmailRequest(); - req.setRecipient(recipient); - req.setSubject(subject); - req.setBody(body); - req.setStatus(EmailStatus.PENDING); - - // Capture-only mode: store but don’t send - if (!outboundEnabled) { - req.setStatus(EmailStatus.PENDING); - req.setErrorMessage("Outbound email suppressed by config (app.email.outbound-enabled=false)"); - return emailRequestRepository.save(req); - } - - try { - MimeMessage message = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper( - message, - true, - StandardCharsets.UTF_8.name() - ); - - helper.setFrom(fromEmail); - helper.setTo(recipient); - helper.setSubject(subject); - helper.setText(body, false); - - mailSender.send(message); - - req.setStatus(EmailStatus.SENT); - req.setSentAt(LocalDateTime.now()); - } catch (Exception e) { - req.setStatus(EmailStatus.FAILED); - req.setErrorMessage(e.getMessage()); - } - - // ✅ Single INSERT (no update spam) - return emailRequestRepository.save(req); - } - - @Override - public void deleteById(Integer id) { - emailRequestRepository.deleteById(id.longValue()); - } - - @Override - public EmailRequest sendEmailHtml(String recipient, String subject, String htmlBody, String textBody) { - return sendEmailHtml(recipient, subject, htmlBody, textBody, null); - } - - @Override - @Transactional - public EmailRequest sendEmailHtml(String recipient, String subject, String htmlBody, String textBody, String templateKey) { - - EmailRequest req = new EmailRequest(); - req.setRecipient(recipient); - req.setSubject(subject); - req.setBody(htmlBody); // storing HTML for now - req.setTemplateKey(templateKey); - req.setStatus(EmailStatus.PENDING); - - // Capture-only mode: store but don’t send - if (!outboundEnabled) { - req.setStatus(EmailStatus.PENDING); - req.setErrorMessage("Outbound email suppressed by config (app.email.outbound-enabled=false)"); - return emailRequestRepository.save(req); - } - - try { - MimeMessage message = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper( - message, - true, - StandardCharsets.UTF_8.name() - ); - - helper.setFrom(fromEmail); - helper.setTo(recipient); - helper.setSubject(subject); - - // plain + html (best practice) - helper.setText(textBody != null ? textBody : "", htmlBody); - - mailSender.send(message); - - req.setStatus(EmailStatus.SENT); - req.setSentAt(LocalDateTime.now()); - } catch (Exception e) { - req.setStatus(EmailStatus.FAILED); - req.setErrorMessage(e.getMessage()); - } - - // ✅ Single INSERT (no extra UPDATEs) - return emailRequestRepository.save(req); - } +package group.goforward.battlbuilder.service.utils.impl; + +import group.goforward.battlbuilder.model.EmailRequest; +import group.goforward.battlbuilder.model.EmailStatus; +import group.goforward.battlbuilder.repo.EmailRequestRepository; +import group.goforward.battlbuilder.service.utils.EmailService; +import jakarta.mail.internet.MimeMessage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; + +@Service +public class EmailServiceImpl implements EmailService { + + @Autowired + private JavaMailSender mailSender; + + @Autowired + private EmailRequestRepository emailRequestRepository; + + @Value("${spring.mail.username}") + private String fromEmail; + + // Kill switch for beta sign up(default true so dev + future prod work normally) + @Value("${app.email.outbound-enabled:true}") + private boolean outboundEnabled; + + @Override + @Transactional + public EmailRequest sendEmail(String recipient, String subject, String body) { + EmailRequest req = new EmailRequest(); + req.setRecipient(recipient); + req.setSubject(subject); + req.setBody(body); + req.setStatus(EmailStatus.PENDING); + + // Capture-only mode: store but don’t send + if (!outboundEnabled) { + req.setStatus(EmailStatus.PENDING); + req.setErrorMessage("Outbound email suppressed by config (app.email.outbound-enabled=false)"); + return emailRequestRepository.save(req); + } + + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper( + message, + true, + StandardCharsets.UTF_8.name() + ); + + helper.setFrom(fromEmail); + helper.setTo(recipient); + helper.setSubject(subject); + helper.setText(body, false); + + mailSender.send(message); + + req.setStatus(EmailStatus.SENT); + req.setSentAt(LocalDateTime.now()); + } catch (Exception e) { + req.setStatus(EmailStatus.FAILED); + req.setErrorMessage(e.getMessage()); + } + + // ✅ Single INSERT (no update spam) + return emailRequestRepository.save(req); + } + + @Override + public void deleteById(Integer id) { + emailRequestRepository.deleteById(id.longValue()); + } + + @Override + public EmailRequest sendEmailHtml(String recipient, String subject, String htmlBody, String textBody) { + return sendEmailHtml(recipient, subject, htmlBody, textBody, null); + } + + @Override + @Transactional + public EmailRequest sendEmailHtml(String recipient, String subject, String htmlBody, String textBody, String templateKey) { + + EmailRequest req = new EmailRequest(); + req.setRecipient(recipient); + req.setSubject(subject); + req.setBody(htmlBody); // storing HTML for now + req.setTemplateKey(templateKey); + req.setStatus(EmailStatus.PENDING); + + // Capture-only mode: store but don’t send + if (!outboundEnabled) { + req.setStatus(EmailStatus.PENDING); + req.setErrorMessage("Outbound email suppressed by config (app.email.outbound-enabled=false)"); + return emailRequestRepository.save(req); + } + + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper( + message, + true, + StandardCharsets.UTF_8.name() + ); + + helper.setFrom(fromEmail); + helper.setTo(recipient); + helper.setSubject(subject); + + // plain + html (best practice) + helper.setText(textBody != null ? textBody : "", htmlBody); + + mailSender.send(message); + + req.setStatus(EmailStatus.SENT); + req.setSentAt(LocalDateTime.now()); + } catch (Exception e) { + req.setStatus(EmailStatus.FAILED); + req.setErrorMessage(e.getMessage()); + } + + // ✅ Single INSERT (no extra UPDATEs) + return emailRequestRepository.save(req); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/service/utils/impl/package-info.java b/src/main/java/group/goforward/battlbuilder/service/utils/impl/package-info.java index c6e08a4..1c09164 100644 --- a/src/main/java/group/goforward/battlbuilder/service/utils/impl/package-info.java +++ b/src/main/java/group/goforward/battlbuilder/service/utils/impl/package-info.java @@ -1,10 +1,10 @@ -/** - * Utility service implementations package for the BattlBuilder application. - *

- * Contains implementation classes for utility service interfaces. - * - * @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.service.utils.impl; +/** + * Utility service implementations package for the BattlBuilder application. + *

+ * Contains implementation classes for utility service interfaces. + * + * @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.service.utils.impl; diff --git a/src/main/java/group/goforward/battlbuilder/web/admin/AdminImportStatusController.java b/src/main/java/group/goforward/battlbuilder/web/admin/AdminImportStatusController.java index 4c6424e..0442c39 100644 --- a/src/main/java/group/goforward/battlbuilder/web/admin/AdminImportStatusController.java +++ b/src/main/java/group/goforward/battlbuilder/web/admin/AdminImportStatusController.java @@ -1,73 +1,73 @@ -package group.goforward.battlbuilder.web.admin; - -import group.goforward.battlbuilder.model.ImportStatus; -import group.goforward.battlbuilder.repo.ProductRepository; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; -import java.util.Map; - -@RestController -@RequestMapping("/api/admin/import-status") -public class AdminImportStatusController { - - private final ProductRepository productRepository; - - public AdminImportStatusController(ProductRepository productRepository) { - this.productRepository = productRepository; - } - - public record ImportSummaryDto( - long totalProducts, - long mappedProducts, - long pendingProducts - ) {} - - public record ByMerchantRowDto( - Integer merchantId, - String merchantName, - String platform, - ImportStatus status, - long count - ) {} - - @GetMapping("/summary") - public ImportSummaryDto summary() { - List> rows = productRepository.aggregateByImportStatus(); - - long total = 0L; - long mapped = 0L; - long pending = 0L; - - for (Map row : rows) { - ImportStatus status = (ImportStatus) row.get("status"); - long count = ((Number) row.get("count")).longValue(); - total += count; - - if (status == ImportStatus.MAPPED) { - mapped += count; - } else if (status == ImportStatus.PENDING_MAPPING) { - pending += count; - } - } - - return new ImportSummaryDto(total, mapped, pending); - } - - @GetMapping("/by-merchant") - public List byMerchant() { - List> rows = productRepository.aggregateByMerchantAndStatus(); - - return rows.stream() - .map(row -> new ByMerchantRowDto( - (Integer) row.get("merchantId"), - (String) row.get("merchantName"), - (String) row.get("platform"), - (ImportStatus) row.get("status"), - ((Number) row.get("count")).longValue() - )) - .toList(); - } +package group.goforward.battlbuilder.web.admin; + +import group.goforward.battlbuilder.model.ImportStatus; +import group.goforward.battlbuilder.repo.ProductRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/import-status") +public class AdminImportStatusController { + + private final ProductRepository productRepository; + + public AdminImportStatusController(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + public record ImportSummaryDto( + long totalProducts, + long mappedProducts, + long pendingProducts + ) {} + + public record ByMerchantRowDto( + Integer merchantId, + String merchantName, + String platform, + ImportStatus status, + long count + ) {} + + @GetMapping("/summary") + public ImportSummaryDto summary() { + List> rows = productRepository.aggregateByImportStatus(); + + long total = 0L; + long mapped = 0L; + long pending = 0L; + + for (Map row : rows) { + ImportStatus status = (ImportStatus) row.get("status"); + long count = ((Number) row.get("count")).longValue(); + total += count; + + if (status == ImportStatus.MAPPED) { + mapped += count; + } else if (status == ImportStatus.PENDING_MAPPING) { + pending += count; + } + } + + return new ImportSummaryDto(total, mapped, pending); + } + + @GetMapping("/by-merchant") + public List byMerchant() { + List> rows = productRepository.aggregateByMerchantAndStatus(); + + return rows.stream() + .map(row -> new ByMerchantRowDto( + (Integer) row.get("merchantId"), + (String) row.get("merchantName"), + (String) row.get("platform"), + (ImportStatus) row.get("status"), + ((Number) row.get("count")).longValue() + )) + .toList(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/admin/AdminMappingController.java b/src/main/java/group/goforward/battlbuilder/web/admin/AdminMappingController.java index 5f6bf5b..adbeed3 100644 --- a/src/main/java/group/goforward/battlbuilder/web/admin/AdminMappingController.java +++ b/src/main/java/group/goforward/battlbuilder/web/admin/AdminMappingController.java @@ -1,120 +1,120 @@ -package group.goforward.battlbuilder.web.admin; - -import group.goforward.battlbuilder.service.MappingAdminService; -import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto; -import group.goforward.battlbuilder.web.dto.MappingOptionsDto; -import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.Map; - -@RestController -@RequestMapping("/api/admin/mapping") -public class AdminMappingController { - - private final MappingAdminService mappingAdminService; - - public AdminMappingController(MappingAdminService mappingAdminService) { - this.mappingAdminService = mappingAdminService; - } - - @GetMapping("/pending-buckets") - public List listPendingBuckets() { - return mappingAdminService.listPendingBuckets(); - } - - public record ApplyMappingRequest( - Integer merchantId, - String rawCategoryKey, - String mappedPartRole - ) {} - - @PostMapping("/apply") - public ResponseEntity> applyMapping(@RequestBody ApplyMappingRequest request) { - int updated = mappingAdminService.applyMapping( - request.merchantId(), - request.rawCategoryKey(), - request.mappedPartRole() - ); - - return ResponseEntity.ok(Map.of( - "ok", true, - "updatedProducts", updated - )); - } - - public record ApplyToProductsRequest( - Integer merchantId, - String rawCategoryKey - ) {} - - /** - * Manual “apply mapping to products” button endpoint (nice for UI dev/testing) - */ - @PostMapping("/apply-to-products") - public ResponseEntity> applyToProducts(@RequestBody ApplyToProductsRequest request) { - int updated = mappingAdminService.applyMappingToProducts( - request.merchantId(), - request.rawCategoryKey() - ); - - return ResponseEntity.ok(Map.of( - "ok", true, - "updatedProducts", updated - )); - } - - /** - * Options for the UI (merchant dropdown + canonical categories dropdown) - */ - @GetMapping("/options") - public MappingOptionsDto options() { - return mappingAdminService.getOptions(); - } - - /** - * Catalog mapping rows: raw categories for a merchant + platform, plus current mapping state - */ - @GetMapping("/raw-categories") - public List rawCategories( - @RequestParam Integer merchantId, - @RequestParam(required = false) String platform, - @RequestParam(required = false) String q, - @RequestParam(required = false, defaultValue = "500") Integer limit - ) { - return mappingAdminService.listRawCategories(merchantId, platform, q, limit); - } - - public record UpsertCatalogMappingRequest( - Integer merchantId, - String platform, // nullable okay - String rawCategory, // maps to merchant_category_map.raw_category - Boolean enabled, // nullable => default true in service - Integer canonicalCategoryId // nullable allowed (unmapped) - ) {} - - /** - * Upsert ONLY the catalog category mapping in merchant_category_map. - * IMPORTANT: does NOT touch canonical_part_role. - */ - @PostMapping("/upsert") - public ResponseEntity> upsertCatalogMapping( - @RequestBody UpsertCatalogMappingRequest request - ) { - var result = mappingAdminService.upsertCatalogMapping( - request.merchantId(), - request.platform(), - request.rawCategory(), - request.enabled(), - request.canonicalCategoryId() - ); - - return ResponseEntity.ok(Map.of( - "ok", true, - "merchantCategoryMapId", result.merchantCategoryMapId(), - "updatedProducts", result.updatedProducts() - )); - } +package group.goforward.battlbuilder.web.admin; + +import group.goforward.battlbuilder.service.MappingAdminService; +import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto; +import group.goforward.battlbuilder.web.dto.MappingOptionsDto; +import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/mapping") +public class AdminMappingController { + + private final MappingAdminService mappingAdminService; + + public AdminMappingController(MappingAdminService mappingAdminService) { + this.mappingAdminService = mappingAdminService; + } + + @GetMapping("/pending-buckets") + public List listPendingBuckets() { + return mappingAdminService.listPendingBuckets(); + } + + public record ApplyMappingRequest( + Integer merchantId, + String rawCategoryKey, + String mappedPartRole + ) {} + + @PostMapping("/apply") + public ResponseEntity> applyMapping(@RequestBody ApplyMappingRequest request) { + int updated = mappingAdminService.applyMapping( + request.merchantId(), + request.rawCategoryKey(), + request.mappedPartRole() + ); + + return ResponseEntity.ok(Map.of( + "ok", true, + "updatedProducts", updated + )); + } + + public record ApplyToProductsRequest( + Integer merchantId, + String rawCategoryKey + ) {} + + /** + * Manual “apply mapping to products” button endpoint (nice for UI dev/testing) + */ + @PostMapping("/apply-to-products") + public ResponseEntity> applyToProducts(@RequestBody ApplyToProductsRequest request) { + int updated = mappingAdminService.applyMappingToProducts( + request.merchantId(), + request.rawCategoryKey() + ); + + return ResponseEntity.ok(Map.of( + "ok", true, + "updatedProducts", updated + )); + } + + /** + * Options for the UI (merchant dropdown + canonical categories dropdown) + */ + @GetMapping("/options") + public MappingOptionsDto options() { + return mappingAdminService.getOptions(); + } + + /** + * Catalog mapping rows: raw categories for a merchant + platform, plus current mapping state + */ + @GetMapping("/raw-categories") + public List rawCategories( + @RequestParam Integer merchantId, + @RequestParam(required = false) String platform, + @RequestParam(required = false) String q, + @RequestParam(required = false, defaultValue = "500") Integer limit + ) { + return mappingAdminService.listRawCategories(merchantId, platform, q, limit); + } + + public record UpsertCatalogMappingRequest( + Integer merchantId, + String platform, // nullable okay + String rawCategory, // maps to merchant_category_map.raw_category + Boolean enabled, // nullable => default true in service + Integer canonicalCategoryId // nullable allowed (unmapped) + ) {} + + /** + * Upsert ONLY the catalog category mapping in merchant_category_map. + * IMPORTANT: does NOT touch canonical_part_role. + */ + @PostMapping("/upsert") + public ResponseEntity> upsertCatalogMapping( + @RequestBody UpsertCatalogMappingRequest request + ) { + var result = mappingAdminService.upsertCatalogMapping( + request.merchantId(), + request.platform(), + request.rawCategory(), + request.enabled(), + request.canonicalCategoryId() + ); + + return ResponseEntity.ok(Map.of( + "ok", true, + "merchantCategoryMapId", result.merchantCategoryMapId(), + "updatedProducts", result.updatedProducts() + )); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/admin/AdminMerchantController.java b/src/main/java/group/goforward/battlbuilder/web/admin/AdminMerchantController.java index 01c0020..64b1f83 100644 --- a/src/main/java/group/goforward/battlbuilder/web/admin/AdminMerchantController.java +++ b/src/main/java/group/goforward/battlbuilder/web/admin/AdminMerchantController.java @@ -1,35 +1,35 @@ -package group.goforward.battlbuilder.web.admin; - -import group.goforward.battlbuilder.service.MerchantFeedImportService; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; - -@RestController -@RequestMapping("/api/admin/merchants") -public class AdminMerchantController { - - private final MerchantFeedImportService merchantFeedImportService; - - public AdminMerchantController(MerchantFeedImportService merchantFeedImportService) { - this.merchantFeedImportService = merchantFeedImportService; - } - - @PostMapping("/{merchantId}/import") - public ResponseEntity> triggerFullImport( - @PathVariable Integer merchantId - ) { - // Fire off the full import for this merchant. - // (Right now this is synchronous; later we can push to a queue if needed.) - merchantFeedImportService.importMerchantFeed(merchantId); - - return ResponseEntity.accepted().body( - Map.of( - "ok", true, - "merchantId", merchantId, - "message", "Import triggered" - ) - ); - } +package group.goforward.battlbuilder.web.admin; + +import group.goforward.battlbuilder.service.MerchantFeedImportService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/merchants") +public class AdminMerchantController { + + private final MerchantFeedImportService merchantFeedImportService; + + public AdminMerchantController(MerchantFeedImportService merchantFeedImportService) { + this.merchantFeedImportService = merchantFeedImportService; + } + + @PostMapping("/{merchantId}/import") + public ResponseEntity> triggerFullImport( + @PathVariable Integer merchantId + ) { + // Fire off the full import for this merchant. + // (Right now this is synchronous; later we can push to a queue if needed.) + merchantFeedImportService.importMerchantFeed(merchantId); + + return ResponseEntity.accepted().body( + Map.of( + "ok", true, + "merchantId", merchantId, + "message", "Import triggered" + ) + ); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/admin/AdminProductController.java b/src/main/java/group/goforward/battlbuilder/web/admin/AdminProductController.java index abb4f0d..6021b93 100644 --- a/src/main/java/group/goforward/battlbuilder/web/admin/AdminProductController.java +++ b/src/main/java/group/goforward/battlbuilder/web/admin/AdminProductController.java @@ -1,45 +1,45 @@ -package group.goforward.battlbuilder.web.admin; - -import group.goforward.battlbuilder.service.admin.AdminProductService; -import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest; -import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest; -import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; - -@RestController -@RequestMapping("/api/v1/admin/products") -public class AdminProductController { - - private final AdminProductService adminProductService; - - public AdminProductController(AdminProductService adminProductService) { - this.adminProductService = adminProductService; - } - - /** - * Admin product list (paged + filterable) - */ - @GetMapping - public Page search( - AdminProductSearchRequest request, - Pageable pageable - ) { - return adminProductService.search(request, pageable); - } - - /** - * Bulk admin actions (disable, hide, lock, etc.) - */ - @PatchMapping("/bulk") - public Map bulkUpdate( - @RequestBody ProductBulkUpdateRequest request - ) { - int updated = adminProductService.bulkUpdate(request); - return Map.of("updatedCount", updated); - } +package group.goforward.battlbuilder.web.admin; + +import group.goforward.battlbuilder.service.admin.AdminProductService; +import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest; +import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest; +import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/admin/products") +public class AdminProductController { + + private final AdminProductService adminProductService; + + public AdminProductController(AdminProductService adminProductService) { + this.adminProductService = adminProductService; + } + + /** + * Admin product list (paged + filterable) + */ + @GetMapping + public Page search( + AdminProductSearchRequest request, + Pageable pageable + ) { + return adminProductService.search(request, pageable); + } + + /** + * Bulk admin actions (disable, hide, lock, etc.) + */ + @PatchMapping("/bulk") + public Map bulkUpdate( + @RequestBody ProductBulkUpdateRequest request + ) { + int updated = adminProductService.bulkUpdate(request); + return Map.of("updatedCount", updated); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/admin/AdminUserController.java b/src/main/java/group/goforward/battlbuilder/web/admin/AdminUserController.java index 2ba62da..0c17637 100644 --- a/src/main/java/group/goforward/battlbuilder/web/admin/AdminUserController.java +++ b/src/main/java/group/goforward/battlbuilder/web/admin/AdminUserController.java @@ -1,37 +1,37 @@ -package group.goforward.battlbuilder.web.admin; - -import group.goforward.battlbuilder.service.admin.AdminUserService; -import group.goforward.battlbuilder.web.dto.admin.AdminUserDto; -import group.goforward.battlbuilder.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); - } +package group.goforward.battlbuilder.web.admin; + +import group.goforward.battlbuilder.service.admin.AdminUserService; +import group.goforward.battlbuilder.web.dto.admin.AdminUserDto; +import group.goforward.battlbuilder.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/battlbuilder/web/admin/CategoryMappingAdminController.java b/src/main/java/group/goforward/battlbuilder/web/admin/CategoryMappingAdminController.java index 53d67a6..041629e 100644 --- a/src/main/java/group/goforward/battlbuilder/web/admin/CategoryMappingAdminController.java +++ b/src/main/java/group/goforward/battlbuilder/web/admin/CategoryMappingAdminController.java @@ -1,66 +1,66 @@ -package group.goforward.battlbuilder.web.admin; - -import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto; -import group.goforward.battlbuilder.service.MappingAdminService; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/admin/mappings") -public class CategoryMappingAdminController { - - private final MappingAdminService mappingAdminService; - - public CategoryMappingAdminController(MappingAdminService mappingAdminService) { - this.mappingAdminService = mappingAdminService; - } - - @GetMapping("/pending") - public List listPending() { - return mappingAdminService.listPendingBuckets(); - } - - public record ApplyMappingRequest( - Integer merchantId, - String rawCategoryKey, - String mappedPartRole - ) {} - - @PostMapping("/apply") - public ResponseEntity apply(@RequestBody ApplyMappingRequest request) { - mappingAdminService.applyMapping( - request.merchantId(), - request.rawCategoryKey(), - request.mappedPartRole() - ); - return ResponseEntity.noContent().build(); - } - -// @RestController -// @RequestMapping("/api/admin/mapping") -// public static class AdminMappingController { -// -// private final MappingAdminService mappingAdminService; -// -// public AdminMappingController(MappingAdminService mappingAdminService) { -// this.mappingAdminService = mappingAdminService; -// } -// -// @GetMapping("/pending-buckets") -// public List listPendingBuckets() { -// return mappingAdminService.listPendingBuckets(); -// } -// -// @PostMapping("/apply") -// public ResponseEntity applyMapping(@RequestBody Map body) { -// Integer merchantId = (Integer) body.get("merchantId"); -// String rawCategoryKey = (String) body.get("rawCategoryKey"); -// String mappedPartRole = (String) body.get("mappedPartRole"); -// -// mappingAdminService.applyMapping(merchantId, rawCategoryKey, mappedPartRole); -// return ResponseEntity.ok(Map.of("ok", true)); -// } -// } +package group.goforward.battlbuilder.web.admin; + +import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto; +import group.goforward.battlbuilder.service.MappingAdminService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/mappings") +public class CategoryMappingAdminController { + + private final MappingAdminService mappingAdminService; + + public CategoryMappingAdminController(MappingAdminService mappingAdminService) { + this.mappingAdminService = mappingAdminService; + } + + @GetMapping("/pending") + public List listPending() { + return mappingAdminService.listPendingBuckets(); + } + + public record ApplyMappingRequest( + Integer merchantId, + String rawCategoryKey, + String mappedPartRole + ) {} + + @PostMapping("/apply") + public ResponseEntity apply(@RequestBody ApplyMappingRequest request) { + mappingAdminService.applyMapping( + request.merchantId(), + request.rawCategoryKey(), + request.mappedPartRole() + ); + return ResponseEntity.noContent().build(); + } + +// @RestController +// @RequestMapping("/api/admin/mapping") +// public static class AdminMappingController { +// +// private final MappingAdminService mappingAdminService; +// +// public AdminMappingController(MappingAdminService mappingAdminService) { +// this.mappingAdminService = mappingAdminService; +// } +// +// @GetMapping("/pending-buckets") +// public List listPendingBuckets() { +// return mappingAdminService.listPendingBuckets(); +// } +// +// @PostMapping("/apply") +// public ResponseEntity applyMapping(@RequestBody Map body) { +// Integer merchantId = (Integer) body.get("merchantId"); +// String rawCategoryKey = (String) body.get("rawCategoryKey"); +// String mappedPartRole = (String) body.get("mappedPartRole"); +// +// mappingAdminService.applyMapping(merchantId, rawCategoryKey, mappedPartRole); +// return ResponseEntity.ok(Map.of("ok", true)); +// } +// } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/admin/package-info.java b/src/main/java/group/goforward/battlbuilder/web/admin/package-info.java index 1e2f357..80563c6 100644 --- a/src/main/java/group/goforward/battlbuilder/web/admin/package-info.java +++ b/src/main/java/group/goforward/battlbuilder/web/admin/package-info.java @@ -1,12 +1,12 @@ - -/** - * Web admin package for the BattlBuilder application. - *

- * Contains web controller for administrative interface operations - * including user management, merchant administration, and import status. - * - * @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.web.admin; + +/** + * Web admin package for the BattlBuilder application. + *

+ * Contains web controller for administrative interface operations + * including user management, merchant administration, and import status. + * + * @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.web.admin; diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/BuildFeedCardDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/BuildFeedCardDto.java index 9c13be3..e6959ab 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/BuildFeedCardDto.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/BuildFeedCardDto.java @@ -1,86 +1,86 @@ -package group.goforward.battlbuilder.web.dto; - -import java.util.List; -import java.util.UUID; - -/** - * Contract for /builds feed cards. - * Matches the Next.js BuildsPage expectations. - *

- * Dev Notes: - * - We are using Option 2 for money: price is INTEGER CENTS (not dollars). - * Example: $19.99 -> 1999 - * - Keep the field name "price" for UI continuity for now. - * (We can rename to priceCents later with a coordinated UI change.) - * - "buildClass" is a string for now ("Rifle" | "Pistol" | "NFA"). - * Later we can move to enum if/when we want strict validation. - */ -public class BuildFeedCardDto { - - // Stable public identifier for routes + API consumers - private UUID uuid; - - // Display - private String title; - - // MVP: can just be uuid string; later can be derived from title (unique per creator) - private String slug; - - // Placeholder until user profiles exist - private String creator; - - // From BuildProfile (meta) - private String caliber; - - // "Rifle" | "Pistol" | "NFA" (string for MVP) - private String buildClass; - - /** - * Estimated build price in CENTS (Option 2). - * Example: 245000 == $2,450.00 - *

- * IMPORTANT: - * - UI must divide by 100 for display. - * - Keep nullable optional; treat null as 0 in the UI. - */ - private Integer price; - - // Aggregated vote count (0 until votes table exists) - private Integer votes; - - // Tag strings (from profile meta) - private List tags; - - // Optional hero image for the card (from build_media or profile) - private String coverImageUrl; - - public UUID getUuid() { return uuid; } - public void setUuid(UUID uuid) { this.uuid = uuid; } - - public String getTitle() { return title; } - public void setTitle(String title) { this.title = title; } - - public String getSlug() { return slug; } - public void setSlug(String slug) { this.slug = slug; } - - public String getCreator() { return creator; } - public void setCreator(String creator) { this.creator = creator; } - - public String getCaliber() { return caliber; } - public void setCaliber(String caliber) { this.caliber = caliber; } - - public String getBuildClass() { return buildClass; } - public void setBuildClass(String buildClass) { this.buildClass = buildClass; } - - public Integer getPrice() { return price; } - public void setPrice(Integer price) { this.price = price; } - - public Integer getVotes() { return votes; } - public void setVotes(Integer votes) { this.votes = votes; } - - public List getTags() { return tags; } - public void setTags(List tags) { this.tags = tags; } - - public String getCoverImageUrl() { return coverImageUrl; } - public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; } +package group.goforward.battlbuilder.web.dto; + +import java.util.List; +import java.util.UUID; + +/** + * Contract for /builds feed cards. + * Matches the Next.js BuildsPage expectations. + *

+ * Dev Notes: + * - We are using Option 2 for money: price is INTEGER CENTS (not dollars). + * Example: $19.99 -> 1999 + * - Keep the field name "price" for UI continuity for now. + * (We can rename to priceCents later with a coordinated UI change.) + * - "buildClass" is a string for now ("Rifle" | "Pistol" | "NFA"). + * Later we can move to enum if/when we want strict validation. + */ +public class BuildFeedCardDto { + + // Stable public identifier for routes + API consumers + private UUID uuid; + + // Display + private String title; + + // MVP: can just be uuid string; later can be derived from title (unique per creator) + private String slug; + + // Placeholder until user profiles exist + private String creator; + + // From BuildProfile (meta) + private String caliber; + + // "Rifle" | "Pistol" | "NFA" (string for MVP) + private String buildClass; + + /** + * Estimated build price in CENTS (Option 2). + * Example: 245000 == $2,450.00 + *

+ * IMPORTANT: + * - UI must divide by 100 for display. + * - Keep nullable optional; treat null as 0 in the UI. + */ + private Integer price; + + // Aggregated vote count (0 until votes table exists) + private Integer votes; + + // Tag strings (from profile meta) + private List tags; + + // Optional hero image for the card (from build_media or profile) + private String coverImageUrl; + + public UUID getUuid() { return uuid; } + public void setUuid(UUID uuid) { this.uuid = uuid; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + + public String getCreator() { return creator; } + public void setCreator(String creator) { this.creator = creator; } + + public String getCaliber() { return caliber; } + public void setCaliber(String caliber) { this.caliber = caliber; } + + public String getBuildClass() { return buildClass; } + public void setBuildClass(String buildClass) { this.buildClass = buildClass; } + + public Integer getPrice() { return price; } + public void setPrice(Integer price) { this.price = price; } + + public Integer getVotes() { return votes; } + public void setVotes(Integer votes) { this.votes = votes; } + + public List getTags() { return tags; } + public void setTags(List tags) { this.tags = tags; } + + public String getCoverImageUrl() { return coverImageUrl; } + public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/UpdateBuildRequest.java b/src/main/java/group/goforward/battlbuilder/web/dto/UpdateBuildRequest.java index 98435a3..94a90ac 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/UpdateBuildRequest.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/UpdateBuildRequest.java @@ -1,69 +1,69 @@ -package group.goforward.battlbuilder.web.dto; - -import java.util.List; - -/** - * Request DTO for creating/updating Build + items. - * Keeps entity fields protected from client-side overposting. - *

- * NOTE: - * - We reuse this for BOTH create (POST /me) and update (PUT /me/{uuid}) for MVP speed. - */ -public class UpdateBuildRequest { - - private String title; - private String description; - private Boolean isPublic; - - // Build items (builder slots) - private List items; - - // optional profile fields (if you update profile in same request later) - private String caliber; - private String buildClass; - private String coverImageUrl; - private List tags; - - public static class Item { - private Integer productId; - private String slot; - private Integer position; - private Integer quantity; - - public Integer getProductId() { return productId; } - public void setProductId(Integer productId) { this.productId = productId; } - - public String getSlot() { return slot; } - public void setSlot(String slot) { this.slot = slot; } - - public Integer getPosition() { return position; } - public void setPosition(Integer position) { this.position = position; } - - public Integer getQuantity() { return quantity; } - public void setQuantity(Integer quantity) { this.quantity = quantity; } - } - - public String getTitle() { return title; } - public void setTitle(String title) { this.title = title; } - - public String getDescription() { return description; } - public void setDescription(String description) { this.description = description; } - - public Boolean getIsPublic() { return isPublic; } - public void setIsPublic(Boolean isPublic) { this.isPublic = isPublic; } - - public List getItems() { return items; } - public void setItems(List items) { this.items = items; } - - public String getCaliber() { return caliber; } - public void setCaliber(String caliber) { this.caliber = caliber; } - - public String getBuildClass() { return buildClass; } - public void setBuildClass(String buildClass) { this.buildClass = buildClass; } - - public String getCoverImageUrl() { return coverImageUrl; } - public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; } - - public List getTags() { return tags; } - public void setTags(List tags) { this.tags = tags; } +package group.goforward.battlbuilder.web.dto; + +import java.util.List; + +/** + * Request DTO for creating/updating Build + items. + * Keeps entity fields protected from client-side overposting. + *

+ * NOTE: + * - We reuse this for BOTH create (POST /me) and update (PUT /me/{uuid}) for MVP speed. + */ +public class UpdateBuildRequest { + + private String title; + private String description; + private Boolean isPublic; + + // Build items (builder slots) + private List items; + + // optional profile fields (if you update profile in same request later) + private String caliber; + private String buildClass; + private String coverImageUrl; + private List tags; + + public static class Item { + private Integer productId; + private String slot; + private Integer position; + private Integer quantity; + + public Integer getProductId() { return productId; } + public void setProductId(Integer productId) { this.productId = productId; } + + public String getSlot() { return slot; } + public void setSlot(String slot) { this.slot = slot; } + + public Integer getPosition() { return position; } + public void setPosition(Integer position) { this.position = position; } + + public Integer getQuantity() { return quantity; } + public void setQuantity(Integer quantity) { this.quantity = quantity; } + } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Boolean getIsPublic() { return isPublic; } + public void setIsPublic(Boolean isPublic) { this.isPublic = isPublic; } + + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + + public String getCaliber() { return caliber; } + public void setCaliber(String caliber) { this.caliber = caliber; } + + public String getBuildClass() { return buildClass; } + public void setBuildClass(String buildClass) { this.buildClass = buildClass; } + + public String getCoverImageUrl() { return coverImageUrl; } + public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; } + + public List getTags() { return tags; } + public void setTags(List tags) { this.tags = tags; } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminDashboardOverviewDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminDashboardOverviewDto.java index 67e87c4..0ab7737 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminDashboardOverviewDto.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminDashboardOverviewDto.java @@ -1,44 +1,44 @@ -package group.goforward.battlbuilder.web.dto.admin; - -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; - } +package group.goforward.battlbuilder.web.dto.admin; + +public class AdminDashboardOverviewDto { + + private long totalProducts; + private long mappedProducts; + private long unmappedProducts; + private long merchantCount; + private long categoryMappingCount; + + public AdminDashboardOverviewDto( + long totalProducts, + long mappedProducts, + long unmappedProducts, + long merchantCount, + long categoryMappingCount + ) { + this.totalProducts = totalProducts; + this.mappedProducts = mappedProducts; + this.unmappedProducts = unmappedProducts; + this.merchantCount = merchantCount; + this.categoryMappingCount = categoryMappingCount; + } + + public long getTotalProducts() { + return totalProducts; + } + + public long getMappedProducts() { + return mappedProducts; + } + + public long getUnmappedProducts() { + return unmappedProducts; + } + + public long getMerchantCount() { + return merchantCount; + } + + public long getCategoryMappingCount() { + return categoryMappingCount; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminProductSearchRequest.java b/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminProductSearchRequest.java index d2faf99..373661e 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminProductSearchRequest.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminProductSearchRequest.java @@ -1,51 +1,51 @@ -package group.goforward.battlbuilder.web.dto.admin; - -import group.goforward.battlbuilder.model.ImportStatus; -import group.goforward.battlbuilder.model.ProductStatus; -import group.goforward.battlbuilder.model.ProductVisibility; - -/** - * Request DTO for searching products in the admin panel. - *

- * Example: - * GET /api/v1/admin/products?platform=AR-15&q=mini&visibility=PUBLIC - */ -public class AdminProductSearchRequest { - - private String q; - private String platform; - private String partRole; - - private ImportStatus importStatus; - private ProductVisibility visibility; - private ProductStatus status; - - private Boolean builderEligible; - private Boolean adminLocked; - - // --- getters/setters --- - - public String getQ() { return q; } - public void setQ(String q) { this.q = q; } - - 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 ImportStatus getImportStatus() { return importStatus; } - public void setImportStatus(ImportStatus importStatus) { this.importStatus = importStatus; } - - public ProductVisibility getVisibility() { return visibility; } - public void setVisibility(ProductVisibility visibility) { this.visibility = visibility; } - - public ProductStatus getStatus() { return status; } - public void setStatus(ProductStatus status) { this.status = status; } - - public Boolean getBuilderEligible() { return builderEligible; } - public void setBuilderEligible(Boolean builderEligible) { this.builderEligible = builderEligible; } - - public Boolean getAdminLocked() { return adminLocked; } - public void setAdminLocked(Boolean adminLocked) { this.adminLocked = adminLocked; } +package group.goforward.battlbuilder.web.dto.admin; + +import group.goforward.battlbuilder.model.ImportStatus; +import group.goforward.battlbuilder.model.ProductStatus; +import group.goforward.battlbuilder.model.ProductVisibility; + +/** + * Request DTO for searching products in the admin panel. + *

+ * Example: + * GET /api/v1/admin/products?platform=AR-15&q=mini&visibility=PUBLIC + */ +public class AdminProductSearchRequest { + + private String q; + private String platform; + private String partRole; + + private ImportStatus importStatus; + private ProductVisibility visibility; + private ProductStatus status; + + private Boolean builderEligible; + private Boolean adminLocked; + + // --- getters/setters --- + + public String getQ() { return q; } + public void setQ(String q) { this.q = q; } + + 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 ImportStatus getImportStatus() { return importStatus; } + public void setImportStatus(ImportStatus importStatus) { this.importStatus = importStatus; } + + public ProductVisibility getVisibility() { return visibility; } + public void setVisibility(ProductVisibility visibility) { this.visibility = visibility; } + + public ProductStatus getStatus() { return status; } + public void setStatus(ProductStatus status) { this.status = status; } + + public Boolean getBuilderEligible() { return builderEligible; } + public void setBuilderEligible(Boolean builderEligible) { this.builderEligible = builderEligible; } + + public Boolean getAdminLocked() { return adminLocked; } + public void setAdminLocked(Boolean adminLocked) { this.adminLocked = adminLocked; } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/auth/package_info.java b/src/main/java/group/goforward/battlbuilder/web/dto/auth/package_info.java index 53f6d38..2147513 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/auth/package_info.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/auth/package_info.java @@ -1,12 +1,12 @@ - -/* - Web authentication DTOs package for the BattlBuilder application. -

- Contains Data Transfer Objects for authentication operations - including login requests, registration, and authentication responses. - - @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.web.dto.auth; + +/* + Web authentication DTOs package for the BattlBuilder application. +

+ Contains Data Transfer Objects for authentication operations + including login requests, registration, and authentication responses. + + @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.web.dto.auth; diff --git a/src/main/java/group/goforward/battlbuilder/web/mapper/package-info.java b/src/main/java/group/goforward/battlbuilder/web/mapper/package-info.java new file mode 100644 index 0000000..0a0e0c5 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/mapper/package-info.java @@ -0,0 +1 @@ +package group.goforward.battlbuilder.web.mapper; \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/package-info.java b/src/main/java/group/goforward/battlbuilder/web/package-info.java index abd4034..720c711 100644 --- a/src/main/java/group/goforward/battlbuilder/web/package-info.java +++ b/src/main/java/group/goforward/battlbuilder/web/package-info.java @@ -1,11 +1,11 @@ -/** - * Web package for the BattlBuilder application. - *

- * Contains web-related classes including controller and DTOs - * for the web layer. - * - * @author Forward Group, LLC - * @version 1.0 - * @since 2025-12-10 - */ -package group.goforward.battlbuilder.web; +/** + * Web package for the BattlBuilder application. + *

+ * Contains web-related classes including controller and DTOs + * for the web layer. + * + * @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.web;