mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
cleanup
This commit is contained in:
@@ -1,87 +1,87 @@
|
|||||||
package group.goforward.battlbuilder.catalog.classification;
|
package group.goforward.battlbuilder.catalog.classification;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PartRoleRule;
|
import group.goforward.battlbuilder.model.PartRoleRule;
|
||||||
import group.goforward.battlbuilder.repo.PartRoleRuleRepository;
|
import group.goforward.battlbuilder.repo.PartRoleRuleRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class PartRoleResolver {
|
public class PartRoleResolver {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(PartRoleResolver.class);
|
private static final Logger log = LoggerFactory.getLogger(PartRoleResolver.class);
|
||||||
|
|
||||||
private final PartRoleRuleRepository repo;
|
private final PartRoleRuleRepository repo;
|
||||||
|
|
||||||
private final List<CompiledRule> rules = new ArrayList<>();
|
private final List<CompiledRule> rules = new ArrayList<>();
|
||||||
|
|
||||||
public PartRoleResolver(PartRoleRuleRepository repo) {
|
public PartRoleResolver(PartRoleRuleRepository repo) {
|
||||||
this.repo = repo;
|
this.repo = repo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void load() {
|
public void load() {
|
||||||
rules.clear();
|
rules.clear();
|
||||||
|
|
||||||
List<PartRoleRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
|
List<PartRoleRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||||
for (PartRoleRule r : active) {
|
for (PartRoleRule r : active) {
|
||||||
try {
|
try {
|
||||||
rules.add(new CompiledRule(
|
rules.add(new CompiledRule(
|
||||||
r.getId(),
|
r.getId(),
|
||||||
r.getTargetPlatform(),
|
r.getTargetPlatform(),
|
||||||
Pattern.compile(r.getNameRegex(), Pattern.CASE_INSENSITIVE),
|
Pattern.compile(r.getNameRegex(), Pattern.CASE_INSENSITIVE),
|
||||||
normalizeRole(r.getTargetPartRole())
|
normalizeRole(r.getTargetPartRole())
|
||||||
));
|
));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Skipping invalid part role rule id={} regex={} err={}",
|
log.warn("Skipping invalid part role rule id={} regex={} err={}",
|
||||||
r.getId(), r.getNameRegex(), e.getMessage());
|
r.getId(), r.getNameRegex(), e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Loaded {} part role rules", rules.size());
|
log.info("Loaded {} part role rules", rules.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
public String resolve(String platform, String productName, String rawCategoryKey) {
|
public String resolve(String platform, String productName, String rawCategoryKey) {
|
||||||
String p = normalizePlatform(platform);
|
String p = normalizePlatform(platform);
|
||||||
|
|
||||||
// we match primarily on productName; optionally also include rawCategoryKey in the text blob
|
// we match primarily on productName; optionally also include rawCategoryKey in the text blob
|
||||||
String text = (productName == null ? "" : productName) +
|
String text = (productName == null ? "" : productName) +
|
||||||
" " +
|
" " +
|
||||||
(rawCategoryKey == null ? "" : rawCategoryKey);
|
(rawCategoryKey == null ? "" : rawCategoryKey);
|
||||||
|
|
||||||
for (CompiledRule r : rules) {
|
for (CompiledRule r : rules) {
|
||||||
if (!r.appliesToPlatform(p)) continue;
|
if (!r.appliesToPlatform(p)) continue;
|
||||||
if (r.pattern.matcher(text).find()) {
|
if (r.pattern.matcher(text).find()) {
|
||||||
return r.targetPartRole; // already normalized
|
return r.targetPartRole; // already normalized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String normalizeRole(String role) {
|
private static String normalizeRole(String role) {
|
||||||
if (role == null) return null;
|
if (role == null) return null;
|
||||||
String t = role.trim();
|
String t = role.trim();
|
||||||
if (t.isEmpty()) return null;
|
if (t.isEmpty()) return null;
|
||||||
return t.toLowerCase(Locale.ROOT).replace('_','-');
|
return t.toLowerCase(Locale.ROOT).replace('_','-');
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String normalizePlatform(String platform) {
|
private static String normalizePlatform(String platform) {
|
||||||
if (platform == null) return null;
|
if (platform == null) return null;
|
||||||
String t = platform.trim();
|
String t = platform.trim();
|
||||||
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private record CompiledRule(Long id, String targetPlatform, Pattern pattern, String targetPartRole) {
|
private record CompiledRule(Long id, String targetPlatform, Pattern pattern, String targetPartRole) {
|
||||||
boolean appliesToPlatform(String platform) {
|
boolean appliesToPlatform(String platform) {
|
||||||
if (targetPlatform == null || targetPlatform.isBlank()) return true;
|
if (targetPlatform == null || targetPlatform.isBlank()) return true;
|
||||||
if (platform == null) return false;
|
if (platform == null) return false;
|
||||||
return targetPlatform.trim().equalsIgnoreCase(platform);
|
return targetPlatform.trim().equalsIgnoreCase(platform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
package group.goforward.battlbuilder.catalog.classification;
|
package group.goforward.battlbuilder.catalog.classification;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result returned by PlatformResolver.
|
* Result returned by PlatformResolver.
|
||||||
* <p>
|
* <p>
|
||||||
* Any of the fields may be null — the importer will only overwrite
|
* Any of the fields may be null — the importer will only overwrite
|
||||||
* product.platform, product.partRole, or product.configuration
|
* product.platform, product.partRole, or product.configuration
|
||||||
* when the returned value is non-null AND non-blank.
|
* when the returned value is non-null AND non-blank.
|
||||||
*/
|
*/
|
||||||
public record PlatformResolutionResult(
|
public record PlatformResolutionResult(
|
||||||
String platform,
|
String platform,
|
||||||
String partRole,
|
String partRole,
|
||||||
String configuration
|
String configuration
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public static PlatformResolutionResult empty() {
|
public static PlatformResolutionResult empty() {
|
||||||
return new PlatformResolutionResult(null, null, null);
|
return new PlatformResolutionResult(null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isEmpty() {
|
public boolean isEmpty() {
|
||||||
return (platform == null || platform.isBlank()) &&
|
return (platform == null || platform.isBlank()) &&
|
||||||
(partRole == null || partRole.isBlank()) &&
|
(partRole == null || partRole.isBlank()) &&
|
||||||
(configuration == null || configuration.isBlank());
|
(configuration == null || configuration.isBlank());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,139 +1,139 @@
|
|||||||
package group.goforward.battlbuilder.catalog.classification;
|
package group.goforward.battlbuilder.catalog.classification;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PlatformRule;
|
import group.goforward.battlbuilder.model.PlatformRule;
|
||||||
import group.goforward.battlbuilder.repo.PlatformRuleRepository;
|
import group.goforward.battlbuilder.repo.PlatformRuleRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a product's PLATFORM (e.g. AR-15, AR-10, NOT-SUPPORTED)
|
* Resolves a product's PLATFORM (e.g. AR-15, AR-10, NOT-SUPPORTED)
|
||||||
* using explicit DB-backed rules.
|
* using explicit DB-backed rules.
|
||||||
* <p>
|
* <p>
|
||||||
* Conservative approach:
|
* Conservative approach:
|
||||||
* - If a rule matches, return its target_platform
|
* - If a rule matches, return its target_platform
|
||||||
* - If nothing matches, return null and let the caller decide fallback behavior
|
* - If nothing matches, return null and let the caller decide fallback behavior
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class PlatformResolver {
|
public class PlatformResolver {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class);
|
private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class);
|
||||||
|
|
||||||
public static final String NOT_SUPPORTED = "NOT-SUPPORTED";
|
public static final String NOT_SUPPORTED = "NOT-SUPPORTED";
|
||||||
|
|
||||||
private final PlatformRuleRepository repo;
|
private final PlatformRuleRepository repo;
|
||||||
private final List<CompiledRule> rules = new ArrayList<>();
|
private final List<CompiledRule> rules = new ArrayList<>();
|
||||||
|
|
||||||
public PlatformResolver(PlatformRuleRepository repo) {
|
public PlatformResolver(PlatformRuleRepository repo) {
|
||||||
this.repo = repo;
|
this.repo = repo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void load() {
|
public void load() {
|
||||||
rules.clear();
|
rules.clear();
|
||||||
|
|
||||||
List<PlatformRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
|
List<PlatformRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||||
|
|
||||||
for (PlatformRule r : active) {
|
for (PlatformRule r : active) {
|
||||||
try {
|
try {
|
||||||
Pattern rawCat = compileNullable(r.getRawCategoryPattern());
|
Pattern rawCat = compileNullable(r.getRawCategoryPattern());
|
||||||
Pattern name = compileNullable(r.getNameRegex());
|
Pattern name = compileNullable(r.getNameRegex());
|
||||||
String target = normalizePlatform(r.getTargetPlatform());
|
String target = normalizePlatform(r.getTargetPlatform());
|
||||||
|
|
||||||
// If a rule has no matchers, it's useless — skip it.
|
// If a rule has no matchers, it's useless — skip it.
|
||||||
if (rawCat == null && name == null) {
|
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());
|
log.warn("Skipping platform rule id={} because it has no patterns (raw_category_pattern/name_regex both blank)", r.getId());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target == null || target.isBlank()) {
|
if (target == null || target.isBlank()) {
|
||||||
log.warn("Skipping platform rule id={} because target_platform is blank", r.getId());
|
log.warn("Skipping platform rule id={} because target_platform is blank", r.getId());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
rules.add(new CompiledRule(
|
rules.add(new CompiledRule(
|
||||||
r.getId(),
|
r.getId(),
|
||||||
r.getMerchantId(),
|
r.getMerchantId(),
|
||||||
r.getBrandId(),
|
r.getBrandId(),
|
||||||
rawCat,
|
rawCat,
|
||||||
name,
|
name,
|
||||||
target
|
target
|
||||||
));
|
));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Skipping invalid platform rule id={} err={}", r.getId(), e.getMessage());
|
log.warn("Skipping invalid platform rule id={} err={}", r.getId(), e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Loaded {} platform rules", rules.size());
|
log.info("Loaded {} platform rules", rules.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return platform string (e.g. AR-15, AR-10, NOT-SUPPORTED) or null if no rule matches.
|
* @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) {
|
public String resolve(Long merchantId, Long brandId, String productName, String rawCategoryKey) {
|
||||||
String text = safe(productName) + " " + safe(rawCategoryKey);
|
String text = safe(productName) + " " + safe(rawCategoryKey);
|
||||||
|
|
||||||
for (CompiledRule r : rules) {
|
for (CompiledRule r : rules) {
|
||||||
if (!r.appliesToMerchant(merchantId)) continue;
|
if (!r.appliesToMerchant(merchantId)) continue;
|
||||||
if (!r.appliesToBrand(brandId)) continue;
|
if (!r.appliesToBrand(brandId)) continue;
|
||||||
|
|
||||||
if (r.matches(text)) {
|
if (r.matches(text)) {
|
||||||
return r.targetPlatform;
|
return r.targetPlatform;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
|
|
||||||
private static Pattern compileNullable(String regex) {
|
private static Pattern compileNullable(String regex) {
|
||||||
if (regex == null || regex.isBlank()) return null;
|
if (regex == null || regex.isBlank()) return null;
|
||||||
return Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
|
return Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String normalizePlatform(String platform) {
|
private static String normalizePlatform(String platform) {
|
||||||
if (platform == null) return null;
|
if (platform == null) return null;
|
||||||
String t = platform.trim();
|
String t = platform.trim();
|
||||||
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String safe(String s) {
|
private static String safe(String s) {
|
||||||
return s == null ? "" : s;
|
return s == null ? "" : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Internal model
|
// Internal model
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
|
|
||||||
private record CompiledRule(
|
private record CompiledRule(
|
||||||
Long id,
|
Long id,
|
||||||
Long merchantId,
|
Long merchantId,
|
||||||
Long brandId,
|
Long brandId,
|
||||||
Pattern rawCategoryPattern,
|
Pattern rawCategoryPattern,
|
||||||
Pattern namePattern,
|
Pattern namePattern,
|
||||||
String targetPlatform
|
String targetPlatform
|
||||||
) {
|
) {
|
||||||
boolean appliesToMerchant(Long merchantId) {
|
boolean appliesToMerchant(Long merchantId) {
|
||||||
return this.merchantId == null || this.merchantId.equals(merchantId);
|
return this.merchantId == null || this.merchantId.equals(merchantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean appliesToBrand(Long brandId) {
|
boolean appliesToBrand(Long brandId) {
|
||||||
return this.brandId == null || this.brandId.equals(brandId);
|
return this.brandId == null || this.brandId.equals(brandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean matches(String text) {
|
boolean matches(String text) {
|
||||||
if (rawCategoryPattern != null && rawCategoryPattern.matcher(text).find()) return true;
|
if (rawCategoryPattern != null && rawCategoryPattern.matcher(text).find()) return true;
|
||||||
if (namePattern != null && namePattern.matcher(text).find()) return true;
|
if (namePattern != null && namePattern.matcher(text).find()) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,245 +1,245 @@
|
|||||||
package group.goforward.battlbuilder.classification.admin;
|
package group.goforward.battlbuilder.classification.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.classification.ClassificationResult;
|
import group.goforward.battlbuilder.classification.ClassificationResult;
|
||||||
import group.goforward.battlbuilder.classification.ProductClassifier;
|
import group.goforward.battlbuilder.classification.ProductClassifier;
|
||||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ClassificationReconcileService {
|
public class ClassificationReconcileService {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final ProductClassifier productClassifier;
|
private final ProductClassifier productClassifier;
|
||||||
private final ProductOfferRepository productOfferRepository;
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
|
||||||
public ClassificationReconcileService(ProductRepository productRepository,
|
public ClassificationReconcileService(ProductRepository productRepository,
|
||||||
ProductClassifier productClassifier,
|
ProductClassifier productClassifier,
|
||||||
ProductOfferRepository productOfferRepository) {
|
ProductOfferRepository productOfferRepository) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.productClassifier = productClassifier;
|
this.productClassifier = productClassifier;
|
||||||
this.productOfferRepository = productOfferRepository;
|
this.productOfferRepository = productOfferRepository;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReconcileResponse reconcile(ReconcileRequest req) {
|
public ReconcileResponse reconcile(ReconcileRequest req) {
|
||||||
|
|
||||||
int limit = req.limit();
|
int limit = req.limit();
|
||||||
boolean dryRun = req.dryRun();
|
boolean dryRun = req.dryRun();
|
||||||
|
|
||||||
// Page in chunks until we hit limit.
|
// Page in chunks until we hit limit.
|
||||||
final int pageSize = Math.min(250, limit);
|
final int pageSize = Math.min(250, limit);
|
||||||
int scanned = 0;
|
int scanned = 0;
|
||||||
int page = 0;
|
int page = 0;
|
||||||
|
|
||||||
// Counts by reconcile outcome.
|
// Counts by reconcile outcome.
|
||||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||||
counts.put("UNCHANGED", 0);
|
counts.put("UNCHANGED", 0);
|
||||||
counts.put("WOULD_UPDATE", 0);
|
counts.put("WOULD_UPDATE", 0);
|
||||||
counts.put("LOCKED", 0);
|
counts.put("LOCKED", 0);
|
||||||
counts.put("IGNORED", 0);
|
counts.put("IGNORED", 0);
|
||||||
counts.put("UNMAPPED", 0);
|
counts.put("UNMAPPED", 0);
|
||||||
counts.put("CONFLICT", 0);
|
counts.put("CONFLICT", 0);
|
||||||
counts.put("RULE_MATCHED", 0);
|
counts.put("RULE_MATCHED", 0);
|
||||||
|
|
||||||
// Sample rows to inspect quickly in API response.
|
// Sample rows to inspect quickly in API response.
|
||||||
List<ReconcileDiffRow> samples = new ArrayList<>();
|
List<ReconcileDiffRow> samples = new ArrayList<>();
|
||||||
|
|
||||||
// Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1)
|
// Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1)
|
||||||
Map<String, Optional<MerchantCategoryMap>> mappingMemo = new HashMap<>();
|
Map<String, Optional<MerchantCategoryMap>> mappingMemo = new HashMap<>();
|
||||||
|
|
||||||
|
|
||||||
while (scanned < limit) {
|
while (scanned < limit) {
|
||||||
var pageable = PageRequest.of(page, pageSize);
|
var pageable = PageRequest.of(page, pageSize);
|
||||||
|
|
||||||
// Merchant is inferred via offers; this query limits products to those with offers for req.merchantId if provided.
|
// 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);
|
var batch = productRepository.pageActiveProductsByOfferMerchant(req.merchantId(), req.platform(), pageable);
|
||||||
if (batch.isEmpty()) break;
|
if (batch.isEmpty()) break;
|
||||||
|
|
||||||
// Avoid N+1: resolve primary merchant for ALL products in this page in one query
|
// Avoid N+1: resolve primary merchant for ALL products in this page in one query
|
||||||
List<Integer> productIds = batch.getContent().stream()
|
List<Integer> productIds = batch.getContent().stream()
|
||||||
.map(Product::getId)
|
.map(Product::getId)
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Map<Integer, Integer> primaryMerchantByProductId = new HashMap<>();
|
Map<Integer, Integer> primaryMerchantByProductId = new HashMap<>();
|
||||||
if (!productIds.isEmpty()) {
|
if (!productIds.isEmpty()) {
|
||||||
productOfferRepository.findPrimaryMerchantsByFirstSeenForProductIds(productIds)
|
productOfferRepository.findPrimaryMerchantsByFirstSeenForProductIds(productIds)
|
||||||
.forEach(r -> primaryMerchantByProductId.put(r.getProductId(), r.getMerchantId()));
|
.forEach(r -> primaryMerchantByProductId.put(r.getProductId(), r.getMerchantId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (Product p : batch.getContent()) {
|
for (Product p : batch.getContent()) {
|
||||||
if (scanned >= limit) break;
|
if (scanned >= limit) break;
|
||||||
|
|
||||||
// Optional: skip locked products unless includeLocked=true.
|
// Optional: skip locked products unless includeLocked=true.
|
||||||
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
|
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
|
||||||
if (!req.includeLocked() && locked) {
|
if (!req.includeLocked() && locked) {
|
||||||
counts.compute("LOCKED", (k, v) -> v + 1);
|
counts.compute("LOCKED", (k, v) -> v + 1);
|
||||||
scanned++;
|
scanned++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the classifier (merchant_category_map + other logic inside classifier).
|
// Run the classifier (merchant_category_map + other logic inside classifier).
|
||||||
Integer resolvedMerchantId = primaryMerchantByProductId.get(p.getId());
|
Integer resolvedMerchantId = primaryMerchantByProductId.get(p.getId());
|
||||||
ClassificationResult resolved = productClassifier.classifyProduct(p, resolvedMerchantId, mappingMemo);
|
ClassificationResult resolved = productClassifier.classifyProduct(p, resolvedMerchantId, mappingMemo);
|
||||||
|
|
||||||
if (resolved != null && resolved.source() != null && resolved.source().startsWith("rules_")) {
|
if (resolved != null && resolved.source() != null && resolved.source().startsWith("rules_")) {
|
||||||
counts.compute("RULE_MATCHED", (k, v) -> v + 1);
|
counts.compute("RULE_MATCHED", (k, v) -> v + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute diff status (dry-run only right now).
|
// Compute diff status (dry-run only right now).
|
||||||
DiffOutcome outcome = diff(p, resolved);
|
DiffOutcome outcome = diff(p, resolved);
|
||||||
|
|
||||||
counts.compute(outcome.status, (k, v) -> v + 1);
|
counts.compute(outcome.status, (k, v) -> v + 1);
|
||||||
scanned++;
|
scanned++;
|
||||||
|
|
||||||
// Keep a small sample set for inspection.
|
// Keep a small sample set for inspection.
|
||||||
boolean interestingStatus =
|
boolean interestingStatus =
|
||||||
outcome.status.equals("WOULD_UPDATE")
|
outcome.status.equals("WOULD_UPDATE")
|
||||||
|| outcome.status.equals("CONFLICT")
|
|| outcome.status.equals("CONFLICT")
|
||||||
|| outcome.status.equals("UNMAPPED")
|
|| outcome.status.equals("UNMAPPED")
|
||||||
|| outcome.status.equals("IGNORED");
|
|| outcome.status.equals("IGNORED");
|
||||||
|
|
||||||
boolean ruleHit =
|
boolean ruleHit =
|
||||||
resolved != null
|
resolved != null
|
||||||
&& resolved.source() != null
|
&& resolved.source() != null
|
||||||
&& resolved.source().startsWith("rules_");
|
&& resolved.source().startsWith("rules_");
|
||||||
|
|
||||||
// Keep a small sample set for inspection.
|
// Keep a small sample set for inspection.
|
||||||
// Include rule hits even if the product ends up UNCHANGED, so we can verify rules are working.
|
// Include rule hits even if the product ends up UNCHANGED, so we can verify rules are working.
|
||||||
if (samples.size() < 50 && (interestingStatus || ruleHit)) {
|
if (samples.size() < 50 && (interestingStatus || ruleHit)) {
|
||||||
samples.add(toRow(p, resolved, outcome.status, outcome.meta));
|
samples.add(toRow(p, resolved, outcome.status, outcome.meta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!batch.hasNext()) break;
|
if (!batch.hasNext()) break;
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dry-run only right now—no writes.
|
// Dry-run only right now—no writes.
|
||||||
return new ReconcileResponse(dryRun, scanned, counts, samples);
|
return new ReconcileResponse(dryRun, scanned, counts, samples);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class DiffOutcome {
|
private static class DiffOutcome {
|
||||||
final String status;
|
final String status;
|
||||||
final Map<String, Object> meta;
|
final Map<String, Object> meta;
|
||||||
|
|
||||||
DiffOutcome(String status, Map<String, Object> meta) {
|
DiffOutcome(String status, Map<String, Object> meta) {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.meta = meta;
|
this.meta = meta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute reconcile status by comparing:
|
* Compute reconcile status by comparing:
|
||||||
* - existing product role/source/locks
|
* - existing product role/source/locks
|
||||||
* - classifier-resolved role/source/confidence/reason
|
* - classifier-resolved role/source/confidence/reason
|
||||||
*/
|
*/
|
||||||
private DiffOutcome diff(Product p, ClassificationResult resolved) {
|
private DiffOutcome diff(Product p, ClassificationResult resolved) {
|
||||||
|
|
||||||
String existingRole = trimToNull(p.getPartRole());
|
String existingRole = trimToNull(p.getPartRole());
|
||||||
String resolvedRole = resolved == null ? null : trimToNull(resolved.partRole());
|
String resolvedRole = resolved == null ? null : trimToNull(resolved.partRole());
|
||||||
|
|
||||||
// Respect locks (never propose changes).
|
// Respect locks (never propose changes).
|
||||||
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
|
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
|
||||||
if (locked) {
|
if (locked) {
|
||||||
return new DiffOutcome("LOCKED", Map.of("note", "partRoleLocked or platformLocked"));
|
return new DiffOutcome("LOCKED", Map.of("note", "partRoleLocked or platformLocked"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Treat known "ignored category" decisions as their own outcome bucket.
|
// Treat known "ignored category" decisions as their own outcome bucket.
|
||||||
if (isIgnored(resolved)) {
|
if (isIgnored(resolved)) {
|
||||||
return new DiffOutcome("IGNORED", Map.of(
|
return new DiffOutcome("IGNORED", Map.of(
|
||||||
"reason", resolved.reason(),
|
"reason", resolved.reason(),
|
||||||
"source", resolved.source()
|
"source", resolved.source()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// No role resolved = still unmapped (needs merchant_category_map or rule-based inference later).
|
// No role resolved = still unmapped (needs merchant_category_map or rule-based inference later).
|
||||||
if (resolvedRole == null) {
|
if (resolvedRole == null) {
|
||||||
return new DiffOutcome("UNMAPPED", Map.of(
|
return new DiffOutcome("UNMAPPED", Map.of(
|
||||||
"reason", resolved == null ? null : resolved.reason(),
|
"reason", resolved == null ? null : resolved.reason(),
|
||||||
"source", resolved == null ? null : resolved.source(),
|
"source", resolved == null ? null : resolved.source(),
|
||||||
"confidence", resolved == null ? null : resolved.confidence()
|
"confidence", resolved == null ? null : resolved.confidence()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing role missing but we resolved one => would update.
|
// Existing role missing but we resolved one => would update.
|
||||||
if (existingRole == null) {
|
if (existingRole == null) {
|
||||||
return new DiffOutcome("WOULD_UPDATE", Map.of("from", null, "to", resolvedRole));
|
return new DiffOutcome("WOULD_UPDATE", Map.of("from", null, "to", resolvedRole));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same role => unchanged.
|
// Same role => unchanged.
|
||||||
if (existingRole.equalsIgnoreCase(resolvedRole)) {
|
if (existingRole.equalsIgnoreCase(resolvedRole)) {
|
||||||
return new DiffOutcome("UNCHANGED", Map.of());
|
return new DiffOutcome("UNCHANGED", Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If existing role came from an override, flag as conflict (don't auto-clobber).
|
// If existing role came from an override, flag as conflict (don't auto-clobber).
|
||||||
PartRoleSource existingSource = p.getPartRoleSource();
|
PartRoleSource existingSource = p.getPartRoleSource();
|
||||||
if (existingSource == PartRoleSource.OVERRIDE) {
|
if (existingSource == PartRoleSource.OVERRIDE) {
|
||||||
return new DiffOutcome(
|
return new DiffOutcome(
|
||||||
"CONFLICT",
|
"CONFLICT",
|
||||||
Map.of("from", existingRole, "to", resolvedRole, "note", "existing source is OVERRIDE")
|
Map.of("from", existingRole, "to", resolvedRole, "note", "existing source is OVERRIDE")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, it’s a normal drift that we would update in apply-mode.
|
// Otherwise, it’s a normal drift that we would update in apply-mode.
|
||||||
return new DiffOutcome("WOULD_UPDATE", Map.of("from", existingRole, "to", resolvedRole));
|
return new DiffOutcome("WOULD_UPDATE", Map.of("from", existingRole, "to", resolvedRole));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect the "non-classifying category ignored" decisions from the classifier.
|
* Detect the "non-classifying category ignored" decisions from the classifier.
|
||||||
* Right now we key off the reason text prefix (fast + simple).
|
* Right now we key off the reason text prefix (fast + simple).
|
||||||
* Later we can formalize this via a dedicated source or meta flag.
|
* Later we can formalize this via a dedicated source or meta flag.
|
||||||
*/
|
*/
|
||||||
private boolean isIgnored(ClassificationResult r) {
|
private boolean isIgnored(ClassificationResult r) {
|
||||||
return r != null && "ignored_category".equalsIgnoreCase(r.source());
|
return r != null && "ignored_category".equalsIgnoreCase(r.source());
|
||||||
}
|
}
|
||||||
|
|
||||||
private ReconcileDiffRow toRow(Product p, ClassificationResult resolved, String status, Map<String, Object> meta) {
|
private ReconcileDiffRow toRow(Product p, ClassificationResult resolved, String status, Map<String, Object> meta) {
|
||||||
|
|
||||||
// Extract the merchant used by classifier (if provided in meta).
|
// Extract the merchant used by classifier (if provided in meta).
|
||||||
Integer resolvedMerchantId = null;
|
Integer resolvedMerchantId = null;
|
||||||
if (resolved != null && resolved.meta() != null) {
|
if (resolved != null && resolved.meta() != null) {
|
||||||
Object v = resolved.meta().get("resolvedMerchantId");
|
Object v = resolved.meta().get("resolvedMerchantId");
|
||||||
if (v instanceof Integer i) resolvedMerchantId = i;
|
if (v instanceof Integer i) resolvedMerchantId = i;
|
||||||
else if (v instanceof Number n) resolvedMerchantId = n.intValue();
|
else if (v instanceof Number n) resolvedMerchantId = n.intValue();
|
||||||
else if (v instanceof String s) {
|
else if (v instanceof String s) {
|
||||||
try { resolvedMerchantId = Integer.parseInt(s); } catch (Exception ignored) {}
|
try { resolvedMerchantId = Integer.parseInt(s); } catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ReconcileDiffRow(
|
return new ReconcileDiffRow(
|
||||||
p.getId(),
|
p.getId(),
|
||||||
p.getName(),
|
p.getName(),
|
||||||
p.getPlatform(),
|
p.getPlatform(),
|
||||||
p.getRawCategoryKey(),
|
p.getRawCategoryKey(),
|
||||||
|
|
||||||
resolvedMerchantId,
|
resolvedMerchantId,
|
||||||
|
|
||||||
p.getPartRole(),
|
p.getPartRole(),
|
||||||
p.getPartRoleSource() == null ? null : p.getPartRoleSource().name(),
|
p.getPartRoleSource() == null ? null : p.getPartRoleSource().name(),
|
||||||
p.getPartRoleLocked(),
|
p.getPartRoleLocked(),
|
||||||
p.getPlatformLocked(),
|
p.getPlatformLocked(),
|
||||||
|
|
||||||
resolved == null ? null : resolved.partRole(),
|
resolved == null ? null : resolved.partRole(),
|
||||||
resolved == null ? null : resolved.source(),
|
resolved == null ? null : resolved.source(),
|
||||||
resolved == null ? 0.0 : resolved.confidence(),
|
resolved == null ? 0.0 : resolved.confidence(),
|
||||||
resolved == null ? null : resolved.reason(),
|
resolved == null ? null : resolved.reason(),
|
||||||
|
|
||||||
status,
|
status,
|
||||||
meta == null ? Map.of() : meta
|
meta == null ? Map.of() : meta
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String trimToNull(String s) {
|
private static String trimToNull(String s) {
|
||||||
if (s == null) return null;
|
if (s == null) return null;
|
||||||
String t = s.trim();
|
String t = s.trim();
|
||||||
return t.isEmpty() ? null : t;
|
return t.isEmpty() ? null : t;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,474 +1,474 @@
|
|||||||
package group.goforward.battlbuilder.classification.impl;
|
package group.goforward.battlbuilder.classification.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.classification.ClassificationResult;
|
import group.goforward.battlbuilder.classification.ClassificationResult;
|
||||||
import group.goforward.battlbuilder.classification.ProductClassifier;
|
import group.goforward.battlbuilder.classification.ProductClassifier;
|
||||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class ProductClassifierImpl implements ProductClassifier {
|
public class ProductClassifierImpl implements ProductClassifier {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bump this whenever you change classification logic.
|
* Bump this whenever you change classification logic.
|
||||||
* Useful for debugging and for "reconcile" runs.
|
* Useful for debugging and for "reconcile" runs.
|
||||||
*/
|
*/
|
||||||
private static final String VERSION = "2025.12.29";
|
private static final String VERSION = "2025.12.29";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Non-classifying "categories" some merchants use as merchandising labels.
|
* Non-classifying "categories" some merchants use as merchandising labels.
|
||||||
* These are orthogonal to part role (condition/marketing), so we ignore them
|
* These are orthogonal to part role (condition/marketing), so we ignore them
|
||||||
* to avoid polluting merchant_category_map.
|
* to avoid polluting merchant_category_map.
|
||||||
*/
|
*/
|
||||||
// IMPORTANT:
|
// IMPORTANT:
|
||||||
// These tokens represent NON-SEMANTIC merchant categories.
|
// These tokens represent NON-SEMANTIC merchant categories.
|
||||||
// They should NEVER be mapped to part roles.
|
// They should NEVER be mapped to part roles.
|
||||||
// If you're tempted to add them to merchant_category_map, add them here instead.
|
// If you're tempted to add them to merchant_category_map, add them here instead.
|
||||||
private static final Set<String> NON_CLASSIFYING_TOKENS = Set.of(
|
private static final Set<String> NON_CLASSIFYING_TOKENS = Set.of(
|
||||||
// marketing / promos
|
// marketing / promos
|
||||||
"sale",
|
"sale",
|
||||||
"clearance",
|
"clearance",
|
||||||
"deal",
|
"deal",
|
||||||
"special",
|
"special",
|
||||||
"markdown",
|
"markdown",
|
||||||
"promo",
|
"promo",
|
||||||
|
|
||||||
// merchandising buckets
|
// merchandising buckets
|
||||||
"general",
|
"general",
|
||||||
"apparel",
|
"apparel",
|
||||||
"accessories",
|
"accessories",
|
||||||
"parts",
|
"parts",
|
||||||
"spare parts",
|
"spare parts",
|
||||||
"shop all",
|
"shop all",
|
||||||
"lineup",
|
"lineup",
|
||||||
"collection",
|
"collection",
|
||||||
|
|
||||||
// bundles / kits / sets
|
// bundles / kits / sets
|
||||||
"bundle",
|
"bundle",
|
||||||
"kit",
|
"kit",
|
||||||
"set",
|
"set",
|
||||||
"builder set",
|
"builder set",
|
||||||
|
|
||||||
// color / variant groupings
|
// color / variant groupings
|
||||||
"colors",
|
"colors",
|
||||||
"finish",
|
"finish",
|
||||||
"variant",
|
"variant",
|
||||||
|
|
||||||
// caliber / platform / type filters (not part roles)
|
// caliber / platform / type filters (not part roles)
|
||||||
"bolt action",
|
"bolt action",
|
||||||
"ar15",
|
"ar15",
|
||||||
"ar-15",
|
"ar-15",
|
||||||
"rifles",
|
"rifles",
|
||||||
"creedmoor",
|
"creedmoor",
|
||||||
"winchester",
|
"winchester",
|
||||||
"grendel",
|
"grendel",
|
||||||
"legend",
|
"legend",
|
||||||
|
|
||||||
// promo shelves
|
// promo shelves
|
||||||
"savings",
|
"savings",
|
||||||
|
|
||||||
// brand shelves
|
// brand shelves
|
||||||
"magpul",
|
"magpul",
|
||||||
|
|
||||||
// caliber shelves (not part roles)
|
// caliber shelves (not part roles)
|
||||||
"5.56",
|
"5.56",
|
||||||
"5.56 nato",
|
"5.56 nato",
|
||||||
"223",
|
"223",
|
||||||
".223",
|
".223",
|
||||||
"223 wylde",
|
"223 wylde",
|
||||||
".223 wylde",
|
".223 wylde",
|
||||||
"wylde",
|
"wylde",
|
||||||
"nato"
|
"nato"
|
||||||
);
|
);
|
||||||
|
|
||||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||||
private final ProductOfferRepository productOfferRepository;
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
|
||||||
public ProductClassifierImpl(MerchantCategoryMapRepository merchantCategoryMapRepository,
|
public ProductClassifierImpl(MerchantCategoryMapRepository merchantCategoryMapRepository,
|
||||||
ProductOfferRepository productOfferRepository) {
|
ProductOfferRepository productOfferRepository) {
|
||||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||||
this.productOfferRepository = productOfferRepository;
|
this.productOfferRepository = productOfferRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ClassificationResult classifyProduct(Product product) {
|
public ClassificationResult classifyProduct(Product product) {
|
||||||
// Backwards compatible path (existing callers)
|
// Backwards compatible path (existing callers)
|
||||||
return classifyProduct(product, null);
|
return classifyProduct(product, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId) {
|
public ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId) {
|
||||||
|
|
||||||
// ===== Guardrails =====
|
// ===== Guardrails =====
|
||||||
if (product == null || product.getId() == null) {
|
if (product == null || product.getId() == null) {
|
||||||
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
|
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBlank(product.getRawCategoryKey())) {
|
if (isBlank(product.getRawCategoryKey())) {
|
||||||
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
|
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize inputs early so we don't accidentally compare mismatched strings.
|
// Normalize inputs early so we don't accidentally compare mismatched strings.
|
||||||
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
|
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
|
||||||
String platform = normalizePlatform(product.getPlatform()); // may be null
|
String platform = normalizePlatform(product.getPlatform()); // may be null
|
||||||
|
|
||||||
// Resolve merchant-of-record:
|
// Resolve merchant-of-record:
|
||||||
// - Prefer caller-provided merchantId (batch-resolved in reconcile to avoid N+1)
|
// - Prefer caller-provided merchantId (batch-resolved in reconcile to avoid N+1)
|
||||||
// - Fall back to DB lookup for normal runtime usage
|
// - Fall back to DB lookup for normal runtime usage
|
||||||
Integer merchantId = resolvedMerchantId;
|
Integer merchantId = resolvedMerchantId;
|
||||||
if (merchantId == null) {
|
if (merchantId == null) {
|
||||||
merchantId = productOfferRepository
|
merchantId = productOfferRepository
|
||||||
.findPrimaryMerchantIdByFirstSeen(product.getId())
|
.findPrimaryMerchantIdByFirstSeen(product.getId())
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Ignore non-classifying categories (e.g. Clearance/Blemished/Caliber shelves) =====
|
// ===== Ignore non-classifying categories (e.g. Clearance/Blemished/Caliber shelves) =====
|
||||||
if (isNonClassifyingCategory(rawCategory)) {
|
if (isNonClassifyingCategory(rawCategory)) {
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
null,
|
null,
|
||||||
"ignored_category",
|
"ignored_category",
|
||||||
"Ignored non-classifying merchant category: " + rawCategory,
|
"Ignored non-classifying merchant category: " + rawCategory,
|
||||||
VERSION,
|
VERSION,
|
||||||
0.0,
|
0.0,
|
||||||
meta(
|
meta(
|
||||||
"resolvedMerchantId", merchantId,
|
"resolvedMerchantId", merchantId,
|
||||||
"rawCategory", rawCategory,
|
"rawCategory", rawCategory,
|
||||||
"platform", platform
|
"platform", platform
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we need merchant mapping or rules, merchantId is required
|
// If we need merchant mapping or rules, merchantId is required
|
||||||
if (merchantId == null) {
|
if (merchantId == null) {
|
||||||
return ClassificationResult.unknown(
|
return ClassificationResult.unknown(
|
||||||
VERSION,
|
VERSION,
|
||||||
"No offers found for product; cannot determine merchant mapping."
|
"No offers found for product; cannot determine merchant mapping."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Rule-based split: broad "Gas System" buckets =====
|
// ===== Rule-based split: broad "Gas System" buckets =====
|
||||||
if (isGasSystemBucket(rawCategory)) {
|
if (isGasSystemBucket(rawCategory)) {
|
||||||
String name = (product.getName() == null) ? "" : product.getName();
|
String name = (product.getName() == null) ? "" : product.getName();
|
||||||
String n = name.toLowerCase(Locale.ROOT);
|
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")) {
|
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(
|
return new ClassificationResult(
|
||||||
"gas-block-tube-combo",
|
"gas-block-tube-combo",
|
||||||
"rules_gas_system",
|
"rules_gas_system",
|
||||||
"Gas System bucket: inferred gas-block-tube-combo from name.",
|
"Gas System bucket: inferred gas-block-tube-combo from name.",
|
||||||
VERSION,
|
VERSION,
|
||||||
0.92,
|
0.92,
|
||||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:combo")
|
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")) {
|
if (containsAny(n, "roll pin", "gas tube roll pin", "gastube roll pin", "tube roll pin")) {
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
"gas-tube-roll-pin",
|
"gas-tube-roll-pin",
|
||||||
"rules_gas_system",
|
"rules_gas_system",
|
||||||
"Gas System bucket: inferred gas-tube-roll-pin from name.",
|
"Gas System bucket: inferred gas-tube-roll-pin from name.",
|
||||||
VERSION,
|
VERSION,
|
||||||
0.90,
|
0.90,
|
||||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:roll-pin")
|
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:roll-pin")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (containsAny(n, "gas tube", "gastube")) {
|
if (containsAny(n, "gas tube", "gastube")) {
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
"gas-tube",
|
"gas-tube",
|
||||||
"rules_gas_system",
|
"rules_gas_system",
|
||||||
"Gas System bucket: inferred gas-tube from name.",
|
"Gas System bucket: inferred gas-tube from name.",
|
||||||
VERSION,
|
VERSION,
|
||||||
0.90,
|
0.90,
|
||||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:tube")
|
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:tube")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (containsAny(n, "gas block", "gasblock")) {
|
if (containsAny(n, "gas block", "gasblock")) {
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
"gas-block",
|
"gas-block",
|
||||||
"rules_gas_system",
|
"rules_gas_system",
|
||||||
"Gas System bucket: inferred gas-block from name.",
|
"Gas System bucket: inferred gas-block from name.",
|
||||||
VERSION,
|
VERSION,
|
||||||
0.90,
|
0.90,
|
||||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:block")
|
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:block")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
null,
|
null,
|
||||||
"rules_gas_system",
|
"rules_gas_system",
|
||||||
"Gas System bucket: no confident keyword match (leave unmapped).",
|
"Gas System bucket: no confident keyword match (leave unmapped).",
|
||||||
VERSION,
|
VERSION,
|
||||||
0.0,
|
0.0,
|
||||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:none")
|
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:none")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Primary classification: merchant_category_map lookup =====
|
// ===== Primary classification: merchant_category_map lookup =====
|
||||||
Optional<MerchantCategoryMap> best =
|
Optional<MerchantCategoryMap> best =
|
||||||
merchantCategoryMapRepository.findBest(merchantId, rawCategory, platform);
|
merchantCategoryMapRepository.findBest(merchantId, rawCategory, platform);
|
||||||
|
|
||||||
if (best.isPresent()) {
|
if (best.isPresent()) {
|
||||||
MerchantCategoryMap map = best.get();
|
MerchantCategoryMap map = best.get();
|
||||||
|
|
||||||
String role = trimToNull(map.getCanonicalPartRole());
|
String role = trimToNull(map.getCanonicalPartRole());
|
||||||
if (role == null) {
|
if (role == null) {
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
null,
|
null,
|
||||||
"merchant_mapping",
|
"merchant_mapping",
|
||||||
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
|
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
|
||||||
VERSION,
|
VERSION,
|
||||||
0.20,
|
0.20,
|
||||||
meta(
|
meta(
|
||||||
"resolvedMerchantId", merchantId,
|
"resolvedMerchantId", merchantId,
|
||||||
"rawCategory", rawCategory,
|
"rawCategory", rawCategory,
|
||||||
"platform", platform,
|
"platform", platform,
|
||||||
"mapId", map.getId(),
|
"mapId", map.getId(),
|
||||||
"mapPlatform", map.getPlatform()
|
"mapPlatform", map.getPlatform()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
double confidence =
|
double confidence =
|
||||||
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
|
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
|
||||||
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
|
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
|
||||||
map.getPlatform() == null ? 0.90 : 0.90;
|
map.getPlatform() == null ? 0.90 : 0.90;
|
||||||
|
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
role,
|
role,
|
||||||
"merchant_mapping",
|
"merchant_mapping",
|
||||||
"Mapped via merchant_category_map (canonical_part_role).",
|
"Mapped via merchant_category_map (canonical_part_role).",
|
||||||
VERSION,
|
VERSION,
|
||||||
confidence,
|
confidence,
|
||||||
meta(
|
meta(
|
||||||
"resolvedMerchantId", merchantId,
|
"resolvedMerchantId", merchantId,
|
||||||
"rawCategory", rawCategory,
|
"rawCategory", rawCategory,
|
||||||
"platform", platform,
|
"platform", platform,
|
||||||
"mapId", map.getId(),
|
"mapId", map.getId(),
|
||||||
"mapPlatform", map.getPlatform()
|
"mapPlatform", map.getPlatform()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
null,
|
null,
|
||||||
"merchant_mapping",
|
"merchant_mapping",
|
||||||
"No enabled mapping found for resolved merchant/rawCategory/platform.",
|
"No enabled mapping found for resolved merchant/rawCategory/platform.",
|
||||||
VERSION,
|
VERSION,
|
||||||
0.0,
|
0.0,
|
||||||
meta(
|
meta(
|
||||||
"resolvedMerchantId", merchantId,
|
"resolvedMerchantId", merchantId,
|
||||||
"rawCategory", rawCategory,
|
"rawCategory", rawCategory,
|
||||||
"platform", platform
|
"platform", platform
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ClassificationResult classifyProduct(
|
public ClassificationResult classifyProduct(
|
||||||
Product product,
|
Product product,
|
||||||
Integer resolvedMerchantId,
|
Integer resolvedMerchantId,
|
||||||
Map<String, Optional<MerchantCategoryMap>> mappingMemo
|
Map<String, Optional<MerchantCategoryMap>> mappingMemo
|
||||||
) {
|
) {
|
||||||
// Safety: if caller passes null, fall back cleanly
|
// Safety: if caller passes null, fall back cleanly
|
||||||
if (mappingMemo == null) {
|
if (mappingMemo == null) {
|
||||||
mappingMemo = new HashMap<>();
|
mappingMemo = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Guardrails =====
|
// ===== Guardrails =====
|
||||||
if (product == null || product.getId() == null) {
|
if (product == null || product.getId() == null) {
|
||||||
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
|
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBlank(product.getRawCategoryKey())) {
|
if (isBlank(product.getRawCategoryKey())) {
|
||||||
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
|
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
|
||||||
}
|
}
|
||||||
|
|
||||||
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
|
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
|
||||||
String platform = normalizePlatform(product.getPlatform());
|
String platform = normalizePlatform(product.getPlatform());
|
||||||
|
|
||||||
Integer merchantId = resolvedMerchantId;
|
Integer merchantId = resolvedMerchantId;
|
||||||
if (merchantId == null) {
|
if (merchantId == null) {
|
||||||
merchantId = productOfferRepository
|
merchantId = productOfferRepository
|
||||||
.findPrimaryMerchantIdByFirstSeen(product.getId())
|
.findPrimaryMerchantIdByFirstSeen(product.getId())
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore merchandising/filters
|
// Ignore merchandising/filters
|
||||||
if (isNonClassifyingCategory(rawCategory)) {
|
if (isNonClassifyingCategory(rawCategory)) {
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
null,
|
null,
|
||||||
"ignored_category",
|
"ignored_category",
|
||||||
"Ignored non-classifying merchant category: " + rawCategory,
|
"Ignored non-classifying merchant category: " + rawCategory,
|
||||||
VERSION,
|
VERSION,
|
||||||
0.0,
|
0.0,
|
||||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
|
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (merchantId == null) {
|
if (merchantId == null) {
|
||||||
return ClassificationResult.unknown(
|
return ClassificationResult.unknown(
|
||||||
VERSION,
|
VERSION,
|
||||||
"No offers found for product; cannot determine merchant mapping."
|
"No offers found for product; cannot determine merchant mapping."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Gas System rules (unchanged) =====
|
// ===== Gas System rules (unchanged) =====
|
||||||
if (isGasSystemBucket(rawCategory)) {
|
if (isGasSystemBucket(rawCategory)) {
|
||||||
// reuse your existing logic by calling the 2-arg version
|
// reuse your existing logic by calling the 2-arg version
|
||||||
return classifyProduct(product, merchantId);
|
return classifyProduct(product, merchantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Memoized merchant_category_map lookup =====
|
// ===== Memoized merchant_category_map lookup =====
|
||||||
// Key is normalized to maximize cache hits across equivalent strings.
|
// Key is normalized to maximize cache hits across equivalent strings.
|
||||||
String memoKey =
|
String memoKey =
|
||||||
merchantId + "|" +
|
merchantId + "|" +
|
||||||
(platform == null ? "null" : platform.toUpperCase(Locale.ROOT)) + "|" +
|
(platform == null ? "null" : platform.toUpperCase(Locale.ROOT)) + "|" +
|
||||||
rawCategory.toLowerCase(Locale.ROOT).trim();
|
rawCategory.toLowerCase(Locale.ROOT).trim();
|
||||||
|
|
||||||
final Integer mid = merchantId;
|
final Integer mid = merchantId;
|
||||||
final String rc = rawCategory;
|
final String rc = rawCategory;
|
||||||
final String pl = platform;
|
final String pl = platform;
|
||||||
|
|
||||||
Optional<MerchantCategoryMap> best = mappingMemo.computeIfAbsent(
|
Optional<MerchantCategoryMap> best = mappingMemo.computeIfAbsent(
|
||||||
memoKey,
|
memoKey,
|
||||||
k -> merchantCategoryMapRepository.findBest(mid, rc, pl)
|
k -> merchantCategoryMapRepository.findBest(mid, rc, pl)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (best.isPresent()) {
|
if (best.isPresent()) {
|
||||||
MerchantCategoryMap map = best.get();
|
MerchantCategoryMap map = best.get();
|
||||||
|
|
||||||
String role = trimToNull(map.getCanonicalPartRole());
|
String role = trimToNull(map.getCanonicalPartRole());
|
||||||
if (role == null) {
|
if (role == null) {
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
null,
|
null,
|
||||||
"merchant_mapping",
|
"merchant_mapping",
|
||||||
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
|
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
|
||||||
VERSION,
|
VERSION,
|
||||||
0.20,
|
0.20,
|
||||||
meta(
|
meta(
|
||||||
"resolvedMerchantId", merchantId,
|
"resolvedMerchantId", merchantId,
|
||||||
"rawCategory", rawCategory,
|
"rawCategory", rawCategory,
|
||||||
"platform", platform,
|
"platform", platform,
|
||||||
"mapId", map.getId(),
|
"mapId", map.getId(),
|
||||||
"mapPlatform", map.getPlatform()
|
"mapPlatform", map.getPlatform()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
double confidence =
|
double confidence =
|
||||||
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
|
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
|
||||||
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
|
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
|
||||||
map.getPlatform() == null ? 0.90 : 0.90;
|
map.getPlatform() == null ? 0.90 : 0.90;
|
||||||
|
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
role,
|
role,
|
||||||
"merchant_mapping",
|
"merchant_mapping",
|
||||||
"Mapped via merchant_category_map (canonical_part_role).",
|
"Mapped via merchant_category_map (canonical_part_role).",
|
||||||
VERSION,
|
VERSION,
|
||||||
confidence,
|
confidence,
|
||||||
meta(
|
meta(
|
||||||
"resolvedMerchantId", merchantId,
|
"resolvedMerchantId", merchantId,
|
||||||
"rawCategory", rawCategory,
|
"rawCategory", rawCategory,
|
||||||
"platform", platform,
|
"platform", platform,
|
||||||
"mapId", map.getId(),
|
"mapId", map.getId(),
|
||||||
"mapPlatform", map.getPlatform()
|
"mapPlatform", map.getPlatform()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ClassificationResult(
|
return new ClassificationResult(
|
||||||
null,
|
null,
|
||||||
"merchant_mapping",
|
"merchant_mapping",
|
||||||
"No enabled mapping found for resolved merchant/rawCategory/platform.",
|
"No enabled mapping found for resolved merchant/rawCategory/platform.",
|
||||||
VERSION,
|
VERSION,
|
||||||
0.0,
|
0.0,
|
||||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
|
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if this raw category is a merchandising/condition label rather than a taxonomy path.
|
* Returns true if this raw category is a merchandising/condition label rather than a taxonomy path.
|
||||||
*/
|
*/
|
||||||
private boolean isNonClassifyingCategory(String rawCategory) {
|
private boolean isNonClassifyingCategory(String rawCategory) {
|
||||||
if (rawCategory == null) return false;
|
if (rawCategory == null) return false;
|
||||||
|
|
||||||
String v = rawCategory.toLowerCase(Locale.ROOT).trim();
|
String v = rawCategory.toLowerCase(Locale.ROOT).trim();
|
||||||
|
|
||||||
// If it's a breadcrumb/taxonomy path, DO NOT treat it as a merchandising label.
|
// If it's a breadcrumb/taxonomy path, DO NOT treat it as a merchandising label.
|
||||||
// Example: "Gunsmithing > ... > Gun Care & Accessories > Ar-15 Complete Uppers"
|
// Example: "Gunsmithing > ... > Gun Care & Accessories > Ar-15 Complete Uppers"
|
||||||
if (v.contains(">")) return false;
|
if (v.contains(">")) return false;
|
||||||
|
|
||||||
// For flat categories, apply token matching.
|
// For flat categories, apply token matching.
|
||||||
return NON_CLASSIFYING_TOKENS.stream().anyMatch(v::contains);
|
return NON_CLASSIFYING_TOKENS.stream().anyMatch(v::contains);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize merchant-provided category strings.
|
* Normalize merchant-provided category strings.
|
||||||
* Keep it light: trim + collapse whitespace.
|
* Keep it light: trim + collapse whitespace.
|
||||||
* (If needed later: unify case or canonicalize separators.)
|
* (If needed later: unify case or canonicalize separators.)
|
||||||
*/
|
*/
|
||||||
private String normalizeRawCategory(String raw) {
|
private String normalizeRawCategory(String raw) {
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
return raw.trim().replaceAll("\\s+", " ");
|
return raw.trim().replaceAll("\\s+", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize platform values. We keep it consistent with expected inputs like "AR-15".
|
* Normalize platform values. We keep it consistent with expected inputs like "AR-15".
|
||||||
* Uppercasing is okay because "AR-15" remains "AR-15".
|
* Uppercasing is okay because "AR-15" remains "AR-15".
|
||||||
*/
|
*/
|
||||||
private String normalizePlatform(String p) {
|
private String normalizePlatform(String p) {
|
||||||
if (p == null) return null;
|
if (p == null) return null;
|
||||||
String t = p.trim();
|
String t = p.trim();
|
||||||
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isBlank(String s) {
|
private boolean isBlank(String s) {
|
||||||
return s == null || s.trim().isEmpty();
|
return s == null || s.trim().isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String trimToNull(String s) {
|
private String trimToNull(String s) {
|
||||||
if (s == null) return null;
|
if (s == null) return null;
|
||||||
String t = s.trim();
|
String t = s.trim();
|
||||||
return t.isEmpty() ? null : t;
|
return t.isEmpty() ? null : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isGasSystemBucket(String rawCategory) {
|
private boolean isGasSystemBucket(String rawCategory) {
|
||||||
if (rawCategory == null) return false;
|
if (rawCategory == null) return false;
|
||||||
String v = rawCategory.toLowerCase(Locale.ROOT);
|
String v = rawCategory.toLowerCase(Locale.ROOT);
|
||||||
return v.contains("gas system");
|
return v.contains("gas system");
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean containsAny(String haystack, String... needles) {
|
private boolean containsAny(String haystack, String... needles) {
|
||||||
if (haystack == null) return false;
|
if (haystack == null) return false;
|
||||||
for (String n : needles) {
|
for (String n : needles) {
|
||||||
if (n != null && !n.isBlank() && haystack.contains(n)) return true;
|
if (n != null && !n.isBlank() && haystack.contains(n)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safe metadata builder.
|
* Safe metadata builder.
|
||||||
* IMPORTANT: Map.of(...) throws if any key/value is null; this helper skips null entries.
|
* IMPORTANT: Map.of(...) throws if any key/value is null; this helper skips null entries.
|
||||||
*/
|
*/
|
||||||
private static Map<String, Object> meta(Object... kv) {
|
private static Map<String, Object> meta(Object... kv) {
|
||||||
Map<String, Object> m = new LinkedHashMap<>();
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
for (int i = 0; i < kv.length; i += 2) {
|
for (int i = 0; i < kv.length; i += 2) {
|
||||||
String k = (String) kv[i];
|
String k = (String) kv[i];
|
||||||
Object v = kv[i + 1];
|
Object v = kv[i + 1];
|
||||||
if (k != null && v != null) m.put(k, v);
|
if (k != null && v != null) m.put(k, v);
|
||||||
}
|
}
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,45 +1,45 @@
|
|||||||
package group.goforward.battlbuilder.cli;
|
package group.goforward.battlbuilder.cli;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
|
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Profile("!prod")
|
@Profile("!prod")
|
||||||
@ConditionalOnProperty(
|
@ConditionalOnProperty(
|
||||||
name = "app.beta.invite.run",
|
name = "app.beta.invite.run",
|
||||||
havingValue = "true"
|
havingValue = "true"
|
||||||
)
|
)
|
||||||
public class BetaInviteCliRunner implements CommandLineRunner {
|
public class BetaInviteCliRunner implements CommandLineRunner {
|
||||||
|
|
||||||
private final BetaInviteService inviteService;
|
private final BetaInviteService inviteService;
|
||||||
|
|
||||||
@Value("${app.beta.invite.limit:0}")
|
@Value("${app.beta.invite.limit:0}")
|
||||||
private int limit;
|
private int limit;
|
||||||
|
|
||||||
@Value("${app.beta.invite.dryRun:true}")
|
@Value("${app.beta.invite.dryRun:true}")
|
||||||
private boolean dryRun;
|
private boolean dryRun;
|
||||||
|
|
||||||
@Value("${app.beta.invite.tokenMinutes:30}")
|
@Value("${app.beta.invite.tokenMinutes:30}")
|
||||||
private int tokenMinutes;
|
private int tokenMinutes;
|
||||||
|
|
||||||
public BetaInviteCliRunner(BetaInviteService inviteService) {
|
public BetaInviteCliRunner(BetaInviteService inviteService) {
|
||||||
this.inviteService = inviteService;
|
this.inviteService = inviteService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(String... args) {
|
public void run(String... args) {
|
||||||
int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
|
int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
|
||||||
|
|
||||||
System.out.println(
|
System.out.println(
|
||||||
"✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun
|
"✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun
|
||||||
);
|
);
|
||||||
|
|
||||||
// Exit so it behaves like a CLI command
|
// Exit so it behaves like a CLI command
|
||||||
System.exit(0);
|
System.exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Utility controller package for the BattlBuilder application.
|
* Utility controller package for the BattlBuilder application.
|
||||||
* <p>
|
* <p>
|
||||||
* Contains utility REST controller for email handling and
|
* Contains utility REST controller for email handling and
|
||||||
* health check operations.
|
* health check operations.
|
||||||
*
|
*
|
||||||
* @author Forward Group, LLC
|
* @author Forward Group, LLC
|
||||||
* @version 1.0
|
* @version 1.0
|
||||||
* @since 2025-12-10
|
* @since 2025-12-10
|
||||||
*/
|
*/
|
||||||
package group.goforward.battlbuilder.common;
|
package group.goforward.battlbuilder.common;
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
package group.goforward.battlbuilder.config;
|
package group.goforward.battlbuilder.config;
|
||||||
|
|
||||||
import io.minio.MinioClient;
|
import io.minio.MinioClient;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class MinioConfig {
|
public class MinioConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public MinioClient minioClient(
|
public MinioClient minioClient(
|
||||||
@Value("${minio.endpoint}") String endpoint,
|
@Value("${minio.endpoint}") String endpoint,
|
||||||
@Value("${minio.access-key}") String accessKey,
|
@Value("${minio.access-key}") String accessKey,
|
||||||
@Value("${minio.secret-key}") String secretKey
|
@Value("${minio.secret-key}") String secretKey
|
||||||
) {
|
) {
|
||||||
return MinioClient.builder()
|
return MinioClient.builder()
|
||||||
.endpoint(endpoint)
|
.endpoint(endpoint)
|
||||||
.credentials(accessKey, secretKey)
|
.credentials(accessKey, secretKey)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
package group.goforward.battlbuilder.configuration;
|
package group.goforward.battlbuilder.configuration;
|
||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class PasswordConfig {
|
public class PasswordConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
// // BCrypt default password
|
// // BCrypt default password
|
||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
}
|
}
|
||||||
}*/
|
}*/
|
||||||
|
|||||||
@@ -1,93 +1,93 @@
|
|||||||
package group.goforward.battlbuilder.config;
|
package group.goforward.battlbuilder.config;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.security.JwtAuthenticationFilter;
|
import group.goforward.battlbuilder.security.JwtAuthenticationFilter;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
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.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
|
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.cors.CorsConfigurationSource;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
|
||||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
.cors(c -> c.configurationSource(corsConfigurationSource()))
|
.cors(c -> c.configurationSource(corsConfigurationSource()))
|
||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
// Public
|
// Public
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
.requestMatchers("/api/auth/**").permitAll()
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||||
.requestMatchers("/api/products/gunbuilder/**").permitAll()
|
.requestMatchers("/api/products/gunbuilder/**").permitAll()
|
||||||
|
|
||||||
// Public builds feed + public build detail (1 path segment only)
|
// Public builds feed + public build detail (1 path segment only)
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/builds/*").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/v1/builds/*").permitAll()
|
||||||
|
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
// Protected
|
// Protected
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
.requestMatchers("/api/v1/builds/me/**").authenticated()
|
.requestMatchers("/api/v1/builds/me/**").authenticated()
|
||||||
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
||||||
|
|
||||||
// Everything else (adjust later as you lock down)
|
// Everything else (adjust later as you lock down)
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
|
|
||||||
// run JWT before AnonymousAuth sets principal="anonymousUser"
|
// run JWT before AnonymousAuth sets principal="anonymousUser"
|
||||||
.addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration cfg = new CorsConfiguration();
|
CorsConfiguration cfg = new CorsConfiguration();
|
||||||
cfg.setAllowedOrigins(List.of("http://localhost:3000"));
|
cfg.setAllowedOrigins(List.of("http://localhost:3000"));
|
||||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||||
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
||||||
cfg.setExposedHeaders(List.of("Authorization"));
|
cfg.setExposedHeaders(List.of("Authorization"));
|
||||||
cfg.setAllowCredentials(true);
|
cfg.setAllowCredentials(true);
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
source.registerCorsConfiguration("/**", cfg);
|
source.registerCorsConfiguration("/**", cfg);
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
|
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
return configuration.getAuthenticationManager();
|
return configuration.getAuthenticationManager();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Configuration package for the BattlBuilder application.
|
* Configuration package for the BattlBuilder application.
|
||||||
* <p>
|
* <p>
|
||||||
* Contains Spring configuration classes for security, CORS, JPA,
|
* Contains Spring configuration classes for security, CORS, JPA,
|
||||||
* caching, and password encoding.
|
* caching, and password encoding.
|
||||||
*
|
*
|
||||||
* @author Forward Group, LLC
|
* @author Forward Group, LLC
|
||||||
* @version 1.0
|
* @version 1.0
|
||||||
* @since 2025-12-10
|
* @since 2025-12-10
|
||||||
*/
|
*/
|
||||||
package group.goforward.battlbuilder.config;
|
package group.goforward.battlbuilder.config;
|
||||||
|
|||||||
@@ -1,227 +1,227 @@
|
|||||||
package group.goforward.battlbuilder.controller;
|
package group.goforward.battlbuilder.controller;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import group.goforward.battlbuilder.repo.UserRepository;
|
import group.goforward.battlbuilder.repo.UserRepository;
|
||||||
import group.goforward.battlbuilder.security.JwtService;
|
import group.goforward.battlbuilder.security.JwtService;
|
||||||
import group.goforward.battlbuilder.service.auth.BetaAuthService;
|
import group.goforward.battlbuilder.service.auth.BetaAuthService;
|
||||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||||
import group.goforward.battlbuilder.web.dto.auth.BetaSignupRequest;
|
import group.goforward.battlbuilder.web.dto.auth.BetaSignupRequest;
|
||||||
import group.goforward.battlbuilder.web.dto.auth.LoginRequest;
|
import group.goforward.battlbuilder.web.dto.auth.LoginRequest;
|
||||||
import group.goforward.battlbuilder.web.dto.auth.RegisterRequest;
|
import group.goforward.battlbuilder.web.dto.auth.RegisterRequest;
|
||||||
import group.goforward.battlbuilder.web.dto.auth.TokenRequest;
|
import group.goforward.battlbuilder.web.dto.auth.TokenRequest;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping({"/api/auth", "/api/v1/auth"})
|
@RequestMapping({"/api/auth", "/api/v1/auth"})
|
||||||
@CrossOrigin
|
@CrossOrigin
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final UserRepository users;
|
private final UserRepository users;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final BetaAuthService betaAuthService;
|
private final BetaAuthService betaAuthService;
|
||||||
|
|
||||||
public AuthController(
|
public AuthController(
|
||||||
UserRepository users,
|
UserRepository users,
|
||||||
PasswordEncoder passwordEncoder,
|
PasswordEncoder passwordEncoder,
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
BetaAuthService betaAuthService
|
BetaAuthService betaAuthService
|
||||||
) {
|
) {
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.betaAuthService = betaAuthService;
|
this.betaAuthService = betaAuthService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Standard Auth
|
// Standard Auth
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<?> register(
|
public ResponseEntity<?> register(
|
||||||
@RequestBody RegisterRequest request,
|
@RequestBody RegisterRequest request,
|
||||||
HttpServletRequest httpRequest
|
HttpServletRequest httpRequest
|
||||||
) {
|
) {
|
||||||
String email = request.getEmail().trim().toLowerCase();
|
String email = request.getEmail().trim().toLowerCase();
|
||||||
|
|
||||||
// ✅ Enforce acceptance
|
// ✅ Enforce acceptance
|
||||||
if (!Boolean.TRUE.equals(request.getAcceptedTos())) {
|
if (!Boolean.TRUE.equals(request.getAcceptedTos())) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(HttpStatus.BAD_REQUEST)
|
.status(HttpStatus.BAD_REQUEST)
|
||||||
.body("Terms of Service acceptance is required");
|
.body("Terms of Service acceptance is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) {
|
if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(HttpStatus.CONFLICT)
|
.status(HttpStatus.CONFLICT)
|
||||||
.body("Email is already registered");
|
.body("Email is already registered");
|
||||||
}
|
}
|
||||||
|
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setUuid(UUID.randomUUID());
|
user.setUuid(UUID.randomUUID());
|
||||||
user.setEmail(email);
|
user.setEmail(email);
|
||||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||||
user.setPasswordSetAt(OffsetDateTime.now());
|
user.setPasswordSetAt(OffsetDateTime.now());
|
||||||
user.setDisplayName(request.getDisplayName());
|
user.setDisplayName(request.getDisplayName());
|
||||||
user.setRole("USER");
|
user.setRole("USER");
|
||||||
user.setActive(true);
|
user.setActive(true);
|
||||||
user.setCreatedAt(OffsetDateTime.now());
|
user.setCreatedAt(OffsetDateTime.now());
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
|
||||||
// ✅ Record ToS acceptance evidence
|
// ✅ Record ToS acceptance evidence
|
||||||
String tosVersion = StringUtils.hasText(request.getTosVersion())
|
String tosVersion = StringUtils.hasText(request.getTosVersion())
|
||||||
? request.getTosVersion().trim()
|
? request.getTosVersion().trim()
|
||||||
: "2025-12-27"; // keep in sync with your ToS page
|
: "2025-12-27"; // keep in sync with your ToS page
|
||||||
|
|
||||||
user.setTosAcceptedAt(OffsetDateTime.now());
|
user.setTosAcceptedAt(OffsetDateTime.now());
|
||||||
user.setTosVersion(tosVersion);
|
user.setTosVersion(tosVersion);
|
||||||
user.setTosIp(extractClientIp(httpRequest));
|
user.setTosIp(extractClientIp(httpRequest));
|
||||||
user.setTosUserAgent(httpRequest.getHeader("User-Agent"));
|
user.setTosUserAgent(httpRequest.getHeader("User-Agent"));
|
||||||
|
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
String token = jwtService.generateToken(user);
|
String token = jwtService.generateToken(user);
|
||||||
|
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(HttpStatus.CREATED)
|
.status(HttpStatus.CREATED)
|
||||||
.body(new AuthResponse(
|
.body(new AuthResponse(
|
||||||
token,
|
token,
|
||||||
user.getEmail(),
|
user.getEmail(),
|
||||||
user.getDisplayName(),
|
user.getDisplayName(),
|
||||||
user.getRole()
|
user.getRole()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String extractClientIp(HttpServletRequest request) {
|
private String extractClientIp(HttpServletRequest request) {
|
||||||
String xff = request.getHeader("X-Forwarded-For");
|
String xff = request.getHeader("X-Forwarded-For");
|
||||||
if (StringUtils.hasText(xff)) {
|
if (StringUtils.hasText(xff)) {
|
||||||
// first IP in the list
|
// first IP in the list
|
||||||
return xff.split(",")[0].trim();
|
return xff.split(",")[0].trim();
|
||||||
}
|
}
|
||||||
String realIp = request.getHeader("X-Real-IP");
|
String realIp = request.getHeader("X-Real-IP");
|
||||||
if (StringUtils.hasText(realIp)) return realIp.trim();
|
if (StringUtils.hasText(realIp)) return realIp.trim();
|
||||||
|
|
||||||
return request.getRemoteAddr();
|
return request.getRemoteAddr();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||||
String email = request.getEmail().trim().toLowerCase();
|
String email = request.getEmail().trim().toLowerCase();
|
||||||
|
|
||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
if (user == null || !user.isActive()) {
|
if (user == null || !user.isActive()) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(HttpStatus.UNAUTHORIZED)
|
.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body("Invalid credentials");
|
.body("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(HttpStatus.UNAUTHORIZED)
|
.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body("Invalid credentials");
|
.body("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
user.setLastLoginAt(OffsetDateTime.now());
|
user.setLastLoginAt(OffsetDateTime.now());
|
||||||
user.incrementLoginCount();
|
user.incrementLoginCount();
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
String token = jwtService.generateToken(user);
|
String token = jwtService.generateToken(user);
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
new AuthResponse(
|
new AuthResponse(
|
||||||
token,
|
token,
|
||||||
user.getEmail(),
|
user.getEmail(),
|
||||||
user.getDisplayName(),
|
user.getDisplayName(),
|
||||||
user.getRole()
|
user.getRole()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Beta Flow
|
// Beta Flow
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
@PostMapping("/beta/signup")
|
@PostMapping("/beta/signup")
|
||||||
public ResponseEntity<Map<String, Object>> betaSignup(@RequestBody BetaSignupRequest request) {
|
public ResponseEntity<Map<String, Object>> betaSignup(@RequestBody BetaSignupRequest request) {
|
||||||
// Always return OK to prevent email enumeration
|
// Always return OK to prevent email enumeration
|
||||||
try {
|
try {
|
||||||
betaAuthService.signup(request.getEmail(), request.getUseCase());
|
betaAuthService.signup(request.getEmail(), request.getUseCase());
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
// Intentionally swallow errors here to avoid leaking state
|
// Intentionally swallow errors here to avoid leaking state
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(Map.of("ok", true));
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/beta/confirm")
|
@PostMapping("/beta/confirm")
|
||||||
public ResponseEntity<?> betaConfirm(@RequestBody TokenRequest request) {
|
public ResponseEntity<?> betaConfirm(@RequestBody TokenRequest request) {
|
||||||
try {
|
try {
|
||||||
return ResponseEntity.ok(betaAuthService.confirmAndExchange(request.getToken()));
|
return ResponseEntity.ok(betaAuthService.confirmAndExchange(request.getToken()));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body("Confirm link is invalid or expired. Please request a new one.");
|
.body("Confirm link is invalid or expired. Please request a new one.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/magic/exchange")
|
@PostMapping("/magic/exchange")
|
||||||
public ResponseEntity<?> magicExchange(@RequestBody TokenRequest request) {
|
public ResponseEntity<?> magicExchange(@RequestBody TokenRequest request) {
|
||||||
try {
|
try {
|
||||||
AuthResponse auth = betaAuthService.exchangeMagicToken(request.getToken());
|
AuthResponse auth = betaAuthService.exchangeMagicToken(request.getToken());
|
||||||
return ResponseEntity.ok(auth);
|
return ResponseEntity.ok(auth);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
// token invalid/expired/consumed
|
// token invalid/expired/consumed
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body("Magic link is invalid or expired. Please request a new one.");
|
.body("Magic link is invalid or expired. Please request a new one.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@PostMapping("/password/forgot")
|
@PostMapping("/password/forgot")
|
||||||
public ResponseEntity<?> forgotPassword(@RequestBody Map<String, String> body) {
|
public ResponseEntity<?> forgotPassword(@RequestBody Map<String, String> body) {
|
||||||
String email = body.getOrDefault("email", "").trim();
|
String email = body.getOrDefault("email", "").trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
betaAuthService.sendPasswordReset(email); // name we’ll add below
|
betaAuthService.sendPasswordReset(email); // name we’ll add below
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
// swallow to avoid enumeration
|
// swallow to avoid enumeration
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok().body("{\"ok\":true}");
|
return ResponseEntity.ok().body("{\"ok\":true}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/password/reset")
|
@PostMapping("/password/reset")
|
||||||
public ResponseEntity<?> resetPassword(@RequestBody Map<String, String> body) {
|
public ResponseEntity<?> resetPassword(@RequestBody Map<String, String> body) {
|
||||||
String token = body.getOrDefault("token", "").trim();
|
String token = body.getOrDefault("token", "").trim();
|
||||||
String password = body.getOrDefault("password", "").trim();
|
String password = body.getOrDefault("password", "").trim();
|
||||||
|
|
||||||
betaAuthService.resetPassword(token, password);
|
betaAuthService.resetPassword(token, password);
|
||||||
|
|
||||||
return ResponseEntity.ok().body("{\"ok\":true}");
|
return ResponseEntity.ok().body("{\"ok\":true}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/magic")
|
@PostMapping("/magic")
|
||||||
public ResponseEntity<?> requestMagic(@RequestBody Map<String, String> body) {
|
public ResponseEntity<?> requestMagic(@RequestBody Map<String, String> body) {
|
||||||
String email = body.getOrDefault("email", "").trim();
|
String email = body.getOrDefault("email", "").trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
betaAuthService.sendMagicLoginLink(email);
|
betaAuthService.sendMagicLoginLink(email);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
// swallow to avoid enumeration
|
// swallow to avoid enumeration
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("ok", true));
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,102 +1,106 @@
|
|||||||
package group.goforward.battlbuilder.controller;
|
package group.goforward.battlbuilder.controller;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
@RestController
|
/**
|
||||||
@RequestMapping({"/api/builder", "/api/v1/builder"})
|
* REST controller responsible for providing bootstrap data for the builder platform.
|
||||||
@CrossOrigin
|
* This controller aggregates and normalizes data required to initialize builder-related UI components.
|
||||||
public class BuilderBootstrapController {
|
*/
|
||||||
|
@RestController
|
||||||
private final PartCategoryRepository partCategoryRepository;
|
@RequestMapping({"/api/builder", "/api/v1/builder"})
|
||||||
private final PartRoleMappingRepository mappingRepository;
|
@CrossOrigin
|
||||||
|
public class BuilderBootstrapController {
|
||||||
public BuilderBootstrapController(
|
|
||||||
PartCategoryRepository partCategoryRepository,
|
private final PartCategoryRepository partCategoryRepository;
|
||||||
PartRoleMappingRepository mappingRepository
|
private final PartRoleMappingRepository mappingRepository;
|
||||||
) {
|
|
||||||
this.partCategoryRepository = partCategoryRepository;
|
public BuilderBootstrapController(
|
||||||
this.mappingRepository = mappingRepository;
|
PartCategoryRepository partCategoryRepository,
|
||||||
}
|
PartRoleMappingRepository mappingRepository
|
||||||
|
) {
|
||||||
/**
|
this.partCategoryRepository = partCategoryRepository;
|
||||||
* Builder bootstrap payload.
|
this.mappingRepository = mappingRepository;
|
||||||
* <p>
|
}
|
||||||
* Returns:
|
|
||||||
* - categories: ordered list for UI navigation
|
/**
|
||||||
* - partRoleMap: normalized partRole -> categorySlug (platform-scoped)
|
* Builder bootstrap payload.
|
||||||
* - categoryRoles: categorySlug -> normalized partRoles (derived)
|
* <p>
|
||||||
*/
|
* Returns:
|
||||||
@GetMapping("/bootstrap")
|
* - categories: ordered list for UI navigation
|
||||||
public BuilderBootstrapDto bootstrap(
|
* - partRoleMap: normalized partRole -> categorySlug (platform-scoped)
|
||||||
@RequestParam(defaultValue = "AR-15") String platform
|
* - categoryRoles: categorySlug -> normalized partRoles (derived)
|
||||||
) {
|
*/
|
||||||
final String platformNorm = normalizePlatform(platform);
|
@GetMapping("/bootstrap")
|
||||||
|
public BuilderBootstrapDto bootstrap(
|
||||||
// 1) Categories in display order
|
@RequestParam(defaultValue = "AR-15") String platform
|
||||||
List<PartCategoryDto> categories = partCategoryRepository
|
) {
|
||||||
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
final String platformNorm = normalizePlatform(platform);
|
||||||
.stream()
|
|
||||||
.map(pc -> new PartCategoryDto(
|
// 1) Categories in display order
|
||||||
pc.getId(),
|
List<PartCategoryDto> categories = partCategoryRepository
|
||||||
pc.getSlug(),
|
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||||
pc.getName(),
|
.stream()
|
||||||
pc.getDescription(),
|
.map(pc -> new PartCategoryDto(
|
||||||
pc.getGroupName(),
|
pc.getId(),
|
||||||
pc.getSortOrder()
|
pc.getSlug(),
|
||||||
))
|
pc.getName(),
|
||||||
.toList();
|
pc.getDescription(),
|
||||||
|
pc.getGroupName(),
|
||||||
// 2) Role -> CategorySlug mapping (platform-scoped)
|
pc.getSortOrder()
|
||||||
// Normalize keys to kebab-case so the UI can treat roles consistently.
|
))
|
||||||
Map<String, String> roleToCategorySlug = new LinkedHashMap<>();
|
.toList();
|
||||||
|
|
||||||
List<PartRoleMapping> mappings = mappingRepository
|
// 2) Role -> CategorySlug mapping (platform-scoped)
|
||||||
.findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(platformNorm);
|
// Normalize keys to kebab-case so the UI can treat roles consistently.
|
||||||
|
Map<String, String> roleToCategorySlug = new LinkedHashMap<>();
|
||||||
for (PartRoleMapping m : mappings) {
|
|
||||||
String roleKey = normalizePartRole(m.getPartRole());
|
List<PartRoleMapping> mappings = mappingRepository
|
||||||
if (roleKey == null || roleKey.isBlank()) continue;
|
.findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(platformNorm);
|
||||||
|
|
||||||
if (m.getPartCategory() == null || m.getPartCategory().getSlug() == null) continue;
|
for (PartRoleMapping m : mappings) {
|
||||||
|
String roleKey = normalizePartRole(m.getPartRole());
|
||||||
// If duplicates exist, keep first and ignore the rest so bootstrap never 500s.
|
if (roleKey == null || roleKey.isBlank()) continue;
|
||||||
roleToCategorySlug.putIfAbsent(roleKey, m.getPartCategory().getSlug());
|
|
||||||
}
|
if (m.getPartCategory() == null || m.getPartCategory().getSlug() == null) continue;
|
||||||
|
|
||||||
// 3) CategorySlug -> Roles (derived)
|
// If duplicates exist, keep first and ignore the rest so bootstrap never 500s.
|
||||||
Map<String, List<String>> categoryToRoles = new LinkedHashMap<>();
|
roleToCategorySlug.putIfAbsent(roleKey, m.getPartCategory().getSlug());
|
||||||
for (Map.Entry<String, String> e : roleToCategorySlug.entrySet()) {
|
}
|
||||||
categoryToRoles.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey());
|
|
||||||
}
|
// 3) CategorySlug -> Roles (derived)
|
||||||
|
Map<String, List<String>> categoryToRoles = new LinkedHashMap<>();
|
||||||
return new BuilderBootstrapDto(platformNorm, categories, roleToCategorySlug, categoryToRoles);
|
for (Map.Entry<String, String> e : roleToCategorySlug.entrySet()) {
|
||||||
}
|
categoryToRoles.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey());
|
||||||
|
}
|
||||||
private String normalizePartRole(String role) {
|
|
||||||
if (role == null) return null;
|
return new BuilderBootstrapDto(platformNorm, categories, roleToCategorySlug, categoryToRoles);
|
||||||
String r = role.trim();
|
}
|
||||||
if (r.isEmpty()) return null;
|
|
||||||
return r.toLowerCase(Locale.ROOT).replace('_', '-');
|
private String normalizePartRole(String role) {
|
||||||
}
|
if (role == null) return null;
|
||||||
|
String r = role.trim();
|
||||||
private String normalizePlatform(String platform) {
|
if (r.isEmpty()) return null;
|
||||||
if (platform == null) return "AR-15";
|
return r.toLowerCase(Locale.ROOT).replace('_', '-');
|
||||||
String p = platform.trim();
|
}
|
||||||
if (p.isEmpty()) return "AR-15";
|
|
||||||
// normalize to AR-15 / AR-10 style
|
private String normalizePlatform(String platform) {
|
||||||
return p.toUpperCase(Locale.ROOT).replace('_', '-');
|
if (platform == null) return "AR-15";
|
||||||
}
|
String p = platform.trim();
|
||||||
|
if (p.isEmpty()) return "AR-15";
|
||||||
public record BuilderBootstrapDto(
|
// normalize to AR-15 / AR-10 style
|
||||||
String platform,
|
return p.toUpperCase(Locale.ROOT).replace('_', '-');
|
||||||
List<PartCategoryDto> categories,
|
}
|
||||||
Map<String, String> partRoleMap,
|
|
||||||
Map<String, List<String>> categoryRoles
|
public record BuilderBootstrapDto(
|
||||||
) {}
|
String platform,
|
||||||
|
List<PartCategoryDto> categories,
|
||||||
|
Map<String, String> partRoleMap,
|
||||||
|
Map<String, List<String>> categoryRoles
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
@@ -1,34 +1,55 @@
|
|||||||
package group.goforward.battlbuilder.controller;
|
package group.goforward.battlbuilder.controller;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
/**
|
||||||
@RequestMapping({"/api/categories", "/api/v1/categories"})
|
* REST controller for managing part categories.
|
||||||
@CrossOrigin // you can tighten origins later
|
*
|
||||||
public class CategoryController {
|
* This controller provides endpoints for retrieving and interacting with
|
||||||
|
* part category data through its associated repository. Part categories are
|
||||||
private final PartCategoryRepository partCategories;
|
* sorted based on their group name, sort order, and name in ascending order.
|
||||||
|
*
|
||||||
public CategoryController(PartCategoryRepository partCategories) {
|
* Annotations:
|
||||||
this.partCategories = partCategories;
|
* - {@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".
|
||||||
@GetMapping
|
* - {@code @CrossOrigin}: Enables cross-origin requests.
|
||||||
public List<PartCategoryDto> list() {
|
*
|
||||||
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
* Constructor:
|
||||||
.stream()
|
* - {@code CategoryController(PartCategoryRepository partCategories)}: Initializes
|
||||||
.map(pc -> new PartCategoryDto(
|
* the controller with the specified repository for accessing part category data.
|
||||||
pc.getId(),
|
*
|
||||||
pc.getSlug(),
|
* Methods:
|
||||||
pc.getName(),
|
* - {@code List<PartCategoryDto> list()}: Retrieves a list of part categories from
|
||||||
pc.getDescription(),
|
* the repository, sorts them, and maps them to DTO objects for output.
|
||||||
pc.getGroupName(),
|
*/
|
||||||
pc.getSortOrder()
|
@RestController
|
||||||
))
|
@RequestMapping({"/api/categories", "/api/v1/categories"})
|
||||||
.toList();
|
@CrossOrigin // you can tighten origins later
|
||||||
}
|
public class CategoryController {
|
||||||
|
|
||||||
|
private final PartCategoryRepository partCategories;
|
||||||
|
|
||||||
|
public CategoryController(PartCategoryRepository partCategories) {
|
||||||
|
this.partCategories = partCategories;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<PartCategoryDto> list() {
|
||||||
|
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||||
|
.stream()
|
||||||
|
.map(pc -> new PartCategoryDto(
|
||||||
|
pc.getId(),
|
||||||
|
pc.getSlug(),
|
||||||
|
pc.getName(),
|
||||||
|
pc.getDescription(),
|
||||||
|
pc.getGroupName(),
|
||||||
|
pc.getSortOrder()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,48 +1,60 @@
|
|||||||
package group.goforward.battlbuilder.controller;
|
package group.goforward.battlbuilder.controller;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.repo.EmailRequestRepository;
|
import group.goforward.battlbuilder.repo.EmailRequestRepository;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@RestController
|
/**
|
||||||
@RequestMapping("/api/email")
|
* The EmailTrackingController handles tracking of email-related events such as
|
||||||
public class EmailTrackingController {
|
* email opens and link clicks. This controller provides endpoints to record
|
||||||
|
* these events and return appropriate responses.
|
||||||
// 1x1 transparent GIF
|
*
|
||||||
private static final byte[] PIXEL = new byte[] {
|
* The tracking of email opens is achieved through a transparent 1x1 GIF image,
|
||||||
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
|
* and link clicks are redirected to the intended URL while capturing relevant metadata.
|
||||||
};
|
*
|
||||||
|
* Request mappings:
|
||||||
private final EmailRequestRepository repo;
|
* 1. "/api/email/open/{id}" - Tracks email open events.
|
||||||
|
* 2. "/api/email/click/{id}" - Tracks email link click events.
|
||||||
public EmailTrackingController(EmailRequestRepository repo) {
|
*/
|
||||||
this.repo = repo;
|
@RestController
|
||||||
}
|
@RequestMapping("/api/email")
|
||||||
|
public class EmailTrackingController {
|
||||||
@GetMapping(value = "/open/{id}", produces = "image/gif")
|
|
||||||
public ResponseEntity<byte[]> open(@PathVariable Long id) {
|
// 1x1 transparent GIF
|
||||||
repo.findById(id).ifPresent(r -> {
|
private static final byte[] PIXEL = new byte[] {
|
||||||
if (r.getOpenedAt() == null) r.setOpenedAt(LocalDateTime.now());
|
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
|
||||||
r.setOpenCount((r.getOpenCount() == null ? 0 : r.getOpenCount()) + 1);
|
};
|
||||||
repo.save(r);
|
|
||||||
});
|
private final EmailRequestRepository repo;
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
public EmailTrackingController(EmailRequestRepository repo) {
|
||||||
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
this.repo = repo;
|
||||||
.body(PIXEL);
|
}
|
||||||
}
|
|
||||||
|
@GetMapping(value = "/open/{id}", produces = "image/gif")
|
||||||
@GetMapping("/click/{id}")
|
public ResponseEntity<byte[]> open(@PathVariable Long id) {
|
||||||
public ResponseEntity<Void> click(@PathVariable Long id, @RequestParam String url) {
|
repo.findById(id).ifPresent(r -> {
|
||||||
repo.findById(id).ifPresent(r -> {
|
if (r.getOpenedAt() == null) r.setOpenedAt(LocalDateTime.now());
|
||||||
if (r.getClickedAt() == null) r.setClickedAt(LocalDateTime.now());
|
r.setOpenCount((r.getOpenCount() == null ? 0 : r.getOpenCount()) + 1);
|
||||||
r.setClickCount((r.getClickCount() == null ? 0 : r.getClickCount()) + 1);
|
repo.save(r);
|
||||||
repo.save(r);
|
});
|
||||||
});
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
return ResponseEntity.status(302).location(URI.create(url)).build();
|
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||||
}
|
.body(PIXEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/click/{id}")
|
||||||
|
public ResponseEntity<Void> 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,15 @@ import group.goforward.battlbuilder.service.MerchantFeedImportService;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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
|
@RestController
|
||||||
@RequestMapping({"/api/admin/imports", "/api/v1/admin/imports"})
|
@RequestMapping({"/api/admin/imports", "/api/v1/admin/imports"})
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import java.util.List;
|
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
|
@RestController
|
||||||
@RequestMapping({"/api/admin", "/api/v1/admin"})
|
@RequestMapping({"/api/admin", "/api/v1/admin"})
|
||||||
public class MerchantDebugController {
|
public class MerchantDebugController {
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
//package group.goforward.battlbuilder.controller;
|
//package group.goforward.battlbuilder.controller;
|
||||||
//
|
//
|
||||||
//import group.goforward.battlbuilder.service.PartRoleMappingService;
|
//import group.goforward.battlbuilder.service.PartRoleMappingService;
|
||||||
//import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
|
//import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
|
||||||
//import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
|
//import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
|
||||||
//import org.springframework.web.bind.annotation.*;
|
//import org.springframework.web.bind.annotation.*;
|
||||||
//
|
//
|
||||||
//import java.util.List;
|
//import java.util.List;
|
||||||
//
|
//
|
||||||
//@RestController
|
//@RestController
|
||||||
//@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"})
|
//@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"})
|
||||||
//public class PartRoleMappingController {
|
//public class PartRoleMappingController {
|
||||||
//
|
//
|
||||||
// private final PartRoleMappingService service;
|
// private final PartRoleMappingService service;
|
||||||
//
|
//
|
||||||
// public PartRoleMappingController(PartRoleMappingService service) {
|
// public PartRoleMappingController(PartRoleMappingService service) {
|
||||||
// this.service = service;
|
// this.service = service;
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// // Full view for admin UI
|
// // Full view for admin UI
|
||||||
// @GetMapping("/{platform}")
|
// @GetMapping("/{platform}")
|
||||||
// public List<PartRoleMappingDto> getMappings(@PathVariable String platform) {
|
// public List<PartRoleMappingDto> getMappings(@PathVariable String platform) {
|
||||||
// return service.getMappingsForPlatform(platform);
|
// return service.getMappingsForPlatform(platform);
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// // Thin mapping for the builder
|
// // Thin mapping for the builder
|
||||||
// @GetMapping("/{platform}/map")
|
// @GetMapping("/{platform}/map")
|
||||||
// public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) {
|
// public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) {
|
||||||
// return service.getRoleToCategoryMap(platform);
|
// return service.getRoleToCategoryMap(platform);
|
||||||
// }
|
// }
|
||||||
//}
|
//}
|
||||||
@@ -1,51 +1,51 @@
|
|||||||
package group.goforward.battlbuilder.controller.admin;
|
package group.goforward.battlbuilder.controller.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
|
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
|
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
|
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/admin/beta")
|
@RequestMapping("/api/v1/admin/beta")
|
||||||
public class AdminBetaInviteController {
|
public class AdminBetaInviteController {
|
||||||
|
|
||||||
private final BetaInviteService betaInviteService;
|
private final BetaInviteService betaInviteService;
|
||||||
|
|
||||||
public AdminBetaInviteController(BetaInviteService betaInviteService) {
|
public AdminBetaInviteController(BetaInviteService betaInviteService) {
|
||||||
this.betaInviteService = betaInviteService;
|
this.betaInviteService = betaInviteService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* //api/v1/admin/beta/invites/send?limit=25&dryRun=true&tokenMinutes=30
|
* //api/v1/admin/beta/invites/send?limit=25&dryRun=true&tokenMinutes=30
|
||||||
* @param limit
|
* @param limit
|
||||||
* @param dryRun
|
* @param dryRun
|
||||||
* @param tokenMinutes
|
* @param tokenMinutes
|
||||||
* @return
|
* @return
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@PostMapping("/invites/send")
|
@PostMapping("/invites/send")
|
||||||
public InviteBatchResponse sendInvites(
|
public InviteBatchResponse sendInvites(
|
||||||
@RequestParam(defaultValue = "0") int limit,
|
@RequestParam(defaultValue = "0") int limit,
|
||||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||||
@RequestParam(defaultValue = "30") int tokenMinutes
|
@RequestParam(defaultValue = "30") int tokenMinutes
|
||||||
) {
|
) {
|
||||||
int processed = betaInviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
|
int processed = betaInviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
|
||||||
return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit);
|
return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/requests")
|
@GetMapping("/requests")
|
||||||
public Page<AdminBetaRequestDto> listBetaRequests(
|
public Page<AdminBetaRequestDto> listBetaRequests(
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "25") int size
|
@RequestParam(defaultValue = "25") int size
|
||||||
) {
|
) {
|
||||||
return betaInviteService.listPendingBetaUsers(page, size);
|
return betaInviteService.listPendingBetaUsers(page, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/requests/{userId}/invite")
|
@PostMapping("/requests/{userId}/invite")
|
||||||
public AdminInviteResponse inviteSingle(@PathVariable Integer userId) {
|
public AdminInviteResponse inviteSingle(@PathVariable Integer userId) {
|
||||||
return betaInviteService.inviteSingleBetaUser(userId);
|
return betaInviteService.inviteSingleBetaUser(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {}
|
public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {}
|
||||||
}
|
}
|
||||||
@@ -1,40 +1,40 @@
|
|||||||
package group.goforward.battlbuilder.controller.admin;
|
package group.goforward.battlbuilder.controller.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PartCategory;
|
import group.goforward.battlbuilder.model.PartCategory;
|
||||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/categories")
|
@RequestMapping("/api/admin/categories")
|
||||||
@CrossOrigin
|
@CrossOrigin
|
||||||
public class AdminCategoryController {
|
public class AdminCategoryController {
|
||||||
|
|
||||||
private final PartCategoryRepository partCategories;
|
private final PartCategoryRepository partCategories;
|
||||||
|
|
||||||
public AdminCategoryController(PartCategoryRepository partCategories) {
|
public AdminCategoryController(PartCategoryRepository partCategories) {
|
||||||
this.partCategories = partCategories;
|
this.partCategories = partCategories;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<PartCategoryDto> listCategories() {
|
public List<PartCategoryDto> listCategories() {
|
||||||
return partCategories
|
return partCategories
|
||||||
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||||
.stream()
|
.stream()
|
||||||
.map(this::toDto)
|
.map(this::toDto)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private PartCategoryDto toDto(PartCategory entity) {
|
private PartCategoryDto toDto(PartCategory entity) {
|
||||||
return new PartCategoryDto(
|
return new PartCategoryDto(
|
||||||
entity.getId(),
|
entity.getId(),
|
||||||
entity.getSlug(),
|
entity.getSlug(),
|
||||||
entity.getName(),
|
entity.getName(),
|
||||||
entity.getDescription(),
|
entity.getDescription(),
|
||||||
entity.getGroupName(),
|
entity.getGroupName(),
|
||||||
entity.getSortOrder()
|
entity.getSortOrder()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
package group.goforward.battlbuilder.controller.admin;
|
package group.goforward.battlbuilder.controller.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.service.admin.impl.AdminDashboardService;
|
import group.goforward.battlbuilder.service.admin.impl.AdminDashboardService;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto;
|
import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/dashboard")
|
@RequestMapping("/api/admin/dashboard")
|
||||||
public class AdminDashboardController {
|
public class AdminDashboardController {
|
||||||
|
|
||||||
private final AdminDashboardService adminDashboardService;
|
private final AdminDashboardService adminDashboardService;
|
||||||
|
|
||||||
public AdminDashboardController(AdminDashboardService adminDashboardService) {
|
public AdminDashboardController(AdminDashboardService adminDashboardService) {
|
||||||
this.adminDashboardService = adminDashboardService;
|
this.adminDashboardService = adminDashboardService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/overview")
|
@GetMapping("/overview")
|
||||||
public ResponseEntity<AdminDashboardOverviewDto> getOverview() {
|
public ResponseEntity<AdminDashboardOverviewDto> getOverview() {
|
||||||
AdminDashboardOverviewDto dto = adminDashboardService.getOverview();
|
AdminDashboardOverviewDto dto = adminDashboardService.getOverview();
|
||||||
return ResponseEntity.ok(dto);
|
return ResponseEntity.ok(dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,34 +1,34 @@
|
|||||||
package group.goforward.battlbuilder.controller.admin;
|
package group.goforward.battlbuilder.controller.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/part-categories")
|
@RequestMapping("/api/admin/part-categories")
|
||||||
@CrossOrigin // keep it loose for now, you can tighten origins later
|
@CrossOrigin // keep it loose for now, you can tighten origins later
|
||||||
public class AdminPartCategoryController {
|
public class AdminPartCategoryController {
|
||||||
|
|
||||||
private final PartCategoryRepository partCategories;
|
private final PartCategoryRepository partCategories;
|
||||||
|
|
||||||
public AdminPartCategoryController(PartCategoryRepository partCategories) {
|
public AdminPartCategoryController(PartCategoryRepository partCategories) {
|
||||||
this.partCategories = partCategories;
|
this.partCategories = partCategories;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<PartCategoryDto> list() {
|
public List<PartCategoryDto> list() {
|
||||||
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||||
.stream()
|
.stream()
|
||||||
.map(pc -> new PartCategoryDto(
|
.map(pc -> new PartCategoryDto(
|
||||||
pc.getId(),
|
pc.getId(),
|
||||||
pc.getSlug(),
|
pc.getSlug(),
|
||||||
pc.getName(),
|
pc.getName(),
|
||||||
pc.getDescription(),
|
pc.getDescription(),
|
||||||
pc.getGroupName(),
|
pc.getGroupName(),
|
||||||
pc.getSortOrder()
|
pc.getSortOrder()
|
||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,124 +1,124 @@
|
|||||||
package group.goforward.battlbuilder.controller.admin;
|
package group.goforward.battlbuilder.controller.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PartCategory;
|
import group.goforward.battlbuilder.model.PartCategory;
|
||||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminPartRoleMappingDto;
|
import group.goforward.battlbuilder.web.dto.admin.AdminPartRoleMappingDto;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.CreatePartRoleMappingRequest;
|
import group.goforward.battlbuilder.web.dto.admin.CreatePartRoleMappingRequest;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.UpdatePartRoleMappingRequest;
|
import group.goforward.battlbuilder.web.dto.admin.UpdatePartRoleMappingRequest;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/part-role-mappings")
|
@RequestMapping("/api/admin/part-role-mappings")
|
||||||
@CrossOrigin
|
@CrossOrigin
|
||||||
public class AdminPartRoleMappingController {
|
public class AdminPartRoleMappingController {
|
||||||
|
|
||||||
private final PartRoleMappingRepository partRoleMappingRepository;
|
private final PartRoleMappingRepository partRoleMappingRepository;
|
||||||
private final PartCategoryRepository partCategoryRepository;
|
private final PartCategoryRepository partCategoryRepository;
|
||||||
|
|
||||||
public AdminPartRoleMappingController(
|
public AdminPartRoleMappingController(
|
||||||
PartRoleMappingRepository partRoleMappingRepository,
|
PartRoleMappingRepository partRoleMappingRepository,
|
||||||
PartCategoryRepository partCategoryRepository
|
PartCategoryRepository partCategoryRepository
|
||||||
) {
|
) {
|
||||||
this.partRoleMappingRepository = partRoleMappingRepository;
|
this.partRoleMappingRepository = partRoleMappingRepository;
|
||||||
this.partCategoryRepository = partCategoryRepository;
|
this.partCategoryRepository = partCategoryRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/admin/part-role-mappings?platform=AR-15
|
// GET /api/admin/part-role-mappings?platform=AR-15
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<AdminPartRoleMappingDto> list(
|
public List<AdminPartRoleMappingDto> list(
|
||||||
@RequestParam(name = "platform", required = false) String platform
|
@RequestParam(name = "platform", required = false) String platform
|
||||||
) {
|
) {
|
||||||
List<PartRoleMapping> mappings;
|
List<PartRoleMapping> mappings;
|
||||||
|
|
||||||
if (platform != null && !platform.isBlank()) {
|
if (platform != null && !platform.isBlank()) {
|
||||||
mappings = partRoleMappingRepository
|
mappings = partRoleMappingRepository
|
||||||
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform);
|
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform);
|
||||||
} else {
|
} else {
|
||||||
mappings = partRoleMappingRepository.findAll();
|
mappings = partRoleMappingRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
return mappings.stream()
|
return mappings.stream()
|
||||||
.map(this::toDto)
|
.map(this::toDto)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/admin/part-role-mappings
|
// POST /api/admin/part-role-mappings
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public AdminPartRoleMappingDto create(
|
public AdminPartRoleMappingDto create(
|
||||||
@RequestBody CreatePartRoleMappingRequest request
|
@RequestBody CreatePartRoleMappingRequest request
|
||||||
) {
|
) {
|
||||||
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
|
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
|
||||||
.orElseThrow(() -> new ResponseStatusException(
|
.orElseThrow(() -> new ResponseStatusException(
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
"PartCategory not found for slug: " + request.categorySlug()
|
"PartCategory not found for slug: " + request.categorySlug()
|
||||||
));
|
));
|
||||||
|
|
||||||
PartRoleMapping mapping = new PartRoleMapping();
|
PartRoleMapping mapping = new PartRoleMapping();
|
||||||
mapping.setPlatform(request.platform());
|
mapping.setPlatform(request.platform());
|
||||||
mapping.setPartRole(request.partRole());
|
mapping.setPartRole(request.partRole());
|
||||||
mapping.setPartCategory(category);
|
mapping.setPartCategory(category);
|
||||||
mapping.setNotes(request.notes());
|
mapping.setNotes(request.notes());
|
||||||
|
|
||||||
mapping = partRoleMappingRepository.save(mapping);
|
mapping = partRoleMappingRepository.save(mapping);
|
||||||
return toDto(mapping);
|
return toDto(mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/admin/part-role-mappings/{id}
|
// PUT /api/admin/part-role-mappings/{id}
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public AdminPartRoleMappingDto update(
|
public AdminPartRoleMappingDto update(
|
||||||
@PathVariable Integer id,
|
@PathVariable Integer id,
|
||||||
@RequestBody UpdatePartRoleMappingRequest request
|
@RequestBody UpdatePartRoleMappingRequest request
|
||||||
) {
|
) {
|
||||||
PartRoleMapping mapping = partRoleMappingRepository.findById(id)
|
PartRoleMapping mapping = partRoleMappingRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"));
|
||||||
|
|
||||||
if (request.platform() != null) {
|
if (request.platform() != null) {
|
||||||
mapping.setPlatform(request.platform());
|
mapping.setPlatform(request.platform());
|
||||||
}
|
}
|
||||||
if (request.partRole() != null) {
|
if (request.partRole() != null) {
|
||||||
mapping.setPartRole(request.partRole());
|
mapping.setPartRole(request.partRole());
|
||||||
}
|
}
|
||||||
if (request.categorySlug() != null) {
|
if (request.categorySlug() != null) {
|
||||||
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
|
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
|
||||||
.orElseThrow(() -> new ResponseStatusException(
|
.orElseThrow(() -> new ResponseStatusException(
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
"PartCategory not found for slug: " + request.categorySlug()
|
"PartCategory not found for slug: " + request.categorySlug()
|
||||||
));
|
));
|
||||||
mapping.setPartCategory(category);
|
mapping.setPartCategory(category);
|
||||||
}
|
}
|
||||||
if (request.notes() != null) {
|
if (request.notes() != null) {
|
||||||
mapping.setNotes(request.notes());
|
mapping.setNotes(request.notes());
|
||||||
}
|
}
|
||||||
|
|
||||||
mapping = partRoleMappingRepository.save(mapping);
|
mapping = partRoleMappingRepository.save(mapping);
|
||||||
return toDto(mapping);
|
return toDto(mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/admin/part-role-mappings/{id}
|
// DELETE /api/admin/part-role-mappings/{id}
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
public void delete(@PathVariable Integer id) {
|
public void delete(@PathVariable Integer id) {
|
||||||
if (!partRoleMappingRepository.existsById(id)) {
|
if (!partRoleMappingRepository.existsById(id)) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found");
|
||||||
}
|
}
|
||||||
partRoleMappingRepository.deleteById(id);
|
partRoleMappingRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AdminPartRoleMappingDto toDto(PartRoleMapping mapping) {
|
private AdminPartRoleMappingDto toDto(PartRoleMapping mapping) {
|
||||||
PartCategory cat = mapping.getPartCategory();
|
PartCategory cat = mapping.getPartCategory();
|
||||||
return new AdminPartRoleMappingDto(
|
return new AdminPartRoleMappingDto(
|
||||||
mapping.getId(),
|
mapping.getId(),
|
||||||
mapping.getPlatform(),
|
mapping.getPlatform(),
|
||||||
mapping.getPartRole(),
|
mapping.getPartRole(),
|
||||||
cat != null ? cat.getSlug() : null,
|
cat != null ? cat.getSlug() : null,
|
||||||
cat != null ? cat.getGroupName() : null,
|
cat != null ? cat.getGroupName() : null,
|
||||||
mapping.getNotes()
|
mapping.getNotes()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Admin controller package for the BattlBuilder application.
|
* Admin controller package for the BattlBuilder application.
|
||||||
* <p>
|
* <p>
|
||||||
* Contains REST controller for administrative operations including
|
* Contains REST controller for administrative operations including
|
||||||
* category management, platform configuration, and merchant administration.
|
* category management, platform configuration, and merchant administration.
|
||||||
*
|
*
|
||||||
* @author Forward Group, LLC
|
* @author Forward Group, LLC
|
||||||
* @version 1.0
|
* @version 1.0
|
||||||
* @since 2025-12-10
|
* @since 2025-12-10
|
||||||
*/
|
*/
|
||||||
package group.goforward.battlbuilder.controller.admin;
|
package group.goforward.battlbuilder.controller.admin;
|
||||||
|
|||||||
@@ -11,6 +11,27 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import java.util.List;
|
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
|
@RestController
|
||||||
@RequestMapping({"/api/v1/brands", "/api/brands"})
|
@RequestMapping({"/api/v1/brands", "/api/brands"})
|
||||||
public class BrandController {
|
public class BrandController {
|
||||||
|
|||||||
@@ -1,96 +1,96 @@
|
|||||||
package group.goforward.battlbuilder.controller.api.v1;
|
package group.goforward.battlbuilder.controller.api.v1;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.service.BuildService;
|
import group.goforward.battlbuilder.service.BuildService;
|
||||||
import group.goforward.battlbuilder.web.dto.BuildDto;
|
import group.goforward.battlbuilder.web.dto.BuildDto;
|
||||||
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
|
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
|
||||||
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
|
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
|
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@CrossOrigin
|
@CrossOrigin
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/builds")
|
@RequestMapping("/api/v1/builds")
|
||||||
public class BuildV1Controller {
|
public class BuildV1Controller {
|
||||||
|
|
||||||
private final BuildService buildService;
|
private final BuildService buildService;
|
||||||
|
|
||||||
public BuildV1Controller(BuildService buildService) {
|
public BuildV1Controller(BuildService buildService) {
|
||||||
this.buildService = buildService;
|
this.buildService = buildService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public builds feed for /builds page.
|
* Public builds feed for /builds page.
|
||||||
* GET /api/v1/builds?limit=50
|
* GET /api/v1/builds?limit=50
|
||||||
*/
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<BuildFeedCardDto>> listPublicBuilds(
|
public ResponseEntity<List<BuildFeedCardDto>> listPublicBuilds(
|
||||||
@RequestParam(name = "limit", required = false, defaultValue = "50") Integer limit
|
@RequestParam(name = "limit", required = false, defaultValue = "50") Integer limit
|
||||||
) {
|
) {
|
||||||
return ResponseEntity.ok(buildService.listPublicBuilds(limit == null ? 50 : limit));
|
return ResponseEntity.ok(buildService.listPublicBuilds(limit == null ? 50 : limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public build detail for /builds/{uuid}
|
* Public build detail for /builds/{uuid}
|
||||||
* GET /api/v1/builds/{uuid}
|
* GET /api/v1/builds/{uuid}
|
||||||
*/
|
*/
|
||||||
@GetMapping("/{uuid}")
|
@GetMapping("/{uuid}")
|
||||||
public ResponseEntity<BuildDto> getPublicBuild(@PathVariable("uuid") UUID uuid) {
|
public ResponseEntity<BuildDto> getPublicBuild(@PathVariable("uuid") UUID uuid) {
|
||||||
return ResponseEntity.ok(buildService.getPublicBuild(uuid));
|
return ResponseEntity.ok(buildService.getPublicBuild(uuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vault builds (authenticated user).
|
* Vault builds (authenticated user).
|
||||||
* GET /api/v1/builds/me?limit=100
|
* GET /api/v1/builds/me?limit=100
|
||||||
*/
|
*/
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
public ResponseEntity<List<BuildSummaryDto>> listMyBuilds(
|
public ResponseEntity<List<BuildSummaryDto>> listMyBuilds(
|
||||||
@RequestParam(name = "limit", required = false, defaultValue = "100") Integer limit
|
@RequestParam(name = "limit", required = false, defaultValue = "100") Integer limit
|
||||||
) {
|
) {
|
||||||
return ResponseEntity.ok(buildService.listMyBuilds(limit == null ? 100 : limit));
|
return ResponseEntity.ok(buildService.listMyBuilds(limit == null ? 100 : limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a single build (Vault edit + Builder ?load=uuid).
|
* Load a single build (Vault edit + Builder ?load=uuid).
|
||||||
* GET /api/v1/builds/me/{uuid}
|
* GET /api/v1/builds/me/{uuid}
|
||||||
*/
|
*/
|
||||||
@GetMapping("/me/{uuid}")
|
@GetMapping("/me/{uuid}")
|
||||||
public ResponseEntity<BuildDto> getMyBuild(@PathVariable("uuid") UUID uuid) {
|
public ResponseEntity<BuildDto> getMyBuild(@PathVariable("uuid") UUID uuid) {
|
||||||
return ResponseEntity.ok(buildService.getMyBuild(uuid));
|
return ResponseEntity.ok(buildService.getMyBuild(uuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a NEW build in Vault (Save As…).
|
* Create a NEW build in Vault (Save As…).
|
||||||
* POST /api/v1/builds/me
|
* POST /api/v1/builds/me
|
||||||
*/
|
*/
|
||||||
@PostMapping("/me")
|
@PostMapping("/me")
|
||||||
public ResponseEntity<BuildDto> createMyBuild(@RequestBody UpdateBuildRequest req) {
|
public ResponseEntity<BuildDto> createMyBuild(@RequestBody UpdateBuildRequest req) {
|
||||||
return ResponseEntity.ok(buildService.createMyBuild(req));
|
return ResponseEntity.ok(buildService.createMyBuild(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update build (authenticated user; must own build eventually).
|
* Update build (authenticated user; must own build eventually).
|
||||||
* PUT /api/v1/builds/me/{uuid}
|
* PUT /api/v1/builds/me/{uuid}
|
||||||
*/
|
*/
|
||||||
@PutMapping("/me/{uuid}")
|
@PutMapping("/me/{uuid}")
|
||||||
public ResponseEntity<BuildDto> updateMyBuild(
|
public ResponseEntity<BuildDto> updateMyBuild(
|
||||||
@PathVariable("uuid") UUID uuid,
|
@PathVariable("uuid") UUID uuid,
|
||||||
@RequestBody UpdateBuildRequest req
|
@RequestBody UpdateBuildRequest req
|
||||||
) {
|
) {
|
||||||
return ResponseEntity.ok(buildService.updateMyBuild(uuid, req));
|
return ResponseEntity.ok(buildService.updateMyBuild(uuid, req));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a build (authenticated user; must own build).
|
* Delete a build (authenticated user; must own build).
|
||||||
* DELETE /api/v1/builds/me/{uuid}
|
* DELETE /api/v1/builds/me/{uuid}
|
||||||
*/
|
*/
|
||||||
@DeleteMapping("/me/{uuid}")
|
@DeleteMapping("/me/{uuid}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
public void deleteMyBuild(@PathVariable("uuid") UUID uuid) {
|
public void deleteMyBuild(@PathVariable("uuid") UUID uuid) {
|
||||||
buildService.deleteMyBuild(uuid);
|
buildService.deleteMyBuild(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,60 +1,60 @@
|
|||||||
package group.goforward.battlbuilder.controller.api.v1;
|
package group.goforward.battlbuilder.controller.api.v1;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.service.CatalogQueryService;
|
import group.goforward.battlbuilder.service.CatalogQueryService;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/catalog")
|
@RequestMapping("/api/v1/catalog")
|
||||||
@CrossOrigin // tighten later
|
@CrossOrigin // tighten later
|
||||||
public class CatalogController {
|
public class CatalogController {
|
||||||
|
|
||||||
private final CatalogQueryService catalogQueryService;
|
private final CatalogQueryService catalogQueryService;
|
||||||
|
|
||||||
public CatalogController(CatalogQueryService catalogQueryService) {
|
public CatalogController(CatalogQueryService catalogQueryService) {
|
||||||
this.catalogQueryService = catalogQueryService;
|
this.catalogQueryService = catalogQueryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/options")
|
@GetMapping("/options")
|
||||||
public Page<ProductSummaryDto> getOptions(
|
public Page<ProductSummaryDto> getOptions(
|
||||||
@RequestParam(required = false) String platform,
|
@RequestParam(required = false) String platform,
|
||||||
@RequestParam(required = false) String partRole,
|
@RequestParam(required = false) String partRole,
|
||||||
@RequestParam(required = false) List<String> partRoles,
|
@RequestParam(required = false) List<String> partRoles,
|
||||||
@RequestParam(required = false, name = "brand") List<String> brands,
|
@RequestParam(required = false, name = "brand") List<String> brands,
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
) {
|
) {
|
||||||
Pageable safe = sanitizeCatalogPageable(pageable);
|
Pageable safe = sanitizeCatalogPageable(pageable);
|
||||||
return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe);
|
return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Pageable sanitizeCatalogPageable(Pageable pageable) {
|
private Pageable sanitizeCatalogPageable(Pageable pageable) {
|
||||||
int page = Math.max(0, pageable.getPageNumber());
|
int page = Math.max(0, pageable.getPageNumber());
|
||||||
|
|
||||||
// hard cap to keep UI snappy + protect DB
|
// hard cap to keep UI snappy + protect DB
|
||||||
int requested = pageable.getPageSize();
|
int requested = pageable.getPageSize();
|
||||||
int size = Math.min(Math.max(requested, 1), 48); // 48 max
|
int size = Math.min(Math.max(requested, 1), 48); // 48 max
|
||||||
|
|
||||||
// default sort if none provided
|
// default sort if none provided
|
||||||
Sort sort = pageable.getSort().isSorted()
|
Sort sort = pageable.getSort().isSorted()
|
||||||
? pageable.getSort()
|
? pageable.getSort()
|
||||||
: Sort.by(Sort.Direction.DESC, "updatedAt");
|
: Sort.by(Sort.Direction.DESC, "updatedAt");
|
||||||
|
|
||||||
return PageRequest.of(page, size, sort);
|
return PageRequest.of(page, size, sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/products/by-ids")
|
@PostMapping("/products/by-ids")
|
||||||
public List<ProductSummaryDto> getProductsByIds(@RequestBody CatalogProductIdsRequest request) {
|
public List<ProductSummaryDto> getProductsByIds(@RequestBody CatalogProductIdsRequest request) {
|
||||||
return catalogQueryService.getProductsByIds(request);
|
return catalogQueryService.getProductsByIds(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,152 +1,152 @@
|
|||||||
package group.goforward.battlbuilder.controller.api.v1;
|
package group.goforward.battlbuilder.controller.api.v1;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.common.ApiResponse;
|
import group.goforward.battlbuilder.common.ApiResponse;
|
||||||
import group.goforward.battlbuilder.dto.EmailRequestDto;
|
import group.goforward.battlbuilder.dto.EmailRequestDto;
|
||||||
import group.goforward.battlbuilder.model.EmailRequest;
|
import group.goforward.battlbuilder.model.EmailRequest;
|
||||||
import group.goforward.battlbuilder.model.EmailStatus;
|
import group.goforward.battlbuilder.model.EmailStatus;
|
||||||
import group.goforward.battlbuilder.repo.EmailRequestRepository;
|
import group.goforward.battlbuilder.repo.EmailRequestRepository;
|
||||||
import group.goforward.battlbuilder.service.utils.EmailService;
|
import group.goforward.battlbuilder.service.utils.EmailService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/email")
|
@RequestMapping("/api/email")
|
||||||
public class EmailController {
|
public class EmailController {
|
||||||
|
|
||||||
private static final EmailStatus EMAIL_STATUS_SENT = EmailStatus.SENT;
|
private static final EmailStatus EMAIL_STATUS_SENT = EmailStatus.SENT;
|
||||||
|
|
||||||
private final EmailService emailService;
|
private final EmailService emailService;
|
||||||
private final EmailRequestRepository emailRequestRepository;
|
private final EmailRequestRepository emailRequestRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public EmailController(EmailService emailService, EmailRequestRepository emailRequestRepository) {
|
public EmailController(EmailService emailService, EmailRequestRepository emailRequestRepository) {
|
||||||
this.emailService = emailService;
|
this.emailService = emailService;
|
||||||
this.emailRequestRepository = emailRequestRepository;
|
this.emailRequestRepository = emailRequestRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/statuses")
|
@GetMapping("/statuses")
|
||||||
public ResponseEntity<ApiResponse<List<String>>> getEmailStatuses() {
|
public ResponseEntity<ApiResponse<List<String>>> getEmailStatuses() {
|
||||||
List<String> statuses = Arrays.stream(EmailStatus.values())
|
List<String> statuses = Arrays.stream(EmailStatus.values())
|
||||||
.map(Enum::name)
|
.map(Enum::name)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
ApiResponse.success(statuses, "Email statuses retrieved successfully")
|
ApiResponse.success(statuses, "Email statuses retrieved successfully")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getAllEmailRequests() {
|
public ResponseEntity<ApiResponse<List<EmailRequest>>> getAllEmailRequests() {
|
||||||
try {
|
try {
|
||||||
List<EmailRequest> emailRequests = emailRequestRepository.findAll();
|
List<EmailRequest> emailRequests = emailRequestRepository.findAll();
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
ApiResponse.success(emailRequests, "Email requests retrieved successfully")
|
ApiResponse.success(emailRequests, "Email requests retrieved successfully")
|
||||||
);
|
);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).body(
|
return ResponseEntity.status(500).body(
|
||||||
ApiResponse.error("Error retrieving email requests: " + e.getMessage(), null)
|
ApiResponse.error("Error retrieving email requests: " + e.getMessage(), null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/allSent")
|
@GetMapping("/allSent")
|
||||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getNotSentEmailRequests() {
|
public ResponseEntity<ApiResponse<List<EmailRequest>>> getNotSentEmailRequests() {
|
||||||
try {
|
try {
|
||||||
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.SENT);
|
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.SENT);
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
ApiResponse.success(emailRequests, "Not sent email requests retrieved successfully")
|
ApiResponse.success(emailRequests, "Not sent email requests retrieved successfully")
|
||||||
);
|
);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).body(
|
return ResponseEntity.status(500).body(
|
||||||
ApiResponse.error("Error retrieving not sent email requests: " + e.getMessage(), null)
|
ApiResponse.error("Error retrieving not sent email requests: " + e.getMessage(), null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/allFailed")
|
@GetMapping("/allFailed")
|
||||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getFailedEmailRequests() {
|
public ResponseEntity<ApiResponse<List<EmailRequest>>> getFailedEmailRequests() {
|
||||||
try {
|
try {
|
||||||
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.FAILED);
|
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.FAILED);
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
ApiResponse.success(emailRequests, "Failed email requests retrieved successfully")
|
ApiResponse.success(emailRequests, "Failed email requests retrieved successfully")
|
||||||
);
|
);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).body(
|
return ResponseEntity.status(500).body(
|
||||||
ApiResponse.error("Error retrieving failed email requests: " + e.getMessage(), null)
|
ApiResponse.error("Error retrieving failed email requests: " + e.getMessage(), null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/allPending")
|
@GetMapping("/allPending")
|
||||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getPendingEmailRequests() {
|
public ResponseEntity<ApiResponse<List<EmailRequest>>> getPendingEmailRequests() {
|
||||||
try {
|
try {
|
||||||
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.PENDING);
|
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.PENDING);
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
ApiResponse.success(emailRequests, "Pending email requests retrieved successfully")
|
ApiResponse.success(emailRequests, "Pending email requests retrieved successfully")
|
||||||
);
|
);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).body(
|
return ResponseEntity.status(500).body(
|
||||||
ApiResponse.error("Error retrieving Pending email requests: " + e.getMessage(), null)
|
ApiResponse.error("Error retrieving Pending email requests: " + e.getMessage(), null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@PostMapping("/send")
|
@PostMapping("/send")
|
||||||
public ResponseEntity<ApiResponse<EmailRequest>> sendEmail(@RequestBody EmailRequestDto emailDto) {
|
public ResponseEntity<ApiResponse<EmailRequest>> sendEmail(@RequestBody EmailRequestDto emailDto) {
|
||||||
try {
|
try {
|
||||||
EmailRequest emailRequest = emailService.sendEmail(
|
EmailRequest emailRequest = emailService.sendEmail(
|
||||||
emailDto.getRecipient(),
|
emailDto.getRecipient(),
|
||||||
emailDto.getSubject(),
|
emailDto.getSubject(),
|
||||||
emailDto.getBody()
|
emailDto.getBody()
|
||||||
);
|
);
|
||||||
return buildEmailResponse(emailRequest);
|
return buildEmailResponse(emailRequest);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return buildErrorResponse(e.getMessage());
|
return buildErrorResponse(e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@DeleteMapping("/delete/{id}")
|
@DeleteMapping("/delete/{id}")
|
||||||
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
|
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
|
||||||
return emailRequestRepository.findById(Long.valueOf(id))
|
return emailRequestRepository.findById(Long.valueOf(id))
|
||||||
.map(item -> {
|
.map(item -> {
|
||||||
emailRequestRepository.deleteById(Long.valueOf(id));
|
emailRequestRepository.deleteById(Long.valueOf(id));
|
||||||
return ResponseEntity.noContent().<Void>build();
|
return ResponseEntity.noContent().<Void>build();
|
||||||
})
|
})
|
||||||
.orElse(ResponseEntity.notFound().build());
|
.orElse(ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
// Replace /delete/{id} with a RESTful DELETE /{id}
|
// Replace /delete/{id} with a RESTful DELETE /{id}
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
public void deleteEmailRequest(@PathVariable Long id) {
|
public void deleteEmailRequest(@PathVariable Long id) {
|
||||||
if (!emailRequestRepository.existsById(id)) {
|
if (!emailRequestRepository.existsById(id)) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email request not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email request not found");
|
||||||
}
|
}
|
||||||
emailRequestRepository.deleteById(id);
|
emailRequestRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<ApiResponse<EmailRequest>> buildEmailResponse(EmailRequest emailRequest) {
|
private ResponseEntity<ApiResponse<EmailRequest>> buildEmailResponse(EmailRequest emailRequest) {
|
||||||
if (EMAIL_STATUS_SENT.equals(emailRequest.getStatus())) {
|
if (EMAIL_STATUS_SENT.equals(emailRequest.getStatus())) {
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
ApiResponse.success(emailRequest, "Email sent successfully")
|
ApiResponse.success(emailRequest, "Email sent successfully")
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
String errorMessage = "Failed to send email: " + emailRequest.getErrorMessage();
|
String errorMessage = "Failed to send email: " + emailRequest.getErrorMessage();
|
||||||
return ResponseEntity.status(500).body(
|
return ResponseEntity.status(500).body(
|
||||||
ApiResponse.error(errorMessage, emailRequest)
|
ApiResponse.error(errorMessage, emailRequest)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<ApiResponse<EmailRequest>> buildErrorResponse(String exceptionMessage) {
|
private ResponseEntity<ApiResponse<EmailRequest>> buildErrorResponse(String exceptionMessage) {
|
||||||
String errorMessage = "Error processing email request: " + exceptionMessage;
|
String errorMessage = "Error processing email request: " + exceptionMessage;
|
||||||
return ResponseEntity.status(500).body(
|
return ResponseEntity.status(500).body(
|
||||||
ApiResponse.error(errorMessage, null)
|
ApiResponse.error(errorMessage, null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,231 +1,231 @@
|
|||||||
package group.goforward.battlbuilder.controller.api.v1;
|
package group.goforward.battlbuilder.controller.api.v1;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import group.goforward.battlbuilder.repo.UserRepository;
|
import group.goforward.battlbuilder.repo.UserRepository;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.springframework.http.HttpStatus.*;
|
import static org.springframework.http.HttpStatus.*;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping({"/api/v1/users/me", "/api/users/me"})
|
@RequestMapping({"/api/v1/users/me", "/api/users/me"})
|
||||||
@CrossOrigin
|
@CrossOrigin
|
||||||
public class MeController {
|
public class MeController {
|
||||||
|
|
||||||
private final UserRepository users;
|
private final UserRepository users;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
public MeController(UserRepository users, PasswordEncoder passwordEncoder) {
|
public MeController(UserRepository users, PasswordEncoder passwordEncoder) {
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
|
|
||||||
private Authentication requireAuth() {
|
private Authentication requireAuth() {
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
if (auth == null || !auth.isAuthenticated()) {
|
if (auth == null || !auth.isAuthenticated()) {
|
||||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||||
}
|
}
|
||||||
// Spring may set "anonymousUser" as a principal when not logged in
|
// Spring may set "anonymousUser" as a principal when not logged in
|
||||||
Object principal = auth.getPrincipal();
|
Object principal = auth.getPrincipal();
|
||||||
if (principal == null || "anonymousUser".equals(principal)) {
|
if (principal == null || "anonymousUser".equals(principal)) {
|
||||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||||
}
|
}
|
||||||
return auth;
|
return auth;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<UUID> tryParseUuid(String s) {
|
private Optional<UUID> tryParseUuid(String s) {
|
||||||
try {
|
try {
|
||||||
return Optional.of(UUID.fromString(s));
|
return Optional.of(UUID.fromString(s));
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private User requireUser() {
|
private User requireUser() {
|
||||||
Authentication auth = requireAuth();
|
Authentication auth = requireAuth();
|
||||||
Object principal = auth.getPrincipal();
|
Object principal = auth.getPrincipal();
|
||||||
|
|
||||||
// Case 1: principal is a String (we commonly set this to UUID string)
|
// Case 1: principal is a String (we commonly set this to UUID string)
|
||||||
if (principal instanceof String s) {
|
if (principal instanceof String s) {
|
||||||
// Prefer UUID lookup
|
// Prefer UUID lookup
|
||||||
Optional<UUID> uuid = tryParseUuid(s);
|
Optional<UUID> uuid = tryParseUuid(s);
|
||||||
if (uuid.isPresent()) {
|
if (uuid.isPresent()) {
|
||||||
return users.findByUuidAndDeletedAtIsNull(uuid.get())
|
return users.findByUuidAndDeletedAtIsNull(uuid.get())
|
||||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to email lookup
|
// Fallback to email lookup
|
||||||
String email = s.trim().toLowerCase();
|
String email = s.trim().toLowerCase();
|
||||||
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 2: principal is a UserDetails (often username=email)
|
// Case 2: principal is a UserDetails (often username=email)
|
||||||
if (principal instanceof UserDetails ud) {
|
if (principal instanceof UserDetails ud) {
|
||||||
String username = ud.getUsername();
|
String username = ud.getUsername();
|
||||||
if (username == null) {
|
if (username == null) {
|
||||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try UUID first, then email
|
// Try UUID first, then email
|
||||||
Optional<UUID> uuid = tryParseUuid(username);
|
Optional<UUID> uuid = tryParseUuid(username);
|
||||||
if (uuid.isPresent()) {
|
if (uuid.isPresent()) {
|
||||||
return users.findByUuidAndDeletedAtIsNull(uuid.get())
|
return users.findByUuidAndDeletedAtIsNull(uuid.get())
|
||||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
String email = username.trim().toLowerCase();
|
String email = username.trim().toLowerCase();
|
||||||
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anything else: unsupported principal type
|
// Anything else: unsupported principal type
|
||||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> toMeResponse(User user) {
|
private Map<String, Object> toMeResponse(User user) {
|
||||||
Map<String, Object> out = new java.util.HashMap<>();
|
Map<String, Object> out = new java.util.HashMap<>();
|
||||||
out.put("email", user.getEmail());
|
out.put("email", user.getEmail());
|
||||||
out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName());
|
out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName());
|
||||||
out.put("username", user.getUsername() == null ? "" : user.getUsername());
|
out.put("username", user.getUsername() == null ? "" : user.getUsername());
|
||||||
out.put("role", user.getRole() == null ? "USER" : user.getRole());
|
out.put("role", user.getRole() == null ? "USER" : user.getRole());
|
||||||
out.put("uuid", String.valueOf(user.getUuid()));
|
out.put("uuid", String.valueOf(user.getUuid()));
|
||||||
out.put("passwordSetAt", user.getPasswordSetAt() == null ? null : user.getPasswordSetAt().toString());
|
out.put("passwordSetAt", user.getPasswordSetAt() == null ? null : user.getPasswordSetAt().toString());
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeUsername(String raw) {
|
private String normalizeUsername(String raw) {
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
String s = raw.trim().toLowerCase();
|
String s = raw.trim().toLowerCase();
|
||||||
return s.isBlank() ? null : s;
|
return s.isBlank() ? null : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isReservedUsername(String u) {
|
private boolean isReservedUsername(String u) {
|
||||||
return switch (u) {
|
return switch (u) {
|
||||||
case "admin", "support", "battl", "battlbuilders", "builder",
|
case "admin", "support", "battl", "battlbuilders", "builder",
|
||||||
"api", "login", "register", "account", "privacy", "tos" -> true;
|
"api", "login", "register", "account", "privacy", "tos" -> true;
|
||||||
default -> false;
|
default -> false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Routes
|
// Routes
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<?> me() {
|
public ResponseEntity<?> me() {
|
||||||
User user = requireUser();
|
User user = requireUser();
|
||||||
return ResponseEntity.ok(toMeResponse(user));
|
return ResponseEntity.ok(toMeResponse(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping
|
@PatchMapping
|
||||||
public ResponseEntity<?> updateMe(@RequestBody Map<String, Object> body) {
|
public ResponseEntity<?> updateMe(@RequestBody Map<String, Object> body) {
|
||||||
User user = requireUser();
|
User user = requireUser();
|
||||||
|
|
||||||
String displayName = null;
|
String displayName = null;
|
||||||
if (body != null && body.get("displayName") != null) {
|
if (body != null && body.get("displayName") != null) {
|
||||||
displayName = String.valueOf(body.get("displayName")).trim();
|
displayName = String.valueOf(body.get("displayName")).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
String username = null;
|
String username = null;
|
||||||
if (body != null && body.get("username") != null) {
|
if (body != null && body.get("username") != null) {
|
||||||
username = normalizeUsername(String.valueOf(body.get("username")));
|
username = normalizeUsername(String.valueOf(body.get("username")));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((displayName == null || displayName.isBlank()) && (username == null)) {
|
if ((displayName == null || displayName.isBlank()) && (username == null)) {
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "username or displayName is required");
|
throw new ResponseStatusException(BAD_REQUEST, "username or displayName is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// display name is flexible
|
// display name is flexible
|
||||||
if (displayName != null && !displayName.isBlank()) {
|
if (displayName != null && !displayName.isBlank()) {
|
||||||
user.setDisplayName(displayName);
|
user.setDisplayName(displayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// username is strict + unique
|
// username is strict + unique
|
||||||
if (username != null) {
|
if (username != null) {
|
||||||
if (username.length() < 3 || username.length() > 20) {
|
if (username.length() < 3 || username.length() > 20) {
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Username must be 3–20 characters");
|
throw new ResponseStatusException(BAD_REQUEST, "Username must be 3–20 characters");
|
||||||
}
|
}
|
||||||
if (!username.matches("^[a-z0-9_]+$")) {
|
if (!username.matches("^[a-z0-9_]+$")) {
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Username can only contain a-z, 0-9, underscore");
|
throw new ResponseStatusException(BAD_REQUEST, "Username can only contain a-z, 0-9, underscore");
|
||||||
}
|
}
|
||||||
if (isReservedUsername(username)) {
|
if (isReservedUsername(username)) {
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "That username is reserved");
|
throw new ResponseStatusException(BAD_REQUEST, "That username is reserved");
|
||||||
}
|
}
|
||||||
|
|
||||||
users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username).ifPresent(existing -> {
|
users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username).ifPresent(existing -> {
|
||||||
if (!existing.getId().equals(user.getId())) {
|
if (!existing.getId().equals(user.getId())) {
|
||||||
throw new ResponseStatusException(CONFLICT, "Username already taken");
|
throw new ResponseStatusException(CONFLICT, "Username already taken");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
user.setUsername(username);
|
user.setUsername(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
return ResponseEntity.ok(toMeResponse(user));
|
return ResponseEntity.ok(toMeResponse(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/password")
|
@PostMapping("/password")
|
||||||
public ResponseEntity<?> setPassword(@RequestBody Map<String, Object> body) {
|
public ResponseEntity<?> setPassword(@RequestBody Map<String, Object> body) {
|
||||||
User user = requireUser();
|
User user = requireUser();
|
||||||
|
|
||||||
String password = null;
|
String password = null;
|
||||||
if (body != null && body.get("password") != null) {
|
if (body != null && body.get("password") != null) {
|
||||||
password = String.valueOf(body.get("password"));
|
password = String.valueOf(body.get("password"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password == null || password.length() < 8) {
|
if (password == null || password.length() < 8) {
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Password must be at least 8 characters");
|
throw new ResponseStatusException(BAD_REQUEST, "Password must be at least 8 characters");
|
||||||
}
|
}
|
||||||
|
|
||||||
user.setPasswordHash(passwordEncoder.encode(password));
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW
|
user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("ok", true, "passwordSetAt", user.getPasswordSetAt().toString()));
|
return ResponseEntity.ok(Map.of("ok", true, "passwordSetAt", user.getPasswordSetAt().toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/username-available")
|
@GetMapping("/username-available")
|
||||||
public ResponseEntity<?> usernameAvailable(@RequestParam("username") String usernameRaw) {
|
public ResponseEntity<?> usernameAvailable(@RequestParam("username") String usernameRaw) {
|
||||||
String username = normalizeUsername(usernameRaw);
|
String username = normalizeUsername(usernameRaw);
|
||||||
|
|
||||||
// Soft fail
|
// Soft fail
|
||||||
if (username == null) return ResponseEntity.ok(Map.of("available", false));
|
if (username == null) return ResponseEntity.ok(Map.of("available", false));
|
||||||
|
|
||||||
if (username.length() < 3 || username.length() > 20) {
|
if (username.length() < 3 || username.length() > 20) {
|
||||||
return ResponseEntity.ok(Map.of("available", false));
|
return ResponseEntity.ok(Map.of("available", false));
|
||||||
}
|
}
|
||||||
if (!username.matches("^[a-z0-9_]+$")) {
|
if (!username.matches("^[a-z0-9_]+$")) {
|
||||||
return ResponseEntity.ok(Map.of("available", false));
|
return ResponseEntity.ok(Map.of("available", false));
|
||||||
}
|
}
|
||||||
if (isReservedUsername(username)) {
|
if (isReservedUsername(username)) {
|
||||||
return ResponseEntity.ok(Map.of("available", false));
|
return ResponseEntity.ok(Map.of("available", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
User me = requireUser();
|
User me = requireUser();
|
||||||
|
|
||||||
boolean available = users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username)
|
boolean available = users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username)
|
||||||
.map(existing -> existing.getId().equals(me.getId())) // if it's mine, treat as available
|
.map(existing -> existing.getId().equals(me.getId())) // if it's mine, treat as available
|
||||||
.orElse(true);
|
.orElse(true);
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("available", available));
|
return ResponseEntity.ok(Map.of("available", available));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,49 +1,49 @@
|
|||||||
package group.goforward.battlbuilder.controller.api.v1;
|
package group.goforward.battlbuilder.controller.api.v1;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.service.ProductQueryService;
|
import group.goforward.battlbuilder.service.ProductQueryService;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.web.PageableDefault;
|
import org.springframework.data.web.PageableDefault;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/products")
|
@RequestMapping("/api/v1/products")
|
||||||
@CrossOrigin
|
@CrossOrigin
|
||||||
public class ProductV1Controller {
|
public class ProductV1Controller {
|
||||||
|
|
||||||
private final ProductQueryService productQueryService;
|
private final ProductQueryService productQueryService;
|
||||||
|
|
||||||
public ProductV1Controller(ProductQueryService productQueryService) {
|
public ProductV1Controller(ProductQueryService productQueryService) {
|
||||||
this.productQueryService = productQueryService;
|
this.productQueryService = productQueryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Cacheable(
|
@Cacheable(
|
||||||
value = "gunbuilderProductsV1",
|
value = "gunbuilderProductsV1",
|
||||||
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
|
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
|
||||||
)
|
)
|
||||||
public Page<ProductSummaryDto> getProducts(
|
public Page<ProductSummaryDto> getProducts(
|
||||||
@RequestParam(defaultValue = "AR-15") String platform,
|
@RequestParam(defaultValue = "AR-15") String platform,
|
||||||
@RequestParam(required = false, name = "partRoles") List<String> partRoles,
|
@RequestParam(required = false, name = "partRoles") List<String> partRoles,
|
||||||
@PageableDefault(size = 50) Pageable pageable
|
@PageableDefault(size = 50) Pageable pageable
|
||||||
) {
|
) {
|
||||||
return productQueryService.getProductsPage(platform, partRoles, pageable);
|
return productQueryService.getProductsPage(platform, partRoles, pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/offers")
|
@GetMapping("/{id}/offers")
|
||||||
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
||||||
return productQueryService.getOffersForProduct(productId);
|
return productQueryService.getOffersForProduct(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
|
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
|
||||||
ProductSummaryDto dto = productQueryService.getProductById(productId);
|
ProductSummaryDto dto = productQueryService.getProductById(productId);
|
||||||
return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build();
|
return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* API controller package for the BattlBuilder application.
|
* API controller package for the BattlBuilder application.
|
||||||
* <p>
|
* <p>
|
||||||
* Contains REST API controller for public-facing endpoints including
|
* Contains REST API controller for public-facing endpoints including
|
||||||
* brand management, state information, and user operations.
|
* brand management, state information, and user operations.
|
||||||
*
|
*
|
||||||
* @author Forward Group, LLC
|
* @author Forward Group, LLC
|
||||||
* @version 1.0
|
* @version 1.0
|
||||||
* @since 2025-12-10
|
* @since 2025-12-10
|
||||||
*/
|
*/
|
||||||
package group.goforward.battlbuilder.controller.api.v1;
|
package group.goforward.battlbuilder.controller.api.v1;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
Web admin DTOs package for the BattlBuilder application.
|
Web admin DTOs package for the BattlBuilder application.
|
||||||
<p>
|
<p>
|
||||||
Contains Data Transfer Objects specific to administrative
|
Contains Data Transfer Objects specific to administrative
|
||||||
operations including user management, mappings, and platform configuration.
|
operations including user management, mappings, and platform configuration.
|
||||||
|
|
||||||
@author Forward Group, LLC
|
@author Forward Group, LLC
|
||||||
* @version 1.0
|
* @version 1.0
|
||||||
* @since 2025-12-10
|
* @since 2025-12-10
|
||||||
*/
|
*/
|
||||||
package group.goforward.battlbuilder.dto;
|
package group.goforward.battlbuilder.dto;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package group.goforward.battlbuilder.enrichment;
|
package group.goforward.battlbuilder.enrichment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enum representing the source of an enrichment.
|
* Enum representing the source of an enrichment.
|
||||||
*/
|
*/
|
||||||
public enum EnrichmentSource {
|
public enum EnrichmentSource {
|
||||||
AI,
|
AI,
|
||||||
RULES,
|
RULES,
|
||||||
HUMAN
|
HUMAN
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
package group.goforward.battlbuilder.enrichment;
|
package group.goforward.battlbuilder.enrichment;
|
||||||
/**
|
/**
|
||||||
* Status of an enrichment in the system.
|
* Status of an enrichment in the system.
|
||||||
*
|
*
|
||||||
* <p>Possible values:
|
* <p>Possible values:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>PENDING_REVIEW - awaiting review</li>
|
* <li>PENDING_REVIEW - awaiting review</li>
|
||||||
* <li>APPROVED - approved to apply</li>
|
* <li>APPROVED - approved to apply</li>
|
||||||
* <li>REJECTED - rejected and will not be applied</li>
|
* <li>REJECTED - rejected and will not be applied</li>
|
||||||
* <li>APPLIED - enrichment has been applied</li>
|
* <li>APPLIED - enrichment has been applied</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public enum EnrichmentStatus {
|
public enum EnrichmentStatus {
|
||||||
PENDING_REVIEW,
|
PENDING_REVIEW,
|
||||||
APPROVED,
|
APPROVED,
|
||||||
REJECTED,
|
REJECTED,
|
||||||
APPLIED
|
APPLIED
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package group.goforward.battlbuilder.enrichment;
|
package group.goforward.battlbuilder.enrichment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enum representing different types of enrichment that can be applied to products.
|
* Enum representing different types of enrichment that can be applied to products.
|
||||||
*/
|
*/
|
||||||
public enum EnrichmentType {
|
public enum EnrichmentType {
|
||||||
CALIBER,
|
CALIBER,
|
||||||
CALIBER_GROUP,
|
CALIBER_GROUP,
|
||||||
BARREL_LENGTH,
|
BARREL_LENGTH,
|
||||||
GAS_SYSTEM,
|
GAS_SYSTEM,
|
||||||
HANDGUARD_LENGTH,
|
HANDGUARD_LENGTH,
|
||||||
CONFIGURATION,
|
CONFIGURATION,
|
||||||
PART_ROLE
|
PART_ROLE
|
||||||
}
|
}
|
||||||
@@ -1,103 +1,103 @@
|
|||||||
package group.goforward.battlbuilder.enrichment.ai;
|
package group.goforward.battlbuilder.enrichment.ai;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.enrichment.*;
|
import group.goforward.battlbuilder.enrichment.*;
|
||||||
import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult;
|
import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult;
|
||||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||||
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
||||||
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
|
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AiEnrichmentOrchestrator {
|
public class AiEnrichmentOrchestrator {
|
||||||
|
|
||||||
private final EnrichmentModelClient modelClient;
|
private final EnrichmentModelClient modelClient;
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final ProductEnrichmentRepository enrichmentRepository;
|
private final ProductEnrichmentRepository enrichmentRepository;
|
||||||
|
|
||||||
@Value("${ai.minConfidence:0.75}")
|
@Value("${ai.minConfidence:0.75}")
|
||||||
private BigDecimal minConfidence;
|
private BigDecimal minConfidence;
|
||||||
|
|
||||||
public AiEnrichmentOrchestrator(
|
public AiEnrichmentOrchestrator(
|
||||||
EnrichmentModelClient modelClient,
|
EnrichmentModelClient modelClient,
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
ProductEnrichmentRepository enrichmentRepository
|
ProductEnrichmentRepository enrichmentRepository
|
||||||
) {
|
) {
|
||||||
this.modelClient = modelClient;
|
this.modelClient = modelClient;
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.enrichmentRepository = enrichmentRepository;
|
this.enrichmentRepository = enrichmentRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int runCaliber(int limit) {
|
public int runCaliber(int limit) {
|
||||||
// pick candidates: caliber missing
|
// pick candidates: caliber missing
|
||||||
List<Product> candidates = productRepository.findProductsMissingCaliber(limit);
|
List<Product> candidates = productRepository.findProductsMissingCaliber(limit);
|
||||||
|
|
||||||
int created = 0;
|
int created = 0;
|
||||||
|
|
||||||
for (Product p : candidates) {
|
for (Product p : candidates) {
|
||||||
CaliberExtractionResult r = modelClient.extractCaliber(p);
|
CaliberExtractionResult r = modelClient.extractCaliber(p);
|
||||||
|
|
||||||
if (r == null || !r.isUsable(minConfidence)) {
|
if (r == null || !r.isUsable(minConfidence)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: avoid duplicates for same product/type/status
|
// Optional: avoid duplicates for same product/type/status
|
||||||
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
|
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
|
||||||
p.getId(), EnrichmentType.CALIBER, EnrichmentStatus.PENDING_REVIEW
|
p.getId(), EnrichmentType.CALIBER, EnrichmentStatus.PENDING_REVIEW
|
||||||
);
|
);
|
||||||
if (exists) continue;
|
if (exists) continue;
|
||||||
|
|
||||||
ProductEnrichment pe = new ProductEnrichment();
|
ProductEnrichment pe = new ProductEnrichment();
|
||||||
pe.setProductId(p.getId());
|
pe.setProductId(p.getId());
|
||||||
pe.setEnrichmentType(EnrichmentType.CALIBER);
|
pe.setEnrichmentType(EnrichmentType.CALIBER);
|
||||||
pe.setSource(EnrichmentSource.AI);
|
pe.setSource(EnrichmentSource.AI);
|
||||||
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
||||||
pe.setSchemaVersion(1);
|
pe.setSchemaVersion(1);
|
||||||
pe.setAttributes(Map.of("caliber", r.caliber()));
|
pe.setAttributes(Map.of("caliber", r.caliber()));
|
||||||
pe.setConfidence(r.confidence());
|
pe.setConfidence(r.confidence());
|
||||||
pe.setRationale(r.reason());
|
pe.setRationale(r.reason());
|
||||||
pe.setMeta(Map.of("provider", modelClient.providerName()));
|
pe.setMeta(Map.of("provider", modelClient.providerName()));
|
||||||
|
|
||||||
enrichmentRepository.save(pe);
|
enrichmentRepository.save(pe);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
public int runCaliberGroup(int limit) {
|
public int runCaliberGroup(int limit) {
|
||||||
List<Product> candidates = productRepository.findProductsMissingCaliberGroup(limit);
|
List<Product> candidates = productRepository.findProductsMissingCaliberGroup(limit);
|
||||||
int created = 0;
|
int created = 0;
|
||||||
|
|
||||||
for (Product p : candidates) {
|
for (Product p : candidates) {
|
||||||
String group = CaliberTaxonomy.groupForCaliber(p.getCaliber());
|
String group = CaliberTaxonomy.groupForCaliber(p.getCaliber());
|
||||||
if (group == null || group.isBlank()) continue;
|
if (group == null || group.isBlank()) continue;
|
||||||
|
|
||||||
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
|
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
|
||||||
p.getId(), EnrichmentType.CALIBER_GROUP, EnrichmentStatus.PENDING_REVIEW
|
p.getId(), EnrichmentType.CALIBER_GROUP, EnrichmentStatus.PENDING_REVIEW
|
||||||
);
|
);
|
||||||
if (exists) continue;
|
if (exists) continue;
|
||||||
|
|
||||||
ProductEnrichment pe = new ProductEnrichment();
|
ProductEnrichment pe = new ProductEnrichment();
|
||||||
pe.setProductId(p.getId());
|
pe.setProductId(p.getId());
|
||||||
pe.setEnrichmentType(EnrichmentType.CALIBER_GROUP);
|
pe.setEnrichmentType(EnrichmentType.CALIBER_GROUP);
|
||||||
pe.setSource(EnrichmentSource.RULES); // derived rules
|
pe.setSource(EnrichmentSource.RULES); // derived rules
|
||||||
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
||||||
pe.setSchemaVersion(1);
|
pe.setSchemaVersion(1);
|
||||||
pe.setAttributes(java.util.Map.of("caliberGroup", group));
|
pe.setAttributes(java.util.Map.of("caliberGroup", group));
|
||||||
pe.setConfidence(new java.math.BigDecimal("1.00"));
|
pe.setConfidence(new java.math.BigDecimal("1.00"));
|
||||||
pe.setRationale("Derived caliberGroup from product.caliber via CaliberTaxonomy");
|
pe.setRationale("Derived caliberGroup from product.caliber via CaliberTaxonomy");
|
||||||
pe.setMeta(java.util.Map.of("provider", "TAXONOMY"));
|
pe.setMeta(java.util.Map.of("provider", "TAXONOMY"));
|
||||||
|
|
||||||
enrichmentRepository.save(pe);
|
enrichmentRepository.save(pe);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,153 +1,153 @@
|
|||||||
package group.goforward.battlbuilder.enrichment.controller;
|
package group.goforward.battlbuilder.enrichment.controller;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
|
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
|
||||||
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
||||||
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
||||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||||
import group.goforward.battlbuilder.enrichment.ai.AiEnrichmentOrchestrator;
|
import group.goforward.battlbuilder.enrichment.ai.AiEnrichmentOrchestrator;
|
||||||
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
||||||
import group.goforward.battlbuilder.enrichment.service.CaliberEnrichmentService;
|
import group.goforward.battlbuilder.enrichment.service.CaliberEnrichmentService;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/enrichment")
|
@RequestMapping("/api/admin/enrichment")
|
||||||
public class AdminEnrichmentController {
|
public class AdminEnrichmentController {
|
||||||
|
|
||||||
private final CaliberEnrichmentService caliberEnrichmentService;
|
private final CaliberEnrichmentService caliberEnrichmentService;
|
||||||
private final ProductEnrichmentRepository enrichmentRepository;
|
private final ProductEnrichmentRepository enrichmentRepository;
|
||||||
private final AiEnrichmentOrchestrator aiEnrichmentOrchestrator;
|
private final AiEnrichmentOrchestrator aiEnrichmentOrchestrator;
|
||||||
|
|
||||||
public AdminEnrichmentController(
|
public AdminEnrichmentController(
|
||||||
CaliberEnrichmentService caliberEnrichmentService,
|
CaliberEnrichmentService caliberEnrichmentService,
|
||||||
ProductEnrichmentRepository enrichmentRepository,
|
ProductEnrichmentRepository enrichmentRepository,
|
||||||
AiEnrichmentOrchestrator aiEnrichmentOrchestrator
|
AiEnrichmentOrchestrator aiEnrichmentOrchestrator
|
||||||
) {
|
) {
|
||||||
this.caliberEnrichmentService = caliberEnrichmentService;
|
this.caliberEnrichmentService = caliberEnrichmentService;
|
||||||
this.enrichmentRepository = enrichmentRepository;
|
this.enrichmentRepository = enrichmentRepository;
|
||||||
this.aiEnrichmentOrchestrator = aiEnrichmentOrchestrator;
|
this.aiEnrichmentOrchestrator = aiEnrichmentOrchestrator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/run")
|
@PostMapping("/run")
|
||||||
public ResponseEntity<?> run(
|
public ResponseEntity<?> run(
|
||||||
@RequestParam EnrichmentType type,
|
@RequestParam EnrichmentType type,
|
||||||
@RequestParam(defaultValue = "200") int limit
|
@RequestParam(defaultValue = "200") int limit
|
||||||
) {
|
) {
|
||||||
if (type != EnrichmentType.CALIBER) {
|
if (type != EnrichmentType.CALIBER) {
|
||||||
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
|
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(caliberEnrichmentService.runRules(limit));
|
return ResponseEntity.ok(caliberEnrichmentService.runRules(limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ NEW: Run AI enrichment
|
// ✅ NEW: Run AI enrichment
|
||||||
@PostMapping("/ai/run")
|
@PostMapping("/ai/run")
|
||||||
public ResponseEntity<?> runAi(
|
public ResponseEntity<?> runAi(
|
||||||
@RequestParam EnrichmentType type,
|
@RequestParam EnrichmentType type,
|
||||||
@RequestParam(defaultValue = "200") int limit
|
@RequestParam(defaultValue = "200") int limit
|
||||||
) {
|
) {
|
||||||
if (type != EnrichmentType.CALIBER) {
|
if (type != EnrichmentType.CALIBER) {
|
||||||
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
|
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// This should create ProductEnrichment rows with source=AI and status=PENDING_REVIEW
|
// This should create ProductEnrichment rows with source=AI and status=PENDING_REVIEW
|
||||||
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliber(limit));
|
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliber(limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/queue")
|
@GetMapping("/queue")
|
||||||
public ResponseEntity<List<ProductEnrichment>> queue(
|
public ResponseEntity<List<ProductEnrichment>> queue(
|
||||||
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
|
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
|
||||||
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
|
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
|
||||||
@RequestParam(defaultValue = "100") int limit
|
@RequestParam(defaultValue = "100") int limit
|
||||||
) {
|
) {
|
||||||
var items = enrichmentRepository
|
var items = enrichmentRepository
|
||||||
.findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(type, status, PageRequest.of(0, limit));
|
.findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(type, status, PageRequest.of(0, limit));
|
||||||
return ResponseEntity.ok(items);
|
return ResponseEntity.ok(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/queue2")
|
@GetMapping("/queue2")
|
||||||
public ResponseEntity<?> queue2(
|
public ResponseEntity<?> queue2(
|
||||||
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
|
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
|
||||||
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
|
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
|
||||||
@RequestParam(defaultValue = "100") int limit
|
@RequestParam(defaultValue = "100") int limit
|
||||||
) {
|
) {
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
enrichmentRepository.queueWithProduct(type, status, PageRequest.of(0, limit))
|
enrichmentRepository.queueWithProduct(type, status, PageRequest.of(0, limit))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/approve")
|
@PostMapping("/{id}/approve")
|
||||||
public ResponseEntity<?> approve(@PathVariable Long id) {
|
public ResponseEntity<?> approve(@PathVariable Long id) {
|
||||||
var e = enrichmentRepository.findById(id)
|
var e = enrichmentRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
||||||
e.setStatus(EnrichmentStatus.APPROVED);
|
e.setStatus(EnrichmentStatus.APPROVED);
|
||||||
enrichmentRepository.save(e);
|
enrichmentRepository.save(e);
|
||||||
return ResponseEntity.ok(e);
|
return ResponseEntity.ok(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/reject")
|
@PostMapping("/{id}/reject")
|
||||||
public ResponseEntity<?> reject(@PathVariable Long id) {
|
public ResponseEntity<?> reject(@PathVariable Long id) {
|
||||||
var e = enrichmentRepository.findById(id)
|
var e = enrichmentRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
||||||
e.setStatus(EnrichmentStatus.REJECTED);
|
e.setStatus(EnrichmentStatus.REJECTED);
|
||||||
enrichmentRepository.save(e);
|
enrichmentRepository.save(e);
|
||||||
return ResponseEntity.ok(e);
|
return ResponseEntity.ok(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/apply")
|
@PostMapping("/{id}/apply")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<?> apply(@PathVariable Long id) {
|
public ResponseEntity<?> apply(@PathVariable Long id) {
|
||||||
var e = enrichmentRepository.findById(id)
|
var e = enrichmentRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
||||||
|
|
||||||
if (e.getStatus() != EnrichmentStatus.APPROVED) {
|
if (e.getStatus() != EnrichmentStatus.APPROVED) {
|
||||||
return ResponseEntity.badRequest().body("Enrichment must be APPROVED before applying.");
|
return ResponseEntity.badRequest().body("Enrichment must be APPROVED before applying.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.getEnrichmentType() == EnrichmentType.CALIBER) {
|
if (e.getEnrichmentType() == EnrichmentType.CALIBER) {
|
||||||
Object caliberObj = e.getAttributes().get("caliber");
|
Object caliberObj = e.getAttributes().get("caliber");
|
||||||
if (!(caliberObj instanceof String caliber) || caliber.trim().isEmpty()) {
|
if (!(caliberObj instanceof String caliber) || caliber.trim().isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body("Missing attributes.caliber");
|
return ResponseEntity.badRequest().body("Missing attributes.caliber");
|
||||||
}
|
}
|
||||||
|
|
||||||
String canonical = CaliberTaxonomy.normalizeCaliber(caliber.trim());
|
String canonical = CaliberTaxonomy.normalizeCaliber(caliber.trim());
|
||||||
int updated = enrichmentRepository.applyCaliberIfBlank(e.getProductId(), canonical);
|
int updated = enrichmentRepository.applyCaliberIfBlank(e.getProductId(), canonical);
|
||||||
|
|
||||||
if (updated == 0) {
|
if (updated == 0) {
|
||||||
return ResponseEntity.badRequest().body("Product caliber already set (or product not found). Not applied.");
|
return ResponseEntity.badRequest().body("Product caliber already set (or product not found). Not applied.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bonus safety: set group if blank
|
// Bonus safety: set group if blank
|
||||||
String group = CaliberTaxonomy.groupForCaliber(canonical);
|
String group = CaliberTaxonomy.groupForCaliber(canonical);
|
||||||
if (group != null && !group.isBlank()) {
|
if (group != null && !group.isBlank()) {
|
||||||
enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group);
|
enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group);
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (e.getEnrichmentType() == EnrichmentType.CALIBER_GROUP) {
|
} else if (e.getEnrichmentType() == EnrichmentType.CALIBER_GROUP) {
|
||||||
Object groupObj = e.getAttributes().get("caliberGroup");
|
Object groupObj = e.getAttributes().get("caliberGroup");
|
||||||
if (!(groupObj instanceof String group) || group.trim().isEmpty()) {
|
if (!(groupObj instanceof String group) || group.trim().isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body("Missing attributes.caliberGroup");
|
return ResponseEntity.badRequest().body("Missing attributes.caliberGroup");
|
||||||
}
|
}
|
||||||
|
|
||||||
int updated = enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group.trim());
|
int updated = enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group.trim());
|
||||||
if (updated == 0) {
|
if (updated == 0) {
|
||||||
return ResponseEntity.badRequest().body("Product caliber_group already set (or product not found). Not applied.");
|
return ResponseEntity.badRequest().body("Product caliber_group already set (or product not found). Not applied.");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return ResponseEntity.badRequest().body("Unsupported enrichment type in v0.");
|
return ResponseEntity.badRequest().body("Unsupported enrichment type in v0.");
|
||||||
}
|
}
|
||||||
|
|
||||||
e.setStatus(EnrichmentStatus.APPLIED);
|
e.setStatus(EnrichmentStatus.APPLIED);
|
||||||
enrichmentRepository.save(e);
|
enrichmentRepository.save(e);
|
||||||
|
|
||||||
return ResponseEntity.ok(e);
|
return ResponseEntity.ok(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/groups/run")
|
@PostMapping("/groups/run")
|
||||||
public ResponseEntity<?> runGroups(@RequestParam(defaultValue = "200") int limit) {
|
public ResponseEntity<?> runGroups(@RequestParam(defaultValue = "200") int limit) {
|
||||||
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliberGroup(limit));
|
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliberGroup(limit));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,95 +1,95 @@
|
|||||||
package group.goforward.battlbuilder.enrichment.model;
|
package group.goforward.battlbuilder.enrichment.model;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.enrichment.EnrichmentSource;
|
import group.goforward.battlbuilder.enrichment.EnrichmentSource;
|
||||||
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
||||||
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import org.hibernate.annotations.JdbcTypeCode;
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
import org.hibernate.type.SqlTypes;
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "product_enrichments")
|
@Table(name = "product_enrichments")
|
||||||
public class ProductEnrichment {
|
public class ProductEnrichment {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Column(name = "product_id", nullable = false)
|
@Column(name = "product_id", nullable = false)
|
||||||
private Integer productId;
|
private Integer productId;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "enrichment_type", nullable = false)
|
@Column(name = "enrichment_type", nullable = false)
|
||||||
private EnrichmentType enrichmentType;
|
private EnrichmentType enrichmentType;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "source", nullable = false)
|
@Column(name = "source", nullable = false)
|
||||||
private EnrichmentSource source = EnrichmentSource.AI;
|
private EnrichmentSource source = EnrichmentSource.AI;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "status", nullable = false)
|
@Column(name = "status", nullable = false)
|
||||||
private EnrichmentStatus status = EnrichmentStatus.PENDING_REVIEW;
|
private EnrichmentStatus status = EnrichmentStatus.PENDING_REVIEW;
|
||||||
|
|
||||||
@Column(name = "schema_version", nullable = false)
|
@Column(name = "schema_version", nullable = false)
|
||||||
private Integer schemaVersion = 1;
|
private Integer schemaVersion = 1;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
@Column(name = "attributes", nullable = false, columnDefinition = "jsonb")
|
@Column(name = "attributes", nullable = false, columnDefinition = "jsonb")
|
||||||
private Map<String, Object> attributes = new HashMap<>();
|
private Map<String, Object> attributes = new HashMap<>();
|
||||||
|
|
||||||
@Column(name = "confidence", precision = 4, scale = 3)
|
@Column(name = "confidence", precision = 4, scale = 3)
|
||||||
private BigDecimal confidence;
|
private BigDecimal confidence;
|
||||||
|
|
||||||
@Column(name = "rationale")
|
@Column(name = "rationale")
|
||||||
private String rationale;
|
private String rationale;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
@Column(name = "meta", nullable = false, columnDefinition = "jsonb")
|
@Column(name = "meta", nullable = false, columnDefinition = "jsonb")
|
||||||
private Map<String, Object> meta = new HashMap<>();
|
private Map<String, Object> meta = new HashMap<>();
|
||||||
|
|
||||||
// DB sets these defaults; we keep them read-only to avoid fighting the trigger/defaults
|
// DB sets these defaults; we keep them read-only to avoid fighting the trigger/defaults
|
||||||
@Column(name = "created_at", insertable = false, updatable = false)
|
@Column(name = "created_at", insertable = false, updatable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
@Column(name = "updated_at", insertable = false, updatable = false)
|
@Column(name = "updated_at", insertable = false, updatable = false)
|
||||||
private OffsetDateTime updatedAt;
|
private OffsetDateTime updatedAt;
|
||||||
|
|
||||||
// --- getters/setters (generate via IDE) ---
|
// --- getters/setters (generate via IDE) ---
|
||||||
|
|
||||||
public Long getId() { return id; }
|
public Long getId() { return id; }
|
||||||
|
|
||||||
public Integer getProductId() { return productId; }
|
public Integer getProductId() { return productId; }
|
||||||
public void setProductId(Integer productId) { this.productId = productId; }
|
public void setProductId(Integer productId) { this.productId = productId; }
|
||||||
|
|
||||||
public EnrichmentType getEnrichmentType() { return enrichmentType; }
|
public EnrichmentType getEnrichmentType() { return enrichmentType; }
|
||||||
public void setEnrichmentType(EnrichmentType enrichmentType) { this.enrichmentType = enrichmentType; }
|
public void setEnrichmentType(EnrichmentType enrichmentType) { this.enrichmentType = enrichmentType; }
|
||||||
|
|
||||||
public EnrichmentSource getSource() { return source; }
|
public EnrichmentSource getSource() { return source; }
|
||||||
public void setSource(EnrichmentSource source) { this.source = source; }
|
public void setSource(EnrichmentSource source) { this.source = source; }
|
||||||
|
|
||||||
public EnrichmentStatus getStatus() { return status; }
|
public EnrichmentStatus getStatus() { return status; }
|
||||||
public void setStatus(EnrichmentStatus status) { this.status = status; }
|
public void setStatus(EnrichmentStatus status) { this.status = status; }
|
||||||
|
|
||||||
public Integer getSchemaVersion() { return schemaVersion; }
|
public Integer getSchemaVersion() { return schemaVersion; }
|
||||||
public void setSchemaVersion(Integer schemaVersion) { this.schemaVersion = schemaVersion; }
|
public void setSchemaVersion(Integer schemaVersion) { this.schemaVersion = schemaVersion; }
|
||||||
|
|
||||||
public Map<String, Object> getAttributes() { return attributes; }
|
public Map<String, Object> getAttributes() { return attributes; }
|
||||||
public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
|
public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
|
||||||
|
|
||||||
public BigDecimal getConfidence() { return confidence; }
|
public BigDecimal getConfidence() { return confidence; }
|
||||||
public void setConfidence(BigDecimal confidence) { this.confidence = confidence; }
|
public void setConfidence(BigDecimal confidence) { this.confidence = confidence; }
|
||||||
|
|
||||||
public String getRationale() { return rationale; }
|
public String getRationale() { return rationale; }
|
||||||
public void setRationale(String rationale) { this.rationale = rationale; }
|
public void setRationale(String rationale) { this.rationale = rationale; }
|
||||||
|
|
||||||
public Map<String, Object> getMeta() { return meta; }
|
public Map<String, Object> getMeta() { return meta; }
|
||||||
public void setMeta(Map<String, Object> meta) { this.meta = meta; }
|
public void setMeta(Map<String, Object> meta) { this.meta = meta; }
|
||||||
|
|
||||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||||
}
|
}
|
||||||
@@ -1,94 +1,94 @@
|
|||||||
package group.goforward.battlbuilder.enrichment.repo;
|
package group.goforward.battlbuilder.enrichment.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
||||||
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
||||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem;
|
import group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ProductEnrichmentRepository extends JpaRepository<ProductEnrichment, Long> {
|
public interface ProductEnrichmentRepository extends JpaRepository<ProductEnrichment, Long> {
|
||||||
|
|
||||||
boolean existsByProductIdAndEnrichmentTypeAndStatus(
|
boolean existsByProductIdAndEnrichmentTypeAndStatus(
|
||||||
Integer productId,
|
Integer productId,
|
||||||
EnrichmentType enrichmentType,
|
EnrichmentType enrichmentType,
|
||||||
EnrichmentStatus status
|
EnrichmentStatus status
|
||||||
);
|
);
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
select e from ProductEnrichment e
|
select e from ProductEnrichment e
|
||||||
where e.productId = :productId
|
where e.productId = :productId
|
||||||
and e.enrichmentType = :type
|
and e.enrichmentType = :type
|
||||||
and e.status in ('PENDING_REVIEW','APPROVED')
|
and e.status in ('PENDING_REVIEW','APPROVED')
|
||||||
""")
|
""")
|
||||||
Optional<ProductEnrichment> findActive(Integer productId, EnrichmentType type);
|
Optional<ProductEnrichment> findActive(Integer productId, EnrichmentType type);
|
||||||
|
|
||||||
List<ProductEnrichment> findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(
|
List<ProductEnrichment> findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(
|
||||||
EnrichmentType type,
|
EnrichmentType type,
|
||||||
EnrichmentStatus status,
|
EnrichmentStatus status,
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
select new group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem(
|
select new group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem(
|
||||||
e.id,
|
e.id,
|
||||||
e.productId,
|
e.productId,
|
||||||
p.name,
|
p.name,
|
||||||
p.slug,
|
p.slug,
|
||||||
p.mainImageUrl,
|
p.mainImageUrl,
|
||||||
b.name,
|
b.name,
|
||||||
e.enrichmentType,
|
e.enrichmentType,
|
||||||
e.source,
|
e.source,
|
||||||
e.status,
|
e.status,
|
||||||
e.schemaVersion,
|
e.schemaVersion,
|
||||||
e.attributes,
|
e.attributes,
|
||||||
e.confidence,
|
e.confidence,
|
||||||
e.rationale,
|
e.rationale,
|
||||||
e.createdAt,
|
e.createdAt,
|
||||||
p.caliber,
|
p.caliber,
|
||||||
p.caliberGroup
|
p.caliberGroup
|
||||||
|
|
||||||
)
|
)
|
||||||
from ProductEnrichment e
|
from ProductEnrichment e
|
||||||
join Product p on p.id = e.productId
|
join Product p on p.id = e.productId
|
||||||
join p.brand b
|
join p.brand b
|
||||||
where e.enrichmentType = :type
|
where e.enrichmentType = :type
|
||||||
and e.status = :status
|
and e.status = :status
|
||||||
order by e.createdAt desc
|
order by e.createdAt desc
|
||||||
""")
|
""")
|
||||||
List<EnrichmentQueueItem> queueWithProduct(
|
List<EnrichmentQueueItem> queueWithProduct(
|
||||||
EnrichmentType type,
|
EnrichmentType type,
|
||||||
EnrichmentStatus status,
|
EnrichmentStatus status,
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query("""
|
@Query("""
|
||||||
update Product p
|
update Product p
|
||||||
set p.caliber = :caliber
|
set p.caliber = :caliber
|
||||||
where p.id = :productId
|
where p.id = :productId
|
||||||
and (p.caliber is null or trim(p.caliber) = '')
|
and (p.caliber is null or trim(p.caliber) = '')
|
||||||
""")
|
""")
|
||||||
int applyCaliberIfBlank(
|
int applyCaliberIfBlank(
|
||||||
@Param("productId") Integer productId,
|
@Param("productId") Integer productId,
|
||||||
@Param("caliber") String caliber
|
@Param("caliber") String caliber
|
||||||
);
|
);
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query("""
|
@Query("""
|
||||||
update Product p
|
update Product p
|
||||||
set p.caliberGroup = :caliberGroup
|
set p.caliberGroup = :caliberGroup
|
||||||
where p.id = :productId
|
where p.id = :productId
|
||||||
and (p.caliberGroup is null or trim(p.caliberGroup) = '')
|
and (p.caliberGroup is null or trim(p.caliberGroup) = '')
|
||||||
""")
|
""")
|
||||||
int applyCaliberGroupIfBlank(
|
int applyCaliberGroupIfBlank(
|
||||||
@Param("productId") Integer productId,
|
@Param("productId") Integer productId,
|
||||||
@Param("caliberGroup") String caliberGroup
|
@Param("caliberGroup") String caliberGroup
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,86 +1,86 @@
|
|||||||
package group.goforward.battlbuilder.enrichment.service;
|
package group.goforward.battlbuilder.enrichment.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.enrichment.*;
|
import group.goforward.battlbuilder.enrichment.*;
|
||||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||||
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
import jakarta.persistence.PersistenceContext;
|
import jakarta.persistence.PersistenceContext;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CaliberEnrichmentService {
|
public class CaliberEnrichmentService {
|
||||||
|
|
||||||
private final ProductEnrichmentRepository enrichmentRepository;
|
private final ProductEnrichmentRepository enrichmentRepository;
|
||||||
|
|
||||||
@PersistenceContext
|
@PersistenceContext
|
||||||
private EntityManager em;
|
private EntityManager em;
|
||||||
|
|
||||||
private final CaliberRuleExtractor extractor = new CaliberRuleExtractor();
|
private final CaliberRuleExtractor extractor = new CaliberRuleExtractor();
|
||||||
|
|
||||||
public CaliberEnrichmentService(ProductEnrichmentRepository enrichmentRepository) {
|
public CaliberEnrichmentService(ProductEnrichmentRepository enrichmentRepository) {
|
||||||
this.enrichmentRepository = enrichmentRepository;
|
this.enrichmentRepository = enrichmentRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RunResult(int scanned, int created) {}
|
public record RunResult(int scanned, int created) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates PENDING_REVIEW caliber enrichments for products that don't already have an active one.
|
* Creates PENDING_REVIEW caliber enrichments for products that don't already have an active one.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public RunResult runRules(int limit) {
|
public RunResult runRules(int limit) {
|
||||||
// Adjust Product entity package if needed:
|
// Adjust Product entity package if needed:
|
||||||
// IMPORTANT: Product must be a mapped @Entity named "Product"
|
// IMPORTANT: Product must be a mapped @Entity named "Product"
|
||||||
List<Object[]> rows = em.createQuery("""
|
List<Object[]> rows = em.createQuery("""
|
||||||
select p.id, p.name, p.description
|
select p.id, p.name, p.description
|
||||||
from Product p
|
from Product p
|
||||||
where p.deletedAt is null
|
where p.deletedAt is null
|
||||||
and not exists (
|
and not exists (
|
||||||
select 1 from ProductEnrichment e
|
select 1 from ProductEnrichment e
|
||||||
where e.productId = p.id
|
where e.productId = p.id
|
||||||
and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER
|
and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER
|
||||||
and e.status in ('PENDING_REVIEW','APPROVED')
|
and e.status in ('PENDING_REVIEW','APPROVED')
|
||||||
)
|
)
|
||||||
order by p.id desc
|
order by p.id desc
|
||||||
""", Object[].class)
|
""", Object[].class)
|
||||||
.setMaxResults(limit)
|
.setMaxResults(limit)
|
||||||
.getResultList();
|
.getResultList();
|
||||||
|
|
||||||
int created = 0;
|
int created = 0;
|
||||||
|
|
||||||
for (Object[] r : rows) {
|
for (Object[] r : rows) {
|
||||||
Integer productId = (Integer) r[0];
|
Integer productId = (Integer) r[0];
|
||||||
String name = (String) r[1];
|
String name = (String) r[1];
|
||||||
String description = (String) r[2];
|
String description = (String) r[2];
|
||||||
|
|
||||||
Optional<CaliberRuleExtractor.Result> res = extractor.extract(name, description);
|
Optional<CaliberRuleExtractor.Result> res = extractor.extract(name, description);
|
||||||
if (res.isEmpty()) continue;
|
if (res.isEmpty()) continue;
|
||||||
|
|
||||||
var result = res.get();
|
var result = res.get();
|
||||||
|
|
||||||
ProductEnrichment e = new ProductEnrichment();
|
ProductEnrichment e = new ProductEnrichment();
|
||||||
e.setProductId(productId);
|
e.setProductId(productId);
|
||||||
e.setEnrichmentType(EnrichmentType.CALIBER);
|
e.setEnrichmentType(EnrichmentType.CALIBER);
|
||||||
e.setSource(EnrichmentSource.RULES);
|
e.setSource(EnrichmentSource.RULES);
|
||||||
e.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
e.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
||||||
e.setSchemaVersion(1);
|
e.setSchemaVersion(1);
|
||||||
|
|
||||||
var attrs = new HashMap<String, Object>();
|
var attrs = new HashMap<String, Object>();
|
||||||
attrs.put("caliber", result.caliber());
|
attrs.put("caliber", result.caliber());
|
||||||
e.setAttributes(attrs);
|
e.setAttributes(attrs);
|
||||||
|
|
||||||
e.setConfidence(BigDecimal.valueOf(result.confidence()));
|
e.setConfidence(BigDecimal.valueOf(result.confidence()));
|
||||||
e.setRationale(result.rationale());
|
e.setRationale(result.rationale());
|
||||||
|
|
||||||
enrichmentRepository.save(e);
|
enrichmentRepository.save(e);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RunResult(rows.size(), created);
|
return new RunResult(rows.size(), created);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,35 @@
|
|||||||
package group.goforward.battlbuilder.enrichment.taxonomies;
|
package group.goforward.battlbuilder.enrichment.taxonomies;
|
||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
public final class CaliberTaxonomy {
|
public final class CaliberTaxonomy {
|
||||||
private CaliberTaxonomy() {}
|
private CaliberTaxonomy() {}
|
||||||
|
|
||||||
public static String normalizeCaliber(String raw) {
|
public static String normalizeCaliber(String raw) {
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
String s = raw.trim();
|
String s = raw.trim();
|
||||||
|
|
||||||
// Canonicalize common variants
|
// Canonicalize common variants
|
||||||
String l = s.toLowerCase(Locale.ROOT);
|
String l = s.toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
if (l.contains("223 wylde") || l.contains(".223 wylde")) return ".223 Wylde";
|
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("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("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";
|
if (l.contains("300 blackout") || l.contains("300 blk") || l.contains("300 aac")) return "300 BLK";
|
||||||
|
|
||||||
// fallback: return trimmed original (you can tighten later)
|
// fallback: return trimmed original (you can tighten later)
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String groupForCaliber(String caliberCanonical) {
|
public static String groupForCaliber(String caliberCanonical) {
|
||||||
if (caliberCanonical == null) return null;
|
if (caliberCanonical == null) return null;
|
||||||
String l = caliberCanonical.toLowerCase(Locale.ROOT);
|
String l = caliberCanonical.toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
if (l.contains("223") || l.contains("5.56") || l.contains("wylde")) return "223/5.56";
|
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";
|
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.
|
// TODO add more buckets: 308/7.62, 6.5 CM, 9mm, etc.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,174 +1,174 @@
|
|||||||
package group.goforward.battlbuilder.model;
|
package group.goforward.battlbuilder.model;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity representing an authentication token.
|
* Entity representing an authentication token.
|
||||||
* Tokens are used for beta verification, magic login links, and password resets.
|
* Tokens are used for beta verification, magic login links, and password resets.
|
||||||
* Tokens are hashed before storage and can be consumed and expired.
|
* Tokens are hashed before storage and can be consumed and expired.
|
||||||
*
|
*
|
||||||
* @see jakarta.persistence.Entity
|
* @see jakarta.persistence.Entity
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(
|
@Table(
|
||||||
name = "auth_tokens",
|
name = "auth_tokens",
|
||||||
indexes = {
|
indexes = {
|
||||||
@Index(name = "idx_auth_tokens_email", columnList = "email"),
|
@Index(name = "idx_auth_tokens_email", columnList = "email"),
|
||||||
@Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash")
|
@Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
public class AuthToken {
|
public class AuthToken {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enumeration of token types.
|
* Enumeration of token types.
|
||||||
*/
|
*/
|
||||||
public enum TokenType {
|
public enum TokenType {
|
||||||
/** Token for beta access verification. */
|
/** Token for beta access verification. */
|
||||||
BETA_VERIFY,
|
BETA_VERIFY,
|
||||||
/** Token for magic link login. */
|
/** Token for magic link login. */
|
||||||
MAGIC_LOGIN,
|
MAGIC_LOGIN,
|
||||||
/** Token for password reset. */
|
/** Token for password reset. */
|
||||||
PASSWORD_RESET
|
PASSWORD_RESET
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The primary key identifier for the token. */
|
/** The primary key identifier for the token. */
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
/** The email address associated with this token. */
|
/** The email address associated with this token. */
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
/** The type of token. */
|
/** The type of token. */
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false, length = 32)
|
@Column(nullable = false, length = 32)
|
||||||
private TokenType type;
|
private TokenType type;
|
||||||
|
|
||||||
/** The hashed token value. */
|
/** The hashed token value. */
|
||||||
@Column(name = "token_hash", nullable = false, length = 64)
|
@Column(name = "token_hash", nullable = false, length = 64)
|
||||||
private String tokenHash;
|
private String tokenHash;
|
||||||
|
|
||||||
/** The timestamp when this token expires. */
|
/** The timestamp when this token expires. */
|
||||||
@Column(name = "expires_at", nullable = false)
|
@Column(name = "expires_at", nullable = false)
|
||||||
private OffsetDateTime expiresAt;
|
private OffsetDateTime expiresAt;
|
||||||
|
|
||||||
/** The timestamp when this token was consumed/used. */
|
/** The timestamp when this token was consumed/used. */
|
||||||
@Column(name = "consumed_at")
|
@Column(name = "consumed_at")
|
||||||
private OffsetDateTime consumedAt;
|
private OffsetDateTime consumedAt;
|
||||||
|
|
||||||
/** The timestamp when this token was created. */
|
/** The timestamp when this token was created. */
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
// getters/setters
|
// getters/setters
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the primary key identifier for the token.
|
* Gets the primary key identifier for the token.
|
||||||
*
|
*
|
||||||
* @return the token ID
|
* @return the token ID
|
||||||
*/
|
*/
|
||||||
public Long getId() { return id; }
|
public Long getId() { return id; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the email address associated with this token.
|
* Gets the email address associated with this token.
|
||||||
*
|
*
|
||||||
* @return the email address
|
* @return the email address
|
||||||
*/
|
*/
|
||||||
public String getEmail() { return email; }
|
public String getEmail() { return email; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the email address associated with this token.
|
* Sets the email address associated with this token.
|
||||||
*
|
*
|
||||||
* @param email the email address to set
|
* @param email the email address to set
|
||||||
*/
|
*/
|
||||||
public void setEmail(String email) { this.email = email; }
|
public void setEmail(String email) { this.email = email; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the type of token.
|
* Gets the type of token.
|
||||||
*
|
*
|
||||||
* @return the token type
|
* @return the token type
|
||||||
*/
|
*/
|
||||||
public TokenType getType() { return type; }
|
public TokenType getType() { return type; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the type of token.
|
* Sets the type of token.
|
||||||
*
|
*
|
||||||
* @param type the token type to set
|
* @param type the token type to set
|
||||||
*/
|
*/
|
||||||
public void setType(TokenType type) { this.type = type; }
|
public void setType(TokenType type) { this.type = type; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the hashed token value.
|
* Gets the hashed token value.
|
||||||
*
|
*
|
||||||
* @return the token hash
|
* @return the token hash
|
||||||
*/
|
*/
|
||||||
public String getTokenHash() { return tokenHash; }
|
public String getTokenHash() { return tokenHash; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the hashed token value.
|
* Sets the hashed token value.
|
||||||
*
|
*
|
||||||
* @param tokenHash the token hash to set
|
* @param tokenHash the token hash to set
|
||||||
*/
|
*/
|
||||||
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
|
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the timestamp when this token expires.
|
* Gets the timestamp when this token expires.
|
||||||
*
|
*
|
||||||
* @return the expiration timestamp
|
* @return the expiration timestamp
|
||||||
*/
|
*/
|
||||||
public OffsetDateTime getExpiresAt() { return expiresAt; }
|
public OffsetDateTime getExpiresAt() { return expiresAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the timestamp when this token expires.
|
* Sets the timestamp when this token expires.
|
||||||
*
|
*
|
||||||
* @param expiresAt the expiration timestamp to set
|
* @param expiresAt the expiration timestamp to set
|
||||||
*/
|
*/
|
||||||
public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; }
|
public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the timestamp when this token was consumed/used.
|
* Gets the timestamp when this token was consumed/used.
|
||||||
*
|
*
|
||||||
* @return the consumed timestamp, or null if not yet consumed
|
* @return the consumed timestamp, or null if not yet consumed
|
||||||
*/
|
*/
|
||||||
public OffsetDateTime getConsumedAt() { return consumedAt; }
|
public OffsetDateTime getConsumedAt() { return consumedAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the timestamp when this token was consumed/used.
|
* Sets the timestamp when this token was consumed/used.
|
||||||
*
|
*
|
||||||
* @param consumedAt the consumed timestamp to set
|
* @param consumedAt the consumed timestamp to set
|
||||||
*/
|
*/
|
||||||
public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; }
|
public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the timestamp when this token was created.
|
* Gets the timestamp when this token was created.
|
||||||
*
|
*
|
||||||
* @return the creation timestamp
|
* @return the creation timestamp
|
||||||
*/
|
*/
|
||||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the timestamp when this token was created.
|
* Sets the timestamp when this token was created.
|
||||||
*
|
*
|
||||||
* @param createdAt the creation timestamp to set
|
* @param createdAt the creation timestamp to set
|
||||||
*/
|
*/
|
||||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if this token has been consumed/used.
|
* Checks if this token has been consumed/used.
|
||||||
*
|
*
|
||||||
* @return true if the token has been consumed, false otherwise
|
* @return true if the token has been consumed, false otherwise
|
||||||
*/
|
*/
|
||||||
@Transient
|
@Transient
|
||||||
public boolean isConsumed() { return consumedAt != null; }
|
public boolean isConsumed() { return consumedAt != null; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if this token has expired at the given time.
|
* Checks if this token has expired at the given time.
|
||||||
*
|
*
|
||||||
* @param now the current time to check against
|
* @param now the current time to check against
|
||||||
* @return true if the token has expired, false otherwise
|
* @return true if the token has expired, false otherwise
|
||||||
*/
|
*/
|
||||||
@Transient
|
@Transient
|
||||||
public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); }
|
public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); }
|
||||||
}
|
}
|
||||||
@@ -1,122 +1,122 @@
|
|||||||
package group.goforward.battlbuilder.model;
|
package group.goforward.battlbuilder.model;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import org.hibernate.annotations.ColumnDefault;
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* build_profiles
|
* build_profiles
|
||||||
* 1:1 with builds (build_id is both PK and FK)
|
* 1:1 with builds (build_id is both PK and FK)
|
||||||
* <p>
|
* <p>
|
||||||
* Dev notes:
|
* Dev notes:
|
||||||
* - This is the "feed/meta" table for Option B (caliber, class, cover image, tags, etc.)
|
* - 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.
|
* - Keep it lightweight. Anything social (votes/comments/media) lives elsewhere.
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "build_profiles")
|
@Table(name = "build_profiles")
|
||||||
public class BuildProfile {
|
public class BuildProfile {
|
||||||
|
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
// Primary Key = FK to builds.id
|
// Primary Key = FK to builds.id
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
@Id
|
@Id
|
||||||
@Column(name = "build_id", nullable = false)
|
@Column(name = "build_id", nullable = false)
|
||||||
private Integer buildId;
|
private Integer buildId;
|
||||||
|
|
||||||
@OneToOne(fetch = FetchType.LAZY, optional = false)
|
@OneToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
@MapsId
|
@MapsId
|
||||||
@JoinColumn(name = "build_id", nullable = false)
|
@JoinColumn(name = "build_id", nullable = false)
|
||||||
private Build build;
|
private Build build;
|
||||||
|
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
// Feed metadata fields (MVP)
|
// Feed metadata fields (MVP)
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Examples: "AR-15", "AR-10", "AR-9"
|
* Examples: "AR-15", "AR-10", "AR-9"
|
||||||
* (String for now; we can enum later once stable.)
|
* (String for now; we can enum later once stable.)
|
||||||
*/
|
*/
|
||||||
@Column(name = "platform")
|
@Column(name = "platform")
|
||||||
private String platform;
|
private String platform;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Examples: "5.56", "9mm", ".300 BLK"
|
* Examples: "5.56", "9mm", ".300 BLK"
|
||||||
*/
|
*/
|
||||||
@Column(name = "caliber")
|
@Column(name = "caliber")
|
||||||
private String caliber;
|
private String caliber;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expected values for UI: "Rifle" | "Pistol" | "NFA"
|
* Expected values for UI: "Rifle" | "Pistol" | "NFA"
|
||||||
* (String for now; UI will default if missing.)
|
* (String for now; UI will default if missing.)
|
||||||
*/
|
*/
|
||||||
@Column(name = "build_class")
|
@Column(name = "build_class")
|
||||||
private String buildClass;
|
private String buildClass;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional hero image used by /builds cards.
|
* Optional hero image used by /builds cards.
|
||||||
*/
|
*/
|
||||||
@Column(name = "cover_image_url")
|
@Column(name = "cover_image_url")
|
||||||
private String coverImageUrl;
|
private String coverImageUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MVP tags storage:
|
* MVP tags storage:
|
||||||
* - store as comma-separated string: "Duty,NV-Ready,LPVO"
|
* - store as comma-separated string: "Duty,NV-Ready,LPVO"
|
||||||
* - later: switch to jsonb or join table when needed
|
* - later: switch to jsonb or join table when needed
|
||||||
*/
|
*/
|
||||||
@Column(name = "tags_csv")
|
@Column(name = "tags_csv")
|
||||||
private String tagsCsv;
|
private String tagsCsv;
|
||||||
|
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
// Timestamps (optional but nice for auditing)
|
// Timestamps (optional but nice for auditing)
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
@ColumnDefault("now()")
|
@ColumnDefault("now()")
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
@ColumnDefault("now()")
|
@ColumnDefault("now()")
|
||||||
@Column(name = "updated_at", nullable = false)
|
@Column(name = "updated_at", nullable = false)
|
||||||
private OffsetDateTime updatedAt;
|
private OffsetDateTime updatedAt;
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
public void prePersist() {
|
public void prePersist() {
|
||||||
OffsetDateTime now = OffsetDateTime.now();
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
if (createdAt == null) createdAt = now;
|
if (createdAt == null) createdAt = now;
|
||||||
if (updatedAt == null) updatedAt = now;
|
if (updatedAt == null) updatedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
public void preUpdate() {
|
public void preUpdate() {
|
||||||
updatedAt = OffsetDateTime.now();
|
updatedAt = OffsetDateTime.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
// Getters / Setters
|
// Getters / Setters
|
||||||
// -----------------------------------------------------
|
// -----------------------------------------------------
|
||||||
public Integer getBuildId() { return buildId; }
|
public Integer getBuildId() { return buildId; }
|
||||||
public void setBuildId(Integer buildId) { this.buildId = buildId; }
|
public void setBuildId(Integer buildId) { this.buildId = buildId; }
|
||||||
|
|
||||||
public Build getBuild() { return build; }
|
public Build getBuild() { return build; }
|
||||||
public void setBuild(Build build) { this.build = build; }
|
public void setBuild(Build build) { this.build = build; }
|
||||||
|
|
||||||
public String getPlatform() { return platform; }
|
public String getPlatform() { return platform; }
|
||||||
public void setPlatform(String platform) { this.platform = platform; }
|
public void setPlatform(String platform) { this.platform = platform; }
|
||||||
|
|
||||||
public String getCaliber() { return caliber; }
|
public String getCaliber() { return caliber; }
|
||||||
public void setCaliber(String caliber) { this.caliber = caliber; }
|
public void setCaliber(String caliber) { this.caliber = caliber; }
|
||||||
|
|
||||||
public String getBuildClass() { return buildClass; }
|
public String getBuildClass() { return buildClass; }
|
||||||
public void setBuildClass(String buildClass) { this.buildClass = buildClass; }
|
public void setBuildClass(String buildClass) { this.buildClass = buildClass; }
|
||||||
|
|
||||||
public String getCoverImageUrl() { return coverImageUrl; }
|
public String getCoverImageUrl() { return coverImageUrl; }
|
||||||
public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; }
|
public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; }
|
||||||
|
|
||||||
public String getTagsCsv() { return tagsCsv; }
|
public String getTagsCsv() { return tagsCsv; }
|
||||||
public void setTagsCsv(String tagsCsv) { this.tagsCsv = tagsCsv; }
|
public void setTagsCsv(String tagsCsv) { this.tagsCsv = tagsCsv; }
|
||||||
|
|
||||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
}
|
}
|
||||||
@@ -1,310 +1,310 @@
|
|||||||
package group.goforward.battlbuilder.model;
|
package group.goforward.battlbuilder.model;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity representing an email request/queue entry.
|
* Entity representing an email request/queue entry.
|
||||||
* Tracks email sending status, delivery, and engagement metrics (opens, clicks).
|
* Tracks email sending status, delivery, and engagement metrics (opens, clicks).
|
||||||
*
|
*
|
||||||
* @see jakarta.persistence.Entity
|
* @see jakarta.persistence.Entity
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "email_requests")
|
@Table(name = "email_requests")
|
||||||
@NamedQueries({
|
@NamedQueries({
|
||||||
@NamedQuery(
|
@NamedQuery(
|
||||||
name = "EmailRequest.findSent",
|
name = "EmailRequest.findSent",
|
||||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT"
|
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT"
|
||||||
),
|
),
|
||||||
@NamedQuery(
|
@NamedQuery(
|
||||||
name = "EmailRequest.findFailed",
|
name = "EmailRequest.findFailed",
|
||||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED"
|
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED"
|
||||||
),
|
),
|
||||||
@NamedQuery(
|
@NamedQuery(
|
||||||
name = "EmailRequest.findPending",
|
name = "EmailRequest.findPending",
|
||||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING"
|
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING"
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
public class EmailRequest {
|
public class EmailRequest {
|
||||||
|
|
||||||
/** The primary key identifier for the email request. */
|
/** The primary key identifier for the email request. */
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
/** The email address of the recipient. */
|
/** The email address of the recipient. */
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String recipient;
|
private String recipient;
|
||||||
|
|
||||||
/** The email subject line. */
|
/** The email subject line. */
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String subject;
|
private String subject;
|
||||||
|
|
||||||
/** The email body content. */
|
/** The email body content. */
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String body;
|
private String body;
|
||||||
|
|
||||||
/** The template key used to generate this email (if applicable). */
|
/** The template key used to generate this email (if applicable). */
|
||||||
@Column(name = "template_key", length = 100)
|
@Column(name = "template_key", length = 100)
|
||||||
private String templateKey;
|
private String templateKey;
|
||||||
|
|
||||||
/** The status of the email (PENDING, SENT, FAILED). */
|
/** The status of the email (PENDING, SENT, FAILED). */
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private EmailStatus status; // PENDING, SENT, FAILED
|
private EmailStatus status; // PENDING, SENT, FAILED
|
||||||
|
|
||||||
/** The timestamp when the email was sent. */
|
/** The timestamp when the email was sent. */
|
||||||
@Column(name = "sent_at")
|
@Column(name = "sent_at")
|
||||||
private LocalDateTime sentAt;
|
private LocalDateTime sentAt;
|
||||||
|
|
||||||
/** The error message if the email failed to send. */
|
/** The error message if the email failed to send. */
|
||||||
@Column(name = "error_message")
|
@Column(name = "error_message")
|
||||||
private String errorMessage;
|
private String errorMessage;
|
||||||
|
|
||||||
/** The timestamp when the email was first opened. */
|
/** The timestamp when the email was first opened. */
|
||||||
@Column(name = "opened_at")
|
@Column(name = "opened_at")
|
||||||
private LocalDateTime openedAt;
|
private LocalDateTime openedAt;
|
||||||
|
|
||||||
/** The number of times the email has been opened. Defaults to 0. */
|
/** The number of times the email has been opened. Defaults to 0. */
|
||||||
@Column(name = "open_count", nullable = false)
|
@Column(name = "open_count", nullable = false)
|
||||||
private Integer openCount = 0;
|
private Integer openCount = 0;
|
||||||
|
|
||||||
/** The timestamp when a link in the email was first clicked. */
|
/** The timestamp when a link in the email was first clicked. */
|
||||||
@Column(name = "clicked_at")
|
@Column(name = "clicked_at")
|
||||||
private LocalDateTime clickedAt;
|
private LocalDateTime clickedAt;
|
||||||
|
|
||||||
/** The number of times links in the email have been clicked. Defaults to 0. */
|
/** The number of times links in the email have been clicked. Defaults to 0. */
|
||||||
@Column(name = "click_count", nullable = false)
|
@Column(name = "click_count", nullable = false)
|
||||||
private Integer clickCount = 0;
|
private Integer clickCount = 0;
|
||||||
|
|
||||||
/** The timestamp when this email request was created. */
|
/** The timestamp when this email request was created. */
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
/** The timestamp when this email request was last updated. */
|
/** The timestamp when this email request was last updated. */
|
||||||
@Column(name = "updated_at", nullable = false)
|
@Column(name = "updated_at", nullable = false)
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle hook called before persisting a new entity.
|
* Lifecycle hook called before persisting a new entity.
|
||||||
* Initializes timestamps and default values.
|
* Initializes timestamps and default values.
|
||||||
*/
|
*/
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
createdAt = now;
|
createdAt = now;
|
||||||
updatedAt = now;
|
updatedAt = now;
|
||||||
|
|
||||||
if (status == null) status = EmailStatus.PENDING;
|
if (status == null) status = EmailStatus.PENDING;
|
||||||
if (openCount == null) openCount = 0;
|
if (openCount == null) openCount = 0;
|
||||||
if (clickCount == null) clickCount = 0;
|
if (clickCount == null) clickCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle hook called before updating an existing entity.
|
* Lifecycle hook called before updating an existing entity.
|
||||||
* Updates the updatedAt timestamp.
|
* Updates the updatedAt timestamp.
|
||||||
*/
|
*/
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Getters / Setters =====
|
// ===== Getters / Setters =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the primary key identifier for the email request.
|
* Gets the primary key identifier for the email request.
|
||||||
*
|
*
|
||||||
* @return the email request ID
|
* @return the email request ID
|
||||||
*/
|
*/
|
||||||
public Long getId() { return id; }
|
public Long getId() { return id; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the primary key identifier for the email request.
|
* Sets the primary key identifier for the email request.
|
||||||
*
|
*
|
||||||
* @param id the email request ID to set
|
* @param id the email request ID to set
|
||||||
*/
|
*/
|
||||||
public void setId(Long id) { this.id = id; }
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the email address of the recipient.
|
* Gets the email address of the recipient.
|
||||||
*
|
*
|
||||||
* @return the recipient email address
|
* @return the recipient email address
|
||||||
*/
|
*/
|
||||||
public String getRecipient() { return recipient; }
|
public String getRecipient() { return recipient; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the email address of the recipient.
|
* Sets the email address of the recipient.
|
||||||
*
|
*
|
||||||
* @param recipient the recipient email address to set
|
* @param recipient the recipient email address to set
|
||||||
*/
|
*/
|
||||||
public void setRecipient(String recipient) { this.recipient = recipient; }
|
public void setRecipient(String recipient) { this.recipient = recipient; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the email subject line.
|
* Gets the email subject line.
|
||||||
*
|
*
|
||||||
* @return the subject
|
* @return the subject
|
||||||
*/
|
*/
|
||||||
public String getSubject() { return subject; }
|
public String getSubject() { return subject; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the email subject line.
|
* Sets the email subject line.
|
||||||
*
|
*
|
||||||
* @param subject the subject to set
|
* @param subject the subject to set
|
||||||
*/
|
*/
|
||||||
public void setSubject(String subject) { this.subject = subject; }
|
public void setSubject(String subject) { this.subject = subject; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the email body content.
|
* Gets the email body content.
|
||||||
*
|
*
|
||||||
* @return the body, or null if not set
|
* @return the body, or null if not set
|
||||||
*/
|
*/
|
||||||
public String getBody() { return body; }
|
public String getBody() { return body; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the email body content.
|
* Sets the email body content.
|
||||||
*
|
*
|
||||||
* @param body the body to set
|
* @param body the body to set
|
||||||
*/
|
*/
|
||||||
public void setBody(String body) { this.body = body; }
|
public void setBody(String body) { this.body = body; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the template key used to generate this email.
|
* Gets the template key used to generate this email.
|
||||||
*
|
*
|
||||||
* @return the template key, or null if not set
|
* @return the template key, or null if not set
|
||||||
*/
|
*/
|
||||||
public String getTemplateKey() { return templateKey; }
|
public String getTemplateKey() { return templateKey; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the template key used to generate this email.
|
* Sets the template key used to generate this email.
|
||||||
*
|
*
|
||||||
* @param templateKey the template key to set
|
* @param templateKey the template key to set
|
||||||
*/
|
*/
|
||||||
public void setTemplateKey(String templateKey) { this.templateKey = templateKey; }
|
public void setTemplateKey(String templateKey) { this.templateKey = templateKey; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the status of the email.
|
* Gets the status of the email.
|
||||||
*
|
*
|
||||||
* @return the email status (PENDING, SENT, FAILED)
|
* @return the email status (PENDING, SENT, FAILED)
|
||||||
*/
|
*/
|
||||||
public EmailStatus getStatus() { return status; }
|
public EmailStatus getStatus() { return status; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the status of the email.
|
* Sets the status of the email.
|
||||||
*
|
*
|
||||||
* @param status the email status to set
|
* @param status the email status to set
|
||||||
*/
|
*/
|
||||||
public void setStatus(EmailStatus status) { this.status = status; }
|
public void setStatus(EmailStatus status) { this.status = status; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the timestamp when the email was sent.
|
* Gets the timestamp when the email was sent.
|
||||||
*
|
*
|
||||||
* @return the sent timestamp, or null if not yet sent
|
* @return the sent timestamp, or null if not yet sent
|
||||||
*/
|
*/
|
||||||
public LocalDateTime getSentAt() { return sentAt; }
|
public LocalDateTime getSentAt() { return sentAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the timestamp when the email was sent.
|
* Sets the timestamp when the email was sent.
|
||||||
*
|
*
|
||||||
* @param sentAt the sent timestamp to set
|
* @param sentAt the sent timestamp to set
|
||||||
*/
|
*/
|
||||||
public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; }
|
public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the error message if the email failed to send.
|
* Gets the error message if the email failed to send.
|
||||||
*
|
*
|
||||||
* @return the error message, or null if no error
|
* @return the error message, or null if no error
|
||||||
*/
|
*/
|
||||||
public String getErrorMessage() { return errorMessage; }
|
public String getErrorMessage() { return errorMessage; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the error message if the email failed to send.
|
* Sets the error message if the email failed to send.
|
||||||
*
|
*
|
||||||
* @param errorMessage the error message to set
|
* @param errorMessage the error message to set
|
||||||
*/
|
*/
|
||||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the timestamp when the email was first opened.
|
* Gets the timestamp when the email was first opened.
|
||||||
*
|
*
|
||||||
* @return the opened timestamp, or null if never opened
|
* @return the opened timestamp, or null if never opened
|
||||||
*/
|
*/
|
||||||
public LocalDateTime getOpenedAt() { return openedAt; }
|
public LocalDateTime getOpenedAt() { return openedAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the timestamp when the email was first opened.
|
* Sets the timestamp when the email was first opened.
|
||||||
*
|
*
|
||||||
* @param openedAt the opened timestamp to set
|
* @param openedAt the opened timestamp to set
|
||||||
*/
|
*/
|
||||||
public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; }
|
public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the number of times the email has been opened.
|
* Gets the number of times the email has been opened.
|
||||||
*
|
*
|
||||||
* @return the open count
|
* @return the open count
|
||||||
*/
|
*/
|
||||||
public Integer getOpenCount() { return openCount; }
|
public Integer getOpenCount() { return openCount; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the number of times the email has been opened.
|
* Sets the number of times the email has been opened.
|
||||||
*
|
*
|
||||||
* @param openCount the open count to set
|
* @param openCount the open count to set
|
||||||
*/
|
*/
|
||||||
public void setOpenCount(Integer openCount) { this.openCount = openCount; }
|
public void setOpenCount(Integer openCount) { this.openCount = openCount; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the timestamp when a link in the email was first clicked.
|
* Gets the timestamp when a link in the email was first clicked.
|
||||||
*
|
*
|
||||||
* @return the clicked timestamp, or null if never clicked
|
* @return the clicked timestamp, or null if never clicked
|
||||||
*/
|
*/
|
||||||
public LocalDateTime getClickedAt() { return clickedAt; }
|
public LocalDateTime getClickedAt() { return clickedAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the timestamp when a link in the email was first clicked.
|
* Sets the timestamp when a link in the email was first clicked.
|
||||||
*
|
*
|
||||||
* @param clickedAt the clicked timestamp to set
|
* @param clickedAt the clicked timestamp to set
|
||||||
*/
|
*/
|
||||||
public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; }
|
public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the number of times links in the email have been clicked.
|
* Gets the number of times links in the email have been clicked.
|
||||||
*
|
*
|
||||||
* @return the click count
|
* @return the click count
|
||||||
*/
|
*/
|
||||||
public Integer getClickCount() { return clickCount; }
|
public Integer getClickCount() { return clickCount; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the number of times links in the email have been clicked.
|
* Sets the number of times links in the email have been clicked.
|
||||||
*
|
*
|
||||||
* @param clickCount the click count to set
|
* @param clickCount the click count to set
|
||||||
*/
|
*/
|
||||||
public void setClickCount(Integer clickCount) { this.clickCount = clickCount; }
|
public void setClickCount(Integer clickCount) { this.clickCount = clickCount; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the timestamp when this email request was created.
|
* Gets the timestamp when this email request was created.
|
||||||
*
|
*
|
||||||
* @return the creation timestamp
|
* @return the creation timestamp
|
||||||
*/
|
*/
|
||||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the timestamp when this email request was created.
|
* Sets the timestamp when this email request was created.
|
||||||
*
|
*
|
||||||
* @param createdAt the creation timestamp to set
|
* @param createdAt the creation timestamp to set
|
||||||
*/
|
*/
|
||||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the timestamp when this email request was last updated.
|
* Gets the timestamp when this email request was last updated.
|
||||||
*
|
*
|
||||||
* @return the last update timestamp
|
* @return the last update timestamp
|
||||||
*/
|
*/
|
||||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the timestamp when this email request was last updated.
|
* Sets the timestamp when this email request was last updated.
|
||||||
*
|
*
|
||||||
* @param updatedAt the last update timestamp to set
|
* @param updatedAt the last update timestamp to set
|
||||||
*/
|
*/
|
||||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,28 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.AuthToken;
|
import group.goforward.battlbuilder.model.AuthToken;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
|
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
|
||||||
|
|
||||||
Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash);
|
Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash);
|
||||||
|
|
||||||
// ✅ Used for "Invited" badge: active (not expired, not consumed) magic token exists
|
// ✅ Used for "Invited" badge: active (not expired, not consumed) magic token exists
|
||||||
@Query("""
|
@Query("""
|
||||||
select (count(t) > 0) from AuthToken t
|
select (count(t) > 0) from AuthToken t
|
||||||
where lower(t.email) = lower(:email)
|
where lower(t.email) = lower(:email)
|
||||||
and t.type = :type
|
and t.type = :type
|
||||||
and t.expiresAt > :now
|
and t.expiresAt > :now
|
||||||
and t.consumedAt is null
|
and t.consumedAt is null
|
||||||
""")
|
""")
|
||||||
boolean hasActiveToken(
|
boolean hasActiveToken(
|
||||||
@Param("email") String email,
|
@Param("email") String email,
|
||||||
@Param("type") AuthToken.TokenType type,
|
@Param("type") AuthToken.TokenType type,
|
||||||
@Param("now") OffsetDateTime now
|
@Param("now") OffsetDateTime now
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.BuildProfile;
|
import group.goforward.battlbuilder.model.BuildProfile;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface BuildProfileRepository extends JpaRepository<BuildProfile, Integer> {
|
public interface BuildProfileRepository extends JpaRepository<BuildProfile, Integer> {
|
||||||
|
|
||||||
List<BuildProfile> findByBuildIdIn(Collection<Integer> buildIds);
|
List<BuildProfile> findByBuildIdIn(Collection<Integer> buildIds);
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.CanonicalCategory;
|
import group.goforward.battlbuilder.model.CanonicalCategory;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface CanonicalCategoryRepository extends JpaRepository<CanonicalCategory, Integer> {
|
public interface CanonicalCategoryRepository extends JpaRepository<CanonicalCategory, Integer> {
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
select c
|
select c
|
||||||
from CanonicalCategory c
|
from CanonicalCategory c
|
||||||
where c.deletedAt is null
|
where c.deletedAt is null
|
||||||
order by c.name asc
|
order by c.name asc
|
||||||
""")
|
""")
|
||||||
List<CanonicalCategory> findAllActive();
|
List<CanonicalCategory> findAllActive();
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.CategoryMapping;
|
import group.goforward.battlbuilder.model.CategoryMapping;
|
||||||
import group.goforward.battlbuilder.model.Merchant;
|
import group.goforward.battlbuilder.model.Merchant;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface CategoryMappingRepository extends JpaRepository<CategoryMapping, Integer> {
|
public interface CategoryMappingRepository extends JpaRepository<CategoryMapping, Integer> {
|
||||||
|
|
||||||
// All mappings for a merchant, ordered nicely
|
// All mappings for a merchant, ordered nicely
|
||||||
List<CategoryMapping> findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId);
|
List<CategoryMapping> findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId);
|
||||||
|
|
||||||
// Merchants that actually have mappings (for the dropdown)
|
// Merchants that actually have mappings (for the dropdown)
|
||||||
@Query("""
|
@Query("""
|
||||||
select distinct cm.merchant
|
select distinct cm.merchant
|
||||||
from CategoryMapping cm
|
from CategoryMapping cm
|
||||||
order by cm.merchant.name asc
|
order by cm.merchant.name asc
|
||||||
""")
|
""")
|
||||||
List<Merchant> findDistinctMerchantsWithMappings();
|
List<Merchant> findDistinctMerchantsWithMappings();
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.EmailRequest;
|
import group.goforward.battlbuilder.model.EmailRequest;
|
||||||
import group.goforward.battlbuilder.model.EmailStatus;
|
import group.goforward.battlbuilder.model.EmailStatus;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface EmailRequestRepository extends JpaRepository<EmailRequest, Long> {
|
public interface EmailRequestRepository extends JpaRepository<EmailRequest, Long> {
|
||||||
|
|
||||||
List<EmailRequest> findByStatus(EmailStatus status);
|
List<EmailRequest> findByStatus(EmailStatus status);
|
||||||
List<EmailRequest> findByStatusOrderByCreatedAtDesc(EmailStatus status);
|
List<EmailRequest> findByStatusOrderByCreatedAtDesc(EmailStatus status);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.EmailTemplate;
|
import group.goforward.battlbuilder.model.EmailTemplate;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface EmailTemplateRepository extends JpaRepository<EmailTemplate, Long> {
|
public interface EmailTemplateRepository extends JpaRepository<EmailTemplate, Long> {
|
||||||
|
|
||||||
Optional<EmailTemplate> findByTemplateKeyAndEnabledTrue(String templateKey);
|
Optional<EmailTemplate> findByTemplateKeyAndEnabledTrue(String templateKey);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,63 +1,63 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
|
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
|
||||||
|
|
||||||
// Pull candidates ordered by platform specificity: exact match first, then ANY/null.
|
// Pull candidates ordered by platform specificity: exact match first, then ANY/null.
|
||||||
@Query("""
|
@Query("""
|
||||||
select m
|
select m
|
||||||
from MerchantCategoryMap m
|
from MerchantCategoryMap m
|
||||||
where m.merchant.id = :merchantId
|
where m.merchant.id = :merchantId
|
||||||
and lower(m.rawCategory) = lower(:rawCategory) and m.enabled = true
|
and lower(m.rawCategory) = lower(:rawCategory) and m.enabled = true
|
||||||
and m.deletedAt is null
|
and m.deletedAt is null
|
||||||
and (m.platform is null or m.platform = 'ANY' or m.platform = :platform)
|
and (m.platform is null or m.platform = 'ANY' or m.platform = :platform)
|
||||||
order by
|
order by
|
||||||
case
|
case
|
||||||
when m.platform = :platform then 0
|
when m.platform = :platform then 0
|
||||||
when m.platform = 'ANY' then 1
|
when m.platform = 'ANY' then 1
|
||||||
when m.platform is null then 2
|
when m.platform is null then 2
|
||||||
else 3
|
else 3
|
||||||
end,
|
end,
|
||||||
m.updatedAt desc
|
m.updatedAt desc
|
||||||
""")
|
""")
|
||||||
List<MerchantCategoryMap> findCandidates(
|
List<MerchantCategoryMap> findCandidates(
|
||||||
@Param("merchantId") Integer merchantId,
|
@Param("merchantId") Integer merchantId,
|
||||||
@Param("rawCategory") String rawCategory,
|
@Param("rawCategory") String rawCategory,
|
||||||
@Param("platform") String platform
|
@Param("platform") String platform
|
||||||
);
|
);
|
||||||
|
|
||||||
default Optional<MerchantCategoryMap> findBest(Integer merchantId, String rawCategory, String platform) {
|
default Optional<MerchantCategoryMap> findBest(Integer merchantId, String rawCategory, String platform) {
|
||||||
List<MerchantCategoryMap> candidates = findCandidates(merchantId, rawCategory, platform);
|
List<MerchantCategoryMap> candidates = findCandidates(merchantId, rawCategory, platform);
|
||||||
return candidates.stream().findFirst();
|
return candidates.stream().findFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional helper if you want a quick "latest mapping regardless of platform"
|
// Optional helper if you want a quick "latest mapping regardless of platform"
|
||||||
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
|
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
|
||||||
Integer merchantId,
|
Integer merchantId,
|
||||||
String rawCategory
|
String rawCategory
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optional: if you still want a role-only lookup list for debugging
|
// Optional: if you still want a role-only lookup list for debugging
|
||||||
@Query("""
|
@Query("""
|
||||||
select mcm.canonicalPartRole
|
select mcm.canonicalPartRole
|
||||||
from MerchantCategoryMap mcm
|
from MerchantCategoryMap mcm
|
||||||
where mcm.merchant.id = :merchantId
|
where mcm.merchant.id = :merchantId
|
||||||
and mcm.rawCategory = :rawCategory
|
and mcm.rawCategory = :rawCategory
|
||||||
and mcm.enabled = true
|
and mcm.enabled = true
|
||||||
and mcm.deletedAt is null
|
and mcm.deletedAt is null
|
||||||
order by mcm.updatedAt desc
|
order by mcm.updatedAt desc
|
||||||
""")
|
""")
|
||||||
List<String> findCanonicalPartRoles(
|
List<String> findCanonicalPartRoles(
|
||||||
@Param("merchantId") Integer merchantId,
|
@Param("merchantId") Integer merchantId,
|
||||||
@Param("rawCategory") String rawCategory
|
@Param("rawCategory") String rawCategory
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PartCategory;
|
import group.goforward.battlbuilder.model.PartCategory;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
|
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
|
||||||
|
|
||||||
Optional<PartCategory> findBySlug(String slug);
|
Optional<PartCategory> findBySlug(String slug);
|
||||||
|
|
||||||
List<PartCategory> findAllByOrderByGroupNameAscSortOrderAscNameAsc();
|
List<PartCategory> findAllByOrderByGroupNameAscSortOrderAscNameAsc();
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
|
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
|
||||||
|
|
||||||
// Used by admin screens / lists (case-sensitive, no platform normalization)
|
// Used by admin screens / lists (case-sensitive, no platform normalization)
|
||||||
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
||||||
|
|
||||||
// Used by builder/bootstrap flows (case-insensitive)
|
// Used by builder/bootstrap flows (case-insensitive)
|
||||||
List<PartRoleMapping> findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
List<PartRoleMapping> findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
||||||
|
|
||||||
// Used by resolvers when mapping a single role (case-insensitive)
|
// Used by resolvers when mapping a single role (case-insensitive)
|
||||||
Optional<PartRoleMapping> findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(
|
Optional<PartRoleMapping> findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(
|
||||||
String platform,
|
String platform,
|
||||||
String partRole
|
String partRole
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PartRoleRule;
|
import group.goforward.battlbuilder.model.PartRoleRule;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface PartRoleRuleRepository extends JpaRepository<PartRoleRule, Long> {
|
public interface PartRoleRuleRepository extends JpaRepository<PartRoleRule, Long> {
|
||||||
List<PartRoleRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
|
List<PartRoleRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PlatformRule;
|
import group.goforward.battlbuilder.model.PlatformRule;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> {
|
public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> {
|
||||||
|
|
||||||
// Active rules, highest priority first (tie-breaker: id asc for stability)
|
// Active rules, highest priority first (tie-breaker: id asc for stability)
|
||||||
List<PlatformRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
|
List<PlatformRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,44 @@
|
|||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface UserRepository extends JpaRepository<User, Integer> {
|
public interface UserRepository extends JpaRepository<User, Integer> {
|
||||||
|
|
||||||
Optional<User> findByEmailIgnoreCaseAndDeletedAtIsNull(String email);
|
Optional<User> findByEmailIgnoreCaseAndDeletedAtIsNull(String email);
|
||||||
|
|
||||||
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
|
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
|
||||||
|
|
||||||
Optional<User> findByUuid(UUID uuid);
|
Optional<User> findByUuid(UUID uuid);
|
||||||
|
|
||||||
boolean existsByRole(String role);
|
boolean existsByRole(String role);
|
||||||
|
|
||||||
Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid);
|
Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid);
|
||||||
|
|
||||||
List<User> findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role);
|
List<User> findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role);
|
||||||
|
|
||||||
// ✅ Pending beta requests (what you described)
|
// ✅ Pending beta requests (what you described)
|
||||||
Page<User> findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc(
|
Page<User> findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc(
|
||||||
String role,
|
String role,
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
|
||||||
// ✅ Optional: find user by verification token for confirm flow (if you don’t already have it)
|
// ✅ Optional: find user by verification token for confirm flow (if you don’t already have it)
|
||||||
Optional<User> findByVerificationTokenAndDeletedAtIsNull(String verificationToken);
|
Optional<User> findByVerificationTokenAndDeletedAtIsNull(String verificationToken);
|
||||||
|
|
||||||
// Set Username
|
// Set Username
|
||||||
Optional<User> findByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
|
Optional<User> findByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
|
||||||
|
|
||||||
boolean existsByUsernameIgnoreCaseAndDeletedAtIsNull(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)
|
@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<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
|
List<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
|
||||||
}
|
}
|
||||||
@@ -1,57 +1,57 @@
|
|||||||
package group.goforward.battlbuilder.repo.catalog.spec;
|
package group.goforward.battlbuilder.repo.catalog.spec;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.model.ProductStatus;
|
import group.goforward.battlbuilder.model.ProductStatus;
|
||||||
import group.goforward.battlbuilder.model.ProductVisibility;
|
import group.goforward.battlbuilder.model.ProductVisibility;
|
||||||
|
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
import jakarta.persistence.criteria.JoinType;
|
import jakarta.persistence.criteria.JoinType;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class CatalogProductSpecifications {
|
public class CatalogProductSpecifications {
|
||||||
|
|
||||||
private CatalogProductSpecifications() {}
|
private CatalogProductSpecifications() {}
|
||||||
|
|
||||||
// Default public catalog rules
|
// Default public catalog rules
|
||||||
public static Specification<Product> isCatalogVisible() {
|
public static Specification<Product> isCatalogVisible() {
|
||||||
return (root, query, cb) -> cb.and(
|
return (root, query, cb) -> cb.and(
|
||||||
cb.isNull(root.get("deletedAt")),
|
cb.isNull(root.get("deletedAt")),
|
||||||
cb.equal(root.get("status"), ProductStatus.ACTIVE),
|
cb.equal(root.get("status"), ProductStatus.ACTIVE),
|
||||||
cb.equal(root.get("visibility"), ProductVisibility.PUBLIC),
|
cb.equal(root.get("visibility"), ProductVisibility.PUBLIC),
|
||||||
cb.isTrue(root.get("builderEligible"))
|
cb.isTrue(root.get("builderEligible"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Specification<Product> platformEquals(String platform) {
|
public static Specification<Product> platformEquals(String platform) {
|
||||||
return (root, query, cb) -> cb.equal(root.get("platform"), platform);
|
return (root, query, cb) -> cb.equal(root.get("platform"), platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Specification<Product> partRoleIn(List<String> roles) {
|
public static Specification<Product> partRoleIn(List<String> roles) {
|
||||||
return (root, query, cb) -> root.get("partRole").in(roles);
|
return (root, query, cb) -> root.get("partRole").in(roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Specification<Product> brandNameIn(List<String> brandNames) {
|
public static Specification<Product> brandNameIn(List<String> brandNames) {
|
||||||
return (root, query, cb) -> {
|
return (root, query, cb) -> {
|
||||||
root.fetch("brand", JoinType.LEFT);
|
root.fetch("brand", JoinType.LEFT);
|
||||||
query.distinct(true);
|
query.distinct(true);
|
||||||
return root.join("brand", JoinType.LEFT).get("name").in(brandNames);
|
return root.join("brand", JoinType.LEFT).get("name").in(brandNames);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Specification<Product> queryLike(String q) {
|
public static Specification<Product> queryLike(String q) {
|
||||||
final String like = "%" + q.toLowerCase().trim() + "%";
|
final String like = "%" + q.toLowerCase().trim() + "%";
|
||||||
return (root, query, cb) -> {
|
return (root, query, cb) -> {
|
||||||
root.fetch("brand", JoinType.LEFT);
|
root.fetch("brand", JoinType.LEFT);
|
||||||
query.distinct(true);
|
query.distinct(true);
|
||||||
|
|
||||||
var brandJoin = root.join("brand", JoinType.LEFT);
|
var brandJoin = root.join("brand", JoinType.LEFT);
|
||||||
return cb.or(
|
return cb.or(
|
||||||
cb.like(cb.lower(root.get("name")), like),
|
cb.like(cb.lower(root.get("name")), like),
|
||||||
cb.like(cb.lower(brandJoin.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("mpn")), like),
|
||||||
cb.like(cb.lower(root.get("upc")), like)
|
cb.like(cb.lower(root.get("upc")), like)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Repositories package for the BattlBuilder application.
|
* Repositories package for the BattlBuilder application.
|
||||||
* <p>
|
* <p>
|
||||||
* Contains Spring Data JPA repository interfaces for database
|
* Contains Spring Data JPA repository interfaces for database
|
||||||
* access and persistence operations.
|
* access and persistence operations.
|
||||||
*
|
*
|
||||||
* @author Forward Group, LLC
|
* @author Forward Group, LLC
|
||||||
* @version 1.0
|
* @version 1.0
|
||||||
* @since 2025-12-10
|
* @since 2025-12-10
|
||||||
*/
|
*/
|
||||||
package group.goforward.battlbuilder.repo;
|
package group.goforward.battlbuilder.repo;
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
package group.goforward.battlbuilder.repo.projections;
|
package group.goforward.battlbuilder.repo.projections;
|
||||||
|
|
||||||
public interface CatalogRow {
|
public interface CatalogRow {
|
||||||
Long getId();
|
Long getId();
|
||||||
String getName();
|
String getName();
|
||||||
String getPlatform();
|
String getPlatform();
|
||||||
String getPartRole();
|
String getPartRole();
|
||||||
String getImageUrl(); // or mainImageUrl depending on your schema
|
String getImageUrl(); // or mainImageUrl depending on your schema
|
||||||
String getBrand();
|
String getBrand();
|
||||||
|
|
||||||
Double getPrice();
|
Double getPrice();
|
||||||
String getBuyUrl();
|
String getBuyUrl();
|
||||||
Boolean getInStock();
|
Boolean getInStock();
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
package group.goforward.battlbuilder.security;
|
package group.goforward.battlbuilder.security;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import group.goforward.battlbuilder.repo.UserRepository;
|
import group.goforward.battlbuilder.repo.UserRepository;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CustomUserDetailsService implements UserDetailsService {
|
public class CustomUserDetailsService implements UserDetailsService {
|
||||||
|
|
||||||
private final UserRepository users;
|
private final UserRepository users;
|
||||||
|
|
||||||
public CustomUserDetailsService(UserRepository users) {
|
public CustomUserDetailsService(UserRepository users) {
|
||||||
this.users = users;
|
this.users = users;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||||
return new CustomUserDetails(user);
|
return new CustomUserDetails(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,88 +1,88 @@
|
|||||||
package group.goforward.battlbuilder.security;
|
package group.goforward.battlbuilder.security;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import group.goforward.battlbuilder.repo.UserRepository;
|
import group.goforward.battlbuilder.repo.UserRepository;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
|
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(
|
protected void doFilterInternal(
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
HttpServletResponse response,
|
HttpServletResponse response,
|
||||||
FilterChain filterChain
|
FilterChain filterChain
|
||||||
) throws ServletException, IOException {
|
) throws ServletException, IOException {
|
||||||
|
|
||||||
String authHeader = request.getHeader("Authorization");
|
String authHeader = request.getHeader("Authorization");
|
||||||
|
|
||||||
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
|
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ If already authenticated with a REAL user, skip.
|
// ✅ If already authenticated with a REAL user, skip.
|
||||||
// ✅ If it's anonymous, we should continue and replace it.
|
// ✅ If it's anonymous, we should continue and replace it.
|
||||||
var existing = SecurityContextHolder.getContext().getAuthentication();
|
var existing = SecurityContextHolder.getContext().getAuthentication();
|
||||||
if (existing != null
|
if (existing != null
|
||||||
&& existing.isAuthenticated()
|
&& existing.isAuthenticated()
|
||||||
&& !(existing instanceof AnonymousAuthenticationToken)) {
|
&& !(existing instanceof AnonymousAuthenticationToken)) {
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String token = authHeader.substring(7);
|
String token = authHeader.substring(7);
|
||||||
|
|
||||||
if (!jwtService.isTokenValid(token)) {
|
if (!jwtService.isTokenValid(token)) {
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UUID userUuid = jwtService.extractUserUuid(token);
|
UUID userUuid = jwtService.extractUserUuid(token);
|
||||||
if (userUuid == null) {
|
if (userUuid == null) {
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
User user = userRepository.findByUuid(userUuid).orElse(null);
|
User user = userRepository.findByUuid(userUuid).orElse(null);
|
||||||
if (user == null || !Boolean.TRUE.equals(user.isActive())) {
|
if (user == null || !Boolean.TRUE.equals(user.isActive())) {
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomUserDetails userDetails = new CustomUserDetails(user);
|
CustomUserDetails userDetails = new CustomUserDetails(user);
|
||||||
|
|
||||||
UsernamePasswordAuthenticationToken authToken =
|
UsernamePasswordAuthenticationToken authToken =
|
||||||
new UsernamePasswordAuthenticationToken(
|
new UsernamePasswordAuthenticationToken(
|
||||||
user.getUuid().toString(), // principal = UUID string
|
user.getUuid().toString(), // principal = UUID string
|
||||||
null,
|
null,
|
||||||
userDetails.getAuthorities()
|
userDetails.getAuthorities()
|
||||||
);
|
);
|
||||||
|
|
||||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||||
|
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Brand;
|
import group.goforward.battlbuilder.model.Brand;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface BrandService {
|
public interface BrandService {
|
||||||
|
|
||||||
List<Brand> findAll();
|
List<Brand> findAll();
|
||||||
|
|
||||||
Optional<Brand> findById(Integer id);
|
Optional<Brand> findById(Integer id);
|
||||||
|
|
||||||
Brand save(Brand item);
|
Brand save(Brand item);
|
||||||
void deleteById(Integer id);
|
void deleteById(Integer id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.web.dto.BuildDto;
|
import group.goforward.battlbuilder.web.dto.BuildDto;
|
||||||
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
|
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
|
||||||
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
|
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
|
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface BuildService {
|
public interface BuildService {
|
||||||
|
|
||||||
List<BuildFeedCardDto> listPublicBuilds(int limit);
|
List<BuildFeedCardDto> listPublicBuilds(int limit);
|
||||||
|
|
||||||
List<BuildSummaryDto> listMyBuilds(int limit);
|
List<BuildSummaryDto> listMyBuilds(int limit);
|
||||||
|
|
||||||
BuildDto getMyBuild(UUID uuid);
|
BuildDto getMyBuild(UUID uuid);
|
||||||
|
|
||||||
BuildDto createMyBuild(UpdateBuildRequest req);
|
BuildDto createMyBuild(UpdateBuildRequest req);
|
||||||
|
|
||||||
BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req);
|
BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req);
|
||||||
|
|
||||||
BuildDto getPublicBuild(UUID uuid);
|
BuildDto getPublicBuild(UUID uuid);
|
||||||
|
|
||||||
void deleteMyBuild(UUID uuid);
|
void deleteMyBuild(UUID uuid);
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface CatalogQueryService {
|
public interface CatalogQueryService {
|
||||||
|
|
||||||
Page<ProductSummaryDto> getOptions(
|
Page<ProductSummaryDto> getOptions(
|
||||||
String platform,
|
String platform,
|
||||||
String partRole,
|
String partRole,
|
||||||
List<String> partRoles,
|
List<String> partRoles,
|
||||||
List<String> brands,
|
List<String> brands,
|
||||||
String q,
|
String q,
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
|
||||||
List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request);
|
List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request);
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||||
import group.goforward.battlbuilder.model.Merchant;
|
import group.goforward.battlbuilder.model.Merchant;
|
||||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||||
|
|
||||||
public interface CategoryClassificationService {
|
public interface CategoryClassificationService {
|
||||||
|
|
||||||
record Result(
|
record Result(
|
||||||
String platform,
|
String platform,
|
||||||
String partRole,
|
String partRole,
|
||||||
String rawCategoryKey,
|
String rawCategoryKey,
|
||||||
PartRoleSource source,
|
PartRoleSource source,
|
||||||
String reason
|
String reason
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy convenience: derives rawCategoryKey + platform from row.
|
* Legacy convenience: derives rawCategoryKey + platform from row.
|
||||||
*/
|
*/
|
||||||
Result classify(Merchant merchant, MerchantFeedRow row);
|
Result classify(Merchant merchant, MerchantFeedRow row);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preferred for ETL: caller already computed platform + rawCategoryKey.
|
* Preferred for ETL: caller already computed platform + rawCategoryKey.
|
||||||
* This prevents platformResolver overrides from drifting vs mapping selection.
|
* This prevents platformResolver overrides from drifting vs mapping selection.
|
||||||
*/
|
*/
|
||||||
Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey);
|
Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey);
|
||||||
}
|
}
|
||||||
@@ -1,72 +1,72 @@
|
|||||||
// src/main/java/group/goforward/ballistic/service/CategoryMappingRecommendationService.java
|
// src/main/java/group/goforward/ballistic/service/CategoryMappingRecommendationService.java
|
||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.CategoryMappingRecommendationDto;
|
import group.goforward.battlbuilder.web.dto.CategoryMappingRecommendationDto;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CategoryMappingRecommendationService {
|
public class CategoryMappingRecommendationService {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
|
|
||||||
public CategoryMappingRecommendationService(ProductRepository productRepository) {
|
public CategoryMappingRecommendationService(ProductRepository productRepository) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<CategoryMappingRecommendationDto> listRecommendations() {
|
public List<CategoryMappingRecommendationDto> listRecommendations() {
|
||||||
var groups = productRepository.findUnmappedCategoryGroups();
|
var groups = productRepository.findUnmappedCategoryGroups();
|
||||||
|
|
||||||
return groups.stream()
|
return groups.stream()
|
||||||
.map(row -> {
|
.map(row -> {
|
||||||
String merchantName = (String) row.get("merchantName");
|
String merchantName = (String) row.get("merchantName");
|
||||||
String rawCategoryKey = (String) row.get("rawCategoryKey");
|
String rawCategoryKey = (String) row.get("rawCategoryKey");
|
||||||
long count = (long) row.get("productCount");
|
long count = (long) row.get("productCount");
|
||||||
|
|
||||||
// Pull one sample product name
|
// Pull one sample product name
|
||||||
List<Product> examples = productRepository
|
List<Product> examples = productRepository
|
||||||
.findExamplesForCategoryGroup(merchantName, rawCategoryKey);
|
.findExamplesForCategoryGroup(merchantName, rawCategoryKey);
|
||||||
String sampleName = examples.isEmpty()
|
String sampleName = examples.isEmpty()
|
||||||
? null
|
? null
|
||||||
: examples.get(0).getName();
|
: examples.get(0).getName();
|
||||||
|
|
||||||
String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName);
|
String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName);
|
||||||
|
|
||||||
return new CategoryMappingRecommendationDto(
|
return new CategoryMappingRecommendationDto(
|
||||||
merchantName,
|
merchantName,
|
||||||
rawCategoryKey,
|
rawCategoryKey,
|
||||||
count,
|
count,
|
||||||
recommendedRole,
|
recommendedRole,
|
||||||
sampleName
|
sampleName
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String inferPartRoleFromRawKey(String rawKey, String sampleName) {
|
private String inferPartRoleFromRawKey(String rawKey, String sampleName) {
|
||||||
String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase();
|
String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase();
|
||||||
|
|
||||||
if (blob.contains("handguard") || blob.contains("rail")) return "handguard";
|
if (blob.contains("handguard") || blob.contains("rail")) return "handguard";
|
||||||
if (blob.contains("barrel")) return "barrel";
|
if (blob.contains("barrel")) return "barrel";
|
||||||
if (blob.contains("upper")) return "upper-receiver";
|
if (blob.contains("upper")) return "upper-receiver";
|
||||||
if (blob.contains("lower")) return "lower-receiver";
|
if (blob.contains("lower")) return "lower-receiver";
|
||||||
if (blob.contains("mag") || blob.contains("magazine")) return "magazine";
|
if (blob.contains("mag") || blob.contains("magazine")) return "magazine";
|
||||||
if (blob.contains("stock") || blob.contains("buttstock")) return "stock";
|
if (blob.contains("stock") || blob.contains("buttstock")) return "stock";
|
||||||
if (blob.contains("grip")) return "grip";
|
if (blob.contains("grip")) return "grip";
|
||||||
if (blob.contains("trigger")) return "trigger";
|
if (blob.contains("trigger")) return "trigger";
|
||||||
if (blob.contains("sight") || blob.contains("iron sights")) return "sights";
|
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("optic") || blob.contains("scope") || blob.contains("red dot")) return "optic";
|
||||||
if (blob.contains("buffer")) return "buffer";
|
if (blob.contains("buffer")) return "buffer";
|
||||||
if (blob.contains("gas block")) return "gas-block";
|
if (blob.contains("gas block")) return "gas-block";
|
||||||
if (blob.contains("gas tube")) return "gas-tube";
|
if (blob.contains("gas tube")) return "gas-tube";
|
||||||
if (blob.contains("muzzle")) return "muzzle-device";
|
if (blob.contains("muzzle")) return "muzzle-device";
|
||||||
if (blob.contains("sling")) return "sling";
|
if (blob.contains("sling")) return "sling";
|
||||||
if (blob.contains("bipod")) return "bipod";
|
if (blob.contains("bipod")) return "bipod";
|
||||||
if (blob.contains("tool")) return "tools";
|
if (blob.contains("tool")) return "tools";
|
||||||
|
|
||||||
return "UNKNOWN";
|
return "UNKNOWN";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,52 +1,52 @@
|
|||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import group.goforward.battlbuilder.repo.UserRepository;
|
import group.goforward.battlbuilder.repo.UserRepository;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CurrentUserService {
|
public class CurrentUserService {
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public CurrentUserService(UserRepository userRepository) {
|
public CurrentUserService(UserRepository userRepository) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the authenticated User (401 if missing/invalid). */
|
/** Returns the authenticated User (401 if missing/invalid). */
|
||||||
public User requireUser() {
|
public User requireUser() {
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
// No auth, or anonymous auth => 401
|
// No auth, or anonymous auth => 401
|
||||||
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
|
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
// In your setup, JwtAuthenticationFilter sets auth name to UUID string
|
// In your setup, JwtAuthenticationFilter sets auth name to UUID string
|
||||||
String principal = auth.getName();
|
String principal = auth.getName();
|
||||||
if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) {
|
if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) {
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
final UUID userUuid;
|
final UUID userUuid;
|
||||||
try {
|
try {
|
||||||
userUuid = UUID.fromString(principal);
|
userUuid = UUID.fromString(principal);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e);
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return userRepository.findByUuid(userUuid)
|
return userRepository.findByUuid(userUuid)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer requireUserId() {
|
public Integer requireUserId() {
|
||||||
return requireUser().getId();
|
return requireUser().getId();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,42 @@
|
|||||||
// src/main/java/group/goforward/ballistic/service/ImportStatusAdminService.java
|
// src/main/java/group/goforward/ballistic/service/ImportStatusAdminService.java
|
||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.ImportStatus;
|
import group.goforward.battlbuilder.model.ImportStatus;
|
||||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.ImportStatusByMerchantDto;
|
import group.goforward.battlbuilder.web.dto.ImportStatusByMerchantDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ImportStatusSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ImportStatusSummaryDto;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ImportStatusAdminService {
|
public class ImportStatusAdminService {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
|
|
||||||
public ImportStatusAdminService(ProductRepository productRepository) {
|
public ImportStatusAdminService(ProductRepository productRepository) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ImportStatusSummaryDto> summarizeByStatus() {
|
public List<ImportStatusSummaryDto> summarizeByStatus() {
|
||||||
return productRepository.aggregateByImportStatus()
|
return productRepository.aggregateByImportStatus()
|
||||||
.stream()
|
.stream()
|
||||||
.map(row -> new ImportStatusSummaryDto(
|
.map(row -> new ImportStatusSummaryDto(
|
||||||
(ImportStatus) row.get("status"),
|
(ImportStatus) row.get("status"),
|
||||||
(long) row.get("count")
|
(long) row.get("count")
|
||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ImportStatusByMerchantDto> summarizeByMerchant() {
|
public List<ImportStatusByMerchantDto> summarizeByMerchant() {
|
||||||
return productRepository.aggregateByMerchantAndStatus()
|
return productRepository.aggregateByMerchantAndStatus()
|
||||||
.stream()
|
.stream()
|
||||||
.map(row -> new ImportStatusByMerchantDto(
|
.map(row -> new ImportStatusByMerchantDto(
|
||||||
(String) row.get("merchantName"),
|
(String) row.get("merchantName"),
|
||||||
(String) row.get("platform"),
|
(String) row.get("platform"),
|
||||||
(ImportStatus) row.get("status"),
|
(ImportStatus) row.get("status"),
|
||||||
(long) row.get("count")
|
(long) row.get("count")
|
||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,228 +1,228 @@
|
|||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.ImportStatus;
|
import group.goforward.battlbuilder.model.ImportStatus;
|
||||||
import group.goforward.battlbuilder.model.Merchant;
|
import group.goforward.battlbuilder.model.Merchant;
|
||||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||||
import group.goforward.battlbuilder.model.CanonicalCategory;
|
import group.goforward.battlbuilder.model.CanonicalCategory;
|
||||||
import group.goforward.battlbuilder.repo.CanonicalCategoryRepository;
|
import group.goforward.battlbuilder.repo.CanonicalCategoryRepository;
|
||||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||||
import group.goforward.battlbuilder.repo.MerchantRepository;
|
import group.goforward.battlbuilder.repo.MerchantRepository;
|
||||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.MappingOptionsDto;
|
import group.goforward.battlbuilder.web.dto.MappingOptionsDto;
|
||||||
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
|
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
|
||||||
import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto;
|
import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MappingAdminService {
|
public class MappingAdminService {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||||
private final MerchantRepository merchantRepository;
|
private final MerchantRepository merchantRepository;
|
||||||
private final CanonicalCategoryRepository canonicalCategoryRepository;
|
private final CanonicalCategoryRepository canonicalCategoryRepository;
|
||||||
private final ReclassificationService reclassificationService;
|
private final ReclassificationService reclassificationService;
|
||||||
|
|
||||||
public MappingAdminService(
|
public MappingAdminService(
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
MerchantCategoryMapRepository merchantCategoryMapRepository,
|
MerchantCategoryMapRepository merchantCategoryMapRepository,
|
||||||
MerchantRepository merchantRepository,
|
MerchantRepository merchantRepository,
|
||||||
CanonicalCategoryRepository canonicalCategoryRepository,
|
CanonicalCategoryRepository canonicalCategoryRepository,
|
||||||
ReclassificationService reclassificationService
|
ReclassificationService reclassificationService
|
||||||
) {
|
) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||||
this.merchantRepository = merchantRepository;
|
this.merchantRepository = merchantRepository;
|
||||||
this.canonicalCategoryRepository = canonicalCategoryRepository;
|
this.canonicalCategoryRepository = canonicalCategoryRepository;
|
||||||
this.reclassificationService = reclassificationService;
|
this.reclassificationService = reclassificationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// 1) EXISTING: Role buckets
|
// 1) EXISTING: Role buckets
|
||||||
// =========================
|
// =========================
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<PendingMappingBucketDto> listPendingBuckets() {
|
public List<PendingMappingBucketDto> listPendingBuckets() {
|
||||||
List<Object[]> rows =
|
List<Object[]> rows =
|
||||||
productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING);
|
productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING);
|
||||||
|
|
||||||
return rows.stream()
|
return rows.stream()
|
||||||
.map(row -> {
|
.map(row -> {
|
||||||
Integer merchantId = (Integer) row[0];
|
Integer merchantId = (Integer) row[0];
|
||||||
String merchantName = (String) row[1];
|
String merchantName = (String) row[1];
|
||||||
String rawCategoryKey = (String) row[2];
|
String rawCategoryKey = (String) row[2];
|
||||||
Long count = (Long) row[3];
|
Long count = (Long) row[3];
|
||||||
|
|
||||||
return new PendingMappingBucketDto(
|
return new PendingMappingBucketDto(
|
||||||
merchantId,
|
merchantId,
|
||||||
merchantName,
|
merchantName,
|
||||||
rawCategoryKey,
|
rawCategoryKey,
|
||||||
count != null ? count : 0L
|
count != null ? count : 0L
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Part Role mapping:
|
* Part Role mapping:
|
||||||
* Writes merchant_category_map.canonical_part_role and applies to products.
|
* Writes merchant_category_map.canonical_part_role and applies to products.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
|
public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
|
||||||
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()
|
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()
|
||||||
|| mappedPartRole == null || mappedPartRole.isBlank()) {
|
|| mappedPartRole == null || mappedPartRole.isBlank()) {
|
||||||
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
|
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
|
||||||
}
|
}
|
||||||
|
|
||||||
Merchant merchant = merchantRepository.findById(merchantId)
|
Merchant merchant = merchantRepository.findById(merchantId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + 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.
|
// NOTE: this creates a new row every time. If you want "upsert", use findBest() like we do below.
|
||||||
MerchantCategoryMap mapping = new MerchantCategoryMap();
|
MerchantCategoryMap mapping = new MerchantCategoryMap();
|
||||||
mapping.setMerchant(merchant);
|
mapping.setMerchant(merchant);
|
||||||
mapping.setRawCategory(rawCategoryKey.trim());
|
mapping.setRawCategory(rawCategoryKey.trim());
|
||||||
mapping.setEnabled(true);
|
mapping.setEnabled(true);
|
||||||
|
|
||||||
// SOURCE OF TRUTH (builder slot mapping)
|
// SOURCE OF TRUTH (builder slot mapping)
|
||||||
mapping.setCanonicalPartRole(mappedPartRole.trim());
|
mapping.setCanonicalPartRole(mappedPartRole.trim());
|
||||||
|
|
||||||
merchantCategoryMapRepository.save(mapping);
|
merchantCategoryMapRepository.save(mapping);
|
||||||
|
|
||||||
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
||||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||||
if (rawCategoryKey == null || rawCategoryKey.isBlank())
|
if (rawCategoryKey == null || rawCategoryKey.isBlank())
|
||||||
throw new IllegalArgumentException("rawCategoryKey is required");
|
throw new IllegalArgumentException("rawCategoryKey is required");
|
||||||
|
|
||||||
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 2) NEW: Options endpoint for Catalog UI
|
// 2) NEW: Options endpoint for Catalog UI
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public MappingOptionsDto getOptions() {
|
public MappingOptionsDto getOptions() {
|
||||||
var merchants = merchantRepository.findAll().stream()
|
var merchants = merchantRepository.findAll().stream()
|
||||||
.map(m -> new MappingOptionsDto.MerchantOptionDto(m.getId(), m.getName()))
|
.map(m -> new MappingOptionsDto.MerchantOptionDto(m.getId(), m.getName()))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
var categories = canonicalCategoryRepository.findAllActive().stream()
|
var categories = canonicalCategoryRepository.findAllActive().stream()
|
||||||
.map(c -> new MappingOptionsDto.CanonicalCategoryOptionDto(
|
.map(c -> new MappingOptionsDto.CanonicalCategoryOptionDto(
|
||||||
c.getId(),
|
c.getId(),
|
||||||
c.getName(),
|
c.getName(),
|
||||||
c.getSlug()
|
c.getSlug()
|
||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return new MappingOptionsDto(merchants, categories);
|
return new MappingOptionsDto(merchants, categories);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// 3) NEW: Raw categories list for Catalog mapping table
|
// 3) NEW: Raw categories list for Catalog mapping table
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<RawCategoryMappingRowDto> listRawCategories(Integer merchantId, String platform, String q, Integer limit) {
|
public List<RawCategoryMappingRowDto> listRawCategories(Integer merchantId, String platform, String q, Integer limit) {
|
||||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||||
|
|
||||||
String plat = normalizePlatform(platform);
|
String plat = normalizePlatform(platform);
|
||||||
String query = (q == null || q.isBlank()) ? null : q.trim();
|
String query = (q == null || q.isBlank()) ? null : q.trim();
|
||||||
int lim = (limit == null || limit <= 0) ? 500 : Math.min(limit, 2000);
|
int lim = (limit == null || limit <= 0) ? 500 : Math.min(limit, 2000);
|
||||||
|
|
||||||
List<Object[]> rows = productRepository.findRawCategoryMappingRows(merchantId, plat, query, lim);
|
List<Object[]> rows = productRepository.findRawCategoryMappingRows(merchantId, plat, query, lim);
|
||||||
|
|
||||||
return rows.stream().map(r -> new RawCategoryMappingRowDto(
|
return rows.stream().map(r -> new RawCategoryMappingRowDto(
|
||||||
(Integer) r[0], // merchantId
|
(Integer) r[0], // merchantId
|
||||||
(String) r[1], // merchantName
|
(String) r[1], // merchantName
|
||||||
(String) r[2], // platform
|
(String) r[2], // platform
|
||||||
(String) r[3], // rawCategoryKey
|
(String) r[3], // rawCategoryKey
|
||||||
((Number) r[4]).longValue(), // productCount
|
((Number) r[4]).longValue(), // productCount
|
||||||
(r[5] == null ? null : ((Number) r[5]).longValue()), // mcmId
|
(r[5] == null ? null : ((Number) r[5]).longValue()), // mcmId
|
||||||
(Boolean) r[6], // enabled
|
(Boolean) r[6], // enabled
|
||||||
(String) r[7], // canonicalPartRole
|
(String) r[7], // canonicalPartRole
|
||||||
|
|
||||||
// IMPORTANT: canonicalCategoryId should be Integer, not Long.
|
// IMPORTANT: canonicalCategoryId should be Integer, not Long.
|
||||||
(r[8] == null ? null : ((Number) r[8]).intValue()), // canonicalCategoryId (Integer)
|
(r[8] == null ? null : ((Number) r[8]).intValue()), // canonicalCategoryId (Integer)
|
||||||
(String) r[9] // canonicalCategoryName
|
(String) r[9] // canonicalCategoryName
|
||||||
)).toList();
|
)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================
|
// ==========================================================
|
||||||
// 4) NEW: Upsert catalog mapping
|
// 4) NEW: Upsert catalog mapping
|
||||||
// ==========================================================
|
// ==========================================================
|
||||||
|
|
||||||
public record UpsertCatalogMappingResult(Integer merchantCategoryMapId, int updatedProducts) {}
|
public record UpsertCatalogMappingResult(Integer merchantCategoryMapId, int updatedProducts) {}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public UpsertCatalogMappingResult upsertCatalogMapping(
|
public UpsertCatalogMappingResult upsertCatalogMapping(
|
||||||
Integer merchantId,
|
Integer merchantId,
|
||||||
String platform,
|
String platform,
|
||||||
String rawCategory,
|
String rawCategory,
|
||||||
Boolean enabled,
|
Boolean enabled,
|
||||||
Integer canonicalCategoryId // <-- Integer (NOT Long)
|
Integer canonicalCategoryId // <-- Integer (NOT Long)
|
||||||
) {
|
) {
|
||||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||||
if (rawCategory == null || rawCategory.isBlank()) throw new IllegalArgumentException("rawCategory is required");
|
if (rawCategory == null || rawCategory.isBlank()) throw new IllegalArgumentException("rawCategory is required");
|
||||||
|
|
||||||
String plat = normalizePlatform(platform);
|
String plat = normalizePlatform(platform);
|
||||||
String raw = rawCategory.trim();
|
String raw = rawCategory.trim();
|
||||||
boolean en = (enabled == null) ? true : enabled;
|
boolean en = (enabled == null) ? true : enabled;
|
||||||
|
|
||||||
Merchant merchant = merchantRepository.findById(merchantId)
|
Merchant merchant = merchantRepository.findById(merchantId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||||
|
|
||||||
CanonicalCategory cat = null;
|
CanonicalCategory cat = null;
|
||||||
if (canonicalCategoryId != null) {
|
if (canonicalCategoryId != null) {
|
||||||
cat = canonicalCategoryRepository.findById(canonicalCategoryId)
|
cat = canonicalCategoryRepository.findById(canonicalCategoryId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException(
|
.orElseThrow(() -> new IllegalArgumentException(
|
||||||
"CanonicalCategory not found: " + canonicalCategoryId
|
"CanonicalCategory not found: " + canonicalCategoryId
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find mapping row (platform-specific first; then ANY/null via your findBest ordering)
|
// Find mapping row (platform-specific first; then ANY/null via your findBest ordering)
|
||||||
Optional<MerchantCategoryMap> existing = merchantCategoryMapRepository.findBest(merchantId, raw, plat);
|
Optional<MerchantCategoryMap> existing = merchantCategoryMapRepository.findBest(merchantId, raw, plat);
|
||||||
MerchantCategoryMap mcm = existing.orElseGet(MerchantCategoryMap::new);
|
MerchantCategoryMap mcm = existing.orElseGet(MerchantCategoryMap::new);
|
||||||
|
|
||||||
// Always ensure required fields are set
|
// Always ensure required fields are set
|
||||||
mcm.setMerchant(merchant);
|
mcm.setMerchant(merchant);
|
||||||
mcm.setRawCategory(raw);
|
mcm.setRawCategory(raw);
|
||||||
mcm.setPlatform(plat);
|
mcm.setPlatform(plat);
|
||||||
mcm.setEnabled(en);
|
mcm.setEnabled(en);
|
||||||
|
|
||||||
// Catalog mapping fields (FK + legacy mirror)
|
// Catalog mapping fields (FK + legacy mirror)
|
||||||
mcm.setCanonicalCategory(cat); // FK (preferred)
|
mcm.setCanonicalCategory(cat); // FK (preferred)
|
||||||
mcm.setCanonicalCategoryText(cat == null ? null : cat.getName()); // legacy mirror
|
mcm.setCanonicalCategoryText(cat == null ? null : cat.getName()); // legacy mirror
|
||||||
|
|
||||||
// IMPORTANT: DO NOT clobber canonicalPartRole here
|
// IMPORTANT: DO NOT clobber canonicalPartRole here
|
||||||
|
|
||||||
merchantCategoryMapRepository.save(mcm);
|
merchantCategoryMapRepository.save(mcm);
|
||||||
|
|
||||||
// Push category FK to products
|
// Push category FK to products
|
||||||
int updated = reclassificationService.applyCatalogCategoryMappingToProducts(
|
int updated = reclassificationService.applyCatalogCategoryMappingToProducts(
|
||||||
merchantId,
|
merchantId,
|
||||||
raw,
|
raw,
|
||||||
canonicalCategoryId // can be null to clear
|
canonicalCategoryId // can be null to clear
|
||||||
);
|
);
|
||||||
|
|
||||||
return new UpsertCatalogMappingResult(mcm.getId(), updated);
|
return new UpsertCatalogMappingResult(mcm.getId(), updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------
|
// -----------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// -----------------
|
// -----------------
|
||||||
|
|
||||||
private String normalizePlatform(String p) {
|
private String normalizePlatform(String p) {
|
||||||
if (p == null) return null;
|
if (p == null) return null;
|
||||||
String t = p.trim();
|
String t = p.trim();
|
||||||
if (t.isEmpty()) return null;
|
if (t.isEmpty()) return null;
|
||||||
return t.toUpperCase(Locale.ROOT);
|
return t.toUpperCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
public interface MerchantFeedImportService {
|
public interface MerchantFeedImportService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full product + offer import for a given merchant.
|
* Full product + offer import for a given merchant.
|
||||||
*/
|
*/
|
||||||
void importMerchantFeed(Integer merchantId);
|
void importMerchantFeed(Integer merchantId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offers-only sync (price / stock) for a given merchant.
|
* Offers-only sync (price / stock) for a given merchant.
|
||||||
*/
|
*/
|
||||||
void syncOffersOnly(Integer merchantId);
|
void syncOffersOnly(Integer merchantId);
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,35 @@
|
|||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PartCategory;
|
import group.goforward.battlbuilder.model.PartCategory;
|
||||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class PartCategoryResolverService {
|
public class PartCategoryResolverService {
|
||||||
|
|
||||||
private final PartRoleMappingRepository partRoleMappingRepository;
|
private final PartRoleMappingRepository partRoleMappingRepository;
|
||||||
|
|
||||||
public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) {
|
public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) {
|
||||||
this.partRoleMappingRepository = partRoleMappingRepository;
|
this.partRoleMappingRepository = partRoleMappingRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a PartCategory for a given platform + partRole.
|
* Resolve a PartCategory for a given platform + partRole.
|
||||||
* Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
|
* Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
|
||||||
*/
|
*/
|
||||||
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
|
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
|
||||||
if (platform == null || partRole == null) return Optional.empty();
|
if (platform == null || partRole == null) return Optional.empty();
|
||||||
|
|
||||||
String p = platform.trim();
|
String p = platform.trim();
|
||||||
String r = partRole.trim();
|
String r = partRole.trim();
|
||||||
|
|
||||||
if (p.isEmpty() || r.isEmpty()) return Optional.empty();
|
if (p.isEmpty() || r.isEmpty()) return Optional.empty();
|
||||||
|
|
||||||
return partRoleMappingRepository
|
return partRoleMappingRepository
|
||||||
.findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r)
|
.findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r)
|
||||||
.map(PartRoleMapping::getPartCategory);
|
.map(PartRoleMapping::getPartCategory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,35 @@
|
|||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
|
import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
|
||||||
import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
|
import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
|
||||||
import group.goforward.battlbuilder.web.mapper.PartRoleMappingMapper;
|
import group.goforward.battlbuilder.web.mapper.PartRoleMappingMapper;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class PartRoleMappingService {
|
public class PartRoleMappingService {
|
||||||
|
|
||||||
private final PartRoleMappingRepository repository;
|
private final PartRoleMappingRepository repository;
|
||||||
|
|
||||||
public PartRoleMappingService(PartRoleMappingRepository repository) {
|
public PartRoleMappingService(PartRoleMappingRepository repository) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<PartRoleMappingDto> getMappingsForPlatform(String platform) {
|
public List<PartRoleMappingDto> getMappingsForPlatform(String platform) {
|
||||||
return repository
|
return repository
|
||||||
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
|
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
|
||||||
.stream()
|
.stream()
|
||||||
.map(PartRoleMappingMapper::toDto)
|
.map(PartRoleMappingMapper::toDto)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<PartRoleToCategoryDto> getRoleToCategoryMap(String platform) {
|
public List<PartRoleToCategoryDto> getRoleToCategoryMap(String platform) {
|
||||||
return repository
|
return repository
|
||||||
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
|
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
|
||||||
.stream()
|
.stream()
|
||||||
.map(PartRoleMappingMapper::toRoleMapDto)
|
.map(PartRoleMappingMapper::toRoleMapDto)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
public interface ProductQueryService {
|
public interface ProductQueryService {
|
||||||
|
|
||||||
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
|
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
|
||||||
|
|
||||||
List<ProductOfferDto> getOffersForProduct(Integer productId);
|
List<ProductOfferDto> getOffersForProduct(Integer productId);
|
||||||
|
|
||||||
ProductSummaryDto getProductById(Integer productId);
|
ProductSummaryDto getProductById(Integer productId);
|
||||||
|
|
||||||
Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable);
|
Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|
||||||
public interface ReclassificationService {
|
public interface ReclassificationService {
|
||||||
int reclassifyPendingForMerchant(Integer merchantId);
|
int reclassifyPendingForMerchant(Integer merchantId);
|
||||||
|
|
||||||
// Existing: apply canonical_part_role mapping to products
|
// Existing: apply canonical_part_role mapping to products
|
||||||
int applyMappingToProducts(Integer merchantId, String rawCategoryKey);
|
int applyMappingToProducts(Integer merchantId, String rawCategoryKey);
|
||||||
|
|
||||||
// NEW: apply canonical_category_id mapping to products
|
// NEW: apply canonical_category_id mapping to products
|
||||||
int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId);
|
int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId);
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
package group.goforward.battlbuilder.service.admin;
|
package group.goforward.battlbuilder.service.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
|
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
|
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
public interface AdminProductService {
|
public interface AdminProductService {
|
||||||
|
|
||||||
Page<ProductAdminRowDto> search(
|
Page<ProductAdminRowDto> search(
|
||||||
AdminProductSearchRequest request,
|
AdminProductSearchRequest request,
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
|
||||||
int bulkUpdate(ProductBulkUpdateRequest request);
|
int bulkUpdate(ProductBulkUpdateRequest request);
|
||||||
}
|
}
|
||||||
@@ -1,55 +1,55 @@
|
|||||||
package group.goforward.battlbuilder.service.admin;
|
package group.goforward.battlbuilder.service.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import group.goforward.battlbuilder.repo.UserRepository;
|
import group.goforward.battlbuilder.repo.UserRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminUserDto;
|
import group.goforward.battlbuilder.web.dto.admin.AdminUserDto;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AdminUserService {
|
public class AdminUserService {
|
||||||
|
|
||||||
private static final Set<String> ALLOWED_ROLES = Set.of("USER", "ADMIN");
|
private static final Set<String> ALLOWED_ROLES = Set.of("USER", "ADMIN");
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public AdminUserService(UserRepository userRepository) {
|
public AdminUserService(UserRepository userRepository) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AdminUserDto> getAllUsersForAdmin() {
|
public List<AdminUserDto> getAllUsersForAdmin() {
|
||||||
return userRepository.findAll()
|
return userRepository.findAll()
|
||||||
.stream()
|
.stream()
|
||||||
.map(AdminUserDto::fromUser)
|
.map(AdminUserDto::fromUser)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AdminUserDto updateUserRole(UUID userUuid, String newRole, Authentication auth) {
|
public AdminUserDto updateUserRole(UUID userUuid, String newRole, Authentication auth) {
|
||||||
if (newRole == null || !ALLOWED_ROLES.contains(newRole.toUpperCase())) {
|
if (newRole == null || !ALLOWED_ROLES.contains(newRole.toUpperCase())) {
|
||||||
throw new IllegalArgumentException("Invalid role: " + newRole);
|
throw new IllegalArgumentException("Invalid role: " + newRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
User user = userRepository.findByUuid(userUuid)
|
User user = userRepository.findByUuid(userUuid)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||||
|
|
||||||
// Optional safety: do not allow demoting yourself (you can loosen this later)
|
// Optional safety: do not allow demoting yourself (you can loosen this later)
|
||||||
String currentEmail = auth != null ? auth.getName() : null;
|
String currentEmail = auth != null ? auth.getName() : null;
|
||||||
boolean isSelf = currentEmail != null
|
boolean isSelf = currentEmail != null
|
||||||
&& currentEmail.equalsIgnoreCase(user.getEmail());
|
&& currentEmail.equalsIgnoreCase(user.getEmail());
|
||||||
|
|
||||||
if (isSelf && !"ADMIN".equalsIgnoreCase(newRole)) {
|
if (isSelf && !"ADMIN".equalsIgnoreCase(newRole)) {
|
||||||
throw new IllegalStateException("You cannot change your own role to non-admin.");
|
throw new IllegalStateException("You cannot change your own role to non-admin.");
|
||||||
}
|
}
|
||||||
|
|
||||||
user.setRole(newRole.toUpperCase());
|
user.setRole(newRole.toUpperCase());
|
||||||
// updatedAt will be handled by your entity / DB defaults
|
// updatedAt will be handled by your entity / DB defaults
|
||||||
|
|
||||||
return AdminUserDto.fromUser(user);
|
return AdminUserDto.fromUser(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
package group.goforward.battlbuilder.service.admin;
|
package group.goforward.battlbuilder.service.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.State;
|
import group.goforward.battlbuilder.model.State;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface StatesService {
|
public interface StatesService {
|
||||||
|
|
||||||
List<State> findAll();
|
List<State> findAll();
|
||||||
|
|
||||||
Optional<State> findById(Integer id);
|
Optional<State> findById(Integer id);
|
||||||
|
|
||||||
State save(State item);
|
State save(State item);
|
||||||
void deleteById(Integer id);
|
void deleteById(Integer id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
package group.goforward.battlbuilder.service.admin;
|
package group.goforward.battlbuilder.service.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface UsersService {
|
public interface UsersService {
|
||||||
|
|
||||||
List<User> findAll();
|
List<User> findAll();
|
||||||
|
|
||||||
Optional<User> findById(Integer id);
|
Optional<User> findById(Integer id);
|
||||||
|
|
||||||
User save(User item);
|
User save(User item);
|
||||||
void deleteById(Integer id);
|
void deleteById(Integer id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Admin service package for the BattlBuilder application.
|
* Admin service package for the BattlBuilder application.
|
||||||
* <p>
|
* <p>
|
||||||
* Contains service classes for administrative business logic
|
* Contains service classes for administrative business logic
|
||||||
* and operations.
|
* and operations.
|
||||||
*
|
*
|
||||||
* @author Forward Group, LLC
|
* @author Forward Group, LLC
|
||||||
* @version 1.0
|
* @version 1.0
|
||||||
* @since 2025-12-10
|
* @since 2025-12-10
|
||||||
*/
|
*/
|
||||||
package group.goforward.battlbuilder.service.admin;
|
package group.goforward.battlbuilder.service.admin;
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
package group.goforward.battlbuilder.service.admin.impl;
|
package group.goforward.battlbuilder.service.admin.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.ImportStatus;
|
import group.goforward.battlbuilder.model.ImportStatus;
|
||||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||||
import group.goforward.battlbuilder.repo.MerchantRepository;
|
import group.goforward.battlbuilder.repo.MerchantRepository;
|
||||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto;
|
import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AdminDashboardService {
|
public class AdminDashboardService {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final MerchantRepository merchantRepository;
|
private final MerchantRepository merchantRepository;
|
||||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||||
|
|
||||||
public AdminDashboardService(
|
public AdminDashboardService(
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
MerchantRepository merchantRepository,
|
MerchantRepository merchantRepository,
|
||||||
MerchantCategoryMapRepository merchantCategoryMapRepository
|
MerchantCategoryMapRepository merchantCategoryMapRepository
|
||||||
) {
|
) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.merchantRepository = merchantRepository;
|
this.merchantRepository = merchantRepository;
|
||||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public AdminDashboardOverviewDto getOverview() {
|
public AdminDashboardOverviewDto getOverview() {
|
||||||
long totalProducts = productRepository.count();
|
long totalProducts = productRepository.count();
|
||||||
long unmappedProducts = productRepository.countByImportStatus(ImportStatus.PENDING_MAPPING);
|
long unmappedProducts = productRepository.countByImportStatus(ImportStatus.PENDING_MAPPING);
|
||||||
long mappedProducts = totalProducts - unmappedProducts;
|
long mappedProducts = totalProducts - unmappedProducts;
|
||||||
|
|
||||||
long merchantCount = merchantRepository.count();
|
long merchantCount = merchantRepository.count();
|
||||||
long categoryMappings = merchantCategoryMapRepository.count();
|
long categoryMappings = merchantCategoryMapRepository.count();
|
||||||
|
|
||||||
return new AdminDashboardOverviewDto(
|
return new AdminDashboardOverviewDto(
|
||||||
totalProducts,
|
totalProducts,
|
||||||
mappedProducts,
|
mappedProducts,
|
||||||
unmappedProducts,
|
unmappedProducts,
|
||||||
merchantCount,
|
merchantCount,
|
||||||
categoryMappings
|
categoryMappings
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,65 +1,65 @@
|
|||||||
package group.goforward.battlbuilder.service.admin.impl;
|
package group.goforward.battlbuilder.service.admin.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||||
import group.goforward.battlbuilder.service.admin.AdminProductService;
|
import group.goforward.battlbuilder.service.admin.AdminProductService;
|
||||||
import group.goforward.battlbuilder.specs.ProductSpecifications;
|
import group.goforward.battlbuilder.specs.ProductSpecifications;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
|
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
|
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional
|
@Transactional
|
||||||
public class AdminProductServiceImpl implements AdminProductService {
|
public class AdminProductServiceImpl implements AdminProductService {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
|
|
||||||
public AdminProductServiceImpl(ProductRepository productRepository) {
|
public AdminProductServiceImpl(ProductRepository productRepository) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<ProductAdminRowDto> search(
|
public Page<ProductAdminRowDto> search(
|
||||||
AdminProductSearchRequest request,
|
AdminProductSearchRequest request,
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
) {
|
) {
|
||||||
Specification<Product> spec =
|
Specification<Product> spec =
|
||||||
ProductSpecifications.adminSearch(request);
|
ProductSpecifications.adminSearch(request);
|
||||||
|
|
||||||
return productRepository
|
return productRepository
|
||||||
.findAll(spec, pageable)
|
.findAll(spec, pageable)
|
||||||
.map(ProductAdminRowDto::fromEntity);
|
.map(ProductAdminRowDto::fromEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int bulkUpdate(ProductBulkUpdateRequest request) {
|
public int bulkUpdate(ProductBulkUpdateRequest request) {
|
||||||
var products = productRepository.findAllById(request.getProductIds());
|
var products = productRepository.findAllById(request.getProductIds());
|
||||||
|
|
||||||
products.forEach(p -> {
|
products.forEach(p -> {
|
||||||
if (request.getVisibility() != null) {
|
if (request.getVisibility() != null) {
|
||||||
p.setVisibility(request.getVisibility());
|
p.setVisibility(request.getVisibility());
|
||||||
}
|
}
|
||||||
if (request.getStatus() != null) {
|
if (request.getStatus() != null) {
|
||||||
p.setStatus(request.getStatus());
|
p.setStatus(request.getStatus());
|
||||||
}
|
}
|
||||||
if (request.getBuilderEligible() != null) {
|
if (request.getBuilderEligible() != null) {
|
||||||
p.setBuilderEligible(request.getBuilderEligible());
|
p.setBuilderEligible(request.getBuilderEligible());
|
||||||
}
|
}
|
||||||
if (request.getAdminLocked() != null) {
|
if (request.getAdminLocked() != null) {
|
||||||
p.setAdminLocked(request.getAdminLocked());
|
p.setAdminLocked(request.getAdminLocked());
|
||||||
}
|
}
|
||||||
if (request.getAdminNote() != null) {
|
if (request.getAdminNote() != null) {
|
||||||
p.setAdminNote(request.getAdminNote());
|
p.setAdminNote(request.getAdminNote());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
productRepository.saveAll(products);
|
productRepository.saveAll(products);
|
||||||
return products.size();
|
return products.size();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
package group.goforward.battlbuilder.service.admin.impl;
|
package group.goforward.battlbuilder.service.admin.impl;
|
||||||
|
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.State;
|
import group.goforward.battlbuilder.model.State;
|
||||||
import group.goforward.battlbuilder.repo.StateRepository;
|
import group.goforward.battlbuilder.repo.StateRepository;
|
||||||
import group.goforward.battlbuilder.service.admin.StatesService;
|
import group.goforward.battlbuilder.service.admin.StatesService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class StatesServiceImpl implements StatesService {
|
public class StatesServiceImpl implements StatesService {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private StateRepository repo;
|
private StateRepository repo;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<State> findAll() {
|
public List<State> findAll() {
|
||||||
return repo.findAll();
|
return repo.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<State> findById(Integer id) {
|
public Optional<State> findById(Integer id) {
|
||||||
return repo.findById(id);
|
return repo.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public State save(State item) {
|
public State save(State item) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void deleteById(Integer id) {
|
public void deleteById(Integer id) {
|
||||||
deleteById(id);
|
deleteById(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
package group.goforward.battlbuilder.service.admin.impl;
|
package group.goforward.battlbuilder.service.admin.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import group.goforward.battlbuilder.repo.UserRepository;
|
import group.goforward.battlbuilder.repo.UserRepository;
|
||||||
import group.goforward.battlbuilder.service.admin.UsersService;
|
import group.goforward.battlbuilder.service.admin.UsersService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class UsersServiceImpl implements UsersService {
|
public class UsersServiceImpl implements UsersService {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private UserRepository repo;
|
private UserRepository repo;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<User> findAll() {
|
public List<User> findAll() {
|
||||||
return repo.findAll();
|
return repo.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<User> findById(Integer id) {
|
public Optional<User> findById(Integer id) {
|
||||||
return repo.findById(id);
|
return repo.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public User save(User item) {
|
public User save(User item) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void deleteById(Integer id) {
|
public void deleteById(Integer id) {
|
||||||
deleteById(id);
|
deleteById(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Provides the classes necessary for the Spring Services implementations for the Battl.Builder application.
|
* Provides the classes necessary for the Spring Services implementations for the Battl.Builder application.
|
||||||
* This package includes Services implementations for Spring-Boot application
|
* This package includes Services implementations for Spring-Boot application
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
* <p>The main entry point for managing the inventory is the
|
* <p>The main entry point for managing the inventory is the
|
||||||
* {@link group.goforward.battlbuilder.BattlBuilderApplication} class.</p>
|
* {@link group.goforward.battlbuilder.BattlBuilderApplication} class.</p>
|
||||||
*
|
*
|
||||||
* @since 1.0
|
* @since 1.0
|
||||||
* @author Don Strawsburg
|
* @author Don Strawsburg
|
||||||
* @version 1.1
|
* @version 1.1
|
||||||
*/
|
*/
|
||||||
package group.goforward.battlbuilder.service.admin;
|
package group.goforward.battlbuilder.service.admin;
|
||||||
@@ -1,29 +1,29 @@
|
|||||||
package group.goforward.battlbuilder.service.auth;
|
package group.goforward.battlbuilder.service.auth;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||||
|
|
||||||
public interface BetaAuthService {
|
public interface BetaAuthService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert a beta signup lead and send a confirmation email with a verify token/link.
|
* 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).
|
* Should NOT throw to the caller for common cases (e.g. already exists).
|
||||||
*/
|
*/
|
||||||
void signup(String email, String useCase);
|
void signup(String email, String useCase);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchanges a "confirm" token for a real JWT session.
|
* Exchanges a "confirm" token for a real JWT session.
|
||||||
* This confirms the email (one-time) AND logs the user in immediately.
|
* This confirms the email (one-time) AND logs the user in immediately.
|
||||||
*/
|
*/
|
||||||
AuthResponse confirmAndExchange(String token);
|
AuthResponse confirmAndExchange(String token);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchanges a "magic link" token for a real JWT session.
|
* Exchanges a "magic link" token for a real JWT session.
|
||||||
* Used for returning users ("email me a sign-in link").
|
* Used for returning users ("email me a sign-in link").
|
||||||
*/
|
*/
|
||||||
AuthResponse exchangeMagicToken(String token);
|
AuthResponse exchangeMagicToken(String token);
|
||||||
|
|
||||||
void sendPasswordReset(String email);
|
void sendPasswordReset(String email);
|
||||||
void resetPassword(String token, String newPassword);
|
void resetPassword(String token, String newPassword);
|
||||||
void sendMagicLoginLink(String email);
|
void sendMagicLoginLink(String email);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,321 +1,321 @@
|
|||||||
package group.goforward.battlbuilder.service.auth.impl;
|
package group.goforward.battlbuilder.service.auth.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.AuthToken;
|
import group.goforward.battlbuilder.model.AuthToken;
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import group.goforward.battlbuilder.repo.AuthTokenRepository;
|
import group.goforward.battlbuilder.repo.AuthTokenRepository;
|
||||||
import group.goforward.battlbuilder.repo.UserRepository;
|
import group.goforward.battlbuilder.repo.UserRepository;
|
||||||
import group.goforward.battlbuilder.security.JwtService;
|
import group.goforward.battlbuilder.security.JwtService;
|
||||||
import group.goforward.battlbuilder.service.auth.BetaAuthService;
|
import group.goforward.battlbuilder.service.auth.BetaAuthService;
|
||||||
import group.goforward.battlbuilder.service.utils.EmailService;
|
import group.goforward.battlbuilder.service.utils.EmailService;
|
||||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class BetaAuthServiceImpl implements BetaAuthService {
|
public class BetaAuthServiceImpl implements BetaAuthService {
|
||||||
|
|
||||||
private final AuthTokenRepository tokens;
|
private final AuthTokenRepository tokens;
|
||||||
private final UserRepository users;
|
private final UserRepository users;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final EmailService emailService;
|
private final EmailService emailService;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
@Value("${app.publicBaseUrl:http://localhost:3000}")
|
@Value("${app.publicBaseUrl:http://localhost:3000}")
|
||||||
private String publicBaseUrl;
|
private String publicBaseUrl;
|
||||||
|
|
||||||
@Value("${app.authTokenPepper:change-me}")
|
@Value("${app.authTokenPepper:change-me}")
|
||||||
private String tokenPepper;
|
private String tokenPepper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When true:
|
* When true:
|
||||||
* - Signup captures users (role=BETA, inactive)
|
* - Signup captures users (role=BETA, inactive)
|
||||||
* - NO tokens are generated
|
* - NO tokens are generated
|
||||||
* - NO emails are sent
|
* - NO emails are sent
|
||||||
*/
|
*/
|
||||||
@Value("${app.beta.captureOnly:true}")
|
@Value("${app.beta.captureOnly:true}")
|
||||||
private boolean betaCaptureOnly;
|
private boolean betaCaptureOnly;
|
||||||
|
|
||||||
private final SecureRandom secureRandom = new SecureRandom();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
public BetaAuthServiceImpl(
|
public BetaAuthServiceImpl(
|
||||||
AuthTokenRepository tokens,
|
AuthTokenRepository tokens,
|
||||||
UserRepository users,
|
UserRepository users,
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
EmailService emailService,
|
EmailService emailService,
|
||||||
PasswordEncoder passwordEncoder
|
PasswordEncoder passwordEncoder
|
||||||
) {
|
) {
|
||||||
this.tokens = tokens;
|
this.tokens = tokens;
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.emailService = emailService;
|
this.emailService = emailService;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A: Beta signup (capture lead + optionally email confirm+login token).
|
* A: Beta signup (capture lead + optionally email confirm+login token).
|
||||||
* The Next page will call /api/auth/beta/confirm and receive AuthResponse.
|
* The Next page will call /api/auth/beta/confirm and receive AuthResponse.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void signup(String rawEmail, String useCase) {
|
public void signup(String rawEmail, String useCase) {
|
||||||
String email = normalizeEmail(rawEmail);
|
String email = normalizeEmail(rawEmail);
|
||||||
|
|
||||||
// ✅ Create or update a "beta lead" user record
|
// ✅ Create or update a "beta lead" user record
|
||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
user = new User();
|
user = new User();
|
||||||
user.setUuid(UUID.randomUUID());
|
user.setUuid(UUID.randomUUID());
|
||||||
user.setEmail(email);
|
user.setEmail(email);
|
||||||
|
|
||||||
// Treat beta signups as users, but not active / not verified yet
|
// Treat beta signups as users, but not active / not verified yet
|
||||||
user.setRole("BETA");
|
user.setRole("BETA");
|
||||||
user.setActive(false);
|
user.setActive(false);
|
||||||
user.setDisplayName(null);
|
user.setDisplayName(null);
|
||||||
|
|
||||||
user.setCreatedAt(OffsetDateTime.now());
|
user.setCreatedAt(OffsetDateTime.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: stash useCase somewhere if desired
|
// Optional: stash useCase somewhere if desired
|
||||||
// user.setPreferences(mergeUseCase(user.getPreferences(), useCase));
|
// user.setPreferences(mergeUseCase(user.getPreferences(), useCase));
|
||||||
|
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
// 🚫 Capture-only mode: do not create tokens, do not send email
|
// 🚫 Capture-only mode: do not create tokens, do not send email
|
||||||
if (betaCaptureOnly) return;
|
if (betaCaptureOnly) return;
|
||||||
|
|
||||||
// --- Invite mode (later) ---
|
// --- Invite mode (later) ---
|
||||||
// 24h confirm token
|
// 24h confirm token
|
||||||
String verifyToken = generateToken();
|
String verifyToken = generateToken();
|
||||||
saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24));
|
saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24));
|
||||||
|
|
||||||
String confirmUrl = publicBaseUrl + "/beta/confirm?token=" + verifyToken;
|
String confirmUrl = publicBaseUrl + "/beta/confirm?token=" + verifyToken;
|
||||||
|
|
||||||
String subject = "Your Battl Builders sign-in link";
|
String subject = "Your Battl Builders sign-in link";
|
||||||
String body = """
|
String body = """
|
||||||
You're on the list.
|
You're on the list.
|
||||||
|
|
||||||
Sign in (and confirm your email) here:
|
Sign in (and confirm your email) here:
|
||||||
%s
|
%s
|
||||||
|
|
||||||
If you didn’t request this, you can ignore this email.
|
If you didn’t request this, you can ignore this email.
|
||||||
""".formatted(confirmUrl);
|
""".formatted(confirmUrl);
|
||||||
|
|
||||||
emailService.sendEmail(email, subject, body);
|
emailService.sendEmail(email, subject, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* B: Existing users only — request a magic login link (no signup/confirm).
|
* B: Existing users only — request a magic login link (no signup/confirm).
|
||||||
* Caller must always return OK to avoid email enumeration.
|
* Caller must always return OK to avoid email enumeration.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void sendMagicLoginLink(String rawEmail) {
|
public void sendMagicLoginLink(String rawEmail) {
|
||||||
String email = normalizeEmail(rawEmail);
|
String email = normalizeEmail(rawEmail);
|
||||||
|
|
||||||
// Only send if user exists (but do NOT reveal that)
|
// Only send if user exists (but do NOT reveal that)
|
||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||||
if (user == null) return;
|
if (user == null) return;
|
||||||
|
|
||||||
boolean isBeta = "BETA".equalsIgnoreCase(user.getRole());
|
boolean isBeta = "BETA".equalsIgnoreCase(user.getRole());
|
||||||
|
|
||||||
// If capture-only mode is enabled, do not generate tokens or send email
|
// If capture-only mode is enabled, do not generate tokens or send email
|
||||||
if (betaCaptureOnly) return;
|
if (betaCaptureOnly) return;
|
||||||
|
|
||||||
// Allow magic link requests for:
|
// Allow magic link requests for:
|
||||||
// - active USERs, OR
|
// - active USERs, OR
|
||||||
// - BETA users (even if inactive), since they may not be activated yet
|
// - BETA users (even if inactive), since they may not be activated yet
|
||||||
if (!Boolean.TRUE.equals(user.isActive()) && !isBeta) return;
|
if (!Boolean.TRUE.equals(user.isActive()) && !isBeta) return;
|
||||||
|
|
||||||
// Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired)
|
// Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired)
|
||||||
if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return;
|
if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return;
|
||||||
|
|
||||||
// 30 minute magic token
|
// 30 minute magic token
|
||||||
String magicToken = generateToken();
|
String magicToken = generateToken();
|
||||||
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(30));
|
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(30));
|
||||||
|
|
||||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
||||||
|
|
||||||
String subject = "Your Battl Builders sign-in link";
|
String subject = "Your Battl Builders sign-in link";
|
||||||
String body = """
|
String body = """
|
||||||
Here’s your secure sign-in link (expires in 30 minutes):
|
Here’s your secure sign-in link (expires in 30 minutes):
|
||||||
%s
|
%s
|
||||||
|
|
||||||
If you didn’t request this, you can ignore this email.
|
If you didn’t request this, you can ignore this email.
|
||||||
""".formatted(magicUrl);
|
""".formatted(magicUrl);
|
||||||
|
|
||||||
emailService.sendEmail(email, subject, body);
|
emailService.sendEmail(email, subject, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately.
|
* Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public AuthResponse confirmAndExchange(String token) {
|
public AuthResponse confirmAndExchange(String token) {
|
||||||
AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token);
|
AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token);
|
||||||
String email = authToken.getEmail();
|
String email = authToken.getEmail();
|
||||||
|
|
||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||||
OffsetDateTime now = OffsetDateTime.now();
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
user = new User();
|
user = new User();
|
||||||
user.setUuid(UUID.randomUUID());
|
user.setUuid(UUID.randomUUID());
|
||||||
user.setEmail(email);
|
user.setEmail(email);
|
||||||
user.setDisplayName(null);
|
user.setDisplayName(null);
|
||||||
user.setRole("USER");
|
user.setRole("USER");
|
||||||
user.setActive(true);
|
user.setActive(true);
|
||||||
user.setCreatedAt(now);
|
user.setCreatedAt(now);
|
||||||
} else {
|
} else {
|
||||||
// Promote BETA -> USER on first successful confirm
|
// Promote BETA -> USER on first successful confirm
|
||||||
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
||||||
user.setRole("USER");
|
user.setRole("USER");
|
||||||
}
|
}
|
||||||
user.setActive(true);
|
user.setActive(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.setLastLoginAt(now);
|
user.setLastLoginAt(now);
|
||||||
user.incrementLoginCount();
|
user.incrementLoginCount();
|
||||||
user.setUpdatedAt(now);
|
user.setUpdatedAt(now);
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
String jwt = jwtService.generateToken(user);
|
String jwt = jwtService.generateToken(user);
|
||||||
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
|
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consumes MAGIC_LOGIN token and returns JWT (returning users).
|
* Consumes MAGIC_LOGIN token and returns JWT (returning users).
|
||||||
* Also promotes BETA->USER and activates the account on first successful login.
|
* Also promotes BETA->USER and activates the account on first successful login.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public AuthResponse exchangeMagicToken(String token) {
|
public AuthResponse exchangeMagicToken(String token) {
|
||||||
AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token);
|
AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token);
|
||||||
|
|
||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
|
||||||
.orElseThrow(() -> new IllegalStateException("User not found for magic token"));
|
.orElseThrow(() -> new IllegalStateException("User not found for magic token"));
|
||||||
|
|
||||||
OffsetDateTime now = OffsetDateTime.now();
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
// Promote/activate beta users on first successful magic login
|
// Promote/activate beta users on first successful magic login
|
||||||
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
||||||
user.setRole("USER");
|
user.setRole("USER");
|
||||||
}
|
}
|
||||||
if (!Boolean.TRUE.equals(user.isActive())) {
|
if (!Boolean.TRUE.equals(user.isActive())) {
|
||||||
user.setActive(true);
|
user.setActive(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.setLastLoginAt(now);
|
user.setLastLoginAt(now);
|
||||||
user.incrementLoginCount();
|
user.incrementLoginCount();
|
||||||
user.setUpdatedAt(now);
|
user.setUpdatedAt(now);
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
String jwt = jwtService.generateToken(user);
|
String jwt = jwtService.generateToken(user);
|
||||||
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
|
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Password Reset
|
// Password Reset
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void sendPasswordReset(String rawEmail) {
|
public void sendPasswordReset(String rawEmail) {
|
||||||
String email = normalizeEmail(rawEmail);
|
String email = normalizeEmail(rawEmail);
|
||||||
|
|
||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||||
if (user == null) return;
|
if (user == null) return;
|
||||||
|
|
||||||
// If capture-only mode is enabled, do not generate tokens or send email
|
// If capture-only mode is enabled, do not generate tokens or send email
|
||||||
if (betaCaptureOnly) return;
|
if (betaCaptureOnly) return;
|
||||||
|
|
||||||
String resetToken = generateToken();
|
String resetToken = generateToken();
|
||||||
saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30));
|
saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30));
|
||||||
|
|
||||||
String resetUrl = publicBaseUrl + "/reset-password?token=" + resetToken;
|
String resetUrl = publicBaseUrl + "/reset-password?token=" + resetToken;
|
||||||
|
|
||||||
String subject = "Reset your Battl Builders password";
|
String subject = "Reset your Battl Builders password";
|
||||||
String body = """
|
String body = """
|
||||||
Reset your password using this link (expires in 30 minutes):
|
Reset your password using this link (expires in 30 minutes):
|
||||||
%s
|
%s
|
||||||
|
|
||||||
If you didn’t request this, you can ignore this email.
|
If you didn’t request this, you can ignore this email.
|
||||||
""".formatted(resetUrl);
|
""".formatted(resetUrl);
|
||||||
|
|
||||||
emailService.sendEmail(email, subject, body);
|
emailService.sendEmail(email, subject, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void resetPassword(String token, String newPassword) {
|
public void resetPassword(String token, String newPassword) {
|
||||||
if (newPassword == null || newPassword.trim().length() < 8) {
|
if (newPassword == null || newPassword.trim().length() < 8) {
|
||||||
throw new IllegalArgumentException("Password must be at least 8 characters");
|
throw new IllegalArgumentException("Password must be at least 8 characters");
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthToken t = consumeToken(AuthToken.TokenType.PASSWORD_RESET, token);
|
AuthToken t = consumeToken(AuthToken.TokenType.PASSWORD_RESET, token);
|
||||||
String email = t.getEmail();
|
String email = t.getEmail();
|
||||||
|
|
||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||||
|
|
||||||
user.setPasswordHash(passwordEncoder.encode(newPassword.trim()));
|
user.setPasswordHash(passwordEncoder.encode(newPassword.trim()));
|
||||||
user.setPasswordSetAt(OffsetDateTime.now());
|
user.setPasswordSetAt(OffsetDateTime.now());
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
users.save(user);
|
users.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) {
|
private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) {
|
||||||
AuthToken t = new AuthToken();
|
AuthToken t = new AuthToken();
|
||||||
t.setEmail(email);
|
t.setEmail(email);
|
||||||
t.setType(type);
|
t.setType(type);
|
||||||
t.setTokenHash(hashToken(token));
|
t.setTokenHash(hashToken(token));
|
||||||
t.setExpiresAt(expiresAt);
|
t.setExpiresAt(expiresAt);
|
||||||
t.setCreatedAt(OffsetDateTime.now());
|
t.setCreatedAt(OffsetDateTime.now());
|
||||||
tokens.save(t);
|
tokens.save(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AuthToken consumeToken(AuthToken.TokenType type, String token) {
|
private AuthToken consumeToken(AuthToken.TokenType type, String token) {
|
||||||
String hash = hashToken(token);
|
String hash = hashToken(token);
|
||||||
|
|
||||||
AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash)
|
AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Invalid token"));
|
.orElseThrow(() -> new IllegalArgumentException("Invalid token"));
|
||||||
|
|
||||||
OffsetDateTime now = OffsetDateTime.now();
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
if (t.isConsumed()) throw new IllegalArgumentException("Token already used");
|
if (t.isConsumed()) throw new IllegalArgumentException("Token already used");
|
||||||
if (t.isExpired(now)) throw new IllegalArgumentException("Token expired");
|
if (t.isExpired(now)) throw new IllegalArgumentException("Token expired");
|
||||||
|
|
||||||
t.setConsumedAt(now);
|
t.setConsumedAt(now);
|
||||||
tokens.save(t);
|
tokens.save(t);
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeEmail(String email) {
|
private String normalizeEmail(String email) {
|
||||||
if (email == null) throw new IllegalArgumentException("Email required");
|
if (email == null) throw new IllegalArgumentException("Email required");
|
||||||
return email.trim().toLowerCase();
|
return email.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String generateToken() {
|
private String generateToken() {
|
||||||
byte[] bytes = new byte[32];
|
byte[] bytes = new byte[32];
|
||||||
secureRandom.nextBytes(bytes);
|
secureRandom.nextBytes(bytes);
|
||||||
return HexFormat.of().formatHex(bytes);
|
return HexFormat.of().formatHex(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String hashToken(String token) {
|
private String hashToken(String token) {
|
||||||
try {
|
try {
|
||||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8));
|
byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8));
|
||||||
return HexFormat.of().formatHex(hashed);
|
return HexFormat.of().formatHex(hashed);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException("Failed to hash token", e);
|
throw new RuntimeException("Failed to hash token", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,185 +1,185 @@
|
|||||||
package group.goforward.battlbuilder.service.auth.impl;
|
package group.goforward.battlbuilder.service.auth.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.AuthToken;
|
import group.goforward.battlbuilder.model.AuthToken;
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import group.goforward.battlbuilder.repo.AuthTokenRepository;
|
import group.goforward.battlbuilder.repo.AuthTokenRepository;
|
||||||
import group.goforward.battlbuilder.repo.UserRepository;
|
import group.goforward.battlbuilder.repo.UserRepository;
|
||||||
import group.goforward.battlbuilder.service.utils.TemplatedEmailService;
|
import group.goforward.battlbuilder.service.utils.TemplatedEmailService;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
|
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
|
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class BetaInviteService {
|
public class BetaInviteService {
|
||||||
|
|
||||||
private final UserRepository users;
|
private final UserRepository users;
|
||||||
private final AuthTokenRepository tokens;
|
private final AuthTokenRepository tokens;
|
||||||
private final TemplatedEmailService templatedEmailService;
|
private final TemplatedEmailService templatedEmailService;
|
||||||
|
|
||||||
@Value("${app.publicBaseUrl:http://localhost:3000}")
|
@Value("${app.publicBaseUrl:http://localhost:3000}")
|
||||||
private String publicBaseUrl;
|
private String publicBaseUrl;
|
||||||
|
|
||||||
@Value("${app.authTokenPepper:change-me}")
|
@Value("${app.authTokenPepper:change-me}")
|
||||||
private String tokenPepper;
|
private String tokenPepper;
|
||||||
|
|
||||||
private final SecureRandom secureRandom = new SecureRandom();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
public BetaInviteService(
|
public BetaInviteService(
|
||||||
UserRepository users,
|
UserRepository users,
|
||||||
AuthTokenRepository tokens,
|
AuthTokenRepository tokens,
|
||||||
TemplatedEmailService templatedEmailService
|
TemplatedEmailService templatedEmailService
|
||||||
) {
|
) {
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.tokens = tokens;
|
this.tokens = tokens;
|
||||||
this.templatedEmailService = templatedEmailService;
|
this.templatedEmailService = templatedEmailService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch invite for all pending BETA users (role=BETA, is_active=false, deleted_at is null).
|
* 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) {
|
public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) {
|
||||||
|
|
||||||
List<User> betaUsers = (limit > 0)
|
List<User> betaUsers = (limit > 0)
|
||||||
? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit)
|
? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit)
|
||||||
: users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
|
: users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
|
||||||
|
|
||||||
int sent = 0;
|
int sent = 0;
|
||||||
|
|
||||||
for (User user : betaUsers) {
|
for (User user : betaUsers) {
|
||||||
inviteUser(user, tokenMinutes, dryRun);
|
inviteUser(user, tokenMinutes, dryRun);
|
||||||
sent++;
|
sent++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sent;
|
return sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin UI list: all pending beta requests (role=BETA, is_active=false).
|
* Admin UI list: all pending beta requests (role=BETA, is_active=false).
|
||||||
* Controller expects Page<AdminBetaRequestDto>.
|
* Controller expects Page<AdminBetaRequestDto>.
|
||||||
*/
|
*/
|
||||||
public Page<AdminBetaRequestDto> listPendingBetaUsers(int page, int size) {
|
public Page<AdminBetaRequestDto> listPendingBetaUsers(int page, int size) {
|
||||||
int safePage = Math.max(0, page);
|
int safePage = Math.max(0, page);
|
||||||
int safeSize = Math.min(Math.max(1, size), 100);
|
int safeSize = Math.min(Math.max(1, size), 100);
|
||||||
|
|
||||||
List<User> pending = users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
|
List<User> pending = users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
|
||||||
|
|
||||||
int from = Math.min(safePage * safeSize, pending.size());
|
int from = Math.min(safePage * safeSize, pending.size());
|
||||||
int to = Math.min(from + safeSize, pending.size());
|
int to = Math.min(from + safeSize, pending.size());
|
||||||
|
|
||||||
OffsetDateTime now = OffsetDateTime.now();
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
List<AdminBetaRequestDto> dtos = pending.subList(from, to).stream()
|
List<AdminBetaRequestDto> dtos = pending.subList(from, to).stream()
|
||||||
.map(u -> {
|
.map(u -> {
|
||||||
AdminBetaRequestDto dto = AdminBetaRequestDto.from(u);
|
AdminBetaRequestDto dto = AdminBetaRequestDto.from(u);
|
||||||
dto.invited = tokens.hasActiveToken(
|
dto.invited = tokens.hasActiveToken(
|
||||||
u.getEmail(),
|
u.getEmail(),
|
||||||
AuthToken.TokenType.MAGIC_LOGIN,
|
AuthToken.TokenType.MAGIC_LOGIN,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
return dto;
|
return dto;
|
||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return new PageImpl<>(dtos, PageRequest.of(safePage, safeSize), pending.size());
|
return new PageImpl<>(dtos, PageRequest.of(safePage, safeSize), pending.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invite a single beta request by userId.
|
* Invite a single beta request by userId.
|
||||||
*/
|
*/
|
||||||
public AdminInviteResponse inviteSingleBetaUser(Integer userId) {
|
public AdminInviteResponse inviteSingleBetaUser(Integer userId) {
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
return new AdminInviteResponse(false, null, "userId is required");
|
return new AdminInviteResponse(false, null, "userId is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
User user = users.findById(userId).orElse(null);
|
User user = users.findById(userId).orElse(null);
|
||||||
if (user == null || user.getDeletedAt() != null) {
|
if (user == null || user.getDeletedAt() != null) {
|
||||||
return new AdminInviteResponse(false, null, "User not found");
|
return new AdminInviteResponse(false, null, "User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!"BETA".equalsIgnoreCase(user.getRole()) || user.isActive()) {
|
if (!"BETA".equalsIgnoreCase(user.getRole()) || user.isActive()) {
|
||||||
return new AdminInviteResponse(false, user.getEmail(), "User is not a pending beta request");
|
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
|
int tokenMinutes = 30; // default for single-invite; feel free to parametrize later
|
||||||
String magicUrl = inviteUser(user, tokenMinutes, false);
|
String magicUrl = inviteUser(user, tokenMinutes, false);
|
||||||
|
|
||||||
return new AdminInviteResponse(true, user.getEmail(), magicUrl);
|
return new AdminInviteResponse(true, user.getEmail(), magicUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates token, persists hash, and (optionally) sends email.
|
* Creates token, persists hash, and (optionally) sends email.
|
||||||
* Returns the magicUrl for logging / admin response.
|
* Returns the magicUrl for logging / admin response.
|
||||||
*/
|
*/
|
||||||
private String inviteUser(User user, int tokenMinutes, boolean dryRun) {
|
private String inviteUser(User user, int tokenMinutes, boolean dryRun) {
|
||||||
String email = user.getEmail();
|
String email = user.getEmail();
|
||||||
|
|
||||||
String magicToken = generateToken();
|
String magicToken = generateToken();
|
||||||
saveToken(
|
saveToken(
|
||||||
email,
|
email,
|
||||||
AuthToken.TokenType.MAGIC_LOGIN,
|
AuthToken.TokenType.MAGIC_LOGIN,
|
||||||
magicToken,
|
magicToken,
|
||||||
OffsetDateTime.now().plusMinutes(tokenMinutes)
|
OffsetDateTime.now().plusMinutes(tokenMinutes)
|
||||||
);
|
);
|
||||||
|
|
||||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
||||||
|
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
templatedEmailService.send(
|
templatedEmailService.send(
|
||||||
"beta_invite",
|
"beta_invite",
|
||||||
email,
|
email,
|
||||||
Map.of(
|
Map.of(
|
||||||
"minutes", String.valueOf(tokenMinutes),
|
"minutes", String.valueOf(tokenMinutes),
|
||||||
"magicUrl", magicUrl
|
"magicUrl", magicUrl
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return magicUrl;
|
return magicUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveToken(
|
private void saveToken(
|
||||||
String email,
|
String email,
|
||||||
AuthToken.TokenType type,
|
AuthToken.TokenType type,
|
||||||
String token,
|
String token,
|
||||||
OffsetDateTime expiresAt
|
OffsetDateTime expiresAt
|
||||||
) {
|
) {
|
||||||
AuthToken t = new AuthToken();
|
AuthToken t = new AuthToken();
|
||||||
t.setEmail(email);
|
t.setEmail(email);
|
||||||
t.setType(type);
|
t.setType(type);
|
||||||
t.setTokenHash(hashToken(token));
|
t.setTokenHash(hashToken(token));
|
||||||
t.setExpiresAt(expiresAt);
|
t.setExpiresAt(expiresAt);
|
||||||
t.setCreatedAt(OffsetDateTime.now());
|
t.setCreatedAt(OffsetDateTime.now());
|
||||||
tokens.save(t);
|
tokens.save(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String generateToken() {
|
private String generateToken() {
|
||||||
byte[] bytes = new byte[32];
|
byte[] bytes = new byte[32];
|
||||||
secureRandom.nextBytes(bytes);
|
secureRandom.nextBytes(bytes);
|
||||||
return HexFormat.of().formatHex(bytes);
|
return HexFormat.of().formatHex(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String hashToken(String token) {
|
private String hashToken(String token) {
|
||||||
try {
|
try {
|
||||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
byte[] hashed = md.digest(
|
byte[] hashed = md.digest(
|
||||||
(tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)
|
(tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)
|
||||||
);
|
);
|
||||||
return HexFormat.of().formatHex(hashed);
|
return HexFormat.of().formatHex(hashed);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException("Failed to hash token", e);
|
throw new RuntimeException("Failed to hash token", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
package group.goforward.battlbuilder.service.impl;
|
package group.goforward.battlbuilder.service.impl;
|
||||||
|
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Brand;
|
import group.goforward.battlbuilder.model.Brand;
|
||||||
import group.goforward.battlbuilder.repo.BrandRepository;
|
import group.goforward.battlbuilder.repo.BrandRepository;
|
||||||
import group.goforward.battlbuilder.service.BrandService;
|
import group.goforward.battlbuilder.service.BrandService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class BrandServiceImpl implements BrandService {
|
public class BrandServiceImpl implements BrandService {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private BrandRepository repo;
|
private BrandRepository repo;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Brand> findAll() {
|
public List<Brand> findAll() {
|
||||||
return repo.findAll();
|
return repo.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Brand> findById(Integer id) {
|
public Optional<Brand> findById(Integer id) {
|
||||||
return repo.findById(id);
|
return repo.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Brand save(Brand item) {
|
public Brand save(Brand item) {
|
||||||
return repo.save(item);
|
return repo.save(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void deleteById(Integer id) {
|
public void deleteById(Integer id) {
|
||||||
deleteById(id);
|
deleteById(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,209 +1,209 @@
|
|||||||
package group.goforward.battlbuilder.service.impl;
|
package group.goforward.battlbuilder.service.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.model.ProductOffer;
|
import group.goforward.battlbuilder.model.ProductOffer;
|
||||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||||
import group.goforward.battlbuilder.repo.catalog.spec.CatalogProductSpecifications;
|
import group.goforward.battlbuilder.repo.catalog.spec.CatalogProductSpecifications;
|
||||||
import group.goforward.battlbuilder.service.CatalogQueryService;
|
import group.goforward.battlbuilder.service.CatalogQueryService;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
||||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||||
|
|
||||||
import org.springframework.data.domain.*;
|
import org.springframework.data.domain.*;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CatalogQueryServiceImpl implements CatalogQueryService {
|
public class CatalogQueryServiceImpl implements CatalogQueryService {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final ProductOfferRepository productOfferRepository;
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
|
||||||
public CatalogQueryServiceImpl(ProductRepository productRepository,
|
public CatalogQueryServiceImpl(ProductRepository productRepository,
|
||||||
ProductOfferRepository productOfferRepository) {
|
ProductOfferRepository productOfferRepository) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.productOfferRepository = productOfferRepository;
|
this.productOfferRepository = productOfferRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<ProductSummaryDto> getOptions(
|
public Page<ProductSummaryDto> getOptions(
|
||||||
String platform,
|
String platform,
|
||||||
String partRole,
|
String partRole,
|
||||||
List<String> partRoles,
|
List<String> partRoles,
|
||||||
List<String> brands,
|
List<String> brands,
|
||||||
String q,
|
String q,
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
) {
|
) {
|
||||||
pageable = sanitizeCatalogPageable(pageable);
|
pageable = sanitizeCatalogPageable(pageable);
|
||||||
|
|
||||||
// Normalize roles: accept partRole OR partRoles
|
// Normalize roles: accept partRole OR partRoles
|
||||||
List<String> roleList = new ArrayList<>();
|
List<String> roleList = new ArrayList<>();
|
||||||
if (partRole != null && !partRole.isBlank()) roleList.add(partRole);
|
if (partRole != null && !partRole.isBlank()) roleList.add(partRole);
|
||||||
if (partRoles != null && !partRoles.isEmpty()) roleList.addAll(partRoles);
|
if (partRoles != null && !partRoles.isEmpty()) roleList.addAll(partRoles);
|
||||||
roleList = roleList.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
|
roleList = roleList.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
|
||||||
|
|
||||||
Specification<Product> spec = Specification.where(CatalogProductSpecifications.isCatalogVisible());
|
Specification<Product> spec = Specification.where(CatalogProductSpecifications.isCatalogVisible());
|
||||||
|
|
||||||
// platform optional: omit/blank/ALL => universal
|
// platform optional: omit/blank/ALL => universal
|
||||||
if (platform != null && !platform.isBlank() && !"ALL".equalsIgnoreCase(platform)) {
|
if (platform != null && !platform.isBlank() && !"ALL".equalsIgnoreCase(platform)) {
|
||||||
spec = spec.and(CatalogProductSpecifications.platformEquals(platform));
|
spec = spec.and(CatalogProductSpecifications.platformEquals(platform));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!roleList.isEmpty()) {
|
if (!roleList.isEmpty()) {
|
||||||
spec = spec.and(CatalogProductSpecifications.partRoleIn(roleList));
|
spec = spec.and(CatalogProductSpecifications.partRoleIn(roleList));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (brands != null && !brands.isEmpty()) {
|
if (brands != null && !brands.isEmpty()) {
|
||||||
spec = spec.and(CatalogProductSpecifications.brandNameIn(brands));
|
spec = spec.and(CatalogProductSpecifications.brandNameIn(brands));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (q != null && !q.isBlank()) {
|
if (q != null && !q.isBlank()) {
|
||||||
spec = spec.and(CatalogProductSpecifications.queryLike(q));
|
spec = spec.and(CatalogProductSpecifications.queryLike(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
Page<Product> page = productRepository.findAll(spec, pageable);
|
Page<Product> page = productRepository.findAll(spec, pageable);
|
||||||
if (page.isEmpty()) {
|
if (page.isEmpty()) {
|
||||||
return new PageImpl<>(List.of(), pageable, 0);
|
return new PageImpl<>(List.of(), pageable, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk offers for this page (no N+1)
|
// Bulk offers for this page (no N+1)
|
||||||
List<Integer> productIds = page.getContent().stream().map(Product::getId).toList();
|
List<Integer> productIds = page.getContent().stream().map(Product::getId).toList();
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(productIds);
|
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||||
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
|
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
|
||||||
|
|
||||||
List<ProductSummaryDto> dtos = page.getContent().stream().map(p -> {
|
List<ProductSummaryDto> dtos = page.getContent().stream().map(p -> {
|
||||||
ProductOffer best = bestOfferByProductId.get(p.getId());
|
ProductOffer best = bestOfferByProductId.get(p.getId());
|
||||||
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
||||||
String buyUrl = best != null ? best.getBuyUrl() : null;
|
String buyUrl = best != null ? best.getBuyUrl() : null;
|
||||||
return ProductMapper.toSummary(p, price, buyUrl);
|
return ProductMapper.toSummary(p, price, buyUrl);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
return new PageImpl<>(dtos, pageable, page.getTotalElements());
|
return new PageImpl<>(dtos, pageable, page.getTotalElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request) {
|
public List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request) {
|
||||||
List<Integer> ids = request != null ? request.getIds() : null;
|
List<Integer> ids = request != null ? request.getIds() : null;
|
||||||
if (ids == null || ids.isEmpty()) return List.of();
|
if (ids == null || ids.isEmpty()) return List.of();
|
||||||
|
|
||||||
ids = ids.stream().filter(Objects::nonNull).distinct().toList();
|
ids = ids.stream().filter(Objects::nonNull).distinct().toList();
|
||||||
|
|
||||||
List<Product> products = productRepository.findByIdIn(ids);
|
List<Product> products = productRepository.findByIdIn(ids);
|
||||||
if (products.isEmpty()) return List.of();
|
if (products.isEmpty()) return List.of();
|
||||||
|
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(ids);
|
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(ids);
|
||||||
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
|
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
|
||||||
|
|
||||||
Map<Integer, Product> productById = products.stream()
|
Map<Integer, Product> productById = products.stream()
|
||||||
.collect(Collectors.toMap(Product::getId, p -> p));
|
.collect(Collectors.toMap(Product::getId, p -> p));
|
||||||
|
|
||||||
List<ProductSummaryDto> out = new ArrayList<>();
|
List<ProductSummaryDto> out = new ArrayList<>();
|
||||||
for (Integer id : ids) {
|
for (Integer id : ids) {
|
||||||
Product p = productById.get(id);
|
Product p = productById.get(id);
|
||||||
if (p == null) continue;
|
if (p == null) continue;
|
||||||
|
|
||||||
ProductOffer best = bestOfferByProductId.get(id);
|
ProductOffer best = bestOfferByProductId.get(id);
|
||||||
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
||||||
String buyUrl = best != null ? best.getBuyUrl() : null;
|
String buyUrl = best != null ? best.getBuyUrl() : null;
|
||||||
|
|
||||||
out.add(ProductMapper.toSummary(p, price, buyUrl));
|
out.add(ProductMapper.toSummary(p, price, buyUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<Integer, ProductOffer> pickBestOffers(List<ProductOffer> offers) {
|
private Map<Integer, ProductOffer> pickBestOffers(List<ProductOffer> offers) {
|
||||||
Map<Integer, ProductOffer> best = new HashMap<>();
|
Map<Integer, ProductOffer> best = new HashMap<>();
|
||||||
if (offers == null || offers.isEmpty()) return best;
|
if (offers == null || offers.isEmpty()) return best;
|
||||||
|
|
||||||
for (ProductOffer o : offers) {
|
for (ProductOffer o : offers) {
|
||||||
if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue;
|
if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue;
|
||||||
|
|
||||||
Integer pid = o.getProduct().getId();
|
Integer pid = o.getProduct().getId();
|
||||||
BigDecimal price = o.getEffectivePrice();
|
BigDecimal price = o.getEffectivePrice();
|
||||||
if (price == null) continue;
|
if (price == null) continue;
|
||||||
|
|
||||||
ProductOffer current = best.get(pid);
|
ProductOffer current = best.get(pid);
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
best.put(pid, o);
|
best.put(pid, o);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ranking rules (in order) ----
|
// ---- ranking rules (in order) ----
|
||||||
// 1) prefer in-stock
|
// 1) prefer in-stock
|
||||||
boolean oStock = Boolean.TRUE.equals(o.getInStock());
|
boolean oStock = Boolean.TRUE.equals(o.getInStock());
|
||||||
boolean cStock = Boolean.TRUE.equals(current.getInStock());
|
boolean cStock = Boolean.TRUE.equals(current.getInStock());
|
||||||
if (oStock != cStock) {
|
if (oStock != cStock) {
|
||||||
if (oStock) best.put(pid, o);
|
if (oStock) best.put(pid, o);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) prefer cheaper price
|
// 2) prefer cheaper price
|
||||||
BigDecimal currentPrice = current.getEffectivePrice();
|
BigDecimal currentPrice = current.getEffectivePrice();
|
||||||
if (currentPrice == null || price.compareTo(currentPrice) < 0) {
|
if (currentPrice == null || price.compareTo(currentPrice) < 0) {
|
||||||
best.put(pid, o);
|
best.put(pid, o);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (price.compareTo(currentPrice) > 0) continue;
|
if (price.compareTo(currentPrice) > 0) continue;
|
||||||
|
|
||||||
// 3) tie-break: most recently seen
|
// 3) tie-break: most recently seen
|
||||||
OffsetDateTime oSeen = o.getLastSeenAt();
|
OffsetDateTime oSeen = o.getLastSeenAt();
|
||||||
OffsetDateTime cSeen = current.getLastSeenAt();
|
OffsetDateTime cSeen = current.getLastSeenAt();
|
||||||
|
|
||||||
if (oSeen != null && cSeen != null && oSeen.isAfter(cSeen)) {
|
if (oSeen != null && cSeen != null && oSeen.isAfter(cSeen)) {
|
||||||
best.put(pid, o);
|
best.put(pid, o);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (oSeen != null && cSeen == null) {
|
if (oSeen != null && cSeen == null) {
|
||||||
best.put(pid, o);
|
best.put(pid, o);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) tie-break: prefer offer with buyUrl
|
// 4) tie-break: prefer offer with buyUrl
|
||||||
String oUrl = o.getBuyUrl();
|
String oUrl = o.getBuyUrl();
|
||||||
String cUrl = current.getBuyUrl();
|
String cUrl = current.getBuyUrl();
|
||||||
if ((oUrl != null && !oUrl.isBlank()) && (cUrl == null || cUrl.isBlank())) {
|
if ((oUrl != null && !oUrl.isBlank()) && (cUrl == null || cUrl.isBlank())) {
|
||||||
best.put(pid, o);
|
best.put(pid, o);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Pageable sanitizeCatalogPageable(Pageable pageable) {
|
private Pageable sanitizeCatalogPageable(Pageable pageable) {
|
||||||
if (pageable == null) {
|
if (pageable == null) {
|
||||||
return PageRequest.of(0, 24, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
return PageRequest.of(0, 24, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
int page = pageable.getPageNumber();
|
int page = pageable.getPageNumber();
|
||||||
int requested = pageable.getPageSize();
|
int requested = pageable.getPageSize();
|
||||||
int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap
|
int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap
|
||||||
|
|
||||||
// Default sort if none provided
|
// Default sort if none provided
|
||||||
if (pageable.getSort() == null || pageable.getSort().isUnsorted()) {
|
if (pageable.getSort() == null || pageable.getSort().isUnsorted()) {
|
||||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow safe sorts (for now)
|
// Only allow safe sorts (for now)
|
||||||
Sort.Order first = pageable.getSort().stream().findFirst().orElse(null);
|
Sort.Order first = pageable.getSort().stream().findFirst().orElse(null);
|
||||||
if (first == null) {
|
if (first == null) {
|
||||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
String prop = first.getProperty();
|
String prop = first.getProperty();
|
||||||
Sort.Direction dir = first.getDirection();
|
Sort.Direction dir = first.getDirection();
|
||||||
|
|
||||||
// IMPORTANT:
|
// IMPORTANT:
|
||||||
// If you're still using JPA Specifications (Product entity), you can only sort by Product fields.
|
// 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.
|
// Once you switch to the native "best offer" query, you can allow "price" and "brand" sorts.
|
||||||
return switch (prop) {
|
return switch (prop) {
|
||||||
case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop));
|
case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop));
|
||||||
default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,135 +1,135 @@
|
|||||||
package group.goforward.battlbuilder.service.impl;
|
package group.goforward.battlbuilder.service.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
|
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
|
||||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||||
import group.goforward.battlbuilder.model.Merchant;
|
import group.goforward.battlbuilder.model.Merchant;
|
||||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||||
import group.goforward.battlbuilder.service.CategoryClassificationService;
|
import group.goforward.battlbuilder.service.CategoryClassificationService;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CategoryClassificationServiceImpl implements CategoryClassificationService {
|
public class CategoryClassificationServiceImpl implements CategoryClassificationService {
|
||||||
|
|
||||||
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||||
private final PartRoleResolver partRoleResolver;
|
private final PartRoleResolver partRoleResolver;
|
||||||
|
|
||||||
public CategoryClassificationServiceImpl(
|
public CategoryClassificationServiceImpl(
|
||||||
MerchantCategoryMappingService merchantCategoryMappingService,
|
MerchantCategoryMappingService merchantCategoryMappingService,
|
||||||
PartRoleResolver partRoleResolver
|
PartRoleResolver partRoleResolver
|
||||||
) {
|
) {
|
||||||
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
||||||
this.partRoleResolver = partRoleResolver;
|
this.partRoleResolver = partRoleResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result classify(Merchant merchant, MerchantFeedRow row) {
|
public Result classify(Merchant merchant, MerchantFeedRow row) {
|
||||||
String rawCategoryKey = buildRawCategoryKey(row);
|
String rawCategoryKey = buildRawCategoryKey(row);
|
||||||
String platformFinal = inferPlatform(row);
|
String platformFinal = inferPlatform(row);
|
||||||
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
||||||
return classify(merchant, row, platformFinal, rawCategoryKey);
|
return classify(merchant, row, platformFinal, rawCategoryKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) {
|
public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) {
|
||||||
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
||||||
|
|
||||||
// 1) merchant map (authoritative if present)
|
// 1) merchant map (authoritative if present)
|
||||||
Optional<String> mapped = merchantCategoryMappingService.resolveMappedPartRole(
|
Optional<String> mapped = merchantCategoryMappingService.resolveMappedPartRole(
|
||||||
merchant != null ? merchant.getId() : null,
|
merchant != null ? merchant.getId() : null,
|
||||||
rawCategoryKey,
|
rawCategoryKey,
|
||||||
platformFinal
|
platformFinal
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mapped.isPresent()) {
|
if (mapped.isPresent()) {
|
||||||
String role = normalizePartRole(mapped.get());
|
String role = normalizePartRole(mapped.get());
|
||||||
return new Result(
|
return new Result(
|
||||||
platformFinal,
|
platformFinal,
|
||||||
role,
|
role,
|
||||||
rawCategoryKey,
|
rawCategoryKey,
|
||||||
PartRoleSource.MERCHANT_MAP,
|
PartRoleSource.MERCHANT_MAP,
|
||||||
"merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")"
|
"merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) rules
|
// 2) rules
|
||||||
String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey);
|
String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey);
|
||||||
if (resolved != null && !resolved.isBlank()) {
|
if (resolved != null && !resolved.isBlank()) {
|
||||||
String role = normalizePartRole(resolved);
|
String role = normalizePartRole(resolved);
|
||||||
return new Result(
|
return new Result(
|
||||||
platformFinal,
|
platformFinal,
|
||||||
role,
|
role,
|
||||||
rawCategoryKey,
|
rawCategoryKey,
|
||||||
PartRoleSource.RULES,
|
PartRoleSource.RULES,
|
||||||
"PartRoleResolver matched"
|
"PartRoleResolver matched"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) no inference: leave unknown and let it flow to PENDING_MAPPING
|
// 3) no inference: leave unknown and let it flow to PENDING_MAPPING
|
||||||
return new Result(
|
return new Result(
|
||||||
platformFinal,
|
platformFinal,
|
||||||
"unknown",
|
"unknown",
|
||||||
rawCategoryKey,
|
rawCategoryKey,
|
||||||
PartRoleSource.UNKNOWN,
|
PartRoleSource.UNKNOWN,
|
||||||
"no mapping or rules match"
|
"no mapping or rules match"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildRawCategoryKey(MerchantFeedRow row) {
|
private String buildRawCategoryKey(MerchantFeedRow row) {
|
||||||
String dept = trimOrNull(row.department());
|
String dept = trimOrNull(row.department());
|
||||||
String cat = trimOrNull(row.category());
|
String cat = trimOrNull(row.category());
|
||||||
String sub = trimOrNull(row.subCategory());
|
String sub = trimOrNull(row.subCategory());
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
if (dept != null) sb.append(dept);
|
if (dept != null) sb.append(dept);
|
||||||
if (cat != null) {
|
if (cat != null) {
|
||||||
if (!sb.isEmpty()) sb.append(" > ");
|
if (!sb.isEmpty()) sb.append(" > ");
|
||||||
sb.append(cat);
|
sb.append(cat);
|
||||||
}
|
}
|
||||||
if (sub != null) {
|
if (sub != null) {
|
||||||
if (!sb.isEmpty()) sb.append(" > ");
|
if (!sb.isEmpty()) sb.append(" > ");
|
||||||
sb.append(sub);
|
sb.append(sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
String result = sb.toString();
|
String result = sb.toString();
|
||||||
return result.isBlank() ? null : result;
|
return result.isBlank() ? null : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String inferPlatform(MerchantFeedRow row) {
|
private String inferPlatform(MerchantFeedRow row) {
|
||||||
String blob = String.join(" ",
|
String blob = String.join(" ",
|
||||||
coalesce(trimOrNull(row.department()), ""),
|
coalesce(trimOrNull(row.department()), ""),
|
||||||
coalesce(trimOrNull(row.category()), ""),
|
coalesce(trimOrNull(row.category()), ""),
|
||||||
coalesce(trimOrNull(row.subCategory()), "")
|
coalesce(trimOrNull(row.subCategory()), "")
|
||||||
).toLowerCase(Locale.ROOT);
|
).toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
|
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-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("ar-9") || blob.contains("ar9")) return "AR-9";
|
||||||
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
|
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
|
||||||
|
|
||||||
return "AR-15";
|
return "AR-15";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private String normalizePartRole(String partRole) {
|
private String normalizePartRole(String partRole) {
|
||||||
if (partRole == null) return "unknown";
|
if (partRole == null) return "unknown";
|
||||||
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
|
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
|
||||||
return t.isBlank() ? "unknown" : t;
|
return t.isBlank() ? "unknown" : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String trimOrNull(String v) {
|
private String trimOrNull(String v) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
String t = v.trim();
|
String t = v.trim();
|
||||||
return t.isEmpty() ? null : t;
|
return t.isEmpty() ? null : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String coalesce(String... values) {
|
private String coalesce(String... values) {
|
||||||
if (values == null) return null;
|
if (values == null) return null;
|
||||||
for (String v : values) {
|
for (String v : values) {
|
||||||
if (v != null && !v.isBlank()) return v;
|
if (v != null && !v.isBlank()) return v;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
package group.goforward.battlbuilder.service.impl;
|
package group.goforward.battlbuilder.service.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MerchantCategoryMappingService {
|
public class MerchantCategoryMappingService {
|
||||||
|
|
||||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||||
|
|
||||||
public MerchantCategoryMappingService(MerchantCategoryMapRepository merchantCategoryMapRepository) {
|
public MerchantCategoryMappingService(MerchantCategoryMapRepository merchantCategoryMapRepository) {
|
||||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<String> resolveMappedPartRole(
|
public Optional<String> resolveMappedPartRole(
|
||||||
Integer merchantId,
|
Integer merchantId,
|
||||||
String rawCategoryKey,
|
String rawCategoryKey,
|
||||||
String platformFinal
|
String platformFinal
|
||||||
) {
|
) {
|
||||||
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) {
|
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> canonicalRoles =
|
List<String> canonicalRoles =
|
||||||
merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey);
|
merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey);
|
||||||
|
|
||||||
if (canonicalRoles == null || canonicalRoles.isEmpty()) {
|
if (canonicalRoles == null || canonicalRoles.isEmpty()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
return canonicalRoles.stream()
|
return canonicalRoles.stream()
|
||||||
.filter(v -> v != null && !v.isBlank())
|
.filter(v -> v != null && !v.isBlank())
|
||||||
.findFirst();
|
.findFirst();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,174 +1,174 @@
|
|||||||
package group.goforward.battlbuilder.service.impl;
|
package group.goforward.battlbuilder.service.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.model.ProductOffer;
|
import group.goforward.battlbuilder.model.ProductOffer;
|
||||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||||
import group.goforward.battlbuilder.service.ProductQueryService;
|
import group.goforward.battlbuilder.service.ProductQueryService;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ProductQueryServiceImpl implements ProductQueryService {
|
public class ProductQueryServiceImpl implements ProductQueryService {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final ProductOfferRepository productOfferRepository;
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
|
||||||
public ProductQueryServiceImpl(
|
public ProductQueryServiceImpl(
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
ProductOfferRepository productOfferRepository
|
ProductOfferRepository productOfferRepository
|
||||||
) {
|
) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.productOfferRepository = productOfferRepository;
|
this.productOfferRepository = productOfferRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ProductSummaryDto> getProducts(String platform, List<String> partRoles) {
|
public List<ProductSummaryDto> getProducts(String platform, List<String> partRoles) {
|
||||||
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
||||||
|
|
||||||
List<Product> products;
|
List<Product> products;
|
||||||
if (partRoles == null || partRoles.isEmpty()) {
|
if (partRoles == null || partRoles.isEmpty()) {
|
||||||
products = allPlatforms
|
products = allPlatforms
|
||||||
? productRepository.findAllWithBrand()
|
? productRepository.findAllWithBrand()
|
||||||
: productRepository.findByPlatformWithBrand(platform);
|
: productRepository.findByPlatformWithBrand(platform);
|
||||||
} else {
|
} else {
|
||||||
products = allPlatforms
|
products = allPlatforms
|
||||||
? productRepository.findByPartRoleInWithBrand(partRoles)
|
? productRepository.findByPartRoleInWithBrand(partRoles)
|
||||||
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (products.isEmpty()) return List.of();
|
if (products.isEmpty()) return List.of();
|
||||||
|
|
||||||
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
||||||
|
|
||||||
// ✅ canonical repo method
|
// ✅ canonical repo method
|
||||||
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||||
|
|
||||||
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
||||||
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
||||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||||
|
|
||||||
return products.stream()
|
return products.stream()
|
||||||
.map(p -> {
|
.map(p -> {
|
||||||
List<ProductOffer> offersForProduct =
|
List<ProductOffer> offersForProduct =
|
||||||
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
|
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
|
||||||
|
|
||||||
ProductOffer bestOffer = pickBestOffer(offersForProduct);
|
ProductOffer bestOffer = pickBestOffer(offersForProduct);
|
||||||
|
|
||||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||||
|
|
||||||
return ProductMapper.toSummary(p, price, buyUrl);
|
return ProductMapper.toSummary(p, price, buyUrl);
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) {
|
public Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) {
|
||||||
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
||||||
|
|
||||||
Page<Product> productPage;
|
Page<Product> productPage;
|
||||||
|
|
||||||
if (partRoles == null || partRoles.isEmpty()) {
|
if (partRoles == null || partRoles.isEmpty()) {
|
||||||
productPage = allPlatforms
|
productPage = allPlatforms
|
||||||
? productRepository.findAllWithBrand(pageable)
|
? productRepository.findAllWithBrand(pageable)
|
||||||
: productRepository.findByPlatformWithBrand(platform, pageable);
|
: productRepository.findByPlatformWithBrand(platform, pageable);
|
||||||
} else {
|
} else {
|
||||||
productPage = allPlatforms
|
productPage = allPlatforms
|
||||||
? productRepository.findByPartRoleInWithBrand(partRoles, pageable)
|
? productRepository.findByPartRoleInWithBrand(partRoles, pageable)
|
||||||
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable);
|
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Product> products = productPage.getContent();
|
List<Product> products = productPage.getContent();
|
||||||
if (products.isEmpty()) {
|
if (products.isEmpty()) {
|
||||||
return Page.empty(pageable);
|
return Page.empty(pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
||||||
|
|
||||||
// Only fetch offers for THIS PAGE of products
|
// Only fetch offers for THIS PAGE of products
|
||||||
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||||
|
|
||||||
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
||||||
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
||||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||||
|
|
||||||
List<ProductSummaryDto> dtos = products.stream()
|
List<ProductSummaryDto> dtos = products.stream()
|
||||||
.map(p -> {
|
.map(p -> {
|
||||||
List<ProductOffer> offersForProduct =
|
List<ProductOffer> offersForProduct =
|
||||||
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
|
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
|
||||||
|
|
||||||
ProductOffer bestOffer = pickBestOffer(offersForProduct);
|
ProductOffer bestOffer = pickBestOffer(offersForProduct);
|
||||||
|
|
||||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||||
|
|
||||||
return ProductMapper.toSummary(p, price, buyUrl);
|
return ProductMapper.toSummary(p, price, buyUrl);
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return new PageImpl<>(dtos, pageable, productPage.getTotalElements());
|
return new PageImpl<>(dtos, pageable, productPage.getTotalElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Product Offers
|
// Product Offers
|
||||||
//
|
//
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ProductOfferDto> getOffersForProduct(Integer productId) {
|
public List<ProductOfferDto> getOffersForProduct(Integer productId) {
|
||||||
// ✅ canonical repo method
|
// ✅ canonical repo method
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
||||||
|
|
||||||
return offers.stream()
|
return offers.stream()
|
||||||
.map(offer -> {
|
.map(offer -> {
|
||||||
ProductOfferDto dto = new ProductOfferDto();
|
ProductOfferDto dto = new ProductOfferDto();
|
||||||
dto.setId(offer.getId().toString());
|
dto.setId(offer.getId().toString());
|
||||||
dto.setMerchantName(offer.getMerchant().getName());
|
dto.setMerchantName(offer.getMerchant().getName());
|
||||||
dto.setPrice(offer.getEffectivePrice());
|
dto.setPrice(offer.getEffectivePrice());
|
||||||
dto.setOriginalPrice(offer.getOriginalPrice());
|
dto.setOriginalPrice(offer.getOriginalPrice());
|
||||||
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
|
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
|
||||||
dto.setBuyUrl(offer.getBuyUrl());
|
dto.setBuyUrl(offer.getBuyUrl());
|
||||||
dto.setLastUpdated(offer.getLastSeenAt());
|
dto.setLastUpdated(offer.getLastSeenAt());
|
||||||
return dto;
|
return dto;
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ProductSummaryDto getProductById(Integer productId) {
|
public ProductSummaryDto getProductById(Integer productId) {
|
||||||
Product product = productRepository.findById(productId).orElse(null);
|
Product product = productRepository.findById(productId).orElse(null);
|
||||||
if (product == null) return null;
|
if (product == null) return null;
|
||||||
|
|
||||||
// ✅ canonical repo method
|
// ✅ canonical repo method
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
||||||
ProductOffer bestOffer = pickBestOffer(offers);
|
ProductOffer bestOffer = pickBestOffer(offers);
|
||||||
|
|
||||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||||
|
|
||||||
return ProductMapper.toSummary(product, price, buyUrl);
|
return ProductMapper.toSummary(product, price, buyUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
||||||
if (offers == null || offers.isEmpty()) return null;
|
if (offers == null || offers.isEmpty()) return null;
|
||||||
|
|
||||||
// MVP: lowest effective price wins. (Later: prefer in-stock, etc.)
|
// MVP: lowest effective price wins. (Later: prefer in-stock, etc.)
|
||||||
return offers.stream()
|
return offers.stream()
|
||||||
.filter(o -> o.getEffectivePrice() != null)
|
.filter(o -> o.getEffectivePrice() != null)
|
||||||
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,175 +1,175 @@
|
|||||||
package group.goforward.battlbuilder.service.impl;
|
package group.goforward.battlbuilder.service.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.ImportStatus;
|
import group.goforward.battlbuilder.model.ImportStatus;
|
||||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||||
import group.goforward.battlbuilder.service.ReclassificationService;
|
import group.goforward.battlbuilder.service.ReclassificationService;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ReclassificationServiceImpl implements ReclassificationService {
|
public class ReclassificationServiceImpl implements ReclassificationService {
|
||||||
|
|
||||||
private static final String CLASSIFIER_VERSION = "v2025-12-28.1";
|
private static final String CLASSIFIER_VERSION = "v2025-12-28.1";
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||||
|
|
||||||
// ✅ Keep ONE constructor. Spring will inject both deps.
|
// ✅ Keep ONE constructor. Spring will inject both deps.
|
||||||
public ReclassificationServiceImpl(
|
public ReclassificationServiceImpl(
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
MerchantCategoryMappingService merchantCategoryMappingService
|
MerchantCategoryMappingService merchantCategoryMappingService
|
||||||
) {
|
) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Catalog category FK backfill
|
// Catalog category FK backfill
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public int applyCatalogCategoryMappingToProducts(
|
public int applyCatalogCategoryMappingToProducts(
|
||||||
Integer merchantId,
|
Integer merchantId,
|
||||||
String rawCategoryKey,
|
String rawCategoryKey,
|
||||||
Integer canonicalCategoryId
|
Integer canonicalCategoryId
|
||||||
) {
|
) {
|
||||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||||
if (rawCategoryKey == null || rawCategoryKey.isBlank())
|
if (rawCategoryKey == null || rawCategoryKey.isBlank())
|
||||||
throw new IllegalArgumentException("rawCategoryKey is required");
|
throw new IllegalArgumentException("rawCategoryKey is required");
|
||||||
|
|
||||||
return productRepository.applyCanonicalCategoryByPrimaryMerchantAndRawCategory(
|
return productRepository.applyCanonicalCategoryByPrimaryMerchantAndRawCategory(
|
||||||
merchantId,
|
merchantId,
|
||||||
rawCategoryKey.trim(),
|
rawCategoryKey.trim(),
|
||||||
canonicalCategoryId
|
canonicalCategoryId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional helper: bulk reclassify only PENDING_MAPPING for a merchant,
|
* Optional helper: bulk reclassify only PENDING_MAPPING for a merchant,
|
||||||
* using ONLY merchant_category_map (no rules, no inference).
|
* using ONLY merchant_category_map (no rules, no inference).
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public int reclassifyPendingForMerchant(Integer merchantId) {
|
public int reclassifyPendingForMerchant(Integer merchantId) {
|
||||||
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
|
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
|
||||||
|
|
||||||
List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId);
|
List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId);
|
||||||
if (pending == null || pending.isEmpty()) return 0;
|
if (pending == null || pending.isEmpty()) return 0;
|
||||||
|
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
List<Product> toSave = new ArrayList<>();
|
List<Product> toSave = new ArrayList<>();
|
||||||
int updated = 0;
|
int updated = 0;
|
||||||
|
|
||||||
for (Product p : pending) {
|
for (Product p : pending) {
|
||||||
if (p.getDeletedAt() != null) continue;
|
if (p.getDeletedAt() != null) continue;
|
||||||
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
|
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
|
||||||
|
|
||||||
String rawCategoryKey = p.getRawCategoryKey();
|
String rawCategoryKey = p.getRawCategoryKey();
|
||||||
if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue;
|
if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue;
|
||||||
|
|
||||||
String platformFinal = normalizePlatformOrNull(p.getPlatform());
|
String platformFinal = normalizePlatformOrNull(p.getPlatform());
|
||||||
|
|
||||||
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
|
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
|
||||||
merchantId, rawCategoryKey, platformFinal
|
merchantId, rawCategoryKey, platformFinal
|
||||||
);
|
);
|
||||||
if (mappedRole.isEmpty()) continue;
|
if (mappedRole.isEmpty()) continue;
|
||||||
|
|
||||||
String normalized = normalizePartRole(mappedRole.get());
|
String normalized = normalizePartRole(mappedRole.get());
|
||||||
if ("unknown".equals(normalized)) continue;
|
if ("unknown".equals(normalized)) continue;
|
||||||
|
|
||||||
String current = normalizePartRole(p.getPartRole());
|
String current = normalizePartRole(p.getPartRole());
|
||||||
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
|
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
|
||||||
|
|
||||||
p.setPartRole(normalized);
|
p.setPartRole(normalized);
|
||||||
p.setImportStatus(ImportStatus.MAPPED);
|
p.setImportStatus(ImportStatus.MAPPED);
|
||||||
|
|
||||||
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
|
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
|
||||||
p.setClassifierVersion(CLASSIFIER_VERSION);
|
p.setClassifierVersion(CLASSIFIER_VERSION);
|
||||||
p.setClassifiedAt(now);
|
p.setClassifiedAt(now);
|
||||||
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
|
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
|
||||||
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
|
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
|
||||||
|
|
||||||
toSave.add(p);
|
toSave.add(p);
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
|
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by MappingAdminService after creating/updating a mapping.
|
* Called by MappingAdminService after creating/updating a mapping.
|
||||||
* Applies mapping to all products for merchant+rawCategoryKey.
|
* Applies mapping to all products for merchant+rawCategoryKey.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
||||||
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
|
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
|
||||||
if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required");
|
if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required");
|
||||||
|
|
||||||
List<Product> products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey);
|
List<Product> products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey);
|
||||||
if (products == null || products.isEmpty()) return 0;
|
if (products == null || products.isEmpty()) return 0;
|
||||||
|
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
List<Product> toSave = new ArrayList<>();
|
List<Product> toSave = new ArrayList<>();
|
||||||
int updated = 0;
|
int updated = 0;
|
||||||
|
|
||||||
for (Product p : products) {
|
for (Product p : products) {
|
||||||
if (p.getDeletedAt() != null) continue;
|
if (p.getDeletedAt() != null) continue;
|
||||||
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
|
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
|
||||||
|
|
||||||
String platformFinal = normalizePlatformOrNull(p.getPlatform());
|
String platformFinal = normalizePlatformOrNull(p.getPlatform());
|
||||||
|
|
||||||
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
|
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
|
||||||
merchantId, rawCategoryKey, platformFinal
|
merchantId, rawCategoryKey, platformFinal
|
||||||
);
|
);
|
||||||
if (mappedRole.isEmpty()) continue;
|
if (mappedRole.isEmpty()) continue;
|
||||||
|
|
||||||
String normalized = normalizePartRole(mappedRole.get());
|
String normalized = normalizePartRole(mappedRole.get());
|
||||||
if ("unknown".equals(normalized)) continue;
|
if ("unknown".equals(normalized)) continue;
|
||||||
|
|
||||||
String current = normalizePartRole(p.getPartRole());
|
String current = normalizePartRole(p.getPartRole());
|
||||||
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
|
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
|
||||||
|
|
||||||
p.setPartRole(normalized);
|
p.setPartRole(normalized);
|
||||||
p.setImportStatus(ImportStatus.MAPPED);
|
p.setImportStatus(ImportStatus.MAPPED);
|
||||||
|
|
||||||
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
|
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
|
||||||
p.setClassifierVersion(CLASSIFIER_VERSION);
|
p.setClassifierVersion(CLASSIFIER_VERSION);
|
||||||
p.setClassifiedAt(now);
|
p.setClassifiedAt(now);
|
||||||
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
|
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
|
||||||
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
|
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
|
||||||
|
|
||||||
toSave.add(p);
|
toSave.add(p);
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
|
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------
|
// -----------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// -----------------
|
// -----------------
|
||||||
|
|
||||||
private String normalizePlatformOrNull(String platform) {
|
private String normalizePlatformOrNull(String platform) {
|
||||||
if (platform == null) return null;
|
if (platform == null) return null;
|
||||||
String t = platform.trim();
|
String t = platform.trim();
|
||||||
return t.isEmpty() ? null : t;
|
return t.isEmpty() ? null : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizePartRole(String partRole) {
|
private String normalizePartRole(String partRole) {
|
||||||
if (partRole == null) return "unknown";
|
if (partRole == null) return "unknown";
|
||||||
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
|
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
|
||||||
return t.isBlank() ? "unknown" : t;
|
return t.isBlank() ? "unknown" : t;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Services package for the BattlBuilder application.
|
* Services package for the BattlBuilder application.
|
||||||
* <p>
|
* <p>
|
||||||
* Contains business logic service classes for product management,
|
* Contains business logic service classes for product management,
|
||||||
* category classification, mapping recommendations, and merchant operations.
|
* category classification, mapping recommendations, and merchant operations.
|
||||||
*
|
*
|
||||||
* @author Forward Group, LLC
|
* @author Forward Group, LLC
|
||||||
* @version 1.0
|
* @version 1.0
|
||||||
* @since 2025-12-10
|
* @since 2025-12-10
|
||||||
*/
|
*/
|
||||||
package group.goforward.battlbuilder.service;
|
package group.goforward.battlbuilder.service;
|
||||||
|
|||||||
@@ -1,183 +1,183 @@
|
|||||||
package group.goforward.battlbuilder.service.utils;
|
package group.goforward.battlbuilder.service.utils;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||||
import io.minio.BucketExistsArgs;
|
import io.minio.BucketExistsArgs;
|
||||||
import io.minio.MakeBucketArgs;
|
import io.minio.MakeBucketArgs;
|
||||||
import io.minio.MinioClient;
|
import io.minio.MinioClient;
|
||||||
import io.minio.PutObjectArgs;
|
import io.minio.PutObjectArgs;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ImageUrlToMinioMigrator {
|
public class ImageUrlToMinioMigrator {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final MinioClient minioClient;
|
private final MinioClient minioClient;
|
||||||
|
|
||||||
private final String bucket;
|
private final String bucket;
|
||||||
private final String publicBaseUrl;
|
private final String publicBaseUrl;
|
||||||
|
|
||||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
.connectTimeout(Duration.ofSeconds(15))
|
.connectTimeout(Duration.ofSeconds(15))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
public ImageUrlToMinioMigrator(ProductRepository productRepository,
|
public ImageUrlToMinioMigrator(ProductRepository productRepository,
|
||||||
MinioClient minioClient,
|
MinioClient minioClient,
|
||||||
@Value("${minio.bucket}") String bucket,
|
@Value("${minio.bucket}") String bucket,
|
||||||
@Value("${minio.public-base-url}") String publicBaseUrl) {
|
@Value("${minio.public-base-url}") String publicBaseUrl) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.minioClient = minioClient;
|
this.minioClient = minioClient;
|
||||||
this.bucket = bucket;
|
this.bucket = bucket;
|
||||||
this.publicBaseUrl = trimTrailingSlash(publicBaseUrl);
|
this.publicBaseUrl = trimTrailingSlash(publicBaseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrates Product.mainImageUrl URLs to MinIO and rewrites mainImageUrl to the new location.
|
* Migrates Product.mainImageUrl URLs to MinIO and rewrites mainImageUrl to the new location.
|
||||||
*
|
*
|
||||||
* @param pageSize batch size for DB paging
|
* @param pageSize batch size for DB paging
|
||||||
* @param dryRun if true: download+upload is skipped and DB is not updated
|
* @param dryRun if true: download+upload is skipped and DB is not updated
|
||||||
* @param maxItems optional cap for safety (null = no cap)
|
* @param maxItems optional cap for safety (null = no cap)
|
||||||
* @return count of successfully migrated products
|
* @return count of successfully migrated products
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public int migrateMainImages(int pageSize, boolean dryRun, Integer maxItems) {
|
public int migrateMainImages(int pageSize, boolean dryRun, Integer maxItems) {
|
||||||
ensureBucketExists();
|
ensureBucketExists();
|
||||||
|
|
||||||
int migrated = 0;
|
int migrated = 0;
|
||||||
int page = 0;
|
int page = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (maxItems != null && migrated >= maxItems) break;
|
if (maxItems != null && migrated >= maxItems) break;
|
||||||
|
|
||||||
Page<Product> batch = productRepository
|
Page<Product> batch = productRepository
|
||||||
.findNeedingBattlImageUrlMigration(PageRequest.of(page, pageSize));
|
.findNeedingBattlImageUrlMigration(PageRequest.of(page, pageSize));
|
||||||
if (batch.isEmpty()) break;
|
if (batch.isEmpty()) break;
|
||||||
|
|
||||||
for (Product p : batch.getContent()) {
|
for (Product p : batch.getContent()) {
|
||||||
if (maxItems != null && migrated >= maxItems) break;
|
if (maxItems != null && migrated >= maxItems) break;
|
||||||
|
|
||||||
String sourceUrl = p.getMainImageUrl();
|
String sourceUrl = p.getMainImageUrl();
|
||||||
if (sourceUrl == null || sourceUrl.isBlank()) continue;
|
if (sourceUrl == null || sourceUrl.isBlank()) continue;
|
||||||
|
|
||||||
// Extra safety: skip if already set (covers any edge cases outside the query)
|
// Extra safety: skip if already set (covers any edge cases outside the query)
|
||||||
if (p.getBattlImageUrl() != null && !p.getBattlImageUrl().trim().isEmpty()) continue;
|
if (p.getBattlImageUrl() != null && !p.getBattlImageUrl().trim().isEmpty()) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
String newUrl = uploadFromUrlToMinio(p, sourceUrl);
|
String newUrl = uploadFromUrlToMinio(p, sourceUrl);
|
||||||
p.setBattlImageUrl(newUrl);
|
p.setBattlImageUrl(newUrl);
|
||||||
productRepository.save(p);
|
productRepository.save(p);
|
||||||
}
|
}
|
||||||
migrated++;
|
migrated++;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
// fail-soft: continue migrating other products
|
// fail-soft: continue migrating other products
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!batch.hasNext()) break;
|
if (!batch.hasNext()) break;
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return migrated;
|
return migrated;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String uploadFromUrlToMinio(Product p, String sourceUrl) throws Exception {
|
private String uploadFromUrlToMinio(Product p, String sourceUrl) throws Exception {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(sourceUrl))
|
.uri(URI.create(sourceUrl))
|
||||||
.timeout(Duration.ofSeconds(60))
|
.timeout(Duration.ofSeconds(60))
|
||||||
.GET()
|
.GET()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||||
|
|
||||||
int status = response.statusCode();
|
int status = response.statusCode();
|
||||||
if (status < 200 || status >= 300) {
|
if (status < 200 || status >= 300) {
|
||||||
throw new IllegalStateException("Failed to download image. HTTP " + status + " url=" + sourceUrl);
|
throw new IllegalStateException("Failed to download image. HTTP " + status + " url=" + sourceUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
String contentType = response.headers()
|
String contentType = response.headers()
|
||||||
.firstValue("content-type")
|
.firstValue("content-type")
|
||||||
.map(v -> v.split(";", 2)[0].trim())
|
.map(v -> v.split(";", 2)[0].trim())
|
||||||
.orElse("application/octet-stream");
|
.orElse("application/octet-stream");
|
||||||
|
|
||||||
long contentLength = response.headers()
|
long contentLength = response.headers()
|
||||||
.firstValue("content-length")
|
.firstValue("content-length")
|
||||||
.flatMap(ImageUrlToMinioMigrator::parseLongSafe)
|
.flatMap(ImageUrlToMinioMigrator::parseLongSafe)
|
||||||
.orElse(-1L);
|
.orElse(-1L);
|
||||||
|
|
||||||
String ext = extensionForContentType(contentType);
|
String ext = extensionForContentType(contentType);
|
||||||
|
|
||||||
// Store under a stable key; adjust if you want per-merchant, hashed names, etc.
|
// Store under a stable key; adjust if you want per-merchant, hashed names, etc.
|
||||||
String objectName = "products/" + p.getId() + "/main" + ext;
|
String objectName = "products/" + p.getId() + "/main" + ext;
|
||||||
|
|
||||||
try (InputStream in = response.body()) {
|
try (InputStream in = response.body()) {
|
||||||
PutObjectArgs.Builder put = PutObjectArgs.builder()
|
PutObjectArgs.Builder put = PutObjectArgs.builder()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.object(objectName)
|
.object(objectName)
|
||||||
.contentType(contentType);
|
.contentType(contentType);
|
||||||
|
|
||||||
if (contentLength >= 0) {
|
if (contentLength >= 0) {
|
||||||
put.stream(in, contentLength, -1);
|
put.stream(in, contentLength, -1);
|
||||||
} else {
|
} else {
|
||||||
put.stream(in, -1, 10L * 1024 * 1024);
|
put.stream(in, -1, 10L * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
minioClient.putObject(put.build());
|
minioClient.putObject(put.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
return publicBaseUrl + "/" + bucket + "/" + objectName;
|
return publicBaseUrl + "/" + bucket + "/" + objectName;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureBucketExists() {
|
private void ensureBucketExists() {
|
||||||
try {
|
try {
|
||||||
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
|
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
|
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new IllegalStateException("Unable to ensure MinIO bucket exists: " + bucket, e);
|
throw new IllegalStateException("Unable to ensure MinIO bucket exists: " + bucket, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean looksAlreadyMigrated(String url) {
|
private boolean looksAlreadyMigrated(String url) {
|
||||||
String prefix = publicBaseUrl + "/" + bucket + "/";
|
String prefix = publicBaseUrl + "/" + bucket + "/";
|
||||||
return url.startsWith(prefix);
|
return url.startsWith(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Optional<Long> parseLongSafe(String v) {
|
private static Optional<Long> parseLongSafe(String v) {
|
||||||
try {
|
try {
|
||||||
return Optional.of(Long.parseLong(v));
|
return Optional.of(Long.parseLong(v));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String extensionForContentType(String contentType) {
|
private static String extensionForContentType(String contentType) {
|
||||||
String ct = contentType.toLowerCase(Locale.ROOT);
|
String ct = contentType.toLowerCase(Locale.ROOT);
|
||||||
if (ct.equals("image/jpeg") || ct.equals("image/jpg")) return ".jpg";
|
if (ct.equals("image/jpeg") || ct.equals("image/jpg")) return ".jpg";
|
||||||
if (ct.equals("image/png")) return ".png";
|
if (ct.equals("image/png")) return ".png";
|
||||||
if (ct.equals("image/webp")) return ".webp";
|
if (ct.equals("image/webp")) return ".webp";
|
||||||
if (ct.equals("image/gif")) return ".gif";
|
if (ct.equals("image/gif")) return ".gif";
|
||||||
if (ct.equals("image/svg+xml")) return ".svg";
|
if (ct.equals("image/svg+xml")) return ".svg";
|
||||||
return ".bin";
|
return ".bin";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String trimTrailingSlash(String s) {
|
private static String trimTrailingSlash(String s) {
|
||||||
if (s == null) return "";
|
if (s == null) return "";
|
||||||
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
package group.goforward.battlbuilder.service.utils;
|
package group.goforward.battlbuilder.service.utils;
|
||||||
|
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Profile("migrate-images-to-minio")
|
@Profile("migrate-images-to-minio")
|
||||||
public class MigrateProductImagesToMinioRunner implements CommandLineRunner {
|
public class MigrateProductImagesToMinioRunner implements CommandLineRunner {
|
||||||
|
|
||||||
private final ImageUrlToMinioMigrator migrator;
|
private final ImageUrlToMinioMigrator migrator;
|
||||||
|
|
||||||
public MigrateProductImagesToMinioRunner(ImageUrlToMinioMigrator migrator) {
|
public MigrateProductImagesToMinioRunner(ImageUrlToMinioMigrator migrator) {
|
||||||
this.migrator = migrator;
|
this.migrator = migrator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(String... args) {
|
public void run(String... args) {
|
||||||
// Tune as needed. Start small; you can remove maxItems once you're confident.
|
// Tune as needed. Start small; you can remove maxItems once you're confident.
|
||||||
int migrated = migrator.migrateMainImages(
|
int migrated = migrator.migrateMainImages(
|
||||||
200, // pageSize
|
200, // pageSize
|
||||||
false, // dryRun
|
false, // dryRun
|
||||||
1000 // maxItems safety cap
|
1000 // maxItems safety cap
|
||||||
);
|
);
|
||||||
|
|
||||||
System.out.println("Migrated product images: " + migrated);
|
System.out.println("Migrated product images: " + migrated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user