mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 08:41:04 -05:00
cleanup
This commit is contained in:
@@ -1,87 +1,87 @@
|
||||
package group.goforward.battlbuilder.catalog.classification;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartRoleRule;
|
||||
import group.goforward.battlbuilder.repo.PartRoleRuleRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
public class PartRoleResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PartRoleResolver.class);
|
||||
|
||||
private final PartRoleRuleRepository repo;
|
||||
|
||||
private final List<CompiledRule> rules = new ArrayList<>();
|
||||
|
||||
public PartRoleResolver(PartRoleRuleRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void load() {
|
||||
rules.clear();
|
||||
|
||||
List<PartRoleRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||
for (PartRoleRule r : active) {
|
||||
try {
|
||||
rules.add(new CompiledRule(
|
||||
r.getId(),
|
||||
r.getTargetPlatform(),
|
||||
Pattern.compile(r.getNameRegex(), Pattern.CASE_INSENSITIVE),
|
||||
normalizeRole(r.getTargetPartRole())
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.warn("Skipping invalid part role rule id={} regex={} err={}",
|
||||
r.getId(), r.getNameRegex(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Loaded {} part role rules", rules.size());
|
||||
}
|
||||
|
||||
public String resolve(String platform, String productName, String rawCategoryKey) {
|
||||
String p = normalizePlatform(platform);
|
||||
|
||||
// we match primarily on productName; optionally also include rawCategoryKey in the text blob
|
||||
String text = (productName == null ? "" : productName) +
|
||||
" " +
|
||||
(rawCategoryKey == null ? "" : rawCategoryKey);
|
||||
|
||||
for (CompiledRule r : rules) {
|
||||
if (!r.appliesToPlatform(p)) continue;
|
||||
if (r.pattern.matcher(text).find()) {
|
||||
return r.targetPartRole; // already normalized
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String normalizeRole(String role) {
|
||||
if (role == null) return null;
|
||||
String t = role.trim();
|
||||
if (t.isEmpty()) return null;
|
||||
return t.toLowerCase(Locale.ROOT).replace('_','-');
|
||||
}
|
||||
|
||||
private static String normalizePlatform(String platform) {
|
||||
if (platform == null) return null;
|
||||
String t = platform.trim();
|
||||
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private record CompiledRule(Long id, String targetPlatform, Pattern pattern, String targetPartRole) {
|
||||
boolean appliesToPlatform(String platform) {
|
||||
if (targetPlatform == null || targetPlatform.isBlank()) return true;
|
||||
if (platform == null) return false;
|
||||
return targetPlatform.trim().equalsIgnoreCase(platform);
|
||||
}
|
||||
}
|
||||
package group.goforward.battlbuilder.catalog.classification;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartRoleRule;
|
||||
import group.goforward.battlbuilder.repo.PartRoleRuleRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
public class PartRoleResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PartRoleResolver.class);
|
||||
|
||||
private final PartRoleRuleRepository repo;
|
||||
|
||||
private final List<CompiledRule> rules = new ArrayList<>();
|
||||
|
||||
public PartRoleResolver(PartRoleRuleRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void load() {
|
||||
rules.clear();
|
||||
|
||||
List<PartRoleRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||
for (PartRoleRule r : active) {
|
||||
try {
|
||||
rules.add(new CompiledRule(
|
||||
r.getId(),
|
||||
r.getTargetPlatform(),
|
||||
Pattern.compile(r.getNameRegex(), Pattern.CASE_INSENSITIVE),
|
||||
normalizeRole(r.getTargetPartRole())
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.warn("Skipping invalid part role rule id={} regex={} err={}",
|
||||
r.getId(), r.getNameRegex(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Loaded {} part role rules", rules.size());
|
||||
}
|
||||
|
||||
public String resolve(String platform, String productName, String rawCategoryKey) {
|
||||
String p = normalizePlatform(platform);
|
||||
|
||||
// we match primarily on productName; optionally also include rawCategoryKey in the text blob
|
||||
String text = (productName == null ? "" : productName) +
|
||||
" " +
|
||||
(rawCategoryKey == null ? "" : rawCategoryKey);
|
||||
|
||||
for (CompiledRule r : rules) {
|
||||
if (!r.appliesToPlatform(p)) continue;
|
||||
if (r.pattern.matcher(text).find()) {
|
||||
return r.targetPartRole; // already normalized
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String normalizeRole(String role) {
|
||||
if (role == null) return null;
|
||||
String t = role.trim();
|
||||
if (t.isEmpty()) return null;
|
||||
return t.toLowerCase(Locale.ROOT).replace('_','-');
|
||||
}
|
||||
|
||||
private static String normalizePlatform(String platform) {
|
||||
if (platform == null) return null;
|
||||
String t = platform.trim();
|
||||
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private record CompiledRule(Long id, String targetPlatform, Pattern pattern, String targetPartRole) {
|
||||
boolean appliesToPlatform(String platform) {
|
||||
if (targetPlatform == null || targetPlatform.isBlank()) return true;
|
||||
if (platform == null) return false;
|
||||
return targetPlatform.trim().equalsIgnoreCase(platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
package group.goforward.battlbuilder.catalog.classification;
|
||||
|
||||
/**
|
||||
* Result returned by PlatformResolver.
|
||||
* <p>
|
||||
* Any of the fields may be null — the importer will only overwrite
|
||||
* product.platform, product.partRole, or product.configuration
|
||||
* when the returned value is non-null AND non-blank.
|
||||
*/
|
||||
public record PlatformResolutionResult(
|
||||
String platform,
|
||||
String partRole,
|
||||
String configuration
|
||||
) {
|
||||
|
||||
public static PlatformResolutionResult empty() {
|
||||
return new PlatformResolutionResult(null, null, null);
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return (platform == null || platform.isBlank()) &&
|
||||
(partRole == null || partRole.isBlank()) &&
|
||||
(configuration == null || configuration.isBlank());
|
||||
}
|
||||
package group.goforward.battlbuilder.catalog.classification;
|
||||
|
||||
/**
|
||||
* Result returned by PlatformResolver.
|
||||
* <p>
|
||||
* Any of the fields may be null — the importer will only overwrite
|
||||
* product.platform, product.partRole, or product.configuration
|
||||
* when the returned value is non-null AND non-blank.
|
||||
*/
|
||||
public record PlatformResolutionResult(
|
||||
String platform,
|
||||
String partRole,
|
||||
String configuration
|
||||
) {
|
||||
|
||||
public static PlatformResolutionResult empty() {
|
||||
return new PlatformResolutionResult(null, null, null);
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return (platform == null || platform.isBlank()) &&
|
||||
(partRole == null || partRole.isBlank()) &&
|
||||
(configuration == null || configuration.isBlank());
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,139 @@
|
||||
package group.goforward.battlbuilder.catalog.classification;
|
||||
|
||||
import group.goforward.battlbuilder.model.PlatformRule;
|
||||
import group.goforward.battlbuilder.repo.PlatformRuleRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Resolves a product's PLATFORM (e.g. AR-15, AR-10, NOT-SUPPORTED)
|
||||
* using explicit DB-backed rules.
|
||||
* <p>
|
||||
* Conservative approach:
|
||||
* - If a rule matches, return its target_platform
|
||||
* - If nothing matches, return null and let the caller decide fallback behavior
|
||||
*/
|
||||
@Component
|
||||
public class PlatformResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class);
|
||||
|
||||
public static final String NOT_SUPPORTED = "NOT-SUPPORTED";
|
||||
|
||||
private final PlatformRuleRepository repo;
|
||||
private final List<CompiledRule> rules = new ArrayList<>();
|
||||
|
||||
public PlatformResolver(PlatformRuleRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void load() {
|
||||
rules.clear();
|
||||
|
||||
List<PlatformRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||
|
||||
for (PlatformRule r : active) {
|
||||
try {
|
||||
Pattern rawCat = compileNullable(r.getRawCategoryPattern());
|
||||
Pattern name = compileNullable(r.getNameRegex());
|
||||
String target = normalizePlatform(r.getTargetPlatform());
|
||||
|
||||
// If a rule has no matchers, it's useless — skip it.
|
||||
if (rawCat == null && name == null) {
|
||||
log.warn("Skipping platform rule id={} because it has no patterns (raw_category_pattern/name_regex both blank)", r.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target == null || target.isBlank()) {
|
||||
log.warn("Skipping platform rule id={} because target_platform is blank", r.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
rules.add(new CompiledRule(
|
||||
r.getId(),
|
||||
r.getMerchantId(),
|
||||
r.getBrandId(),
|
||||
rawCat,
|
||||
name,
|
||||
target
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.warn("Skipping invalid platform rule id={} err={}", r.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Loaded {} platform rules", rules.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return platform string (e.g. AR-15, AR-10, NOT-SUPPORTED) or null if no rule matches.
|
||||
*/
|
||||
public String resolve(Long merchantId, Long brandId, String productName, String rawCategoryKey) {
|
||||
String text = safe(productName) + " " + safe(rawCategoryKey);
|
||||
|
||||
for (CompiledRule r : rules) {
|
||||
if (!r.appliesToMerchant(merchantId)) continue;
|
||||
if (!r.appliesToBrand(brandId)) continue;
|
||||
|
||||
if (r.matches(text)) {
|
||||
return r.targetPlatform;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Helpers
|
||||
// -----------------------------
|
||||
|
||||
private static Pattern compileNullable(String regex) {
|
||||
if (regex == null || regex.isBlank()) return null;
|
||||
return Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
private static String normalizePlatform(String platform) {
|
||||
if (platform == null) return null;
|
||||
String t = platform.trim();
|
||||
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String safe(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Internal model
|
||||
// -----------------------------
|
||||
|
||||
private record CompiledRule(
|
||||
Long id,
|
||||
Long merchantId,
|
||||
Long brandId,
|
||||
Pattern rawCategoryPattern,
|
||||
Pattern namePattern,
|
||||
String targetPlatform
|
||||
) {
|
||||
boolean appliesToMerchant(Long merchantId) {
|
||||
return this.merchantId == null || this.merchantId.equals(merchantId);
|
||||
}
|
||||
|
||||
boolean appliesToBrand(Long brandId) {
|
||||
return this.brandId == null || this.brandId.equals(brandId);
|
||||
}
|
||||
|
||||
boolean matches(String text) {
|
||||
if (rawCategoryPattern != null && rawCategoryPattern.matcher(text).find()) return true;
|
||||
if (namePattern != null && namePattern.matcher(text).find()) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
package group.goforward.battlbuilder.catalog.classification;
|
||||
|
||||
import group.goforward.battlbuilder.model.PlatformRule;
|
||||
import group.goforward.battlbuilder.repo.PlatformRuleRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Resolves a product's PLATFORM (e.g. AR-15, AR-10, NOT-SUPPORTED)
|
||||
* using explicit DB-backed rules.
|
||||
* <p>
|
||||
* Conservative approach:
|
||||
* - If a rule matches, return its target_platform
|
||||
* - If nothing matches, return null and let the caller decide fallback behavior
|
||||
*/
|
||||
@Component
|
||||
public class PlatformResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class);
|
||||
|
||||
public static final String NOT_SUPPORTED = "NOT-SUPPORTED";
|
||||
|
||||
private final PlatformRuleRepository repo;
|
||||
private final List<CompiledRule> rules = new ArrayList<>();
|
||||
|
||||
public PlatformResolver(PlatformRuleRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void load() {
|
||||
rules.clear();
|
||||
|
||||
List<PlatformRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||
|
||||
for (PlatformRule r : active) {
|
||||
try {
|
||||
Pattern rawCat = compileNullable(r.getRawCategoryPattern());
|
||||
Pattern name = compileNullable(r.getNameRegex());
|
||||
String target = normalizePlatform(r.getTargetPlatform());
|
||||
|
||||
// If a rule has no matchers, it's useless — skip it.
|
||||
if (rawCat == null && name == null) {
|
||||
log.warn("Skipping platform rule id={} because it has no patterns (raw_category_pattern/name_regex both blank)", r.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target == null || target.isBlank()) {
|
||||
log.warn("Skipping platform rule id={} because target_platform is blank", r.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
rules.add(new CompiledRule(
|
||||
r.getId(),
|
||||
r.getMerchantId(),
|
||||
r.getBrandId(),
|
||||
rawCat,
|
||||
name,
|
||||
target
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.warn("Skipping invalid platform rule id={} err={}", r.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Loaded {} platform rules", rules.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return platform string (e.g. AR-15, AR-10, NOT-SUPPORTED) or null if no rule matches.
|
||||
*/
|
||||
public String resolve(Long merchantId, Long brandId, String productName, String rawCategoryKey) {
|
||||
String text = safe(productName) + " " + safe(rawCategoryKey);
|
||||
|
||||
for (CompiledRule r : rules) {
|
||||
if (!r.appliesToMerchant(merchantId)) continue;
|
||||
if (!r.appliesToBrand(brandId)) continue;
|
||||
|
||||
if (r.matches(text)) {
|
||||
return r.targetPlatform;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Helpers
|
||||
// -----------------------------
|
||||
|
||||
private static Pattern compileNullable(String regex) {
|
||||
if (regex == null || regex.isBlank()) return null;
|
||||
return Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
private static String normalizePlatform(String platform) {
|
||||
if (platform == null) return null;
|
||||
String t = platform.trim();
|
||||
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String safe(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Internal model
|
||||
// -----------------------------
|
||||
|
||||
private record CompiledRule(
|
||||
Long id,
|
||||
Long merchantId,
|
||||
Long brandId,
|
||||
Pattern rawCategoryPattern,
|
||||
Pattern namePattern,
|
||||
String targetPlatform
|
||||
) {
|
||||
boolean appliesToMerchant(Long merchantId) {
|
||||
return this.merchantId == null || this.merchantId.equals(merchantId);
|
||||
}
|
||||
|
||||
boolean appliesToBrand(Long brandId) {
|
||||
return this.brandId == null || this.brandId.equals(brandId);
|
||||
}
|
||||
|
||||
boolean matches(String text) {
|
||||
if (rawCategoryPattern != null && rawCategoryPattern.matcher(text).find()) return true;
|
||||
if (namePattern != null && namePattern.matcher(text).find()) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,245 +1,245 @@
|
||||
package group.goforward.battlbuilder.classification.admin;
|
||||
|
||||
import group.goforward.battlbuilder.classification.ClassificationResult;
|
||||
import group.goforward.battlbuilder.classification.ProductClassifier;
|
||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class ClassificationReconcileService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductClassifier productClassifier;
|
||||
private final ProductOfferRepository productOfferRepository;
|
||||
|
||||
public ClassificationReconcileService(ProductRepository productRepository,
|
||||
ProductClassifier productClassifier,
|
||||
ProductOfferRepository productOfferRepository) {
|
||||
this.productRepository = productRepository;
|
||||
this.productClassifier = productClassifier;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
|
||||
}
|
||||
|
||||
public ReconcileResponse reconcile(ReconcileRequest req) {
|
||||
|
||||
int limit = req.limit();
|
||||
boolean dryRun = req.dryRun();
|
||||
|
||||
// Page in chunks until we hit limit.
|
||||
final int pageSize = Math.min(250, limit);
|
||||
int scanned = 0;
|
||||
int page = 0;
|
||||
|
||||
// Counts by reconcile outcome.
|
||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
counts.put("UNCHANGED", 0);
|
||||
counts.put("WOULD_UPDATE", 0);
|
||||
counts.put("LOCKED", 0);
|
||||
counts.put("IGNORED", 0);
|
||||
counts.put("UNMAPPED", 0);
|
||||
counts.put("CONFLICT", 0);
|
||||
counts.put("RULE_MATCHED", 0);
|
||||
|
||||
// Sample rows to inspect quickly in API response.
|
||||
List<ReconcileDiffRow> samples = new ArrayList<>();
|
||||
|
||||
// Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1)
|
||||
Map<String, Optional<MerchantCategoryMap>> mappingMemo = new HashMap<>();
|
||||
|
||||
|
||||
while (scanned < limit) {
|
||||
var pageable = PageRequest.of(page, pageSize);
|
||||
|
||||
// Merchant is inferred via offers; this query limits products to those with offers for req.merchantId if provided.
|
||||
var batch = productRepository.pageActiveProductsByOfferMerchant(req.merchantId(), req.platform(), pageable);
|
||||
if (batch.isEmpty()) break;
|
||||
|
||||
// Avoid N+1: resolve primary merchant for ALL products in this page in one query
|
||||
List<Integer> productIds = batch.getContent().stream()
|
||||
.map(Product::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
|
||||
Map<Integer, Integer> primaryMerchantByProductId = new HashMap<>();
|
||||
if (!productIds.isEmpty()) {
|
||||
productOfferRepository.findPrimaryMerchantsByFirstSeenForProductIds(productIds)
|
||||
.forEach(r -> primaryMerchantByProductId.put(r.getProductId(), r.getMerchantId()));
|
||||
}
|
||||
|
||||
for (Product p : batch.getContent()) {
|
||||
if (scanned >= limit) break;
|
||||
|
||||
// Optional: skip locked products unless includeLocked=true.
|
||||
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
|
||||
if (!req.includeLocked() && locked) {
|
||||
counts.compute("LOCKED", (k, v) -> v + 1);
|
||||
scanned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Run the classifier (merchant_category_map + other logic inside classifier).
|
||||
Integer resolvedMerchantId = primaryMerchantByProductId.get(p.getId());
|
||||
ClassificationResult resolved = productClassifier.classifyProduct(p, resolvedMerchantId, mappingMemo);
|
||||
|
||||
if (resolved != null && resolved.source() != null && resolved.source().startsWith("rules_")) {
|
||||
counts.compute("RULE_MATCHED", (k, v) -> v + 1);
|
||||
}
|
||||
|
||||
// Compute diff status (dry-run only right now).
|
||||
DiffOutcome outcome = diff(p, resolved);
|
||||
|
||||
counts.compute(outcome.status, (k, v) -> v + 1);
|
||||
scanned++;
|
||||
|
||||
// Keep a small sample set for inspection.
|
||||
boolean interestingStatus =
|
||||
outcome.status.equals("WOULD_UPDATE")
|
||||
|| outcome.status.equals("CONFLICT")
|
||||
|| outcome.status.equals("UNMAPPED")
|
||||
|| outcome.status.equals("IGNORED");
|
||||
|
||||
boolean ruleHit =
|
||||
resolved != null
|
||||
&& resolved.source() != null
|
||||
&& resolved.source().startsWith("rules_");
|
||||
|
||||
// Keep a small sample set for inspection.
|
||||
// Include rule hits even if the product ends up UNCHANGED, so we can verify rules are working.
|
||||
if (samples.size() < 50 && (interestingStatus || ruleHit)) {
|
||||
samples.add(toRow(p, resolved, outcome.status, outcome.meta));
|
||||
}
|
||||
}
|
||||
|
||||
if (!batch.hasNext()) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
// Dry-run only right now—no writes.
|
||||
return new ReconcileResponse(dryRun, scanned, counts, samples);
|
||||
}
|
||||
|
||||
private static class DiffOutcome {
|
||||
final String status;
|
||||
final Map<String, Object> meta;
|
||||
|
||||
DiffOutcome(String status, Map<String, Object> meta) {
|
||||
this.status = status;
|
||||
this.meta = meta;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute reconcile status by comparing:
|
||||
* - existing product role/source/locks
|
||||
* - classifier-resolved role/source/confidence/reason
|
||||
*/
|
||||
private DiffOutcome diff(Product p, ClassificationResult resolved) {
|
||||
|
||||
String existingRole = trimToNull(p.getPartRole());
|
||||
String resolvedRole = resolved == null ? null : trimToNull(resolved.partRole());
|
||||
|
||||
// Respect locks (never propose changes).
|
||||
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
|
||||
if (locked) {
|
||||
return new DiffOutcome("LOCKED", Map.of("note", "partRoleLocked or platformLocked"));
|
||||
}
|
||||
|
||||
// Treat known "ignored category" decisions as their own outcome bucket.
|
||||
if (isIgnored(resolved)) {
|
||||
return new DiffOutcome("IGNORED", Map.of(
|
||||
"reason", resolved.reason(),
|
||||
"source", resolved.source()
|
||||
));
|
||||
}
|
||||
|
||||
// No role resolved = still unmapped (needs merchant_category_map or rule-based inference later).
|
||||
if (resolvedRole == null) {
|
||||
return new DiffOutcome("UNMAPPED", Map.of(
|
||||
"reason", resolved == null ? null : resolved.reason(),
|
||||
"source", resolved == null ? null : resolved.source(),
|
||||
"confidence", resolved == null ? null : resolved.confidence()
|
||||
));
|
||||
}
|
||||
|
||||
// Existing role missing but we resolved one => would update.
|
||||
if (existingRole == null) {
|
||||
return new DiffOutcome("WOULD_UPDATE", Map.of("from", null, "to", resolvedRole));
|
||||
}
|
||||
|
||||
// Same role => unchanged.
|
||||
if (existingRole.equalsIgnoreCase(resolvedRole)) {
|
||||
return new DiffOutcome("UNCHANGED", Map.of());
|
||||
}
|
||||
|
||||
// If existing role came from an override, flag as conflict (don't auto-clobber).
|
||||
PartRoleSource existingSource = p.getPartRoleSource();
|
||||
if (existingSource == PartRoleSource.OVERRIDE) {
|
||||
return new DiffOutcome(
|
||||
"CONFLICT",
|
||||
Map.of("from", existingRole, "to", resolvedRole, "note", "existing source is OVERRIDE")
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, it’s a normal drift that we would update in apply-mode.
|
||||
return new DiffOutcome("WOULD_UPDATE", Map.of("from", existingRole, "to", resolvedRole));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the "non-classifying category ignored" decisions from the classifier.
|
||||
* Right now we key off the reason text prefix (fast + simple).
|
||||
* Later we can formalize this via a dedicated source or meta flag.
|
||||
*/
|
||||
private boolean isIgnored(ClassificationResult r) {
|
||||
return r != null && "ignored_category".equalsIgnoreCase(r.source());
|
||||
}
|
||||
|
||||
private ReconcileDiffRow toRow(Product p, ClassificationResult resolved, String status, Map<String, Object> meta) {
|
||||
|
||||
// Extract the merchant used by classifier (if provided in meta).
|
||||
Integer resolvedMerchantId = null;
|
||||
if (resolved != null && resolved.meta() != null) {
|
||||
Object v = resolved.meta().get("resolvedMerchantId");
|
||||
if (v instanceof Integer i) resolvedMerchantId = i;
|
||||
else if (v instanceof Number n) resolvedMerchantId = n.intValue();
|
||||
else if (v instanceof String s) {
|
||||
try { resolvedMerchantId = Integer.parseInt(s); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
return new ReconcileDiffRow(
|
||||
p.getId(),
|
||||
p.getName(),
|
||||
p.getPlatform(),
|
||||
p.getRawCategoryKey(),
|
||||
|
||||
resolvedMerchantId,
|
||||
|
||||
p.getPartRole(),
|
||||
p.getPartRoleSource() == null ? null : p.getPartRoleSource().name(),
|
||||
p.getPartRoleLocked(),
|
||||
p.getPlatformLocked(),
|
||||
|
||||
resolved == null ? null : resolved.partRole(),
|
||||
resolved == null ? null : resolved.source(),
|
||||
resolved == null ? 0.0 : resolved.confidence(),
|
||||
resolved == null ? null : resolved.reason(),
|
||||
|
||||
status,
|
||||
meta == null ? Map.of() : meta
|
||||
);
|
||||
}
|
||||
|
||||
private static String trimToNull(String s) {
|
||||
if (s == null) return null;
|
||||
String t = s.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
package group.goforward.battlbuilder.classification.admin;
|
||||
|
||||
import group.goforward.battlbuilder.classification.ClassificationResult;
|
||||
import group.goforward.battlbuilder.classification.ProductClassifier;
|
||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class ClassificationReconcileService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductClassifier productClassifier;
|
||||
private final ProductOfferRepository productOfferRepository;
|
||||
|
||||
public ClassificationReconcileService(ProductRepository productRepository,
|
||||
ProductClassifier productClassifier,
|
||||
ProductOfferRepository productOfferRepository) {
|
||||
this.productRepository = productRepository;
|
||||
this.productClassifier = productClassifier;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
|
||||
}
|
||||
|
||||
public ReconcileResponse reconcile(ReconcileRequest req) {
|
||||
|
||||
int limit = req.limit();
|
||||
boolean dryRun = req.dryRun();
|
||||
|
||||
// Page in chunks until we hit limit.
|
||||
final int pageSize = Math.min(250, limit);
|
||||
int scanned = 0;
|
||||
int page = 0;
|
||||
|
||||
// Counts by reconcile outcome.
|
||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
counts.put("UNCHANGED", 0);
|
||||
counts.put("WOULD_UPDATE", 0);
|
||||
counts.put("LOCKED", 0);
|
||||
counts.put("IGNORED", 0);
|
||||
counts.put("UNMAPPED", 0);
|
||||
counts.put("CONFLICT", 0);
|
||||
counts.put("RULE_MATCHED", 0);
|
||||
|
||||
// Sample rows to inspect quickly in API response.
|
||||
List<ReconcileDiffRow> samples = new ArrayList<>();
|
||||
|
||||
// Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1)
|
||||
Map<String, Optional<MerchantCategoryMap>> mappingMemo = new HashMap<>();
|
||||
|
||||
|
||||
while (scanned < limit) {
|
||||
var pageable = PageRequest.of(page, pageSize);
|
||||
|
||||
// Merchant is inferred via offers; this query limits products to those with offers for req.merchantId if provided.
|
||||
var batch = productRepository.pageActiveProductsByOfferMerchant(req.merchantId(), req.platform(), pageable);
|
||||
if (batch.isEmpty()) break;
|
||||
|
||||
// Avoid N+1: resolve primary merchant for ALL products in this page in one query
|
||||
List<Integer> productIds = batch.getContent().stream()
|
||||
.map(Product::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
|
||||
Map<Integer, Integer> primaryMerchantByProductId = new HashMap<>();
|
||||
if (!productIds.isEmpty()) {
|
||||
productOfferRepository.findPrimaryMerchantsByFirstSeenForProductIds(productIds)
|
||||
.forEach(r -> primaryMerchantByProductId.put(r.getProductId(), r.getMerchantId()));
|
||||
}
|
||||
|
||||
for (Product p : batch.getContent()) {
|
||||
if (scanned >= limit) break;
|
||||
|
||||
// Optional: skip locked products unless includeLocked=true.
|
||||
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
|
||||
if (!req.includeLocked() && locked) {
|
||||
counts.compute("LOCKED", (k, v) -> v + 1);
|
||||
scanned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Run the classifier (merchant_category_map + other logic inside classifier).
|
||||
Integer resolvedMerchantId = primaryMerchantByProductId.get(p.getId());
|
||||
ClassificationResult resolved = productClassifier.classifyProduct(p, resolvedMerchantId, mappingMemo);
|
||||
|
||||
if (resolved != null && resolved.source() != null && resolved.source().startsWith("rules_")) {
|
||||
counts.compute("RULE_MATCHED", (k, v) -> v + 1);
|
||||
}
|
||||
|
||||
// Compute diff status (dry-run only right now).
|
||||
DiffOutcome outcome = diff(p, resolved);
|
||||
|
||||
counts.compute(outcome.status, (k, v) -> v + 1);
|
||||
scanned++;
|
||||
|
||||
// Keep a small sample set for inspection.
|
||||
boolean interestingStatus =
|
||||
outcome.status.equals("WOULD_UPDATE")
|
||||
|| outcome.status.equals("CONFLICT")
|
||||
|| outcome.status.equals("UNMAPPED")
|
||||
|| outcome.status.equals("IGNORED");
|
||||
|
||||
boolean ruleHit =
|
||||
resolved != null
|
||||
&& resolved.source() != null
|
||||
&& resolved.source().startsWith("rules_");
|
||||
|
||||
// Keep a small sample set for inspection.
|
||||
// Include rule hits even if the product ends up UNCHANGED, so we can verify rules are working.
|
||||
if (samples.size() < 50 && (interestingStatus || ruleHit)) {
|
||||
samples.add(toRow(p, resolved, outcome.status, outcome.meta));
|
||||
}
|
||||
}
|
||||
|
||||
if (!batch.hasNext()) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
// Dry-run only right now—no writes.
|
||||
return new ReconcileResponse(dryRun, scanned, counts, samples);
|
||||
}
|
||||
|
||||
private static class DiffOutcome {
|
||||
final String status;
|
||||
final Map<String, Object> meta;
|
||||
|
||||
DiffOutcome(String status, Map<String, Object> meta) {
|
||||
this.status = status;
|
||||
this.meta = meta;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute reconcile status by comparing:
|
||||
* - existing product role/source/locks
|
||||
* - classifier-resolved role/source/confidence/reason
|
||||
*/
|
||||
private DiffOutcome diff(Product p, ClassificationResult resolved) {
|
||||
|
||||
String existingRole = trimToNull(p.getPartRole());
|
||||
String resolvedRole = resolved == null ? null : trimToNull(resolved.partRole());
|
||||
|
||||
// Respect locks (never propose changes).
|
||||
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
|
||||
if (locked) {
|
||||
return new DiffOutcome("LOCKED", Map.of("note", "partRoleLocked or platformLocked"));
|
||||
}
|
||||
|
||||
// Treat known "ignored category" decisions as their own outcome bucket.
|
||||
if (isIgnored(resolved)) {
|
||||
return new DiffOutcome("IGNORED", Map.of(
|
||||
"reason", resolved.reason(),
|
||||
"source", resolved.source()
|
||||
));
|
||||
}
|
||||
|
||||
// No role resolved = still unmapped (needs merchant_category_map or rule-based inference later).
|
||||
if (resolvedRole == null) {
|
||||
return new DiffOutcome("UNMAPPED", Map.of(
|
||||
"reason", resolved == null ? null : resolved.reason(),
|
||||
"source", resolved == null ? null : resolved.source(),
|
||||
"confidence", resolved == null ? null : resolved.confidence()
|
||||
));
|
||||
}
|
||||
|
||||
// Existing role missing but we resolved one => would update.
|
||||
if (existingRole == null) {
|
||||
return new DiffOutcome("WOULD_UPDATE", Map.of("from", null, "to", resolvedRole));
|
||||
}
|
||||
|
||||
// Same role => unchanged.
|
||||
if (existingRole.equalsIgnoreCase(resolvedRole)) {
|
||||
return new DiffOutcome("UNCHANGED", Map.of());
|
||||
}
|
||||
|
||||
// If existing role came from an override, flag as conflict (don't auto-clobber).
|
||||
PartRoleSource existingSource = p.getPartRoleSource();
|
||||
if (existingSource == PartRoleSource.OVERRIDE) {
|
||||
return new DiffOutcome(
|
||||
"CONFLICT",
|
||||
Map.of("from", existingRole, "to", resolvedRole, "note", "existing source is OVERRIDE")
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, it’s a normal drift that we would update in apply-mode.
|
||||
return new DiffOutcome("WOULD_UPDATE", Map.of("from", existingRole, "to", resolvedRole));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the "non-classifying category ignored" decisions from the classifier.
|
||||
* Right now we key off the reason text prefix (fast + simple).
|
||||
* Later we can formalize this via a dedicated source or meta flag.
|
||||
*/
|
||||
private boolean isIgnored(ClassificationResult r) {
|
||||
return r != null && "ignored_category".equalsIgnoreCase(r.source());
|
||||
}
|
||||
|
||||
private ReconcileDiffRow toRow(Product p, ClassificationResult resolved, String status, Map<String, Object> meta) {
|
||||
|
||||
// Extract the merchant used by classifier (if provided in meta).
|
||||
Integer resolvedMerchantId = null;
|
||||
if (resolved != null && resolved.meta() != null) {
|
||||
Object v = resolved.meta().get("resolvedMerchantId");
|
||||
if (v instanceof Integer i) resolvedMerchantId = i;
|
||||
else if (v instanceof Number n) resolvedMerchantId = n.intValue();
|
||||
else if (v instanceof String s) {
|
||||
try { resolvedMerchantId = Integer.parseInt(s); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
return new ReconcileDiffRow(
|
||||
p.getId(),
|
||||
p.getName(),
|
||||
p.getPlatform(),
|
||||
p.getRawCategoryKey(),
|
||||
|
||||
resolvedMerchantId,
|
||||
|
||||
p.getPartRole(),
|
||||
p.getPartRoleSource() == null ? null : p.getPartRoleSource().name(),
|
||||
p.getPartRoleLocked(),
|
||||
p.getPlatformLocked(),
|
||||
|
||||
resolved == null ? null : resolved.partRole(),
|
||||
resolved == null ? null : resolved.source(),
|
||||
resolved == null ? 0.0 : resolved.confidence(),
|
||||
resolved == null ? null : resolved.reason(),
|
||||
|
||||
status,
|
||||
meta == null ? Map.of() : meta
|
||||
);
|
||||
}
|
||||
|
||||
private static String trimToNull(String s) {
|
||||
if (s == null) return null;
|
||||
String t = s.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
}
|
||||
@@ -1,474 +1,474 @@
|
||||
package group.goforward.battlbuilder.classification.impl;
|
||||
|
||||
import group.goforward.battlbuilder.classification.ClassificationResult;
|
||||
import group.goforward.battlbuilder.classification.ProductClassifier;
|
||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.HashMap;
|
||||
|
||||
@Component
|
||||
public class ProductClassifierImpl implements ProductClassifier {
|
||||
|
||||
/**
|
||||
* Bump this whenever you change classification logic.
|
||||
* Useful for debugging and for "reconcile" runs.
|
||||
*/
|
||||
private static final String VERSION = "2025.12.29";
|
||||
|
||||
/**
|
||||
* Non-classifying "categories" some merchants use as merchandising labels.
|
||||
* These are orthogonal to part role (condition/marketing), so we ignore them
|
||||
* to avoid polluting merchant_category_map.
|
||||
*/
|
||||
// IMPORTANT:
|
||||
// These tokens represent NON-SEMANTIC merchant categories.
|
||||
// They should NEVER be mapped to part roles.
|
||||
// If you're tempted to add them to merchant_category_map, add them here instead.
|
||||
private static final Set<String> NON_CLASSIFYING_TOKENS = Set.of(
|
||||
// marketing / promos
|
||||
"sale",
|
||||
"clearance",
|
||||
"deal",
|
||||
"special",
|
||||
"markdown",
|
||||
"promo",
|
||||
|
||||
// merchandising buckets
|
||||
"general",
|
||||
"apparel",
|
||||
"accessories",
|
||||
"parts",
|
||||
"spare parts",
|
||||
"shop all",
|
||||
"lineup",
|
||||
"collection",
|
||||
|
||||
// bundles / kits / sets
|
||||
"bundle",
|
||||
"kit",
|
||||
"set",
|
||||
"builder set",
|
||||
|
||||
// color / variant groupings
|
||||
"colors",
|
||||
"finish",
|
||||
"variant",
|
||||
|
||||
// caliber / platform / type filters (not part roles)
|
||||
"bolt action",
|
||||
"ar15",
|
||||
"ar-15",
|
||||
"rifles",
|
||||
"creedmoor",
|
||||
"winchester",
|
||||
"grendel",
|
||||
"legend",
|
||||
|
||||
// promo shelves
|
||||
"savings",
|
||||
|
||||
// brand shelves
|
||||
"magpul",
|
||||
|
||||
// caliber shelves (not part roles)
|
||||
"5.56",
|
||||
"5.56 nato",
|
||||
"223",
|
||||
".223",
|
||||
"223 wylde",
|
||||
".223 wylde",
|
||||
"wylde",
|
||||
"nato"
|
||||
);
|
||||
|
||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||
private final ProductOfferRepository productOfferRepository;
|
||||
|
||||
public ProductClassifierImpl(MerchantCategoryMapRepository merchantCategoryMapRepository,
|
||||
ProductOfferRepository productOfferRepository) {
|
||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationResult classifyProduct(Product product) {
|
||||
// Backwards compatible path (existing callers)
|
||||
return classifyProduct(product, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId) {
|
||||
|
||||
// ===== Guardrails =====
|
||||
if (product == null || product.getId() == null) {
|
||||
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
|
||||
}
|
||||
|
||||
if (isBlank(product.getRawCategoryKey())) {
|
||||
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
|
||||
}
|
||||
|
||||
// Normalize inputs early so we don't accidentally compare mismatched strings.
|
||||
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
|
||||
String platform = normalizePlatform(product.getPlatform()); // may be null
|
||||
|
||||
// Resolve merchant-of-record:
|
||||
// - Prefer caller-provided merchantId (batch-resolved in reconcile to avoid N+1)
|
||||
// - Fall back to DB lookup for normal runtime usage
|
||||
Integer merchantId = resolvedMerchantId;
|
||||
if (merchantId == null) {
|
||||
merchantId = productOfferRepository
|
||||
.findPrimaryMerchantIdByFirstSeen(product.getId())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
// ===== Ignore non-classifying categories (e.g. Clearance/Blemished/Caliber shelves) =====
|
||||
if (isNonClassifyingCategory(rawCategory)) {
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"ignored_category",
|
||||
"Ignored non-classifying merchant category: " + rawCategory,
|
||||
VERSION,
|
||||
0.0,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If we need merchant mapping or rules, merchantId is required
|
||||
if (merchantId == null) {
|
||||
return ClassificationResult.unknown(
|
||||
VERSION,
|
||||
"No offers found for product; cannot determine merchant mapping."
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Rule-based split: broad "Gas System" buckets =====
|
||||
if (isGasSystemBucket(rawCategory)) {
|
||||
String name = (product.getName() == null) ? "" : product.getName();
|
||||
String n = name.toLowerCase(Locale.ROOT);
|
||||
|
||||
if (containsAny(n, "gas block + tube", "gas block and tube", "gas block w/ tube", "gas block with tube", "block/tube", "block + tube")) {
|
||||
return new ClassificationResult(
|
||||
"gas-block-tube-combo",
|
||||
"rules_gas_system",
|
||||
"Gas System bucket: inferred gas-block-tube-combo from name.",
|
||||
VERSION,
|
||||
0.92,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:combo")
|
||||
);
|
||||
}
|
||||
|
||||
if (containsAny(n, "roll pin", "gas tube roll pin", "gastube roll pin", "tube roll pin")) {
|
||||
return new ClassificationResult(
|
||||
"gas-tube-roll-pin",
|
||||
"rules_gas_system",
|
||||
"Gas System bucket: inferred gas-tube-roll-pin from name.",
|
||||
VERSION,
|
||||
0.90,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:roll-pin")
|
||||
);
|
||||
}
|
||||
|
||||
if (containsAny(n, "gas tube", "gastube")) {
|
||||
return new ClassificationResult(
|
||||
"gas-tube",
|
||||
"rules_gas_system",
|
||||
"Gas System bucket: inferred gas-tube from name.",
|
||||
VERSION,
|
||||
0.90,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:tube")
|
||||
);
|
||||
}
|
||||
|
||||
if (containsAny(n, "gas block", "gasblock")) {
|
||||
return new ClassificationResult(
|
||||
"gas-block",
|
||||
"rules_gas_system",
|
||||
"Gas System bucket: inferred gas-block from name.",
|
||||
VERSION,
|
||||
0.90,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:block")
|
||||
);
|
||||
}
|
||||
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"rules_gas_system",
|
||||
"Gas System bucket: no confident keyword match (leave unmapped).",
|
||||
VERSION,
|
||||
0.0,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:none")
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Primary classification: merchant_category_map lookup =====
|
||||
Optional<MerchantCategoryMap> best =
|
||||
merchantCategoryMapRepository.findBest(merchantId, rawCategory, platform);
|
||||
|
||||
if (best.isPresent()) {
|
||||
MerchantCategoryMap map = best.get();
|
||||
|
||||
String role = trimToNull(map.getCanonicalPartRole());
|
||||
if (role == null) {
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"merchant_mapping",
|
||||
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
|
||||
VERSION,
|
||||
0.20,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform,
|
||||
"mapId", map.getId(),
|
||||
"mapPlatform", map.getPlatform()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
double confidence =
|
||||
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
|
||||
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
|
||||
map.getPlatform() == null ? 0.90 : 0.90;
|
||||
|
||||
return new ClassificationResult(
|
||||
role,
|
||||
"merchant_mapping",
|
||||
"Mapped via merchant_category_map (canonical_part_role).",
|
||||
VERSION,
|
||||
confidence,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform,
|
||||
"mapId", map.getId(),
|
||||
"mapPlatform", map.getPlatform()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"merchant_mapping",
|
||||
"No enabled mapping found for resolved merchant/rawCategory/platform.",
|
||||
VERSION,
|
||||
0.0,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationResult classifyProduct(
|
||||
Product product,
|
||||
Integer resolvedMerchantId,
|
||||
Map<String, Optional<MerchantCategoryMap>> mappingMemo
|
||||
) {
|
||||
// Safety: if caller passes null, fall back cleanly
|
||||
if (mappingMemo == null) {
|
||||
mappingMemo = new HashMap<>();
|
||||
}
|
||||
|
||||
// ===== Guardrails =====
|
||||
if (product == null || product.getId() == null) {
|
||||
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
|
||||
}
|
||||
|
||||
if (isBlank(product.getRawCategoryKey())) {
|
||||
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
|
||||
}
|
||||
|
||||
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
|
||||
String platform = normalizePlatform(product.getPlatform());
|
||||
|
||||
Integer merchantId = resolvedMerchantId;
|
||||
if (merchantId == null) {
|
||||
merchantId = productOfferRepository
|
||||
.findPrimaryMerchantIdByFirstSeen(product.getId())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
// Ignore merchandising/filters
|
||||
if (isNonClassifyingCategory(rawCategory)) {
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"ignored_category",
|
||||
"Ignored non-classifying merchant category: " + rawCategory,
|
||||
VERSION,
|
||||
0.0,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
|
||||
);
|
||||
}
|
||||
|
||||
if (merchantId == null) {
|
||||
return ClassificationResult.unknown(
|
||||
VERSION,
|
||||
"No offers found for product; cannot determine merchant mapping."
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Gas System rules (unchanged) =====
|
||||
if (isGasSystemBucket(rawCategory)) {
|
||||
// reuse your existing logic by calling the 2-arg version
|
||||
return classifyProduct(product, merchantId);
|
||||
}
|
||||
|
||||
// ===== Memoized merchant_category_map lookup =====
|
||||
// Key is normalized to maximize cache hits across equivalent strings.
|
||||
String memoKey =
|
||||
merchantId + "|" +
|
||||
(platform == null ? "null" : platform.toUpperCase(Locale.ROOT)) + "|" +
|
||||
rawCategory.toLowerCase(Locale.ROOT).trim();
|
||||
|
||||
final Integer mid = merchantId;
|
||||
final String rc = rawCategory;
|
||||
final String pl = platform;
|
||||
|
||||
Optional<MerchantCategoryMap> best = mappingMemo.computeIfAbsent(
|
||||
memoKey,
|
||||
k -> merchantCategoryMapRepository.findBest(mid, rc, pl)
|
||||
);
|
||||
|
||||
if (best.isPresent()) {
|
||||
MerchantCategoryMap map = best.get();
|
||||
|
||||
String role = trimToNull(map.getCanonicalPartRole());
|
||||
if (role == null) {
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"merchant_mapping",
|
||||
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
|
||||
VERSION,
|
||||
0.20,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform,
|
||||
"mapId", map.getId(),
|
||||
"mapPlatform", map.getPlatform()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
double confidence =
|
||||
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
|
||||
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
|
||||
map.getPlatform() == null ? 0.90 : 0.90;
|
||||
|
||||
return new ClassificationResult(
|
||||
role,
|
||||
"merchant_mapping",
|
||||
"Mapped via merchant_category_map (canonical_part_role).",
|
||||
VERSION,
|
||||
confidence,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform,
|
||||
"mapId", map.getId(),
|
||||
"mapPlatform", map.getPlatform()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"merchant_mapping",
|
||||
"No enabled mapping found for resolved merchant/rawCategory/platform.",
|
||||
VERSION,
|
||||
0.0,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns true if this raw category is a merchandising/condition label rather than a taxonomy path.
|
||||
*/
|
||||
private boolean isNonClassifyingCategory(String rawCategory) {
|
||||
if (rawCategory == null) return false;
|
||||
|
||||
String v = rawCategory.toLowerCase(Locale.ROOT).trim();
|
||||
|
||||
// If it's a breadcrumb/taxonomy path, DO NOT treat it as a merchandising label.
|
||||
// Example: "Gunsmithing > ... > Gun Care & Accessories > Ar-15 Complete Uppers"
|
||||
if (v.contains(">")) return false;
|
||||
|
||||
// For flat categories, apply token matching.
|
||||
return NON_CLASSIFYING_TOKENS.stream().anyMatch(v::contains);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize merchant-provided category strings.
|
||||
* Keep it light: trim + collapse whitespace.
|
||||
* (If needed later: unify case or canonicalize separators.)
|
||||
*/
|
||||
private String normalizeRawCategory(String raw) {
|
||||
if (raw == null) return null;
|
||||
return raw.trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize platform values. We keep it consistent with expected inputs like "AR-15".
|
||||
* Uppercasing is okay because "AR-15" remains "AR-15".
|
||||
*/
|
||||
private String normalizePlatform(String p) {
|
||||
if (p == null) return null;
|
||||
String t = p.trim();
|
||||
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private boolean isBlank(String s) {
|
||||
return s == null || s.trim().isEmpty();
|
||||
}
|
||||
|
||||
private String trimToNull(String s) {
|
||||
if (s == null) return null;
|
||||
String t = s.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
private boolean isGasSystemBucket(String rawCategory) {
|
||||
if (rawCategory == null) return false;
|
||||
String v = rawCategory.toLowerCase(Locale.ROOT);
|
||||
return v.contains("gas system");
|
||||
}
|
||||
|
||||
private boolean containsAny(String haystack, String... needles) {
|
||||
if (haystack == null) return false;
|
||||
for (String n : needles) {
|
||||
if (n != null && !n.isBlank() && haystack.contains(n)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe metadata builder.
|
||||
* IMPORTANT: Map.of(...) throws if any key/value is null; this helper skips null entries.
|
||||
*/
|
||||
private static Map<String, Object> meta(Object... kv) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
for (int i = 0; i < kv.length; i += 2) {
|
||||
String k = (String) kv[i];
|
||||
Object v = kv[i + 1];
|
||||
if (k != null && v != null) m.put(k, v);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
package group.goforward.battlbuilder.classification.impl;
|
||||
|
||||
import group.goforward.battlbuilder.classification.ClassificationResult;
|
||||
import group.goforward.battlbuilder.classification.ProductClassifier;
|
||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.HashMap;
|
||||
|
||||
@Component
|
||||
public class ProductClassifierImpl implements ProductClassifier {
|
||||
|
||||
/**
|
||||
* Bump this whenever you change classification logic.
|
||||
* Useful for debugging and for "reconcile" runs.
|
||||
*/
|
||||
private static final String VERSION = "2025.12.29";
|
||||
|
||||
/**
|
||||
* Non-classifying "categories" some merchants use as merchandising labels.
|
||||
* These are orthogonal to part role (condition/marketing), so we ignore them
|
||||
* to avoid polluting merchant_category_map.
|
||||
*/
|
||||
// IMPORTANT:
|
||||
// These tokens represent NON-SEMANTIC merchant categories.
|
||||
// They should NEVER be mapped to part roles.
|
||||
// If you're tempted to add them to merchant_category_map, add them here instead.
|
||||
private static final Set<String> NON_CLASSIFYING_TOKENS = Set.of(
|
||||
// marketing / promos
|
||||
"sale",
|
||||
"clearance",
|
||||
"deal",
|
||||
"special",
|
||||
"markdown",
|
||||
"promo",
|
||||
|
||||
// merchandising buckets
|
||||
"general",
|
||||
"apparel",
|
||||
"accessories",
|
||||
"parts",
|
||||
"spare parts",
|
||||
"shop all",
|
||||
"lineup",
|
||||
"collection",
|
||||
|
||||
// bundles / kits / sets
|
||||
"bundle",
|
||||
"kit",
|
||||
"set",
|
||||
"builder set",
|
||||
|
||||
// color / variant groupings
|
||||
"colors",
|
||||
"finish",
|
||||
"variant",
|
||||
|
||||
// caliber / platform / type filters (not part roles)
|
||||
"bolt action",
|
||||
"ar15",
|
||||
"ar-15",
|
||||
"rifles",
|
||||
"creedmoor",
|
||||
"winchester",
|
||||
"grendel",
|
||||
"legend",
|
||||
|
||||
// promo shelves
|
||||
"savings",
|
||||
|
||||
// brand shelves
|
||||
"magpul",
|
||||
|
||||
// caliber shelves (not part roles)
|
||||
"5.56",
|
||||
"5.56 nato",
|
||||
"223",
|
||||
".223",
|
||||
"223 wylde",
|
||||
".223 wylde",
|
||||
"wylde",
|
||||
"nato"
|
||||
);
|
||||
|
||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||
private final ProductOfferRepository productOfferRepository;
|
||||
|
||||
public ProductClassifierImpl(MerchantCategoryMapRepository merchantCategoryMapRepository,
|
||||
ProductOfferRepository productOfferRepository) {
|
||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationResult classifyProduct(Product product) {
|
||||
// Backwards compatible path (existing callers)
|
||||
return classifyProduct(product, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId) {
|
||||
|
||||
// ===== Guardrails =====
|
||||
if (product == null || product.getId() == null) {
|
||||
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
|
||||
}
|
||||
|
||||
if (isBlank(product.getRawCategoryKey())) {
|
||||
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
|
||||
}
|
||||
|
||||
// Normalize inputs early so we don't accidentally compare mismatched strings.
|
||||
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
|
||||
String platform = normalizePlatform(product.getPlatform()); // may be null
|
||||
|
||||
// Resolve merchant-of-record:
|
||||
// - Prefer caller-provided merchantId (batch-resolved in reconcile to avoid N+1)
|
||||
// - Fall back to DB lookup for normal runtime usage
|
||||
Integer merchantId = resolvedMerchantId;
|
||||
if (merchantId == null) {
|
||||
merchantId = productOfferRepository
|
||||
.findPrimaryMerchantIdByFirstSeen(product.getId())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
// ===== Ignore non-classifying categories (e.g. Clearance/Blemished/Caliber shelves) =====
|
||||
if (isNonClassifyingCategory(rawCategory)) {
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"ignored_category",
|
||||
"Ignored non-classifying merchant category: " + rawCategory,
|
||||
VERSION,
|
||||
0.0,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If we need merchant mapping or rules, merchantId is required
|
||||
if (merchantId == null) {
|
||||
return ClassificationResult.unknown(
|
||||
VERSION,
|
||||
"No offers found for product; cannot determine merchant mapping."
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Rule-based split: broad "Gas System" buckets =====
|
||||
if (isGasSystemBucket(rawCategory)) {
|
||||
String name = (product.getName() == null) ? "" : product.getName();
|
||||
String n = name.toLowerCase(Locale.ROOT);
|
||||
|
||||
if (containsAny(n, "gas block + tube", "gas block and tube", "gas block w/ tube", "gas block with tube", "block/tube", "block + tube")) {
|
||||
return new ClassificationResult(
|
||||
"gas-block-tube-combo",
|
||||
"rules_gas_system",
|
||||
"Gas System bucket: inferred gas-block-tube-combo from name.",
|
||||
VERSION,
|
||||
0.92,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:combo")
|
||||
);
|
||||
}
|
||||
|
||||
if (containsAny(n, "roll pin", "gas tube roll pin", "gastube roll pin", "tube roll pin")) {
|
||||
return new ClassificationResult(
|
||||
"gas-tube-roll-pin",
|
||||
"rules_gas_system",
|
||||
"Gas System bucket: inferred gas-tube-roll-pin from name.",
|
||||
VERSION,
|
||||
0.90,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:roll-pin")
|
||||
);
|
||||
}
|
||||
|
||||
if (containsAny(n, "gas tube", "gastube")) {
|
||||
return new ClassificationResult(
|
||||
"gas-tube",
|
||||
"rules_gas_system",
|
||||
"Gas System bucket: inferred gas-tube from name.",
|
||||
VERSION,
|
||||
0.90,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:tube")
|
||||
);
|
||||
}
|
||||
|
||||
if (containsAny(n, "gas block", "gasblock")) {
|
||||
return new ClassificationResult(
|
||||
"gas-block",
|
||||
"rules_gas_system",
|
||||
"Gas System bucket: inferred gas-block from name.",
|
||||
VERSION,
|
||||
0.90,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:block")
|
||||
);
|
||||
}
|
||||
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"rules_gas_system",
|
||||
"Gas System bucket: no confident keyword match (leave unmapped).",
|
||||
VERSION,
|
||||
0.0,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:none")
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Primary classification: merchant_category_map lookup =====
|
||||
Optional<MerchantCategoryMap> best =
|
||||
merchantCategoryMapRepository.findBest(merchantId, rawCategory, platform);
|
||||
|
||||
if (best.isPresent()) {
|
||||
MerchantCategoryMap map = best.get();
|
||||
|
||||
String role = trimToNull(map.getCanonicalPartRole());
|
||||
if (role == null) {
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"merchant_mapping",
|
||||
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
|
||||
VERSION,
|
||||
0.20,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform,
|
||||
"mapId", map.getId(),
|
||||
"mapPlatform", map.getPlatform()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
double confidence =
|
||||
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
|
||||
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
|
||||
map.getPlatform() == null ? 0.90 : 0.90;
|
||||
|
||||
return new ClassificationResult(
|
||||
role,
|
||||
"merchant_mapping",
|
||||
"Mapped via merchant_category_map (canonical_part_role).",
|
||||
VERSION,
|
||||
confidence,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform,
|
||||
"mapId", map.getId(),
|
||||
"mapPlatform", map.getPlatform()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"merchant_mapping",
|
||||
"No enabled mapping found for resolved merchant/rawCategory/platform.",
|
||||
VERSION,
|
||||
0.0,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationResult classifyProduct(
|
||||
Product product,
|
||||
Integer resolvedMerchantId,
|
||||
Map<String, Optional<MerchantCategoryMap>> mappingMemo
|
||||
) {
|
||||
// Safety: if caller passes null, fall back cleanly
|
||||
if (mappingMemo == null) {
|
||||
mappingMemo = new HashMap<>();
|
||||
}
|
||||
|
||||
// ===== Guardrails =====
|
||||
if (product == null || product.getId() == null) {
|
||||
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
|
||||
}
|
||||
|
||||
if (isBlank(product.getRawCategoryKey())) {
|
||||
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
|
||||
}
|
||||
|
||||
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
|
||||
String platform = normalizePlatform(product.getPlatform());
|
||||
|
||||
Integer merchantId = resolvedMerchantId;
|
||||
if (merchantId == null) {
|
||||
merchantId = productOfferRepository
|
||||
.findPrimaryMerchantIdByFirstSeen(product.getId())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
// Ignore merchandising/filters
|
||||
if (isNonClassifyingCategory(rawCategory)) {
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"ignored_category",
|
||||
"Ignored non-classifying merchant category: " + rawCategory,
|
||||
VERSION,
|
||||
0.0,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
|
||||
);
|
||||
}
|
||||
|
||||
if (merchantId == null) {
|
||||
return ClassificationResult.unknown(
|
||||
VERSION,
|
||||
"No offers found for product; cannot determine merchant mapping."
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Gas System rules (unchanged) =====
|
||||
if (isGasSystemBucket(rawCategory)) {
|
||||
// reuse your existing logic by calling the 2-arg version
|
||||
return classifyProduct(product, merchantId);
|
||||
}
|
||||
|
||||
// ===== Memoized merchant_category_map lookup =====
|
||||
// Key is normalized to maximize cache hits across equivalent strings.
|
||||
String memoKey =
|
||||
merchantId + "|" +
|
||||
(platform == null ? "null" : platform.toUpperCase(Locale.ROOT)) + "|" +
|
||||
rawCategory.toLowerCase(Locale.ROOT).trim();
|
||||
|
||||
final Integer mid = merchantId;
|
||||
final String rc = rawCategory;
|
||||
final String pl = platform;
|
||||
|
||||
Optional<MerchantCategoryMap> best = mappingMemo.computeIfAbsent(
|
||||
memoKey,
|
||||
k -> merchantCategoryMapRepository.findBest(mid, rc, pl)
|
||||
);
|
||||
|
||||
if (best.isPresent()) {
|
||||
MerchantCategoryMap map = best.get();
|
||||
|
||||
String role = trimToNull(map.getCanonicalPartRole());
|
||||
if (role == null) {
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"merchant_mapping",
|
||||
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
|
||||
VERSION,
|
||||
0.20,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform,
|
||||
"mapId", map.getId(),
|
||||
"mapPlatform", map.getPlatform()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
double confidence =
|
||||
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
|
||||
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
|
||||
map.getPlatform() == null ? 0.90 : 0.90;
|
||||
|
||||
return new ClassificationResult(
|
||||
role,
|
||||
"merchant_mapping",
|
||||
"Mapped via merchant_category_map (canonical_part_role).",
|
||||
VERSION,
|
||||
confidence,
|
||||
meta(
|
||||
"resolvedMerchantId", merchantId,
|
||||
"rawCategory", rawCategory,
|
||||
"platform", platform,
|
||||
"mapId", map.getId(),
|
||||
"mapPlatform", map.getPlatform()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return new ClassificationResult(
|
||||
null,
|
||||
"merchant_mapping",
|
||||
"No enabled mapping found for resolved merchant/rawCategory/platform.",
|
||||
VERSION,
|
||||
0.0,
|
||||
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns true if this raw category is a merchandising/condition label rather than a taxonomy path.
|
||||
*/
|
||||
private boolean isNonClassifyingCategory(String rawCategory) {
|
||||
if (rawCategory == null) return false;
|
||||
|
||||
String v = rawCategory.toLowerCase(Locale.ROOT).trim();
|
||||
|
||||
// If it's a breadcrumb/taxonomy path, DO NOT treat it as a merchandising label.
|
||||
// Example: "Gunsmithing > ... > Gun Care & Accessories > Ar-15 Complete Uppers"
|
||||
if (v.contains(">")) return false;
|
||||
|
||||
// For flat categories, apply token matching.
|
||||
return NON_CLASSIFYING_TOKENS.stream().anyMatch(v::contains);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize merchant-provided category strings.
|
||||
* Keep it light: trim + collapse whitespace.
|
||||
* (If needed later: unify case or canonicalize separators.)
|
||||
*/
|
||||
private String normalizeRawCategory(String raw) {
|
||||
if (raw == null) return null;
|
||||
return raw.trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize platform values. We keep it consistent with expected inputs like "AR-15".
|
||||
* Uppercasing is okay because "AR-15" remains "AR-15".
|
||||
*/
|
||||
private String normalizePlatform(String p) {
|
||||
if (p == null) return null;
|
||||
String t = p.trim();
|
||||
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private boolean isBlank(String s) {
|
||||
return s == null || s.trim().isEmpty();
|
||||
}
|
||||
|
||||
private String trimToNull(String s) {
|
||||
if (s == null) return null;
|
||||
String t = s.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
private boolean isGasSystemBucket(String rawCategory) {
|
||||
if (rawCategory == null) return false;
|
||||
String v = rawCategory.toLowerCase(Locale.ROOT);
|
||||
return v.contains("gas system");
|
||||
}
|
||||
|
||||
private boolean containsAny(String haystack, String... needles) {
|
||||
if (haystack == null) return false;
|
||||
for (String n : needles) {
|
||||
if (n != null && !n.isBlank() && haystack.contains(n)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe metadata builder.
|
||||
* IMPORTANT: Map.of(...) throws if any key/value is null; this helper skips null entries.
|
||||
*/
|
||||
private static Map<String, Object> meta(Object... kv) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
for (int i = 0; i < kv.length; i += 2) {
|
||||
String k = (String) kv[i];
|
||||
Object v = kv[i + 1];
|
||||
if (k != null && v != null) m.put(k, v);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,45 @@
|
||||
package group.goforward.battlbuilder.cli;
|
||||
|
||||
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
|
||||
@Component
|
||||
@Profile("!prod")
|
||||
@ConditionalOnProperty(
|
||||
name = "app.beta.invite.run",
|
||||
havingValue = "true"
|
||||
)
|
||||
public class BetaInviteCliRunner implements CommandLineRunner {
|
||||
|
||||
private final BetaInviteService inviteService;
|
||||
|
||||
@Value("${app.beta.invite.limit:0}")
|
||||
private int limit;
|
||||
|
||||
@Value("${app.beta.invite.dryRun:true}")
|
||||
private boolean dryRun;
|
||||
|
||||
@Value("${app.beta.invite.tokenMinutes:30}")
|
||||
private int tokenMinutes;
|
||||
|
||||
public BetaInviteCliRunner(BetaInviteService inviteService) {
|
||||
this.inviteService = inviteService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
|
||||
|
||||
System.out.println(
|
||||
"✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun
|
||||
);
|
||||
|
||||
// Exit so it behaves like a CLI command
|
||||
System.exit(0);
|
||||
}
|
||||
package group.goforward.battlbuilder.cli;
|
||||
|
||||
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
|
||||
@Component
|
||||
@Profile("!prod")
|
||||
@ConditionalOnProperty(
|
||||
name = "app.beta.invite.run",
|
||||
havingValue = "true"
|
||||
)
|
||||
public class BetaInviteCliRunner implements CommandLineRunner {
|
||||
|
||||
private final BetaInviteService inviteService;
|
||||
|
||||
@Value("${app.beta.invite.limit:0}")
|
||||
private int limit;
|
||||
|
||||
@Value("${app.beta.invite.dryRun:true}")
|
||||
private boolean dryRun;
|
||||
|
||||
@Value("${app.beta.invite.tokenMinutes:30}")
|
||||
private int tokenMinutes;
|
||||
|
||||
public BetaInviteCliRunner(BetaInviteService inviteService) {
|
||||
this.inviteService = inviteService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
|
||||
|
||||
System.out.println(
|
||||
"✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun
|
||||
);
|
||||
|
||||
// Exit so it behaves like a CLI command
|
||||
System.exit(0);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Utility controller package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains utility REST controller for email handling and
|
||||
* health check operations.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.common;
|
||||
/**
|
||||
* Utility controller package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains utility REST controller for email handling and
|
||||
* health check operations.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.common;
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
package group.goforward.battlbuilder.config;
|
||||
|
||||
import io.minio.MinioClient;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MinioConfig {
|
||||
|
||||
@Bean
|
||||
public MinioClient minioClient(
|
||||
@Value("${minio.endpoint}") String endpoint,
|
||||
@Value("${minio.access-key}") String accessKey,
|
||||
@Value("${minio.secret-key}") String secretKey
|
||||
) {
|
||||
return MinioClient.builder()
|
||||
.endpoint(endpoint)
|
||||
.credentials(accessKey, secretKey)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
package group.goforward.battlbuilder.config;
|
||||
|
||||
import io.minio.MinioClient;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MinioConfig {
|
||||
|
||||
@Bean
|
||||
public MinioClient minioClient(
|
||||
@Value("${minio.endpoint}") String endpoint,
|
||||
@Value("${minio.access-key}") String accessKey,
|
||||
@Value("${minio.secret-key}") String secretKey
|
||||
) {
|
||||
return MinioClient.builder()
|
||||
.endpoint(endpoint)
|
||||
.credentials(accessKey, secretKey)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
/*
|
||||
package group.goforward.battlbuilder.configuration;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@Configuration
|
||||
public class PasswordConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
// // BCrypt default password
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}*/
|
||||
/*
|
||||
package group.goforward.battlbuilder.configuration;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@Configuration
|
||||
public class PasswordConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
// // BCrypt default password
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}*/
|
||||
|
||||
@@ -1,93 +1,93 @@
|
||||
package group.goforward.battlbuilder.config;
|
||||
|
||||
import group.goforward.battlbuilder.security.JwtAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(c -> c.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
|
||||
// ----------------------------
|
||||
// Public
|
||||
// ----------------------------
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||
.requestMatchers("/api/products/gunbuilder/**").permitAll()
|
||||
|
||||
// Public builds feed + public build detail (1 path segment only)
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/builds/*").permitAll()
|
||||
|
||||
// ----------------------------
|
||||
// Protected
|
||||
// ----------------------------
|
||||
.requestMatchers("/api/v1/builds/me/**").authenticated()
|
||||
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
||||
|
||||
// Everything else (adjust later as you lock down)
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
|
||||
// run JWT before AnonymousAuth sets principal="anonymousUser"
|
||||
.addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOrigins(List.of("http://localhost:3000"));
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
||||
cfg.setExposedHeaders(List.of("Authorization"));
|
||||
cfg.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
|
||||
throws Exception {
|
||||
return configuration.getAuthenticationManager();
|
||||
}
|
||||
package group.goforward.battlbuilder.config;
|
||||
|
||||
import group.goforward.battlbuilder.security.JwtAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(c -> c.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
|
||||
// ----------------------------
|
||||
// Public
|
||||
// ----------------------------
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||
.requestMatchers("/api/products/gunbuilder/**").permitAll()
|
||||
|
||||
// Public builds feed + public build detail (1 path segment only)
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/builds/*").permitAll()
|
||||
|
||||
// ----------------------------
|
||||
// Protected
|
||||
// ----------------------------
|
||||
.requestMatchers("/api/v1/builds/me/**").authenticated()
|
||||
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
||||
|
||||
// Everything else (adjust later as you lock down)
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
|
||||
// run JWT before AnonymousAuth sets principal="anonymousUser"
|
||||
.addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOrigins(List.of("http://localhost:3000"));
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
||||
cfg.setExposedHeaders(List.of("Authorization"));
|
||||
cfg.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
|
||||
throws Exception {
|
||||
return configuration.getAuthenticationManager();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Configuration package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains Spring configuration classes for security, CORS, JPA,
|
||||
* caching, and password encoding.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.config;
|
||||
/**
|
||||
* Configuration package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains Spring configuration classes for security, CORS, JPA,
|
||||
* caching, and password encoding.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.config;
|
||||
|
||||
@@ -1,227 +1,227 @@
|
||||
package group.goforward.battlbuilder.controller;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import group.goforward.battlbuilder.security.JwtService;
|
||||
import group.goforward.battlbuilder.service.auth.BetaAuthService;
|
||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||
import group.goforward.battlbuilder.web.dto.auth.BetaSignupRequest;
|
||||
import group.goforward.battlbuilder.web.dto.auth.LoginRequest;
|
||||
import group.goforward.battlbuilder.web.dto.auth.RegisterRequest;
|
||||
import group.goforward.battlbuilder.web.dto.auth.TokenRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping({"/api/auth", "/api/v1/auth"})
|
||||
@CrossOrigin
|
||||
public class AuthController {
|
||||
|
||||
private final UserRepository users;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final BetaAuthService betaAuthService;
|
||||
|
||||
public AuthController(
|
||||
UserRepository users,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtService jwtService,
|
||||
BetaAuthService betaAuthService
|
||||
) {
|
||||
this.users = users;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.betaAuthService = betaAuthService;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Standard Auth
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(
|
||||
@RequestBody RegisterRequest request,
|
||||
HttpServletRequest httpRequest
|
||||
) {
|
||||
String email = request.getEmail().trim().toLowerCase();
|
||||
|
||||
// ✅ Enforce acceptance
|
||||
if (!Boolean.TRUE.equals(request.getAcceptedTos())) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body("Terms of Service acceptance is required");
|
||||
}
|
||||
|
||||
if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CONFLICT)
|
||||
.body("Email is already registered");
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUuid(UUID.randomUUID());
|
||||
user.setEmail(email);
|
||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||
user.setPasswordSetAt(OffsetDateTime.now());
|
||||
user.setDisplayName(request.getDisplayName());
|
||||
user.setRole("USER");
|
||||
user.setActive(true);
|
||||
user.setCreatedAt(OffsetDateTime.now());
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
|
||||
// ✅ Record ToS acceptance evidence
|
||||
String tosVersion = StringUtils.hasText(request.getTosVersion())
|
||||
? request.getTosVersion().trim()
|
||||
: "2025-12-27"; // keep in sync with your ToS page
|
||||
|
||||
user.setTosAcceptedAt(OffsetDateTime.now());
|
||||
user.setTosVersion(tosVersion);
|
||||
user.setTosIp(extractClientIp(httpRequest));
|
||||
user.setTosUserAgent(httpRequest.getHeader("User-Agent"));
|
||||
|
||||
users.save(user);
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CREATED)
|
||||
.body(new AuthResponse(
|
||||
token,
|
||||
user.getEmail(),
|
||||
user.getDisplayName(),
|
||||
user.getRole()
|
||||
));
|
||||
}
|
||||
|
||||
private String extractClientIp(HttpServletRequest request) {
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (StringUtils.hasText(xff)) {
|
||||
// first IP in the list
|
||||
return xff.split(",")[0].trim();
|
||||
}
|
||||
String realIp = request.getHeader("X-Real-IP");
|
||||
if (StringUtils.hasText(realIp)) return realIp.trim();
|
||||
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||
String email = request.getEmail().trim().toLowerCase();
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElse(null);
|
||||
|
||||
if (user == null || !user.isActive()) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Invalid credentials");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Invalid credentials");
|
||||
}
|
||||
|
||||
user.setLastLoginAt(OffsetDateTime.now());
|
||||
user.incrementLoginCount();
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
new AuthResponse(
|
||||
token,
|
||||
user.getEmail(),
|
||||
user.getDisplayName(),
|
||||
user.getRole()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Beta Flow
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@PostMapping("/beta/signup")
|
||||
public ResponseEntity<Map<String, Object>> betaSignup(@RequestBody BetaSignupRequest request) {
|
||||
// Always return OK to prevent email enumeration
|
||||
try {
|
||||
betaAuthService.signup(request.getEmail(), request.getUseCase());
|
||||
} catch (Exception ignored) {
|
||||
// Intentionally swallow errors here to avoid leaking state
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/beta/confirm")
|
||||
public ResponseEntity<?> betaConfirm(@RequestBody TokenRequest request) {
|
||||
try {
|
||||
return ResponseEntity.ok(betaAuthService.confirmAndExchange(request.getToken()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Confirm link is invalid or expired. Please request a new one.");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/magic/exchange")
|
||||
public ResponseEntity<?> magicExchange(@RequestBody TokenRequest request) {
|
||||
try {
|
||||
AuthResponse auth = betaAuthService.exchangeMagicToken(request.getToken());
|
||||
return ResponseEntity.ok(auth);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// token invalid/expired/consumed
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Magic link is invalid or expired. Please request a new one.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@PostMapping("/password/forgot")
|
||||
public ResponseEntity<?> forgotPassword(@RequestBody Map<String, String> body) {
|
||||
String email = body.getOrDefault("email", "").trim();
|
||||
|
||||
try {
|
||||
betaAuthService.sendPasswordReset(email); // name we’ll add below
|
||||
} catch (Exception ignored) {
|
||||
// swallow to avoid enumeration
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().body("{\"ok\":true}");
|
||||
}
|
||||
|
||||
@PostMapping("/password/reset")
|
||||
public ResponseEntity<?> resetPassword(@RequestBody Map<String, String> body) {
|
||||
String token = body.getOrDefault("token", "").trim();
|
||||
String password = body.getOrDefault("password", "").trim();
|
||||
|
||||
betaAuthService.resetPassword(token, password);
|
||||
|
||||
return ResponseEntity.ok().body("{\"ok\":true}");
|
||||
}
|
||||
|
||||
@PostMapping("/magic")
|
||||
public ResponseEntity<?> requestMagic(@RequestBody Map<String, String> body) {
|
||||
String email = body.getOrDefault("email", "").trim();
|
||||
|
||||
try {
|
||||
betaAuthService.sendMagicLoginLink(email);
|
||||
} catch (Exception ignored) {
|
||||
// swallow to avoid enumeration
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
package group.goforward.battlbuilder.controller;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import group.goforward.battlbuilder.security.JwtService;
|
||||
import group.goforward.battlbuilder.service.auth.BetaAuthService;
|
||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||
import group.goforward.battlbuilder.web.dto.auth.BetaSignupRequest;
|
||||
import group.goforward.battlbuilder.web.dto.auth.LoginRequest;
|
||||
import group.goforward.battlbuilder.web.dto.auth.RegisterRequest;
|
||||
import group.goforward.battlbuilder.web.dto.auth.TokenRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping({"/api/auth", "/api/v1/auth"})
|
||||
@CrossOrigin
|
||||
public class AuthController {
|
||||
|
||||
private final UserRepository users;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final BetaAuthService betaAuthService;
|
||||
|
||||
public AuthController(
|
||||
UserRepository users,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtService jwtService,
|
||||
BetaAuthService betaAuthService
|
||||
) {
|
||||
this.users = users;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.betaAuthService = betaAuthService;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Standard Auth
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(
|
||||
@RequestBody RegisterRequest request,
|
||||
HttpServletRequest httpRequest
|
||||
) {
|
||||
String email = request.getEmail().trim().toLowerCase();
|
||||
|
||||
// ✅ Enforce acceptance
|
||||
if (!Boolean.TRUE.equals(request.getAcceptedTos())) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body("Terms of Service acceptance is required");
|
||||
}
|
||||
|
||||
if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CONFLICT)
|
||||
.body("Email is already registered");
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUuid(UUID.randomUUID());
|
||||
user.setEmail(email);
|
||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||
user.setPasswordSetAt(OffsetDateTime.now());
|
||||
user.setDisplayName(request.getDisplayName());
|
||||
user.setRole("USER");
|
||||
user.setActive(true);
|
||||
user.setCreatedAt(OffsetDateTime.now());
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
|
||||
// ✅ Record ToS acceptance evidence
|
||||
String tosVersion = StringUtils.hasText(request.getTosVersion())
|
||||
? request.getTosVersion().trim()
|
||||
: "2025-12-27"; // keep in sync with your ToS page
|
||||
|
||||
user.setTosAcceptedAt(OffsetDateTime.now());
|
||||
user.setTosVersion(tosVersion);
|
||||
user.setTosIp(extractClientIp(httpRequest));
|
||||
user.setTosUserAgent(httpRequest.getHeader("User-Agent"));
|
||||
|
||||
users.save(user);
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CREATED)
|
||||
.body(new AuthResponse(
|
||||
token,
|
||||
user.getEmail(),
|
||||
user.getDisplayName(),
|
||||
user.getRole()
|
||||
));
|
||||
}
|
||||
|
||||
private String extractClientIp(HttpServletRequest request) {
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (StringUtils.hasText(xff)) {
|
||||
// first IP in the list
|
||||
return xff.split(",")[0].trim();
|
||||
}
|
||||
String realIp = request.getHeader("X-Real-IP");
|
||||
if (StringUtils.hasText(realIp)) return realIp.trim();
|
||||
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||
String email = request.getEmail().trim().toLowerCase();
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElse(null);
|
||||
|
||||
if (user == null || !user.isActive()) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Invalid credentials");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Invalid credentials");
|
||||
}
|
||||
|
||||
user.setLastLoginAt(OffsetDateTime.now());
|
||||
user.incrementLoginCount();
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
new AuthResponse(
|
||||
token,
|
||||
user.getEmail(),
|
||||
user.getDisplayName(),
|
||||
user.getRole()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Beta Flow
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@PostMapping("/beta/signup")
|
||||
public ResponseEntity<Map<String, Object>> betaSignup(@RequestBody BetaSignupRequest request) {
|
||||
// Always return OK to prevent email enumeration
|
||||
try {
|
||||
betaAuthService.signup(request.getEmail(), request.getUseCase());
|
||||
} catch (Exception ignored) {
|
||||
// Intentionally swallow errors here to avoid leaking state
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/beta/confirm")
|
||||
public ResponseEntity<?> betaConfirm(@RequestBody TokenRequest request) {
|
||||
try {
|
||||
return ResponseEntity.ok(betaAuthService.confirmAndExchange(request.getToken()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Confirm link is invalid or expired. Please request a new one.");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/magic/exchange")
|
||||
public ResponseEntity<?> magicExchange(@RequestBody TokenRequest request) {
|
||||
try {
|
||||
AuthResponse auth = betaAuthService.exchangeMagicToken(request.getToken());
|
||||
return ResponseEntity.ok(auth);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// token invalid/expired/consumed
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Magic link is invalid or expired. Please request a new one.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@PostMapping("/password/forgot")
|
||||
public ResponseEntity<?> forgotPassword(@RequestBody Map<String, String> body) {
|
||||
String email = body.getOrDefault("email", "").trim();
|
||||
|
||||
try {
|
||||
betaAuthService.sendPasswordReset(email); // name we’ll add below
|
||||
} catch (Exception ignored) {
|
||||
// swallow to avoid enumeration
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().body("{\"ok\":true}");
|
||||
}
|
||||
|
||||
@PostMapping("/password/reset")
|
||||
public ResponseEntity<?> resetPassword(@RequestBody Map<String, String> body) {
|
||||
String token = body.getOrDefault("token", "").trim();
|
||||
String password = body.getOrDefault("password", "").trim();
|
||||
|
||||
betaAuthService.resetPassword(token, password);
|
||||
|
||||
return ResponseEntity.ok().body("{\"ok\":true}");
|
||||
}
|
||||
|
||||
@PostMapping("/magic")
|
||||
public ResponseEntity<?> requestMagic(@RequestBody Map<String, String> body) {
|
||||
String email = body.getOrDefault("email", "").trim();
|
||||
|
||||
try {
|
||||
betaAuthService.sendMagicLoginLink(email);
|
||||
} catch (Exception ignored) {
|
||||
// swallow to avoid enumeration
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
}
|
||||
@@ -1,102 +1,106 @@
|
||||
package group.goforward.battlbuilder.controller;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping({"/api/builder", "/api/v1/builder"})
|
||||
@CrossOrigin
|
||||
public class BuilderBootstrapController {
|
||||
|
||||
private final PartCategoryRepository partCategoryRepository;
|
||||
private final PartRoleMappingRepository mappingRepository;
|
||||
|
||||
public BuilderBootstrapController(
|
||||
PartCategoryRepository partCategoryRepository,
|
||||
PartRoleMappingRepository mappingRepository
|
||||
) {
|
||||
this.partCategoryRepository = partCategoryRepository;
|
||||
this.mappingRepository = mappingRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder bootstrap payload.
|
||||
* <p>
|
||||
* Returns:
|
||||
* - categories: ordered list for UI navigation
|
||||
* - partRoleMap: normalized partRole -> categorySlug (platform-scoped)
|
||||
* - categoryRoles: categorySlug -> normalized partRoles (derived)
|
||||
*/
|
||||
@GetMapping("/bootstrap")
|
||||
public BuilderBootstrapDto bootstrap(
|
||||
@RequestParam(defaultValue = "AR-15") String platform
|
||||
) {
|
||||
final String platformNorm = normalizePlatform(platform);
|
||||
|
||||
// 1) Categories in display order
|
||||
List<PartCategoryDto> categories = partCategoryRepository
|
||||
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||
.stream()
|
||||
.map(pc -> new PartCategoryDto(
|
||||
pc.getId(),
|
||||
pc.getSlug(),
|
||||
pc.getName(),
|
||||
pc.getDescription(),
|
||||
pc.getGroupName(),
|
||||
pc.getSortOrder()
|
||||
))
|
||||
.toList();
|
||||
|
||||
// 2) Role -> CategorySlug mapping (platform-scoped)
|
||||
// Normalize keys to kebab-case so the UI can treat roles consistently.
|
||||
Map<String, String> roleToCategorySlug = new LinkedHashMap<>();
|
||||
|
||||
List<PartRoleMapping> mappings = mappingRepository
|
||||
.findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(platformNorm);
|
||||
|
||||
for (PartRoleMapping m : mappings) {
|
||||
String roleKey = normalizePartRole(m.getPartRole());
|
||||
if (roleKey == null || roleKey.isBlank()) continue;
|
||||
|
||||
if (m.getPartCategory() == null || m.getPartCategory().getSlug() == null) continue;
|
||||
|
||||
// If duplicates exist, keep first and ignore the rest so bootstrap never 500s.
|
||||
roleToCategorySlug.putIfAbsent(roleKey, m.getPartCategory().getSlug());
|
||||
}
|
||||
|
||||
// 3) CategorySlug -> Roles (derived)
|
||||
Map<String, List<String>> categoryToRoles = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String> e : roleToCategorySlug.entrySet()) {
|
||||
categoryToRoles.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey());
|
||||
}
|
||||
|
||||
return new BuilderBootstrapDto(platformNorm, categories, roleToCategorySlug, categoryToRoles);
|
||||
}
|
||||
|
||||
private String normalizePartRole(String role) {
|
||||
if (role == null) return null;
|
||||
String r = role.trim();
|
||||
if (r.isEmpty()) return null;
|
||||
return r.toLowerCase(Locale.ROOT).replace('_', '-');
|
||||
}
|
||||
|
||||
private String normalizePlatform(String platform) {
|
||||
if (platform == null) return "AR-15";
|
||||
String p = platform.trim();
|
||||
if (p.isEmpty()) return "AR-15";
|
||||
// normalize to AR-15 / AR-10 style
|
||||
return p.toUpperCase(Locale.ROOT).replace('_', '-');
|
||||
}
|
||||
|
||||
public record BuilderBootstrapDto(
|
||||
String platform,
|
||||
List<PartCategoryDto> categories,
|
||||
Map<String, String> partRoleMap,
|
||||
Map<String, List<String>> categoryRoles
|
||||
) {}
|
||||
package group.goforward.battlbuilder.controller;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* REST controller responsible for providing bootstrap data for the builder platform.
|
||||
* This controller aggregates and normalizes data required to initialize builder-related UI components.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/builder", "/api/v1/builder"})
|
||||
@CrossOrigin
|
||||
public class BuilderBootstrapController {
|
||||
|
||||
private final PartCategoryRepository partCategoryRepository;
|
||||
private final PartRoleMappingRepository mappingRepository;
|
||||
|
||||
public BuilderBootstrapController(
|
||||
PartCategoryRepository partCategoryRepository,
|
||||
PartRoleMappingRepository mappingRepository
|
||||
) {
|
||||
this.partCategoryRepository = partCategoryRepository;
|
||||
this.mappingRepository = mappingRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder bootstrap payload.
|
||||
* <p>
|
||||
* Returns:
|
||||
* - categories: ordered list for UI navigation
|
||||
* - partRoleMap: normalized partRole -> categorySlug (platform-scoped)
|
||||
* - categoryRoles: categorySlug -> normalized partRoles (derived)
|
||||
*/
|
||||
@GetMapping("/bootstrap")
|
||||
public BuilderBootstrapDto bootstrap(
|
||||
@RequestParam(defaultValue = "AR-15") String platform
|
||||
) {
|
||||
final String platformNorm = normalizePlatform(platform);
|
||||
|
||||
// 1) Categories in display order
|
||||
List<PartCategoryDto> categories = partCategoryRepository
|
||||
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||
.stream()
|
||||
.map(pc -> new PartCategoryDto(
|
||||
pc.getId(),
|
||||
pc.getSlug(),
|
||||
pc.getName(),
|
||||
pc.getDescription(),
|
||||
pc.getGroupName(),
|
||||
pc.getSortOrder()
|
||||
))
|
||||
.toList();
|
||||
|
||||
// 2) Role -> CategorySlug mapping (platform-scoped)
|
||||
// Normalize keys to kebab-case so the UI can treat roles consistently.
|
||||
Map<String, String> roleToCategorySlug = new LinkedHashMap<>();
|
||||
|
||||
List<PartRoleMapping> mappings = mappingRepository
|
||||
.findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(platformNorm);
|
||||
|
||||
for (PartRoleMapping m : mappings) {
|
||||
String roleKey = normalizePartRole(m.getPartRole());
|
||||
if (roleKey == null || roleKey.isBlank()) continue;
|
||||
|
||||
if (m.getPartCategory() == null || m.getPartCategory().getSlug() == null) continue;
|
||||
|
||||
// If duplicates exist, keep first and ignore the rest so bootstrap never 500s.
|
||||
roleToCategorySlug.putIfAbsent(roleKey, m.getPartCategory().getSlug());
|
||||
}
|
||||
|
||||
// 3) CategorySlug -> Roles (derived)
|
||||
Map<String, List<String>> categoryToRoles = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String> e : roleToCategorySlug.entrySet()) {
|
||||
categoryToRoles.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey());
|
||||
}
|
||||
|
||||
return new BuilderBootstrapDto(platformNorm, categories, roleToCategorySlug, categoryToRoles);
|
||||
}
|
||||
|
||||
private String normalizePartRole(String role) {
|
||||
if (role == null) return null;
|
||||
String r = role.trim();
|
||||
if (r.isEmpty()) return null;
|
||||
return r.toLowerCase(Locale.ROOT).replace('_', '-');
|
||||
}
|
||||
|
||||
private String normalizePlatform(String platform) {
|
||||
if (platform == null) return "AR-15";
|
||||
String p = platform.trim();
|
||||
if (p.isEmpty()) return "AR-15";
|
||||
// normalize to AR-15 / AR-10 style
|
||||
return p.toUpperCase(Locale.ROOT).replace('_', '-');
|
||||
}
|
||||
|
||||
public record BuilderBootstrapDto(
|
||||
String platform,
|
||||
List<PartCategoryDto> categories,
|
||||
Map<String, String> partRoleMap,
|
||||
Map<String, List<String>> categoryRoles
|
||||
) {}
|
||||
}
|
||||
@@ -1,34 +1,55 @@
|
||||
package group.goforward.battlbuilder.controller;
|
||||
|
||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping({"/api/categories", "/api/v1/categories"})
|
||||
@CrossOrigin // you can tighten origins later
|
||||
public class CategoryController {
|
||||
|
||||
private final PartCategoryRepository partCategories;
|
||||
|
||||
public CategoryController(PartCategoryRepository partCategories) {
|
||||
this.partCategories = partCategories;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PartCategoryDto> list() {
|
||||
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||
.stream()
|
||||
.map(pc -> new PartCategoryDto(
|
||||
pc.getId(),
|
||||
pc.getSlug(),
|
||||
pc.getName(),
|
||||
pc.getDescription(),
|
||||
pc.getGroupName(),
|
||||
pc.getSortOrder()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
package group.goforward.battlbuilder.controller;
|
||||
|
||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* REST controller for managing part categories.
|
||||
*
|
||||
* This controller provides endpoints for retrieving and interacting with
|
||||
* part category data through its associated repository. Part categories are
|
||||
* sorted based on their group name, sort order, and name in ascending order.
|
||||
*
|
||||
* Annotations:
|
||||
* - {@code @RestController}: Indicates that this class is a REST controller.
|
||||
* - {@code @RequestMapping}: Maps HTTP requests to specific endpoints. Supported
|
||||
* paths are "/api/categories" and "/api/v1/categories".
|
||||
* - {@code @CrossOrigin}: Enables cross-origin requests.
|
||||
*
|
||||
* Constructor:
|
||||
* - {@code CategoryController(PartCategoryRepository partCategories)}: Initializes
|
||||
* the controller with the specified repository for accessing part category data.
|
||||
*
|
||||
* Methods:
|
||||
* - {@code List<PartCategoryDto> list()}: Retrieves a list of part categories from
|
||||
* the repository, sorts them, and maps them to DTO objects for output.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/categories", "/api/v1/categories"})
|
||||
@CrossOrigin // you can tighten origins later
|
||||
public class CategoryController {
|
||||
|
||||
private final PartCategoryRepository partCategories;
|
||||
|
||||
public CategoryController(PartCategoryRepository partCategories) {
|
||||
this.partCategories = partCategories;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<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;
|
||||
|
||||
import group.goforward.battlbuilder.repo.EmailRequestRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/email")
|
||||
public class EmailTrackingController {
|
||||
|
||||
// 1x1 transparent GIF
|
||||
private static final byte[] PIXEL = new byte[] {
|
||||
71,73,70,56,57,97,1,0,1,0,-128,0,0,0,0,0,-1,-1,-1,33,-7,4,1,0,0,0,0,44,0,0,0,0,1,0,1,0,0,2,2,68,1,0,59
|
||||
};
|
||||
|
||||
private final EmailRequestRepository repo;
|
||||
|
||||
public EmailTrackingController(EmailRequestRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/open/{id}", produces = "image/gif")
|
||||
public ResponseEntity<byte[]> open(@PathVariable Long id) {
|
||||
repo.findById(id).ifPresent(r -> {
|
||||
if (r.getOpenedAt() == null) r.setOpenedAt(LocalDateTime.now());
|
||||
r.setOpenCount((r.getOpenCount() == null ? 0 : r.getOpenCount()) + 1);
|
||||
repo.save(r);
|
||||
});
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
.body(PIXEL);
|
||||
}
|
||||
|
||||
@GetMapping("/click/{id}")
|
||||
public ResponseEntity<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();
|
||||
}
|
||||
package group.goforward.battlbuilder.controller;
|
||||
|
||||
import group.goforward.battlbuilder.repo.EmailRequestRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* The EmailTrackingController handles tracking of email-related events such as
|
||||
* email opens and link clicks. This controller provides endpoints to record
|
||||
* these events and return appropriate responses.
|
||||
*
|
||||
* The tracking of email opens is achieved through a transparent 1x1 GIF image,
|
||||
* and link clicks are redirected to the intended URL while capturing relevant metadata.
|
||||
*
|
||||
* Request mappings:
|
||||
* 1. "/api/email/open/{id}" - Tracks email open events.
|
||||
* 2. "/api/email/click/{id}" - Tracks email link click events.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/email")
|
||||
public class EmailTrackingController {
|
||||
|
||||
// 1x1 transparent GIF
|
||||
private static final byte[] PIXEL = new byte[] {
|
||||
71,73,70,56,57,97,1,0,1,0,-128,0,0,0,0,0,-1,-1,-1,33,-7,4,1,0,0,0,0,44,0,0,0,0,1,0,1,0,0,2,2,68,1,0,59
|
||||
};
|
||||
|
||||
private final EmailRequestRepository repo;
|
||||
|
||||
public EmailTrackingController(EmailRequestRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/open/{id}", produces = "image/gif")
|
||||
public ResponseEntity<byte[]> open(@PathVariable Long id) {
|
||||
repo.findById(id).ifPresent(r -> {
|
||||
if (r.getOpenedAt() == null) r.setOpenedAt(LocalDateTime.now());
|
||||
r.setOpenCount((r.getOpenCount() == null ? 0 : r.getOpenCount()) + 1);
|
||||
repo.save(r);
|
||||
});
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
.body(PIXEL);
|
||||
}
|
||||
|
||||
@GetMapping("/click/{id}")
|
||||
public ResponseEntity<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.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* REST controller responsible for handling import operations for merchants.
|
||||
* Supports full product and offer imports as well as offers-only synchronization.
|
||||
*
|
||||
* Mapped to the following base endpoints:
|
||||
* /api/admin/imports and /api/v1/admin/imports
|
||||
*
|
||||
* Cross-origin requests are permitted from http://localhost:3000.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/admin/imports", "/api/v1/admin/imports"})
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* REST controller for debugging merchant-related functionalities in the admin API.
|
||||
* Provides endpoints for retrieving merchant data for administrative purposes.
|
||||
*
|
||||
* Mapped to both "/api/admin" and "/api/v1/admin" base paths.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/admin", "/api/v1/admin"})
|
||||
public class MerchantDebugController {
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
//package group.goforward.battlbuilder.controller;
|
||||
//
|
||||
//import group.goforward.battlbuilder.service.PartRoleMappingService;
|
||||
//import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
|
||||
//import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
|
||||
//import org.springframework.web.bind.annotation.*;
|
||||
//
|
||||
//import java.util.List;
|
||||
//
|
||||
//@RestController
|
||||
//@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"})
|
||||
//public class PartRoleMappingController {
|
||||
//
|
||||
// private final PartRoleMappingService service;
|
||||
//
|
||||
// public PartRoleMappingController(PartRoleMappingService service) {
|
||||
// this.service = service;
|
||||
// }
|
||||
//
|
||||
// // Full view for admin UI
|
||||
// @GetMapping("/{platform}")
|
||||
// public List<PartRoleMappingDto> getMappings(@PathVariable String platform) {
|
||||
// return service.getMappingsForPlatform(platform);
|
||||
// }
|
||||
//
|
||||
// // Thin mapping for the builder
|
||||
// @GetMapping("/{platform}/map")
|
||||
// public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) {
|
||||
// return service.getRoleToCategoryMap(platform);
|
||||
// }
|
||||
//package group.goforward.battlbuilder.controller;
|
||||
//
|
||||
//import group.goforward.battlbuilder.service.PartRoleMappingService;
|
||||
//import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
|
||||
//import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
|
||||
//import org.springframework.web.bind.annotation.*;
|
||||
//
|
||||
//import java.util.List;
|
||||
//
|
||||
//@RestController
|
||||
//@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"})
|
||||
//public class PartRoleMappingController {
|
||||
//
|
||||
// private final PartRoleMappingService service;
|
||||
//
|
||||
// public PartRoleMappingController(PartRoleMappingService service) {
|
||||
// this.service = service;
|
||||
// }
|
||||
//
|
||||
// // Full view for admin UI
|
||||
// @GetMapping("/{platform}")
|
||||
// public List<PartRoleMappingDto> getMappings(@PathVariable String platform) {
|
||||
// return service.getMappingsForPlatform(platform);
|
||||
// }
|
||||
//
|
||||
// // Thin mapping for the builder
|
||||
// @GetMapping("/{platform}/map")
|
||||
// public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) {
|
||||
// return service.getRoleToCategoryMap(platform);
|
||||
// }
|
||||
//}
|
||||
@@ -1,51 +1,51 @@
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
|
||||
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/beta")
|
||||
public class AdminBetaInviteController {
|
||||
|
||||
private final BetaInviteService betaInviteService;
|
||||
|
||||
public AdminBetaInviteController(BetaInviteService betaInviteService) {
|
||||
this.betaInviteService = betaInviteService;
|
||||
}
|
||||
|
||||
/**
|
||||
* //api/v1/admin/beta/invites/send?limit=25&dryRun=true&tokenMinutes=30
|
||||
* @param limit
|
||||
* @param dryRun
|
||||
* @param tokenMinutes
|
||||
* @return
|
||||
*/
|
||||
|
||||
@PostMapping("/invites/send")
|
||||
public InviteBatchResponse sendInvites(
|
||||
@RequestParam(defaultValue = "0") int limit,
|
||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||
@RequestParam(defaultValue = "30") int tokenMinutes
|
||||
) {
|
||||
int processed = betaInviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
|
||||
return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit);
|
||||
}
|
||||
|
||||
@GetMapping("/requests")
|
||||
public Page<AdminBetaRequestDto> listBetaRequests(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size
|
||||
) {
|
||||
return betaInviteService.listPendingBetaUsers(page, size);
|
||||
}
|
||||
|
||||
@PostMapping("/requests/{userId}/invite")
|
||||
public AdminInviteResponse inviteSingle(@PathVariable Integer userId) {
|
||||
return betaInviteService.inviteSingleBetaUser(userId);
|
||||
}
|
||||
|
||||
public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {}
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
|
||||
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/beta")
|
||||
public class AdminBetaInviteController {
|
||||
|
||||
private final BetaInviteService betaInviteService;
|
||||
|
||||
public AdminBetaInviteController(BetaInviteService betaInviteService) {
|
||||
this.betaInviteService = betaInviteService;
|
||||
}
|
||||
|
||||
/**
|
||||
* //api/v1/admin/beta/invites/send?limit=25&dryRun=true&tokenMinutes=30
|
||||
* @param limit
|
||||
* @param dryRun
|
||||
* @param tokenMinutes
|
||||
* @return
|
||||
*/
|
||||
|
||||
@PostMapping("/invites/send")
|
||||
public InviteBatchResponse sendInvites(
|
||||
@RequestParam(defaultValue = "0") int limit,
|
||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||
@RequestParam(defaultValue = "30") int tokenMinutes
|
||||
) {
|
||||
int processed = betaInviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
|
||||
return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit);
|
||||
}
|
||||
|
||||
@GetMapping("/requests")
|
||||
public Page<AdminBetaRequestDto> listBetaRequests(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size
|
||||
) {
|
||||
return betaInviteService.listPendingBetaUsers(page, size);
|
||||
}
|
||||
|
||||
@PostMapping("/requests/{userId}/invite")
|
||||
public AdminInviteResponse inviteSingle(@PathVariable Integer userId) {
|
||||
return betaInviteService.inviteSingleBetaUser(userId);
|
||||
}
|
||||
|
||||
public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {}
|
||||
}
|
||||
@@ -1,40 +1,40 @@
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartCategory;
|
||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/categories")
|
||||
@CrossOrigin
|
||||
public class AdminCategoryController {
|
||||
|
||||
private final PartCategoryRepository partCategories;
|
||||
|
||||
public AdminCategoryController(PartCategoryRepository partCategories) {
|
||||
this.partCategories = partCategories;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PartCategoryDto> listCategories() {
|
||||
return partCategories
|
||||
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||
.stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private PartCategoryDto toDto(PartCategory entity) {
|
||||
return new PartCategoryDto(
|
||||
entity.getId(),
|
||||
entity.getSlug(),
|
||||
entity.getName(),
|
||||
entity.getDescription(),
|
||||
entity.getGroupName(),
|
||||
entity.getSortOrder()
|
||||
);
|
||||
}
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartCategory;
|
||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/categories")
|
||||
@CrossOrigin
|
||||
public class AdminCategoryController {
|
||||
|
||||
private final PartCategoryRepository partCategories;
|
||||
|
||||
public AdminCategoryController(PartCategoryRepository partCategories) {
|
||||
this.partCategories = partCategories;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PartCategoryDto> listCategories() {
|
||||
return partCategories
|
||||
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||
.stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private PartCategoryDto toDto(PartCategory entity) {
|
||||
return new PartCategoryDto(
|
||||
entity.getId(),
|
||||
entity.getSlug(),
|
||||
entity.getName(),
|
||||
entity.getDescription(),
|
||||
entity.getGroupName(),
|
||||
entity.getSortOrder()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
|
||||
import group.goforward.battlbuilder.service.admin.impl.AdminDashboardService;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/dashboard")
|
||||
public class AdminDashboardController {
|
||||
|
||||
private final AdminDashboardService adminDashboardService;
|
||||
|
||||
public AdminDashboardController(AdminDashboardService adminDashboardService) {
|
||||
this.adminDashboardService = adminDashboardService;
|
||||
}
|
||||
|
||||
@GetMapping("/overview")
|
||||
public ResponseEntity<AdminDashboardOverviewDto> getOverview() {
|
||||
AdminDashboardOverviewDto dto = adminDashboardService.getOverview();
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
|
||||
import group.goforward.battlbuilder.service.admin.impl.AdminDashboardService;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/dashboard")
|
||||
public class AdminDashboardController {
|
||||
|
||||
private final AdminDashboardService adminDashboardService;
|
||||
|
||||
public AdminDashboardController(AdminDashboardService adminDashboardService) {
|
||||
this.adminDashboardService = adminDashboardService;
|
||||
}
|
||||
|
||||
@GetMapping("/overview")
|
||||
public ResponseEntity<AdminDashboardOverviewDto> getOverview() {
|
||||
AdminDashboardOverviewDto dto = adminDashboardService.getOverview();
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,34 @@
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
|
||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/part-categories")
|
||||
@CrossOrigin // keep it loose for now, you can tighten origins later
|
||||
public class AdminPartCategoryController {
|
||||
|
||||
private final PartCategoryRepository partCategories;
|
||||
|
||||
public AdminPartCategoryController(PartCategoryRepository partCategories) {
|
||||
this.partCategories = partCategories;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PartCategoryDto> list() {
|
||||
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||
.stream()
|
||||
.map(pc -> new PartCategoryDto(
|
||||
pc.getId(),
|
||||
pc.getSlug(),
|
||||
pc.getName(),
|
||||
pc.getDescription(),
|
||||
pc.getGroupName(),
|
||||
pc.getSortOrder()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
|
||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/part-categories")
|
||||
@CrossOrigin // keep it loose for now, you can tighten origins later
|
||||
public class AdminPartCategoryController {
|
||||
|
||||
private final PartCategoryRepository partCategories;
|
||||
|
||||
public AdminPartCategoryController(PartCategoryRepository partCategories) {
|
||||
this.partCategories = partCategories;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PartCategoryDto> list() {
|
||||
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||
.stream()
|
||||
.map(pc -> new PartCategoryDto(
|
||||
pc.getId(),
|
||||
pc.getSlug(),
|
||||
pc.getName(),
|
||||
pc.getDescription(),
|
||||
pc.getGroupName(),
|
||||
pc.getSortOrder()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -1,124 +1,124 @@
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartCategory;
|
||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminPartRoleMappingDto;
|
||||
import group.goforward.battlbuilder.web.dto.admin.CreatePartRoleMappingRequest;
|
||||
import group.goforward.battlbuilder.web.dto.admin.UpdatePartRoleMappingRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/part-role-mappings")
|
||||
@CrossOrigin
|
||||
public class AdminPartRoleMappingController {
|
||||
|
||||
private final PartRoleMappingRepository partRoleMappingRepository;
|
||||
private final PartCategoryRepository partCategoryRepository;
|
||||
|
||||
public AdminPartRoleMappingController(
|
||||
PartRoleMappingRepository partRoleMappingRepository,
|
||||
PartCategoryRepository partCategoryRepository
|
||||
) {
|
||||
this.partRoleMappingRepository = partRoleMappingRepository;
|
||||
this.partCategoryRepository = partCategoryRepository;
|
||||
}
|
||||
|
||||
// GET /api/admin/part-role-mappings?platform=AR-15
|
||||
@GetMapping
|
||||
public List<AdminPartRoleMappingDto> list(
|
||||
@RequestParam(name = "platform", required = false) String platform
|
||||
) {
|
||||
List<PartRoleMapping> mappings;
|
||||
|
||||
if (platform != null && !platform.isBlank()) {
|
||||
mappings = partRoleMappingRepository
|
||||
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform);
|
||||
} else {
|
||||
mappings = partRoleMappingRepository.findAll();
|
||||
}
|
||||
|
||||
return mappings.stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// POST /api/admin/part-role-mappings
|
||||
@PostMapping
|
||||
public AdminPartRoleMappingDto create(
|
||||
@RequestBody CreatePartRoleMappingRequest request
|
||||
) {
|
||||
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"PartCategory not found for slug: " + request.categorySlug()
|
||||
));
|
||||
|
||||
PartRoleMapping mapping = new PartRoleMapping();
|
||||
mapping.setPlatform(request.platform());
|
||||
mapping.setPartRole(request.partRole());
|
||||
mapping.setPartCategory(category);
|
||||
mapping.setNotes(request.notes());
|
||||
|
||||
mapping = partRoleMappingRepository.save(mapping);
|
||||
return toDto(mapping);
|
||||
}
|
||||
|
||||
// PUT /api/admin/part-role-mappings/{id}
|
||||
@PutMapping("/{id}")
|
||||
public AdminPartRoleMappingDto update(
|
||||
@PathVariable Integer id,
|
||||
@RequestBody UpdatePartRoleMappingRequest request
|
||||
) {
|
||||
PartRoleMapping mapping = partRoleMappingRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"));
|
||||
|
||||
if (request.platform() != null) {
|
||||
mapping.setPlatform(request.platform());
|
||||
}
|
||||
if (request.partRole() != null) {
|
||||
mapping.setPartRole(request.partRole());
|
||||
}
|
||||
if (request.categorySlug() != null) {
|
||||
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"PartCategory not found for slug: " + request.categorySlug()
|
||||
));
|
||||
mapping.setPartCategory(category);
|
||||
}
|
||||
if (request.notes() != null) {
|
||||
mapping.setNotes(request.notes());
|
||||
}
|
||||
|
||||
mapping = partRoleMappingRepository.save(mapping);
|
||||
return toDto(mapping);
|
||||
}
|
||||
|
||||
// DELETE /api/admin/part-role-mappings/{id}
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable Integer id) {
|
||||
if (!partRoleMappingRepository.existsById(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found");
|
||||
}
|
||||
partRoleMappingRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private AdminPartRoleMappingDto toDto(PartRoleMapping mapping) {
|
||||
PartCategory cat = mapping.getPartCategory();
|
||||
return new AdminPartRoleMappingDto(
|
||||
mapping.getId(),
|
||||
mapping.getPlatform(),
|
||||
mapping.getPartRole(),
|
||||
cat != null ? cat.getSlug() : null,
|
||||
cat != null ? cat.getGroupName() : null,
|
||||
mapping.getNotes()
|
||||
);
|
||||
}
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartCategory;
|
||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||
import group.goforward.battlbuilder.repo.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminPartRoleMappingDto;
|
||||
import group.goforward.battlbuilder.web.dto.admin.CreatePartRoleMappingRequest;
|
||||
import group.goforward.battlbuilder.web.dto.admin.UpdatePartRoleMappingRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/part-role-mappings")
|
||||
@CrossOrigin
|
||||
public class AdminPartRoleMappingController {
|
||||
|
||||
private final PartRoleMappingRepository partRoleMappingRepository;
|
||||
private final PartCategoryRepository partCategoryRepository;
|
||||
|
||||
public AdminPartRoleMappingController(
|
||||
PartRoleMappingRepository partRoleMappingRepository,
|
||||
PartCategoryRepository partCategoryRepository
|
||||
) {
|
||||
this.partRoleMappingRepository = partRoleMappingRepository;
|
||||
this.partCategoryRepository = partCategoryRepository;
|
||||
}
|
||||
|
||||
// GET /api/admin/part-role-mappings?platform=AR-15
|
||||
@GetMapping
|
||||
public List<AdminPartRoleMappingDto> list(
|
||||
@RequestParam(name = "platform", required = false) String platform
|
||||
) {
|
||||
List<PartRoleMapping> mappings;
|
||||
|
||||
if (platform != null && !platform.isBlank()) {
|
||||
mappings = partRoleMappingRepository
|
||||
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform);
|
||||
} else {
|
||||
mappings = partRoleMappingRepository.findAll();
|
||||
}
|
||||
|
||||
return mappings.stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// POST /api/admin/part-role-mappings
|
||||
@PostMapping
|
||||
public AdminPartRoleMappingDto create(
|
||||
@RequestBody CreatePartRoleMappingRequest request
|
||||
) {
|
||||
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"PartCategory not found for slug: " + request.categorySlug()
|
||||
));
|
||||
|
||||
PartRoleMapping mapping = new PartRoleMapping();
|
||||
mapping.setPlatform(request.platform());
|
||||
mapping.setPartRole(request.partRole());
|
||||
mapping.setPartCategory(category);
|
||||
mapping.setNotes(request.notes());
|
||||
|
||||
mapping = partRoleMappingRepository.save(mapping);
|
||||
return toDto(mapping);
|
||||
}
|
||||
|
||||
// PUT /api/admin/part-role-mappings/{id}
|
||||
@PutMapping("/{id}")
|
||||
public AdminPartRoleMappingDto update(
|
||||
@PathVariable Integer id,
|
||||
@RequestBody UpdatePartRoleMappingRequest request
|
||||
) {
|
||||
PartRoleMapping mapping = partRoleMappingRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"));
|
||||
|
||||
if (request.platform() != null) {
|
||||
mapping.setPlatform(request.platform());
|
||||
}
|
||||
if (request.partRole() != null) {
|
||||
mapping.setPartRole(request.partRole());
|
||||
}
|
||||
if (request.categorySlug() != null) {
|
||||
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"PartCategory not found for slug: " + request.categorySlug()
|
||||
));
|
||||
mapping.setPartCategory(category);
|
||||
}
|
||||
if (request.notes() != null) {
|
||||
mapping.setNotes(request.notes());
|
||||
}
|
||||
|
||||
mapping = partRoleMappingRepository.save(mapping);
|
||||
return toDto(mapping);
|
||||
}
|
||||
|
||||
// DELETE /api/admin/part-role-mappings/{id}
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable Integer id) {
|
||||
if (!partRoleMappingRepository.existsById(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found");
|
||||
}
|
||||
partRoleMappingRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private AdminPartRoleMappingDto toDto(PartRoleMapping mapping) {
|
||||
PartCategory cat = mapping.getPartCategory();
|
||||
return new AdminPartRoleMappingDto(
|
||||
mapping.getId(),
|
||||
mapping.getPlatform(),
|
||||
mapping.getPartRole(),
|
||||
cat != null ? cat.getSlug() : null,
|
||||
cat != null ? cat.getGroupName() : null,
|
||||
mapping.getNotes()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Admin controller package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains REST controller for administrative operations including
|
||||
* category management, platform configuration, and merchant administration.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
/**
|
||||
* Admin controller package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains REST controller for administrative operations including
|
||||
* category management, platform configuration, and merchant administration.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.controller.admin;
|
||||
|
||||
@@ -11,6 +11,27 @@ import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/**
|
||||
* REST controller for managing brand entities.
|
||||
* Provides endpoints for performing CRUD operations on brands.
|
||||
*
|
||||
* The controller exposes REST APIs to:
|
||||
* - Retrieve a list of all brands.
|
||||
* - Fetch a single brand by its ID.
|
||||
* - Create a new brand.
|
||||
* - Delete a brand by its ID.
|
||||
*
|
||||
* This controller interacts with the persistence layer through `BrandRepository` and with the
|
||||
* business logic through `BrandService`.
|
||||
*
|
||||
* Mapped base endpoints:
|
||||
* - `/api/v1/brands`
|
||||
* - `/api/brands`
|
||||
*
|
||||
* Dependencies:
|
||||
* - `BrandRepository` for direct database access.
|
||||
* - `BrandService` for handling the business logic of brands.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/brands", "/api/brands"})
|
||||
public class BrandController {
|
||||
|
||||
@@ -1,96 +1,96 @@
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
|
||||
import group.goforward.battlbuilder.service.BuildService;
|
||||
import group.goforward.battlbuilder.web.dto.BuildDto;
|
||||
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
|
||||
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
|
||||
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@CrossOrigin
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/builds")
|
||||
public class BuildV1Controller {
|
||||
|
||||
private final BuildService buildService;
|
||||
|
||||
public BuildV1Controller(BuildService buildService) {
|
||||
this.buildService = buildService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public builds feed for /builds page.
|
||||
* GET /api/v1/builds?limit=50
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<List<BuildFeedCardDto>> listPublicBuilds(
|
||||
@RequestParam(name = "limit", required = false, defaultValue = "50") Integer limit
|
||||
) {
|
||||
return ResponseEntity.ok(buildService.listPublicBuilds(limit == null ? 50 : limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Public build detail for /builds/{uuid}
|
||||
* GET /api/v1/builds/{uuid}
|
||||
*/
|
||||
@GetMapping("/{uuid}")
|
||||
public ResponseEntity<BuildDto> getPublicBuild(@PathVariable("uuid") UUID uuid) {
|
||||
return ResponseEntity.ok(buildService.getPublicBuild(uuid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vault builds (authenticated user).
|
||||
* GET /api/v1/builds/me?limit=100
|
||||
*/
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<List<BuildSummaryDto>> listMyBuilds(
|
||||
@RequestParam(name = "limit", required = false, defaultValue = "100") Integer limit
|
||||
) {
|
||||
return ResponseEntity.ok(buildService.listMyBuilds(limit == null ? 100 : limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single build (Vault edit + Builder ?load=uuid).
|
||||
* GET /api/v1/builds/me/{uuid}
|
||||
*/
|
||||
@GetMapping("/me/{uuid}")
|
||||
public ResponseEntity<BuildDto> getMyBuild(@PathVariable("uuid") UUID uuid) {
|
||||
return ResponseEntity.ok(buildService.getMyBuild(uuid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NEW build in Vault (Save As…).
|
||||
* POST /api/v1/builds/me
|
||||
*/
|
||||
@PostMapping("/me")
|
||||
public ResponseEntity<BuildDto> createMyBuild(@RequestBody UpdateBuildRequest req) {
|
||||
return ResponseEntity.ok(buildService.createMyBuild(req));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update build (authenticated user; must own build eventually).
|
||||
* PUT /api/v1/builds/me/{uuid}
|
||||
*/
|
||||
@PutMapping("/me/{uuid}")
|
||||
public ResponseEntity<BuildDto> updateMyBuild(
|
||||
@PathVariable("uuid") UUID uuid,
|
||||
@RequestBody UpdateBuildRequest req
|
||||
) {
|
||||
return ResponseEntity.ok(buildService.updateMyBuild(uuid, req));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a build (authenticated user; must own build).
|
||||
* DELETE /api/v1/builds/me/{uuid}
|
||||
*/
|
||||
@DeleteMapping("/me/{uuid}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteMyBuild(@PathVariable("uuid") UUID uuid) {
|
||||
buildService.deleteMyBuild(uuid);
|
||||
}
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
|
||||
import group.goforward.battlbuilder.service.BuildService;
|
||||
import group.goforward.battlbuilder.web.dto.BuildDto;
|
||||
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
|
||||
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
|
||||
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@CrossOrigin
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/builds")
|
||||
public class BuildV1Controller {
|
||||
|
||||
private final BuildService buildService;
|
||||
|
||||
public BuildV1Controller(BuildService buildService) {
|
||||
this.buildService = buildService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public builds feed for /builds page.
|
||||
* GET /api/v1/builds?limit=50
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<List<BuildFeedCardDto>> listPublicBuilds(
|
||||
@RequestParam(name = "limit", required = false, defaultValue = "50") Integer limit
|
||||
) {
|
||||
return ResponseEntity.ok(buildService.listPublicBuilds(limit == null ? 50 : limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Public build detail for /builds/{uuid}
|
||||
* GET /api/v1/builds/{uuid}
|
||||
*/
|
||||
@GetMapping("/{uuid}")
|
||||
public ResponseEntity<BuildDto> getPublicBuild(@PathVariable("uuid") UUID uuid) {
|
||||
return ResponseEntity.ok(buildService.getPublicBuild(uuid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vault builds (authenticated user).
|
||||
* GET /api/v1/builds/me?limit=100
|
||||
*/
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<List<BuildSummaryDto>> listMyBuilds(
|
||||
@RequestParam(name = "limit", required = false, defaultValue = "100") Integer limit
|
||||
) {
|
||||
return ResponseEntity.ok(buildService.listMyBuilds(limit == null ? 100 : limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single build (Vault edit + Builder ?load=uuid).
|
||||
* GET /api/v1/builds/me/{uuid}
|
||||
*/
|
||||
@GetMapping("/me/{uuid}")
|
||||
public ResponseEntity<BuildDto> getMyBuild(@PathVariable("uuid") UUID uuid) {
|
||||
return ResponseEntity.ok(buildService.getMyBuild(uuid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NEW build in Vault (Save As…).
|
||||
* POST /api/v1/builds/me
|
||||
*/
|
||||
@PostMapping("/me")
|
||||
public ResponseEntity<BuildDto> createMyBuild(@RequestBody UpdateBuildRequest req) {
|
||||
return ResponseEntity.ok(buildService.createMyBuild(req));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update build (authenticated user; must own build eventually).
|
||||
* PUT /api/v1/builds/me/{uuid}
|
||||
*/
|
||||
@PutMapping("/me/{uuid}")
|
||||
public ResponseEntity<BuildDto> updateMyBuild(
|
||||
@PathVariable("uuid") UUID uuid,
|
||||
@RequestBody UpdateBuildRequest req
|
||||
) {
|
||||
return ResponseEntity.ok(buildService.updateMyBuild(uuid, req));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a build (authenticated user; must own build).
|
||||
* DELETE /api/v1/builds/me/{uuid}
|
||||
*/
|
||||
@DeleteMapping("/me/{uuid}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteMyBuild(@PathVariable("uuid") UUID uuid) {
|
||||
buildService.deleteMyBuild(uuid);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,60 @@
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
|
||||
import group.goforward.battlbuilder.service.CatalogQueryService;
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/catalog")
|
||||
@CrossOrigin // tighten later
|
||||
public class CatalogController {
|
||||
|
||||
private final CatalogQueryService catalogQueryService;
|
||||
|
||||
public CatalogController(CatalogQueryService catalogQueryService) {
|
||||
this.catalogQueryService = catalogQueryService;
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
public Page<ProductSummaryDto> getOptions(
|
||||
@RequestParam(required = false) String platform,
|
||||
@RequestParam(required = false) String partRole,
|
||||
@RequestParam(required = false) List<String> partRoles,
|
||||
@RequestParam(required = false, name = "brand") List<String> brands,
|
||||
@RequestParam(required = false) String q,
|
||||
Pageable pageable
|
||||
) {
|
||||
Pageable safe = sanitizeCatalogPageable(pageable);
|
||||
return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe);
|
||||
}
|
||||
|
||||
private Pageable sanitizeCatalogPageable(Pageable pageable) {
|
||||
int page = Math.max(0, pageable.getPageNumber());
|
||||
|
||||
// hard cap to keep UI snappy + protect DB
|
||||
int requested = pageable.getPageSize();
|
||||
int size = Math.min(Math.max(requested, 1), 48); // 48 max
|
||||
|
||||
// default sort if none provided
|
||||
Sort sort = pageable.getSort().isSorted()
|
||||
? pageable.getSort()
|
||||
: Sort.by(Sort.Direction.DESC, "updatedAt");
|
||||
|
||||
return PageRequest.of(page, size, sort);
|
||||
}
|
||||
|
||||
@PostMapping("/products/by-ids")
|
||||
public List<ProductSummaryDto> getProductsByIds(@RequestBody CatalogProductIdsRequest request) {
|
||||
return catalogQueryService.getProductsByIds(request);
|
||||
}
|
||||
|
||||
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
|
||||
import group.goforward.battlbuilder.service.CatalogQueryService;
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/catalog")
|
||||
@CrossOrigin // tighten later
|
||||
public class CatalogController {
|
||||
|
||||
private final CatalogQueryService catalogQueryService;
|
||||
|
||||
public CatalogController(CatalogQueryService catalogQueryService) {
|
||||
this.catalogQueryService = catalogQueryService;
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
public Page<ProductSummaryDto> getOptions(
|
||||
@RequestParam(required = false) String platform,
|
||||
@RequestParam(required = false) String partRole,
|
||||
@RequestParam(required = false) List<String> partRoles,
|
||||
@RequestParam(required = false, name = "brand") List<String> brands,
|
||||
@RequestParam(required = false) String q,
|
||||
Pageable pageable
|
||||
) {
|
||||
Pageable safe = sanitizeCatalogPageable(pageable);
|
||||
return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe);
|
||||
}
|
||||
|
||||
private Pageable sanitizeCatalogPageable(Pageable pageable) {
|
||||
int page = Math.max(0, pageable.getPageNumber());
|
||||
|
||||
// hard cap to keep UI snappy + protect DB
|
||||
int requested = pageable.getPageSize();
|
||||
int size = Math.min(Math.max(requested, 1), 48); // 48 max
|
||||
|
||||
// default sort if none provided
|
||||
Sort sort = pageable.getSort().isSorted()
|
||||
? pageable.getSort()
|
||||
: Sort.by(Sort.Direction.DESC, "updatedAt");
|
||||
|
||||
return PageRequest.of(page, size, sort);
|
||||
}
|
||||
|
||||
@PostMapping("/products/by-ids")
|
||||
public List<ProductSummaryDto> getProductsByIds(@RequestBody CatalogProductIdsRequest request) {
|
||||
return catalogQueryService.getProductsByIds(request);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,152 +1,152 @@
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
|
||||
import group.goforward.battlbuilder.common.ApiResponse;
|
||||
import group.goforward.battlbuilder.dto.EmailRequestDto;
|
||||
import group.goforward.battlbuilder.model.EmailRequest;
|
||||
import group.goforward.battlbuilder.model.EmailStatus;
|
||||
import group.goforward.battlbuilder.repo.EmailRequestRepository;
|
||||
import group.goforward.battlbuilder.service.utils.EmailService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/email")
|
||||
public class EmailController {
|
||||
|
||||
private static final EmailStatus EMAIL_STATUS_SENT = EmailStatus.SENT;
|
||||
|
||||
private final EmailService emailService;
|
||||
private final EmailRequestRepository emailRequestRepository;
|
||||
|
||||
@Autowired
|
||||
public EmailController(EmailService emailService, EmailRequestRepository emailRequestRepository) {
|
||||
this.emailService = emailService;
|
||||
this.emailRequestRepository = emailRequestRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/statuses")
|
||||
public ResponseEntity<ApiResponse<List<String>>> getEmailStatuses() {
|
||||
List<String> statuses = Arrays.stream(EmailStatus.values())
|
||||
.map(Enum::name)
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(statuses, "Email statuses retrieved successfully")
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getAllEmailRequests() {
|
||||
try {
|
||||
List<EmailRequest> emailRequests = emailRequestRepository.findAll();
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(emailRequests, "Email requests retrieved successfully")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error("Error retrieving email requests: " + e.getMessage(), null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/allSent")
|
||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getNotSentEmailRequests() {
|
||||
try {
|
||||
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.SENT);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(emailRequests, "Not sent email requests retrieved successfully")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error("Error retrieving not sent email requests: " + e.getMessage(), null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/allFailed")
|
||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getFailedEmailRequests() {
|
||||
try {
|
||||
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.FAILED);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(emailRequests, "Failed email requests retrieved successfully")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error("Error retrieving failed email requests: " + e.getMessage(), null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/allPending")
|
||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getPendingEmailRequests() {
|
||||
try {
|
||||
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.PENDING);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(emailRequests, "Pending email requests retrieved successfully")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error("Error retrieving Pending email requests: " + e.getMessage(), null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/send")
|
||||
public ResponseEntity<ApiResponse<EmailRequest>> sendEmail(@RequestBody EmailRequestDto emailDto) {
|
||||
try {
|
||||
EmailRequest emailRequest = emailService.sendEmail(
|
||||
emailDto.getRecipient(),
|
||||
emailDto.getSubject(),
|
||||
emailDto.getBody()
|
||||
);
|
||||
return buildEmailResponse(emailRequest);
|
||||
} catch (Exception e) {
|
||||
return buildErrorResponse(e.getMessage());
|
||||
}
|
||||
}
|
||||
@DeleteMapping("/delete/{id}")
|
||||
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
|
||||
return emailRequestRepository.findById(Long.valueOf(id))
|
||||
.map(item -> {
|
||||
emailRequestRepository.deleteById(Long.valueOf(id));
|
||||
return ResponseEntity.noContent().<Void>build();
|
||||
})
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
// Replace /delete/{id} with a RESTful DELETE /{id}
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteEmailRequest(@PathVariable Long id) {
|
||||
if (!emailRequestRepository.existsById(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email request not found");
|
||||
}
|
||||
emailRequestRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<EmailRequest>> buildEmailResponse(EmailRequest emailRequest) {
|
||||
if (EMAIL_STATUS_SENT.equals(emailRequest.getStatus())) {
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(emailRequest, "Email sent successfully")
|
||||
);
|
||||
} else {
|
||||
String errorMessage = "Failed to send email: " + emailRequest.getErrorMessage();
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error(errorMessage, emailRequest)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<EmailRequest>> buildErrorResponse(String exceptionMessage) {
|
||||
String errorMessage = "Error processing email request: " + exceptionMessage;
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error(errorMessage, null)
|
||||
);
|
||||
}
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
|
||||
import group.goforward.battlbuilder.common.ApiResponse;
|
||||
import group.goforward.battlbuilder.dto.EmailRequestDto;
|
||||
import group.goforward.battlbuilder.model.EmailRequest;
|
||||
import group.goforward.battlbuilder.model.EmailStatus;
|
||||
import group.goforward.battlbuilder.repo.EmailRequestRepository;
|
||||
import group.goforward.battlbuilder.service.utils.EmailService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/email")
|
||||
public class EmailController {
|
||||
|
||||
private static final EmailStatus EMAIL_STATUS_SENT = EmailStatus.SENT;
|
||||
|
||||
private final EmailService emailService;
|
||||
private final EmailRequestRepository emailRequestRepository;
|
||||
|
||||
@Autowired
|
||||
public EmailController(EmailService emailService, EmailRequestRepository emailRequestRepository) {
|
||||
this.emailService = emailService;
|
||||
this.emailRequestRepository = emailRequestRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/statuses")
|
||||
public ResponseEntity<ApiResponse<List<String>>> getEmailStatuses() {
|
||||
List<String> statuses = Arrays.stream(EmailStatus.values())
|
||||
.map(Enum::name)
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(statuses, "Email statuses retrieved successfully")
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getAllEmailRequests() {
|
||||
try {
|
||||
List<EmailRequest> emailRequests = emailRequestRepository.findAll();
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(emailRequests, "Email requests retrieved successfully")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error("Error retrieving email requests: " + e.getMessage(), null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/allSent")
|
||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getNotSentEmailRequests() {
|
||||
try {
|
||||
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.SENT);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(emailRequests, "Not sent email requests retrieved successfully")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error("Error retrieving not sent email requests: " + e.getMessage(), null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/allFailed")
|
||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getFailedEmailRequests() {
|
||||
try {
|
||||
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.FAILED);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(emailRequests, "Failed email requests retrieved successfully")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error("Error retrieving failed email requests: " + e.getMessage(), null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/allPending")
|
||||
public ResponseEntity<ApiResponse<List<EmailRequest>>> getPendingEmailRequests() {
|
||||
try {
|
||||
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.PENDING);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(emailRequests, "Pending email requests retrieved successfully")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error("Error retrieving Pending email requests: " + e.getMessage(), null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/send")
|
||||
public ResponseEntity<ApiResponse<EmailRequest>> sendEmail(@RequestBody EmailRequestDto emailDto) {
|
||||
try {
|
||||
EmailRequest emailRequest = emailService.sendEmail(
|
||||
emailDto.getRecipient(),
|
||||
emailDto.getSubject(),
|
||||
emailDto.getBody()
|
||||
);
|
||||
return buildEmailResponse(emailRequest);
|
||||
} catch (Exception e) {
|
||||
return buildErrorResponse(e.getMessage());
|
||||
}
|
||||
}
|
||||
@DeleteMapping("/delete/{id}")
|
||||
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
|
||||
return emailRequestRepository.findById(Long.valueOf(id))
|
||||
.map(item -> {
|
||||
emailRequestRepository.deleteById(Long.valueOf(id));
|
||||
return ResponseEntity.noContent().<Void>build();
|
||||
})
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
// Replace /delete/{id} with a RESTful DELETE /{id}
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteEmailRequest(@PathVariable Long id) {
|
||||
if (!emailRequestRepository.existsById(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email request not found");
|
||||
}
|
||||
emailRequestRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<EmailRequest>> buildEmailResponse(EmailRequest emailRequest) {
|
||||
if (EMAIL_STATUS_SENT.equals(emailRequest.getStatus())) {
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(emailRequest, "Email sent successfully")
|
||||
);
|
||||
} else {
|
||||
String errorMessage = "Failed to send email: " + emailRequest.getErrorMessage();
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error(errorMessage, emailRequest)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<EmailRequest>> buildErrorResponse(String exceptionMessage) {
|
||||
String errorMessage = "Error processing email request: " + exceptionMessage;
|
||||
return ResponseEntity.status(500).body(
|
||||
ApiResponse.error(errorMessage, null)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,231 +1,231 @@
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.http.HttpStatus.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/users/me", "/api/users/me"})
|
||||
@CrossOrigin
|
||||
public class MeController {
|
||||
|
||||
private final UserRepository users;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public MeController(UserRepository users, PasswordEncoder passwordEncoder) {
|
||||
this.users = users;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Helpers
|
||||
// -----------------------------
|
||||
|
||||
private Authentication requireAuth() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
// Spring may set "anonymousUser" as a principal when not logged in
|
||||
Object principal = auth.getPrincipal();
|
||||
if (principal == null || "anonymousUser".equals(principal)) {
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
private Optional<UUID> tryParseUuid(String s) {
|
||||
try {
|
||||
return Optional.of(UUID.fromString(s));
|
||||
} catch (Exception ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private User requireUser() {
|
||||
Authentication auth = requireAuth();
|
||||
Object principal = auth.getPrincipal();
|
||||
|
||||
// Case 1: principal is a String (we commonly set this to UUID string)
|
||||
if (principal instanceof String s) {
|
||||
// Prefer UUID lookup
|
||||
Optional<UUID> uuid = tryParseUuid(s);
|
||||
if (uuid.isPresent()) {
|
||||
return users.findByUuidAndDeletedAtIsNull(uuid.get())
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
// Fallback to email lookup
|
||||
String email = s.trim().toLowerCase();
|
||||
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
// Case 2: principal is a UserDetails (often username=email)
|
||||
if (principal instanceof UserDetails ud) {
|
||||
String username = ud.getUsername();
|
||||
if (username == null) {
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
|
||||
// Try UUID first, then email
|
||||
Optional<UUID> uuid = tryParseUuid(username);
|
||||
if (uuid.isPresent()) {
|
||||
return users.findByUuidAndDeletedAtIsNull(uuid.get())
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
String email = username.trim().toLowerCase();
|
||||
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
// Anything else: unsupported principal type
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
|
||||
private Map<String, Object> toMeResponse(User user) {
|
||||
Map<String, Object> out = new java.util.HashMap<>();
|
||||
out.put("email", user.getEmail());
|
||||
out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName());
|
||||
out.put("username", user.getUsername() == null ? "" : user.getUsername());
|
||||
out.put("role", user.getRole() == null ? "USER" : user.getRole());
|
||||
out.put("uuid", String.valueOf(user.getUuid()));
|
||||
out.put("passwordSetAt", user.getPasswordSetAt() == null ? null : user.getPasswordSetAt().toString());
|
||||
return out;
|
||||
}
|
||||
|
||||
private String normalizeUsername(String raw) {
|
||||
if (raw == null) return null;
|
||||
String s = raw.trim().toLowerCase();
|
||||
return s.isBlank() ? null : s;
|
||||
}
|
||||
|
||||
private boolean isReservedUsername(String u) {
|
||||
return switch (u) {
|
||||
case "admin", "support", "battl", "battlbuilders", "builder",
|
||||
"api", "login", "register", "account", "privacy", "tos" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Routes
|
||||
// -----------------------------
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<?> me() {
|
||||
User user = requireUser();
|
||||
return ResponseEntity.ok(toMeResponse(user));
|
||||
}
|
||||
|
||||
@PatchMapping
|
||||
public ResponseEntity<?> updateMe(@RequestBody Map<String, Object> body) {
|
||||
User user = requireUser();
|
||||
|
||||
String displayName = null;
|
||||
if (body != null && body.get("displayName") != null) {
|
||||
displayName = String.valueOf(body.get("displayName")).trim();
|
||||
}
|
||||
|
||||
String username = null;
|
||||
if (body != null && body.get("username") != null) {
|
||||
username = normalizeUsername(String.valueOf(body.get("username")));
|
||||
}
|
||||
|
||||
if ((displayName == null || displayName.isBlank()) && (username == null)) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "username or displayName is required");
|
||||
}
|
||||
|
||||
// display name is flexible
|
||||
if (displayName != null && !displayName.isBlank()) {
|
||||
user.setDisplayName(displayName);
|
||||
}
|
||||
|
||||
// username is strict + unique
|
||||
if (username != null) {
|
||||
if (username.length() < 3 || username.length() > 20) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Username must be 3–20 characters");
|
||||
}
|
||||
if (!username.matches("^[a-z0-9_]+$")) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Username can only contain a-z, 0-9, underscore");
|
||||
}
|
||||
if (isReservedUsername(username)) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "That username is reserved");
|
||||
}
|
||||
|
||||
users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username).ifPresent(existing -> {
|
||||
if (!existing.getId().equals(user.getId())) {
|
||||
throw new ResponseStatusException(CONFLICT, "Username already taken");
|
||||
}
|
||||
});
|
||||
|
||||
user.setUsername(username);
|
||||
}
|
||||
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
return ResponseEntity.ok(toMeResponse(user));
|
||||
}
|
||||
|
||||
@PostMapping("/password")
|
||||
public ResponseEntity<?> setPassword(@RequestBody Map<String, Object> body) {
|
||||
User user = requireUser();
|
||||
|
||||
String password = null;
|
||||
if (body != null && body.get("password") != null) {
|
||||
password = String.valueOf(body.get("password"));
|
||||
}
|
||||
|
||||
if (password == null || password.length() < 8) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Password must be at least 8 characters");
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(password));
|
||||
user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
return ResponseEntity.ok(Map.of("ok", true, "passwordSetAt", user.getPasswordSetAt().toString()));
|
||||
}
|
||||
|
||||
@GetMapping("/username-available")
|
||||
public ResponseEntity<?> usernameAvailable(@RequestParam("username") String usernameRaw) {
|
||||
String username = normalizeUsername(usernameRaw);
|
||||
|
||||
// Soft fail
|
||||
if (username == null) return ResponseEntity.ok(Map.of("available", false));
|
||||
|
||||
if (username.length() < 3 || username.length() > 20) {
|
||||
return ResponseEntity.ok(Map.of("available", false));
|
||||
}
|
||||
if (!username.matches("^[a-z0-9_]+$")) {
|
||||
return ResponseEntity.ok(Map.of("available", false));
|
||||
}
|
||||
if (isReservedUsername(username)) {
|
||||
return ResponseEntity.ok(Map.of("available", false));
|
||||
}
|
||||
|
||||
User me = requireUser();
|
||||
|
||||
boolean available = users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username)
|
||||
.map(existing -> existing.getId().equals(me.getId())) // if it's mine, treat as available
|
||||
.orElse(true);
|
||||
|
||||
return ResponseEntity.ok(Map.of("available", available));
|
||||
}
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.http.HttpStatus.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping({"/api/v1/users/me", "/api/users/me"})
|
||||
@CrossOrigin
|
||||
public class MeController {
|
||||
|
||||
private final UserRepository users;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public MeController(UserRepository users, PasswordEncoder passwordEncoder) {
|
||||
this.users = users;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Helpers
|
||||
// -----------------------------
|
||||
|
||||
private Authentication requireAuth() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
// Spring may set "anonymousUser" as a principal when not logged in
|
||||
Object principal = auth.getPrincipal();
|
||||
if (principal == null || "anonymousUser".equals(principal)) {
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
private Optional<UUID> tryParseUuid(String s) {
|
||||
try {
|
||||
return Optional.of(UUID.fromString(s));
|
||||
} catch (Exception ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private User requireUser() {
|
||||
Authentication auth = requireAuth();
|
||||
Object principal = auth.getPrincipal();
|
||||
|
||||
// Case 1: principal is a String (we commonly set this to UUID string)
|
||||
if (principal instanceof String s) {
|
||||
// Prefer UUID lookup
|
||||
Optional<UUID> uuid = tryParseUuid(s);
|
||||
if (uuid.isPresent()) {
|
||||
return users.findByUuidAndDeletedAtIsNull(uuid.get())
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
// Fallback to email lookup
|
||||
String email = s.trim().toLowerCase();
|
||||
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
// Case 2: principal is a UserDetails (often username=email)
|
||||
if (principal instanceof UserDetails ud) {
|
||||
String username = ud.getUsername();
|
||||
if (username == null) {
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
|
||||
// Try UUID first, then email
|
||||
Optional<UUID> uuid = tryParseUuid(username);
|
||||
if (uuid.isPresent()) {
|
||||
return users.findByUuidAndDeletedAtIsNull(uuid.get())
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
String email = username.trim().toLowerCase();
|
||||
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
// Anything else: unsupported principal type
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
|
||||
private Map<String, Object> toMeResponse(User user) {
|
||||
Map<String, Object> out = new java.util.HashMap<>();
|
||||
out.put("email", user.getEmail());
|
||||
out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName());
|
||||
out.put("username", user.getUsername() == null ? "" : user.getUsername());
|
||||
out.put("role", user.getRole() == null ? "USER" : user.getRole());
|
||||
out.put("uuid", String.valueOf(user.getUuid()));
|
||||
out.put("passwordSetAt", user.getPasswordSetAt() == null ? null : user.getPasswordSetAt().toString());
|
||||
return out;
|
||||
}
|
||||
|
||||
private String normalizeUsername(String raw) {
|
||||
if (raw == null) return null;
|
||||
String s = raw.trim().toLowerCase();
|
||||
return s.isBlank() ? null : s;
|
||||
}
|
||||
|
||||
private boolean isReservedUsername(String u) {
|
||||
return switch (u) {
|
||||
case "admin", "support", "battl", "battlbuilders", "builder",
|
||||
"api", "login", "register", "account", "privacy", "tos" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Routes
|
||||
// -----------------------------
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<?> me() {
|
||||
User user = requireUser();
|
||||
return ResponseEntity.ok(toMeResponse(user));
|
||||
}
|
||||
|
||||
@PatchMapping
|
||||
public ResponseEntity<?> updateMe(@RequestBody Map<String, Object> body) {
|
||||
User user = requireUser();
|
||||
|
||||
String displayName = null;
|
||||
if (body != null && body.get("displayName") != null) {
|
||||
displayName = String.valueOf(body.get("displayName")).trim();
|
||||
}
|
||||
|
||||
String username = null;
|
||||
if (body != null && body.get("username") != null) {
|
||||
username = normalizeUsername(String.valueOf(body.get("username")));
|
||||
}
|
||||
|
||||
if ((displayName == null || displayName.isBlank()) && (username == null)) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "username or displayName is required");
|
||||
}
|
||||
|
||||
// display name is flexible
|
||||
if (displayName != null && !displayName.isBlank()) {
|
||||
user.setDisplayName(displayName);
|
||||
}
|
||||
|
||||
// username is strict + unique
|
||||
if (username != null) {
|
||||
if (username.length() < 3 || username.length() > 20) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Username must be 3–20 characters");
|
||||
}
|
||||
if (!username.matches("^[a-z0-9_]+$")) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Username can only contain a-z, 0-9, underscore");
|
||||
}
|
||||
if (isReservedUsername(username)) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "That username is reserved");
|
||||
}
|
||||
|
||||
users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username).ifPresent(existing -> {
|
||||
if (!existing.getId().equals(user.getId())) {
|
||||
throw new ResponseStatusException(CONFLICT, "Username already taken");
|
||||
}
|
||||
});
|
||||
|
||||
user.setUsername(username);
|
||||
}
|
||||
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
return ResponseEntity.ok(toMeResponse(user));
|
||||
}
|
||||
|
||||
@PostMapping("/password")
|
||||
public ResponseEntity<?> setPassword(@RequestBody Map<String, Object> body) {
|
||||
User user = requireUser();
|
||||
|
||||
String password = null;
|
||||
if (body != null && body.get("password") != null) {
|
||||
password = String.valueOf(body.get("password"));
|
||||
}
|
||||
|
||||
if (password == null || password.length() < 8) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Password must be at least 8 characters");
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(password));
|
||||
user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
return ResponseEntity.ok(Map.of("ok", true, "passwordSetAt", user.getPasswordSetAt().toString()));
|
||||
}
|
||||
|
||||
@GetMapping("/username-available")
|
||||
public ResponseEntity<?> usernameAvailable(@RequestParam("username") String usernameRaw) {
|
||||
String username = normalizeUsername(usernameRaw);
|
||||
|
||||
// Soft fail
|
||||
if (username == null) return ResponseEntity.ok(Map.of("available", false));
|
||||
|
||||
if (username.length() < 3 || username.length() > 20) {
|
||||
return ResponseEntity.ok(Map.of("available", false));
|
||||
}
|
||||
if (!username.matches("^[a-z0-9_]+$")) {
|
||||
return ResponseEntity.ok(Map.of("available", false));
|
||||
}
|
||||
if (isReservedUsername(username)) {
|
||||
return ResponseEntity.ok(Map.of("available", false));
|
||||
}
|
||||
|
||||
User me = requireUser();
|
||||
|
||||
boolean available = users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username)
|
||||
.map(existing -> existing.getId().equals(me.getId())) // if it's mine, treat as available
|
||||
.orElse(true);
|
||||
|
||||
return ResponseEntity.ok(Map.of("available", available));
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,49 @@
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
|
||||
import group.goforward.battlbuilder.service.ProductQueryService;
|
||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/products")
|
||||
@CrossOrigin
|
||||
public class ProductV1Controller {
|
||||
|
||||
private final ProductQueryService productQueryService;
|
||||
|
||||
public ProductV1Controller(ProductQueryService productQueryService) {
|
||||
this.productQueryService = productQueryService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Cacheable(
|
||||
value = "gunbuilderProductsV1",
|
||||
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
|
||||
)
|
||||
public Page<ProductSummaryDto> getProducts(
|
||||
@RequestParam(defaultValue = "AR-15") String platform,
|
||||
@RequestParam(required = false, name = "partRoles") List<String> partRoles,
|
||||
@PageableDefault(size = 50) Pageable pageable
|
||||
) {
|
||||
return productQueryService.getProductsPage(platform, partRoles, pageable);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/offers")
|
||||
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
||||
return productQueryService.getOffersForProduct(productId);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
|
||||
ProductSummaryDto dto = productQueryService.getProductById(productId);
|
||||
return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build();
|
||||
}
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
|
||||
import group.goforward.battlbuilder.service.ProductQueryService;
|
||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/products")
|
||||
@CrossOrigin
|
||||
public class ProductV1Controller {
|
||||
|
||||
private final ProductQueryService productQueryService;
|
||||
|
||||
public ProductV1Controller(ProductQueryService productQueryService) {
|
||||
this.productQueryService = productQueryService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Cacheable(
|
||||
value = "gunbuilderProductsV1",
|
||||
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
|
||||
)
|
||||
public Page<ProductSummaryDto> getProducts(
|
||||
@RequestParam(defaultValue = "AR-15") String platform,
|
||||
@RequestParam(required = false, name = "partRoles") List<String> partRoles,
|
||||
@PageableDefault(size = 50) Pageable pageable
|
||||
) {
|
||||
return productQueryService.getProductsPage(platform, partRoles, pageable);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/offers")
|
||||
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
||||
return productQueryService.getOffersForProduct(productId);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
|
||||
ProductSummaryDto dto = productQueryService.getProductById(productId);
|
||||
return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* API controller package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains REST API controller for public-facing endpoints including
|
||||
* brand management, state information, and user operations.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
/**
|
||||
* API controller package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains REST API controller for public-facing endpoints including
|
||||
* brand management, state information, and user operations.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.controller.api.v1;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
|
||||
/*
|
||||
Web admin DTOs package for the BattlBuilder application.
|
||||
<p>
|
||||
Contains Data Transfer Objects specific to administrative
|
||||
operations including user management, mappings, and platform configuration.
|
||||
|
||||
@author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.dto;
|
||||
|
||||
/*
|
||||
Web admin DTOs package for the BattlBuilder application.
|
||||
<p>
|
||||
Contains Data Transfer Objects specific to administrative
|
||||
operations including user management, mappings, and platform configuration.
|
||||
|
||||
@author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.dto;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package group.goforward.battlbuilder.enrichment;
|
||||
|
||||
/**
|
||||
* Enum representing the source of an enrichment.
|
||||
*/
|
||||
public enum EnrichmentSource {
|
||||
AI,
|
||||
RULES,
|
||||
HUMAN
|
||||
package group.goforward.battlbuilder.enrichment;
|
||||
|
||||
/**
|
||||
* Enum representing the source of an enrichment.
|
||||
*/
|
||||
public enum EnrichmentSource {
|
||||
AI,
|
||||
RULES,
|
||||
HUMAN
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
package group.goforward.battlbuilder.enrichment;
|
||||
/**
|
||||
* Status of an enrichment in the system.
|
||||
*
|
||||
* <p>Possible values:
|
||||
* <ul>
|
||||
* <li>PENDING_REVIEW - awaiting review</li>
|
||||
* <li>APPROVED - approved to apply</li>
|
||||
* <li>REJECTED - rejected and will not be applied</li>
|
||||
* <li>APPLIED - enrichment has been applied</li>
|
||||
* </ul>
|
||||
*/
|
||||
|
||||
public enum EnrichmentStatus {
|
||||
PENDING_REVIEW,
|
||||
APPROVED,
|
||||
REJECTED,
|
||||
APPLIED
|
||||
package group.goforward.battlbuilder.enrichment;
|
||||
/**
|
||||
* Status of an enrichment in the system.
|
||||
*
|
||||
* <p>Possible values:
|
||||
* <ul>
|
||||
* <li>PENDING_REVIEW - awaiting review</li>
|
||||
* <li>APPROVED - approved to apply</li>
|
||||
* <li>REJECTED - rejected and will not be applied</li>
|
||||
* <li>APPLIED - enrichment has been applied</li>
|
||||
* </ul>
|
||||
*/
|
||||
|
||||
public enum EnrichmentStatus {
|
||||
PENDING_REVIEW,
|
||||
APPROVED,
|
||||
REJECTED,
|
||||
APPLIED
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package group.goforward.battlbuilder.enrichment;
|
||||
|
||||
/**
|
||||
* Enum representing different types of enrichment that can be applied to products.
|
||||
*/
|
||||
public enum EnrichmentType {
|
||||
CALIBER,
|
||||
CALIBER_GROUP,
|
||||
BARREL_LENGTH,
|
||||
GAS_SYSTEM,
|
||||
HANDGUARD_LENGTH,
|
||||
CONFIGURATION,
|
||||
PART_ROLE
|
||||
package group.goforward.battlbuilder.enrichment;
|
||||
|
||||
/**
|
||||
* Enum representing different types of enrichment that can be applied to products.
|
||||
*/
|
||||
public enum EnrichmentType {
|
||||
CALIBER,
|
||||
CALIBER_GROUP,
|
||||
BARREL_LENGTH,
|
||||
GAS_SYSTEM,
|
||||
HANDGUARD_LENGTH,
|
||||
CONFIGURATION,
|
||||
PART_ROLE
|
||||
}
|
||||
@@ -1,103 +1,103 @@
|
||||
package group.goforward.battlbuilder.enrichment.ai;
|
||||
|
||||
import group.goforward.battlbuilder.enrichment.*;
|
||||
import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult;
|
||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
||||
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class AiEnrichmentOrchestrator {
|
||||
|
||||
private final EnrichmentModelClient modelClient;
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductEnrichmentRepository enrichmentRepository;
|
||||
|
||||
@Value("${ai.minConfidence:0.75}")
|
||||
private BigDecimal minConfidence;
|
||||
|
||||
public AiEnrichmentOrchestrator(
|
||||
EnrichmentModelClient modelClient,
|
||||
ProductRepository productRepository,
|
||||
ProductEnrichmentRepository enrichmentRepository
|
||||
) {
|
||||
this.modelClient = modelClient;
|
||||
this.productRepository = productRepository;
|
||||
this.enrichmentRepository = enrichmentRepository;
|
||||
}
|
||||
|
||||
public int runCaliber(int limit) {
|
||||
// pick candidates: caliber missing
|
||||
List<Product> candidates = productRepository.findProductsMissingCaliber(limit);
|
||||
|
||||
int created = 0;
|
||||
|
||||
for (Product p : candidates) {
|
||||
CaliberExtractionResult r = modelClient.extractCaliber(p);
|
||||
|
||||
if (r == null || !r.isUsable(minConfidence)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optional: avoid duplicates for same product/type/status
|
||||
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
|
||||
p.getId(), EnrichmentType.CALIBER, EnrichmentStatus.PENDING_REVIEW
|
||||
);
|
||||
if (exists) continue;
|
||||
|
||||
ProductEnrichment pe = new ProductEnrichment();
|
||||
pe.setProductId(p.getId());
|
||||
pe.setEnrichmentType(EnrichmentType.CALIBER);
|
||||
pe.setSource(EnrichmentSource.AI);
|
||||
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
||||
pe.setSchemaVersion(1);
|
||||
pe.setAttributes(Map.of("caliber", r.caliber()));
|
||||
pe.setConfidence(r.confidence());
|
||||
pe.setRationale(r.reason());
|
||||
pe.setMeta(Map.of("provider", modelClient.providerName()));
|
||||
|
||||
enrichmentRepository.save(pe);
|
||||
created++;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
public int runCaliberGroup(int limit) {
|
||||
List<Product> candidates = productRepository.findProductsMissingCaliberGroup(limit);
|
||||
int created = 0;
|
||||
|
||||
for (Product p : candidates) {
|
||||
String group = CaliberTaxonomy.groupForCaliber(p.getCaliber());
|
||||
if (group == null || group.isBlank()) continue;
|
||||
|
||||
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
|
||||
p.getId(), EnrichmentType.CALIBER_GROUP, EnrichmentStatus.PENDING_REVIEW
|
||||
);
|
||||
if (exists) continue;
|
||||
|
||||
ProductEnrichment pe = new ProductEnrichment();
|
||||
pe.setProductId(p.getId());
|
||||
pe.setEnrichmentType(EnrichmentType.CALIBER_GROUP);
|
||||
pe.setSource(EnrichmentSource.RULES); // derived rules
|
||||
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
||||
pe.setSchemaVersion(1);
|
||||
pe.setAttributes(java.util.Map.of("caliberGroup", group));
|
||||
pe.setConfidence(new java.math.BigDecimal("1.00"));
|
||||
pe.setRationale("Derived caliberGroup from product.caliber via CaliberTaxonomy");
|
||||
pe.setMeta(java.util.Map.of("provider", "TAXONOMY"));
|
||||
|
||||
enrichmentRepository.save(pe);
|
||||
created++;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
package group.goforward.battlbuilder.enrichment.ai;
|
||||
|
||||
import group.goforward.battlbuilder.enrichment.*;
|
||||
import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult;
|
||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
||||
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class AiEnrichmentOrchestrator {
|
||||
|
||||
private final EnrichmentModelClient modelClient;
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductEnrichmentRepository enrichmentRepository;
|
||||
|
||||
@Value("${ai.minConfidence:0.75}")
|
||||
private BigDecimal minConfidence;
|
||||
|
||||
public AiEnrichmentOrchestrator(
|
||||
EnrichmentModelClient modelClient,
|
||||
ProductRepository productRepository,
|
||||
ProductEnrichmentRepository enrichmentRepository
|
||||
) {
|
||||
this.modelClient = modelClient;
|
||||
this.productRepository = productRepository;
|
||||
this.enrichmentRepository = enrichmentRepository;
|
||||
}
|
||||
|
||||
public int runCaliber(int limit) {
|
||||
// pick candidates: caliber missing
|
||||
List<Product> candidates = productRepository.findProductsMissingCaliber(limit);
|
||||
|
||||
int created = 0;
|
||||
|
||||
for (Product p : candidates) {
|
||||
CaliberExtractionResult r = modelClient.extractCaliber(p);
|
||||
|
||||
if (r == null || !r.isUsable(minConfidence)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optional: avoid duplicates for same product/type/status
|
||||
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
|
||||
p.getId(), EnrichmentType.CALIBER, EnrichmentStatus.PENDING_REVIEW
|
||||
);
|
||||
if (exists) continue;
|
||||
|
||||
ProductEnrichment pe = new ProductEnrichment();
|
||||
pe.setProductId(p.getId());
|
||||
pe.setEnrichmentType(EnrichmentType.CALIBER);
|
||||
pe.setSource(EnrichmentSource.AI);
|
||||
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
||||
pe.setSchemaVersion(1);
|
||||
pe.setAttributes(Map.of("caliber", r.caliber()));
|
||||
pe.setConfidence(r.confidence());
|
||||
pe.setRationale(r.reason());
|
||||
pe.setMeta(Map.of("provider", modelClient.providerName()));
|
||||
|
||||
enrichmentRepository.save(pe);
|
||||
created++;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
public int runCaliberGroup(int limit) {
|
||||
List<Product> candidates = productRepository.findProductsMissingCaliberGroup(limit);
|
||||
int created = 0;
|
||||
|
||||
for (Product p : candidates) {
|
||||
String group = CaliberTaxonomy.groupForCaliber(p.getCaliber());
|
||||
if (group == null || group.isBlank()) continue;
|
||||
|
||||
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
|
||||
p.getId(), EnrichmentType.CALIBER_GROUP, EnrichmentStatus.PENDING_REVIEW
|
||||
);
|
||||
if (exists) continue;
|
||||
|
||||
ProductEnrichment pe = new ProductEnrichment();
|
||||
pe.setProductId(p.getId());
|
||||
pe.setEnrichmentType(EnrichmentType.CALIBER_GROUP);
|
||||
pe.setSource(EnrichmentSource.RULES); // derived rules
|
||||
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
||||
pe.setSchemaVersion(1);
|
||||
pe.setAttributes(java.util.Map.of("caliberGroup", group));
|
||||
pe.setConfidence(new java.math.BigDecimal("1.00"));
|
||||
pe.setRationale("Derived caliberGroup from product.caliber via CaliberTaxonomy");
|
||||
pe.setMeta(java.util.Map.of("provider", "TAXONOMY"));
|
||||
|
||||
enrichmentRepository.save(pe);
|
||||
created++;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -1,153 +1,153 @@
|
||||
package group.goforward.battlbuilder.enrichment.controller;
|
||||
|
||||
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||
import group.goforward.battlbuilder.enrichment.ai.AiEnrichmentOrchestrator;
|
||||
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
||||
import group.goforward.battlbuilder.enrichment.service.CaliberEnrichmentService;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/enrichment")
|
||||
public class AdminEnrichmentController {
|
||||
|
||||
private final CaliberEnrichmentService caliberEnrichmentService;
|
||||
private final ProductEnrichmentRepository enrichmentRepository;
|
||||
private final AiEnrichmentOrchestrator aiEnrichmentOrchestrator;
|
||||
|
||||
public AdminEnrichmentController(
|
||||
CaliberEnrichmentService caliberEnrichmentService,
|
||||
ProductEnrichmentRepository enrichmentRepository,
|
||||
AiEnrichmentOrchestrator aiEnrichmentOrchestrator
|
||||
) {
|
||||
this.caliberEnrichmentService = caliberEnrichmentService;
|
||||
this.enrichmentRepository = enrichmentRepository;
|
||||
this.aiEnrichmentOrchestrator = aiEnrichmentOrchestrator;
|
||||
}
|
||||
|
||||
@PostMapping("/run")
|
||||
public ResponseEntity<?> run(
|
||||
@RequestParam EnrichmentType type,
|
||||
@RequestParam(defaultValue = "200") int limit
|
||||
) {
|
||||
if (type != EnrichmentType.CALIBER) {
|
||||
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
|
||||
}
|
||||
return ResponseEntity.ok(caliberEnrichmentService.runRules(limit));
|
||||
}
|
||||
|
||||
// ✅ NEW: Run AI enrichment
|
||||
@PostMapping("/ai/run")
|
||||
public ResponseEntity<?> runAi(
|
||||
@RequestParam EnrichmentType type,
|
||||
@RequestParam(defaultValue = "200") int limit
|
||||
) {
|
||||
if (type != EnrichmentType.CALIBER) {
|
||||
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
|
||||
}
|
||||
|
||||
// This should create ProductEnrichment rows with source=AI and status=PENDING_REVIEW
|
||||
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliber(limit));
|
||||
}
|
||||
|
||||
@GetMapping("/queue")
|
||||
public ResponseEntity<List<ProductEnrichment>> queue(
|
||||
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
|
||||
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
|
||||
@RequestParam(defaultValue = "100") int limit
|
||||
) {
|
||||
var items = enrichmentRepository
|
||||
.findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(type, status, PageRequest.of(0, limit));
|
||||
return ResponseEntity.ok(items);
|
||||
}
|
||||
|
||||
@GetMapping("/queue2")
|
||||
public ResponseEntity<?> queue2(
|
||||
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
|
||||
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
|
||||
@RequestParam(defaultValue = "100") int limit
|
||||
) {
|
||||
return ResponseEntity.ok(
|
||||
enrichmentRepository.queueWithProduct(type, status, PageRequest.of(0, limit))
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public ResponseEntity<?> approve(@PathVariable Long id) {
|
||||
var e = enrichmentRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
||||
e.setStatus(EnrichmentStatus.APPROVED);
|
||||
enrichmentRepository.save(e);
|
||||
return ResponseEntity.ok(e);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public ResponseEntity<?> reject(@PathVariable Long id) {
|
||||
var e = enrichmentRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
||||
e.setStatus(EnrichmentStatus.REJECTED);
|
||||
enrichmentRepository.save(e);
|
||||
return ResponseEntity.ok(e);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/apply")
|
||||
@Transactional
|
||||
public ResponseEntity<?> apply(@PathVariable Long id) {
|
||||
var e = enrichmentRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
||||
|
||||
if (e.getStatus() != EnrichmentStatus.APPROVED) {
|
||||
return ResponseEntity.badRequest().body("Enrichment must be APPROVED before applying.");
|
||||
}
|
||||
|
||||
if (e.getEnrichmentType() == EnrichmentType.CALIBER) {
|
||||
Object caliberObj = e.getAttributes().get("caliber");
|
||||
if (!(caliberObj instanceof String caliber) || caliber.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body("Missing attributes.caliber");
|
||||
}
|
||||
|
||||
String canonical = CaliberTaxonomy.normalizeCaliber(caliber.trim());
|
||||
int updated = enrichmentRepository.applyCaliberIfBlank(e.getProductId(), canonical);
|
||||
|
||||
if (updated == 0) {
|
||||
return ResponseEntity.badRequest().body("Product caliber already set (or product not found). Not applied.");
|
||||
}
|
||||
|
||||
// Bonus safety: set group if blank
|
||||
String group = CaliberTaxonomy.groupForCaliber(canonical);
|
||||
if (group != null && !group.isBlank()) {
|
||||
enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group);
|
||||
}
|
||||
|
||||
} else if (e.getEnrichmentType() == EnrichmentType.CALIBER_GROUP) {
|
||||
Object groupObj = e.getAttributes().get("caliberGroup");
|
||||
if (!(groupObj instanceof String group) || group.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body("Missing attributes.caliberGroup");
|
||||
}
|
||||
|
||||
int updated = enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group.trim());
|
||||
if (updated == 0) {
|
||||
return ResponseEntity.badRequest().body("Product caliber_group already set (or product not found). Not applied.");
|
||||
}
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body("Unsupported enrichment type in v0.");
|
||||
}
|
||||
|
||||
e.setStatus(EnrichmentStatus.APPLIED);
|
||||
enrichmentRepository.save(e);
|
||||
|
||||
return ResponseEntity.ok(e);
|
||||
}
|
||||
|
||||
@PostMapping("/groups/run")
|
||||
public ResponseEntity<?> runGroups(@RequestParam(defaultValue = "200") int limit) {
|
||||
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliberGroup(limit));
|
||||
}
|
||||
package group.goforward.battlbuilder.enrichment.controller;
|
||||
|
||||
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||
import group.goforward.battlbuilder.enrichment.ai.AiEnrichmentOrchestrator;
|
||||
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
||||
import group.goforward.battlbuilder.enrichment.service.CaliberEnrichmentService;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/enrichment")
|
||||
public class AdminEnrichmentController {
|
||||
|
||||
private final CaliberEnrichmentService caliberEnrichmentService;
|
||||
private final ProductEnrichmentRepository enrichmentRepository;
|
||||
private final AiEnrichmentOrchestrator aiEnrichmentOrchestrator;
|
||||
|
||||
public AdminEnrichmentController(
|
||||
CaliberEnrichmentService caliberEnrichmentService,
|
||||
ProductEnrichmentRepository enrichmentRepository,
|
||||
AiEnrichmentOrchestrator aiEnrichmentOrchestrator
|
||||
) {
|
||||
this.caliberEnrichmentService = caliberEnrichmentService;
|
||||
this.enrichmentRepository = enrichmentRepository;
|
||||
this.aiEnrichmentOrchestrator = aiEnrichmentOrchestrator;
|
||||
}
|
||||
|
||||
@PostMapping("/run")
|
||||
public ResponseEntity<?> run(
|
||||
@RequestParam EnrichmentType type,
|
||||
@RequestParam(defaultValue = "200") int limit
|
||||
) {
|
||||
if (type != EnrichmentType.CALIBER) {
|
||||
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
|
||||
}
|
||||
return ResponseEntity.ok(caliberEnrichmentService.runRules(limit));
|
||||
}
|
||||
|
||||
// ✅ NEW: Run AI enrichment
|
||||
@PostMapping("/ai/run")
|
||||
public ResponseEntity<?> runAi(
|
||||
@RequestParam EnrichmentType type,
|
||||
@RequestParam(defaultValue = "200") int limit
|
||||
) {
|
||||
if (type != EnrichmentType.CALIBER) {
|
||||
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
|
||||
}
|
||||
|
||||
// This should create ProductEnrichment rows with source=AI and status=PENDING_REVIEW
|
||||
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliber(limit));
|
||||
}
|
||||
|
||||
@GetMapping("/queue")
|
||||
public ResponseEntity<List<ProductEnrichment>> queue(
|
||||
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
|
||||
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
|
||||
@RequestParam(defaultValue = "100") int limit
|
||||
) {
|
||||
var items = enrichmentRepository
|
||||
.findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(type, status, PageRequest.of(0, limit));
|
||||
return ResponseEntity.ok(items);
|
||||
}
|
||||
|
||||
@GetMapping("/queue2")
|
||||
public ResponseEntity<?> queue2(
|
||||
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
|
||||
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
|
||||
@RequestParam(defaultValue = "100") int limit
|
||||
) {
|
||||
return ResponseEntity.ok(
|
||||
enrichmentRepository.queueWithProduct(type, status, PageRequest.of(0, limit))
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public ResponseEntity<?> approve(@PathVariable Long id) {
|
||||
var e = enrichmentRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
||||
e.setStatus(EnrichmentStatus.APPROVED);
|
||||
enrichmentRepository.save(e);
|
||||
return ResponseEntity.ok(e);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public ResponseEntity<?> reject(@PathVariable Long id) {
|
||||
var e = enrichmentRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
||||
e.setStatus(EnrichmentStatus.REJECTED);
|
||||
enrichmentRepository.save(e);
|
||||
return ResponseEntity.ok(e);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/apply")
|
||||
@Transactional
|
||||
public ResponseEntity<?> apply(@PathVariable Long id) {
|
||||
var e = enrichmentRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
|
||||
|
||||
if (e.getStatus() != EnrichmentStatus.APPROVED) {
|
||||
return ResponseEntity.badRequest().body("Enrichment must be APPROVED before applying.");
|
||||
}
|
||||
|
||||
if (e.getEnrichmentType() == EnrichmentType.CALIBER) {
|
||||
Object caliberObj = e.getAttributes().get("caliber");
|
||||
if (!(caliberObj instanceof String caliber) || caliber.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body("Missing attributes.caliber");
|
||||
}
|
||||
|
||||
String canonical = CaliberTaxonomy.normalizeCaliber(caliber.trim());
|
||||
int updated = enrichmentRepository.applyCaliberIfBlank(e.getProductId(), canonical);
|
||||
|
||||
if (updated == 0) {
|
||||
return ResponseEntity.badRequest().body("Product caliber already set (or product not found). Not applied.");
|
||||
}
|
||||
|
||||
// Bonus safety: set group if blank
|
||||
String group = CaliberTaxonomy.groupForCaliber(canonical);
|
||||
if (group != null && !group.isBlank()) {
|
||||
enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group);
|
||||
}
|
||||
|
||||
} else if (e.getEnrichmentType() == EnrichmentType.CALIBER_GROUP) {
|
||||
Object groupObj = e.getAttributes().get("caliberGroup");
|
||||
if (!(groupObj instanceof String group) || group.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body("Missing attributes.caliberGroup");
|
||||
}
|
||||
|
||||
int updated = enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group.trim());
|
||||
if (updated == 0) {
|
||||
return ResponseEntity.badRequest().body("Product caliber_group already set (or product not found). Not applied.");
|
||||
}
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body("Unsupported enrichment type in v0.");
|
||||
}
|
||||
|
||||
e.setStatus(EnrichmentStatus.APPLIED);
|
||||
enrichmentRepository.save(e);
|
||||
|
||||
return ResponseEntity.ok(e);
|
||||
}
|
||||
|
||||
@PostMapping("/groups/run")
|
||||
public ResponseEntity<?> runGroups(@RequestParam(defaultValue = "200") int limit) {
|
||||
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliberGroup(limit));
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,95 @@
|
||||
package group.goforward.battlbuilder.enrichment.model;
|
||||
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentSource;
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
||||
import jakarta.persistence.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Entity
|
||||
@Table(name = "product_enrichments")
|
||||
public class ProductEnrichment {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "product_id", nullable = false)
|
||||
private Integer productId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "enrichment_type", nullable = false)
|
||||
private EnrichmentType enrichmentType;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "source", nullable = false)
|
||||
private EnrichmentSource source = EnrichmentSource.AI;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false)
|
||||
private EnrichmentStatus status = EnrichmentStatus.PENDING_REVIEW;
|
||||
|
||||
@Column(name = "schema_version", nullable = false)
|
||||
private Integer schemaVersion = 1;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "attributes", nullable = false, columnDefinition = "jsonb")
|
||||
private Map<String, Object> attributes = new HashMap<>();
|
||||
|
||||
@Column(name = "confidence", precision = 4, scale = 3)
|
||||
private BigDecimal confidence;
|
||||
|
||||
@Column(name = "rationale")
|
||||
private String rationale;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "meta", nullable = false, columnDefinition = "jsonb")
|
||||
private Map<String, Object> meta = new HashMap<>();
|
||||
|
||||
// DB sets these defaults; we keep them read-only to avoid fighting the trigger/defaults
|
||||
@Column(name = "created_at", insertable = false, updatable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", insertable = false, updatable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
// --- getters/setters (generate via IDE) ---
|
||||
|
||||
public Long getId() { return id; }
|
||||
|
||||
public Integer getProductId() { return productId; }
|
||||
public void setProductId(Integer productId) { this.productId = productId; }
|
||||
|
||||
public EnrichmentType getEnrichmentType() { return enrichmentType; }
|
||||
public void setEnrichmentType(EnrichmentType enrichmentType) { this.enrichmentType = enrichmentType; }
|
||||
|
||||
public EnrichmentSource getSource() { return source; }
|
||||
public void setSource(EnrichmentSource source) { this.source = source; }
|
||||
|
||||
public EnrichmentStatus getStatus() { return status; }
|
||||
public void setStatus(EnrichmentStatus status) { this.status = status; }
|
||||
|
||||
public Integer getSchemaVersion() { return schemaVersion; }
|
||||
public void setSchemaVersion(Integer schemaVersion) { this.schemaVersion = schemaVersion; }
|
||||
|
||||
public Map<String, Object> getAttributes() { return attributes; }
|
||||
public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
|
||||
|
||||
public BigDecimal getConfidence() { return confidence; }
|
||||
public void setConfidence(BigDecimal confidence) { this.confidence = confidence; }
|
||||
|
||||
public String getRationale() { return rationale; }
|
||||
public void setRationale(String rationale) { this.rationale = rationale; }
|
||||
|
||||
public Map<String, Object> getMeta() { return meta; }
|
||||
public void setMeta(Map<String, Object> meta) { this.meta = meta; }
|
||||
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
package group.goforward.battlbuilder.enrichment.model;
|
||||
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentSource;
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
||||
import jakarta.persistence.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Entity
|
||||
@Table(name = "product_enrichments")
|
||||
public class ProductEnrichment {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "product_id", nullable = false)
|
||||
private Integer productId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "enrichment_type", nullable = false)
|
||||
private EnrichmentType enrichmentType;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "source", nullable = false)
|
||||
private EnrichmentSource source = EnrichmentSource.AI;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false)
|
||||
private EnrichmentStatus status = EnrichmentStatus.PENDING_REVIEW;
|
||||
|
||||
@Column(name = "schema_version", nullable = false)
|
||||
private Integer schemaVersion = 1;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "attributes", nullable = false, columnDefinition = "jsonb")
|
||||
private Map<String, Object> attributes = new HashMap<>();
|
||||
|
||||
@Column(name = "confidence", precision = 4, scale = 3)
|
||||
private BigDecimal confidence;
|
||||
|
||||
@Column(name = "rationale")
|
||||
private String rationale;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "meta", nullable = false, columnDefinition = "jsonb")
|
||||
private Map<String, Object> meta = new HashMap<>();
|
||||
|
||||
// DB sets these defaults; we keep them read-only to avoid fighting the trigger/defaults
|
||||
@Column(name = "created_at", insertable = false, updatable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", insertable = false, updatable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
// --- getters/setters (generate via IDE) ---
|
||||
|
||||
public Long getId() { return id; }
|
||||
|
||||
public Integer getProductId() { return productId; }
|
||||
public void setProductId(Integer productId) { this.productId = productId; }
|
||||
|
||||
public EnrichmentType getEnrichmentType() { return enrichmentType; }
|
||||
public void setEnrichmentType(EnrichmentType enrichmentType) { this.enrichmentType = enrichmentType; }
|
||||
|
||||
public EnrichmentSource getSource() { return source; }
|
||||
public void setSource(EnrichmentSource source) { this.source = source; }
|
||||
|
||||
public EnrichmentStatus getStatus() { return status; }
|
||||
public void setStatus(EnrichmentStatus status) { this.status = status; }
|
||||
|
||||
public Integer getSchemaVersion() { return schemaVersion; }
|
||||
public void setSchemaVersion(Integer schemaVersion) { this.schemaVersion = schemaVersion; }
|
||||
|
||||
public Map<String, Object> getAttributes() { return attributes; }
|
||||
public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
|
||||
|
||||
public BigDecimal getConfidence() { return confidence; }
|
||||
public void setConfidence(BigDecimal confidence) { this.confidence = confidence; }
|
||||
|
||||
public String getRationale() { return rationale; }
|
||||
public void setRationale(String rationale) { this.rationale = rationale; }
|
||||
|
||||
public Map<String, Object> getMeta() { return meta; }
|
||||
public void setMeta(Map<String, Object> meta) { this.meta = meta; }
|
||||
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
@@ -1,94 +1,94 @@
|
||||
package group.goforward.battlbuilder.enrichment.repo;
|
||||
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ProductEnrichmentRepository extends JpaRepository<ProductEnrichment, Long> {
|
||||
|
||||
boolean existsByProductIdAndEnrichmentTypeAndStatus(
|
||||
Integer productId,
|
||||
EnrichmentType enrichmentType,
|
||||
EnrichmentStatus status
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select e from ProductEnrichment e
|
||||
where e.productId = :productId
|
||||
and e.enrichmentType = :type
|
||||
and e.status in ('PENDING_REVIEW','APPROVED')
|
||||
""")
|
||||
Optional<ProductEnrichment> findActive(Integer productId, EnrichmentType type);
|
||||
|
||||
List<ProductEnrichment> findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(
|
||||
EnrichmentType type,
|
||||
EnrichmentStatus status,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select new group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem(
|
||||
e.id,
|
||||
e.productId,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.mainImageUrl,
|
||||
b.name,
|
||||
e.enrichmentType,
|
||||
e.source,
|
||||
e.status,
|
||||
e.schemaVersion,
|
||||
e.attributes,
|
||||
e.confidence,
|
||||
e.rationale,
|
||||
e.createdAt,
|
||||
p.caliber,
|
||||
p.caliberGroup
|
||||
|
||||
)
|
||||
from ProductEnrichment e
|
||||
join Product p on p.id = e.productId
|
||||
join p.brand b
|
||||
where e.enrichmentType = :type
|
||||
and e.status = :status
|
||||
order by e.createdAt desc
|
||||
""")
|
||||
List<EnrichmentQueueItem> queueWithProduct(
|
||||
EnrichmentType type,
|
||||
EnrichmentStatus status,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
update Product p
|
||||
set p.caliber = :caliber
|
||||
where p.id = :productId
|
||||
and (p.caliber is null or trim(p.caliber) = '')
|
||||
""")
|
||||
int applyCaliberIfBlank(
|
||||
@Param("productId") Integer productId,
|
||||
@Param("caliber") String caliber
|
||||
);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
update Product p
|
||||
set p.caliberGroup = :caliberGroup
|
||||
where p.id = :productId
|
||||
and (p.caliberGroup is null or trim(p.caliberGroup) = '')
|
||||
""")
|
||||
int applyCaliberGroupIfBlank(
|
||||
@Param("productId") Integer productId,
|
||||
@Param("caliberGroup") String caliberGroup
|
||||
);
|
||||
package group.goforward.battlbuilder.enrichment.repo;
|
||||
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
|
||||
import group.goforward.battlbuilder.enrichment.EnrichmentType;
|
||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ProductEnrichmentRepository extends JpaRepository<ProductEnrichment, Long> {
|
||||
|
||||
boolean existsByProductIdAndEnrichmentTypeAndStatus(
|
||||
Integer productId,
|
||||
EnrichmentType enrichmentType,
|
||||
EnrichmentStatus status
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select e from ProductEnrichment e
|
||||
where e.productId = :productId
|
||||
and e.enrichmentType = :type
|
||||
and e.status in ('PENDING_REVIEW','APPROVED')
|
||||
""")
|
||||
Optional<ProductEnrichment> findActive(Integer productId, EnrichmentType type);
|
||||
|
||||
List<ProductEnrichment> findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(
|
||||
EnrichmentType type,
|
||||
EnrichmentStatus status,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select new group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem(
|
||||
e.id,
|
||||
e.productId,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.mainImageUrl,
|
||||
b.name,
|
||||
e.enrichmentType,
|
||||
e.source,
|
||||
e.status,
|
||||
e.schemaVersion,
|
||||
e.attributes,
|
||||
e.confidence,
|
||||
e.rationale,
|
||||
e.createdAt,
|
||||
p.caliber,
|
||||
p.caliberGroup
|
||||
|
||||
)
|
||||
from ProductEnrichment e
|
||||
join Product p on p.id = e.productId
|
||||
join p.brand b
|
||||
where e.enrichmentType = :type
|
||||
and e.status = :status
|
||||
order by e.createdAt desc
|
||||
""")
|
||||
List<EnrichmentQueueItem> queueWithProduct(
|
||||
EnrichmentType type,
|
||||
EnrichmentStatus status,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
update Product p
|
||||
set p.caliber = :caliber
|
||||
where p.id = :productId
|
||||
and (p.caliber is null or trim(p.caliber) = '')
|
||||
""")
|
||||
int applyCaliberIfBlank(
|
||||
@Param("productId") Integer productId,
|
||||
@Param("caliber") String caliber
|
||||
);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
update Product p
|
||||
set p.caliberGroup = :caliberGroup
|
||||
where p.id = :productId
|
||||
and (p.caliberGroup is null or trim(p.caliberGroup) = '')
|
||||
""")
|
||||
int applyCaliberGroupIfBlank(
|
||||
@Param("productId") Integer productId,
|
||||
@Param("caliberGroup") String caliberGroup
|
||||
);
|
||||
}
|
||||
@@ -1,86 +1,86 @@
|
||||
package group.goforward.battlbuilder.enrichment.service;
|
||||
|
||||
import group.goforward.battlbuilder.enrichment.*;
|
||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class CaliberEnrichmentService {
|
||||
|
||||
private final ProductEnrichmentRepository enrichmentRepository;
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager em;
|
||||
|
||||
private final CaliberRuleExtractor extractor = new CaliberRuleExtractor();
|
||||
|
||||
public CaliberEnrichmentService(ProductEnrichmentRepository enrichmentRepository) {
|
||||
this.enrichmentRepository = enrichmentRepository;
|
||||
}
|
||||
|
||||
public record RunResult(int scanned, int created) {}
|
||||
|
||||
/**
|
||||
* Creates PENDING_REVIEW caliber enrichments for products that don't already have an active one.
|
||||
*/
|
||||
@Transactional
|
||||
public RunResult runRules(int limit) {
|
||||
// Adjust Product entity package if needed:
|
||||
// IMPORTANT: Product must be a mapped @Entity named "Product"
|
||||
List<Object[]> rows = em.createQuery("""
|
||||
select p.id, p.name, p.description
|
||||
from Product p
|
||||
where p.deletedAt is null
|
||||
and not exists (
|
||||
select 1 from ProductEnrichment e
|
||||
where e.productId = p.id
|
||||
and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER
|
||||
and e.status in ('PENDING_REVIEW','APPROVED')
|
||||
)
|
||||
order by p.id desc
|
||||
""", Object[].class)
|
||||
.setMaxResults(limit)
|
||||
.getResultList();
|
||||
|
||||
int created = 0;
|
||||
|
||||
for (Object[] r : rows) {
|
||||
Integer productId = (Integer) r[0];
|
||||
String name = (String) r[1];
|
||||
String description = (String) r[2];
|
||||
|
||||
Optional<CaliberRuleExtractor.Result> res = extractor.extract(name, description);
|
||||
if (res.isEmpty()) continue;
|
||||
|
||||
var result = res.get();
|
||||
|
||||
ProductEnrichment e = new ProductEnrichment();
|
||||
e.setProductId(productId);
|
||||
e.setEnrichmentType(EnrichmentType.CALIBER);
|
||||
e.setSource(EnrichmentSource.RULES);
|
||||
e.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
||||
e.setSchemaVersion(1);
|
||||
|
||||
var attrs = new HashMap<String, Object>();
|
||||
attrs.put("caliber", result.caliber());
|
||||
e.setAttributes(attrs);
|
||||
|
||||
e.setConfidence(BigDecimal.valueOf(result.confidence()));
|
||||
e.setRationale(result.rationale());
|
||||
|
||||
enrichmentRepository.save(e);
|
||||
created++;
|
||||
}
|
||||
|
||||
return new RunResult(rows.size(), created);
|
||||
}
|
||||
package group.goforward.battlbuilder.enrichment.service;
|
||||
|
||||
import group.goforward.battlbuilder.enrichment.*;
|
||||
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
|
||||
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class CaliberEnrichmentService {
|
||||
|
||||
private final ProductEnrichmentRepository enrichmentRepository;
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager em;
|
||||
|
||||
private final CaliberRuleExtractor extractor = new CaliberRuleExtractor();
|
||||
|
||||
public CaliberEnrichmentService(ProductEnrichmentRepository enrichmentRepository) {
|
||||
this.enrichmentRepository = enrichmentRepository;
|
||||
}
|
||||
|
||||
public record RunResult(int scanned, int created) {}
|
||||
|
||||
/**
|
||||
* Creates PENDING_REVIEW caliber enrichments for products that don't already have an active one.
|
||||
*/
|
||||
@Transactional
|
||||
public RunResult runRules(int limit) {
|
||||
// Adjust Product entity package if needed:
|
||||
// IMPORTANT: Product must be a mapped @Entity named "Product"
|
||||
List<Object[]> rows = em.createQuery("""
|
||||
select p.id, p.name, p.description
|
||||
from Product p
|
||||
where p.deletedAt is null
|
||||
and not exists (
|
||||
select 1 from ProductEnrichment e
|
||||
where e.productId = p.id
|
||||
and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER
|
||||
and e.status in ('PENDING_REVIEW','APPROVED')
|
||||
)
|
||||
order by p.id desc
|
||||
""", Object[].class)
|
||||
.setMaxResults(limit)
|
||||
.getResultList();
|
||||
|
||||
int created = 0;
|
||||
|
||||
for (Object[] r : rows) {
|
||||
Integer productId = (Integer) r[0];
|
||||
String name = (String) r[1];
|
||||
String description = (String) r[2];
|
||||
|
||||
Optional<CaliberRuleExtractor.Result> res = extractor.extract(name, description);
|
||||
if (res.isEmpty()) continue;
|
||||
|
||||
var result = res.get();
|
||||
|
||||
ProductEnrichment e = new ProductEnrichment();
|
||||
e.setProductId(productId);
|
||||
e.setEnrichmentType(EnrichmentType.CALIBER);
|
||||
e.setSource(EnrichmentSource.RULES);
|
||||
e.setStatus(EnrichmentStatus.PENDING_REVIEW);
|
||||
e.setSchemaVersion(1);
|
||||
|
||||
var attrs = new HashMap<String, Object>();
|
||||
attrs.put("caliber", result.caliber());
|
||||
e.setAttributes(attrs);
|
||||
|
||||
e.setConfidence(BigDecimal.valueOf(result.confidence()));
|
||||
e.setRationale(result.rationale());
|
||||
|
||||
enrichmentRepository.save(e);
|
||||
created++;
|
||||
}
|
||||
|
||||
return new RunResult(rows.size(), created);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,35 @@
|
||||
package group.goforward.battlbuilder.enrichment.taxonomies;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public final class CaliberTaxonomy {
|
||||
private CaliberTaxonomy() {}
|
||||
|
||||
public static String normalizeCaliber(String raw) {
|
||||
if (raw == null) return null;
|
||||
String s = raw.trim();
|
||||
|
||||
// Canonicalize common variants
|
||||
String l = s.toLowerCase(Locale.ROOT);
|
||||
|
||||
if (l.contains("223 wylde") || l.contains(".223 wylde")) return ".223 Wylde";
|
||||
if (l.contains("5.56") || l.contains("5,56") || l.contains("5.56x45") || l.contains("5.56x45mm")) return "5.56 NATO";
|
||||
if (l.contains("223") || l.contains(".223") || l.contains("223 rem") || l.contains("223 remington")) return ".223 Remington";
|
||||
|
||||
if (l.contains("300 blackout") || l.contains("300 blk") || l.contains("300 aac")) return "300 BLK";
|
||||
|
||||
// fallback: return trimmed original (you can tighten later)
|
||||
return s;
|
||||
}
|
||||
|
||||
public static String groupForCaliber(String caliberCanonical) {
|
||||
if (caliberCanonical == null) return null;
|
||||
String l = caliberCanonical.toLowerCase(Locale.ROOT);
|
||||
|
||||
if (l.contains("223") || l.contains("5.56") || l.contains("wylde")) return "223/5.56";
|
||||
if (l.contains("300 blk") || l.contains("300 blackout") || l.contains("300 aac")) return "300 BLK";
|
||||
|
||||
// TODO add more buckets: 308/7.62, 6.5 CM, 9mm, etc.
|
||||
return null;
|
||||
}
|
||||
package group.goforward.battlbuilder.enrichment.taxonomies;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public final class CaliberTaxonomy {
|
||||
private CaliberTaxonomy() {}
|
||||
|
||||
public static String normalizeCaliber(String raw) {
|
||||
if (raw == null) return null;
|
||||
String s = raw.trim();
|
||||
|
||||
// Canonicalize common variants
|
||||
String l = s.toLowerCase(Locale.ROOT);
|
||||
|
||||
if (l.contains("223 wylde") || l.contains(".223 wylde")) return ".223 Wylde";
|
||||
if (l.contains("5.56") || l.contains("5,56") || l.contains("5.56x45") || l.contains("5.56x45mm")) return "5.56 NATO";
|
||||
if (l.contains("223") || l.contains(".223") || l.contains("223 rem") || l.contains("223 remington")) return ".223 Remington";
|
||||
|
||||
if (l.contains("300 blackout") || l.contains("300 blk") || l.contains("300 aac")) return "300 BLK";
|
||||
|
||||
// fallback: return trimmed original (you can tighten later)
|
||||
return s;
|
||||
}
|
||||
|
||||
public static String groupForCaliber(String caliberCanonical) {
|
||||
if (caliberCanonical == null) return null;
|
||||
String l = caliberCanonical.toLowerCase(Locale.ROOT);
|
||||
|
||||
if (l.contains("223") || l.contains("5.56") || l.contains("wylde")) return "223/5.56";
|
||||
if (l.contains("300 blk") || l.contains("300 blackout") || l.contains("300 aac")) return "300 BLK";
|
||||
|
||||
// TODO add more buckets: 308/7.62, 6.5 CM, 9mm, etc.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,174 +1,174 @@
|
||||
package group.goforward.battlbuilder.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Entity representing an authentication token.
|
||||
* Tokens are used for beta verification, magic login links, and password resets.
|
||||
* Tokens are hashed before storage and can be consumed and expired.
|
||||
*
|
||||
* @see jakarta.persistence.Entity
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "auth_tokens",
|
||||
indexes = {
|
||||
@Index(name = "idx_auth_tokens_email", columnList = "email"),
|
||||
@Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash")
|
||||
}
|
||||
)
|
||||
public class AuthToken {
|
||||
|
||||
/**
|
||||
* Enumeration of token types.
|
||||
*/
|
||||
public enum TokenType {
|
||||
/** Token for beta access verification. */
|
||||
BETA_VERIFY,
|
||||
/** Token for magic link login. */
|
||||
MAGIC_LOGIN,
|
||||
/** Token for password reset. */
|
||||
PASSWORD_RESET
|
||||
}
|
||||
|
||||
/** The primary key identifier for the token. */
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** The email address associated with this token. */
|
||||
@Column(nullable = false)
|
||||
private String email;
|
||||
|
||||
/** The type of token. */
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private TokenType type;
|
||||
|
||||
/** The hashed token value. */
|
||||
@Column(name = "token_hash", nullable = false, length = 64)
|
||||
private String tokenHash;
|
||||
|
||||
/** The timestamp when this token expires. */
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private OffsetDateTime expiresAt;
|
||||
|
||||
/** The timestamp when this token was consumed/used. */
|
||||
@Column(name = "consumed_at")
|
||||
private OffsetDateTime consumedAt;
|
||||
|
||||
/** The timestamp when this token was created. */
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
// getters/setters
|
||||
|
||||
/**
|
||||
* Gets the primary key identifier for the token.
|
||||
*
|
||||
* @return the token ID
|
||||
*/
|
||||
public Long getId() { return id; }
|
||||
|
||||
/**
|
||||
* Gets the email address associated with this token.
|
||||
*
|
||||
* @return the email address
|
||||
*/
|
||||
public String getEmail() { return email; }
|
||||
|
||||
/**
|
||||
* Sets the email address associated with this token.
|
||||
*
|
||||
* @param email the email address to set
|
||||
*/
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
|
||||
/**
|
||||
* Gets the type of token.
|
||||
*
|
||||
* @return the token type
|
||||
*/
|
||||
public TokenType getType() { return type; }
|
||||
|
||||
/**
|
||||
* Sets the type of token.
|
||||
*
|
||||
* @param type the token type to set
|
||||
*/
|
||||
public void setType(TokenType type) { this.type = type; }
|
||||
|
||||
/**
|
||||
* Gets the hashed token value.
|
||||
*
|
||||
* @return the token hash
|
||||
*/
|
||||
public String getTokenHash() { return tokenHash; }
|
||||
|
||||
/**
|
||||
* Sets the hashed token value.
|
||||
*
|
||||
* @param tokenHash the token hash to set
|
||||
*/
|
||||
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when this token expires.
|
||||
*
|
||||
* @return the expiration timestamp
|
||||
*/
|
||||
public OffsetDateTime getExpiresAt() { return expiresAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when this token expires.
|
||||
*
|
||||
* @param expiresAt the expiration timestamp to set
|
||||
*/
|
||||
public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when this token was consumed/used.
|
||||
*
|
||||
* @return the consumed timestamp, or null if not yet consumed
|
||||
*/
|
||||
public OffsetDateTime getConsumedAt() { return consumedAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when this token was consumed/used.
|
||||
*
|
||||
* @param consumedAt the consumed timestamp to set
|
||||
*/
|
||||
public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when this token was created.
|
||||
*
|
||||
* @return the creation timestamp
|
||||
*/
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when this token was created.
|
||||
*
|
||||
* @param createdAt the creation timestamp to set
|
||||
*/
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
/**
|
||||
* Checks if this token has been consumed/used.
|
||||
*
|
||||
* @return true if the token has been consumed, false otherwise
|
||||
*/
|
||||
@Transient
|
||||
public boolean isConsumed() { return consumedAt != null; }
|
||||
|
||||
/**
|
||||
* Checks if this token has expired at the given time.
|
||||
*
|
||||
* @param now the current time to check against
|
||||
* @return true if the token has expired, false otherwise
|
||||
*/
|
||||
@Transient
|
||||
public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); }
|
||||
package group.goforward.battlbuilder.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Entity representing an authentication token.
|
||||
* Tokens are used for beta verification, magic login links, and password resets.
|
||||
* Tokens are hashed before storage and can be consumed and expired.
|
||||
*
|
||||
* @see jakarta.persistence.Entity
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "auth_tokens",
|
||||
indexes = {
|
||||
@Index(name = "idx_auth_tokens_email", columnList = "email"),
|
||||
@Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash")
|
||||
}
|
||||
)
|
||||
public class AuthToken {
|
||||
|
||||
/**
|
||||
* Enumeration of token types.
|
||||
*/
|
||||
public enum TokenType {
|
||||
/** Token for beta access verification. */
|
||||
BETA_VERIFY,
|
||||
/** Token for magic link login. */
|
||||
MAGIC_LOGIN,
|
||||
/** Token for password reset. */
|
||||
PASSWORD_RESET
|
||||
}
|
||||
|
||||
/** The primary key identifier for the token. */
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** The email address associated with this token. */
|
||||
@Column(nullable = false)
|
||||
private String email;
|
||||
|
||||
/** The type of token. */
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private TokenType type;
|
||||
|
||||
/** The hashed token value. */
|
||||
@Column(name = "token_hash", nullable = false, length = 64)
|
||||
private String tokenHash;
|
||||
|
||||
/** The timestamp when this token expires. */
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private OffsetDateTime expiresAt;
|
||||
|
||||
/** The timestamp when this token was consumed/used. */
|
||||
@Column(name = "consumed_at")
|
||||
private OffsetDateTime consumedAt;
|
||||
|
||||
/** The timestamp when this token was created. */
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
// getters/setters
|
||||
|
||||
/**
|
||||
* Gets the primary key identifier for the token.
|
||||
*
|
||||
* @return the token ID
|
||||
*/
|
||||
public Long getId() { return id; }
|
||||
|
||||
/**
|
||||
* Gets the email address associated with this token.
|
||||
*
|
||||
* @return the email address
|
||||
*/
|
||||
public String getEmail() { return email; }
|
||||
|
||||
/**
|
||||
* Sets the email address associated with this token.
|
||||
*
|
||||
* @param email the email address to set
|
||||
*/
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
|
||||
/**
|
||||
* Gets the type of token.
|
||||
*
|
||||
* @return the token type
|
||||
*/
|
||||
public TokenType getType() { return type; }
|
||||
|
||||
/**
|
||||
* Sets the type of token.
|
||||
*
|
||||
* @param type the token type to set
|
||||
*/
|
||||
public void setType(TokenType type) { this.type = type; }
|
||||
|
||||
/**
|
||||
* Gets the hashed token value.
|
||||
*
|
||||
* @return the token hash
|
||||
*/
|
||||
public String getTokenHash() { return tokenHash; }
|
||||
|
||||
/**
|
||||
* Sets the hashed token value.
|
||||
*
|
||||
* @param tokenHash the token hash to set
|
||||
*/
|
||||
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when this token expires.
|
||||
*
|
||||
* @return the expiration timestamp
|
||||
*/
|
||||
public OffsetDateTime getExpiresAt() { return expiresAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when this token expires.
|
||||
*
|
||||
* @param expiresAt the expiration timestamp to set
|
||||
*/
|
||||
public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when this token was consumed/used.
|
||||
*
|
||||
* @return the consumed timestamp, or null if not yet consumed
|
||||
*/
|
||||
public OffsetDateTime getConsumedAt() { return consumedAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when this token was consumed/used.
|
||||
*
|
||||
* @param consumedAt the consumed timestamp to set
|
||||
*/
|
||||
public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when this token was created.
|
||||
*
|
||||
* @return the creation timestamp
|
||||
*/
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when this token was created.
|
||||
*
|
||||
* @param createdAt the creation timestamp to set
|
||||
*/
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
/**
|
||||
* Checks if this token has been consumed/used.
|
||||
*
|
||||
* @return true if the token has been consumed, false otherwise
|
||||
*/
|
||||
@Transient
|
||||
public boolean isConsumed() { return consumedAt != null; }
|
||||
|
||||
/**
|
||||
* Checks if this token has expired at the given time.
|
||||
*
|
||||
* @param now the current time to check against
|
||||
* @return true if the token has expired, false otherwise
|
||||
*/
|
||||
@Transient
|
||||
public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); }
|
||||
}
|
||||
@@ -1,122 +1,122 @@
|
||||
package group.goforward.battlbuilder.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* build_profiles
|
||||
* 1:1 with builds (build_id is both PK and FK)
|
||||
* <p>
|
||||
* Dev notes:
|
||||
* - This is the "feed/meta" table for Option B (caliber, class, cover image, tags, etc.)
|
||||
* - Keep it lightweight. Anything social (votes/comments/media) lives elsewhere.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "build_profiles")
|
||||
public class BuildProfile {
|
||||
|
||||
// -----------------------------------------------------
|
||||
// Primary Key = FK to builds.id
|
||||
// -----------------------------------------------------
|
||||
@Id
|
||||
@Column(name = "build_id", nullable = false)
|
||||
private Integer buildId;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@MapsId
|
||||
@JoinColumn(name = "build_id", nullable = false)
|
||||
private Build build;
|
||||
|
||||
// -----------------------------------------------------
|
||||
// Feed metadata fields (MVP)
|
||||
// -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Examples: "AR-15", "AR-10", "AR-9"
|
||||
* (String for now; we can enum later once stable.)
|
||||
*/
|
||||
@Column(name = "platform")
|
||||
private String platform;
|
||||
|
||||
/**
|
||||
* Examples: "5.56", "9mm", ".300 BLK"
|
||||
*/
|
||||
@Column(name = "caliber")
|
||||
private String caliber;
|
||||
|
||||
/**
|
||||
* Expected values for UI: "Rifle" | "Pistol" | "NFA"
|
||||
* (String for now; UI will default if missing.)
|
||||
*/
|
||||
@Column(name = "build_class")
|
||||
private String buildClass;
|
||||
|
||||
/**
|
||||
* Optional hero image used by /builds cards.
|
||||
*/
|
||||
@Column(name = "cover_image_url")
|
||||
private String coverImageUrl;
|
||||
|
||||
/**
|
||||
* MVP tags storage:
|
||||
* - store as comma-separated string: "Duty,NV-Ready,LPVO"
|
||||
* - later: switch to jsonb or join table when needed
|
||||
*/
|
||||
@Column(name = "tags_csv")
|
||||
private String tagsCsv;
|
||||
|
||||
// -----------------------------------------------------
|
||||
// Timestamps (optional but nice for auditing)
|
||||
// -----------------------------------------------------
|
||||
@ColumnDefault("now()")
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@ColumnDefault("now()")
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
if (createdAt == null) createdAt = now;
|
||||
if (updatedAt == null) updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// Getters / Setters
|
||||
// -----------------------------------------------------
|
||||
public Integer getBuildId() { return buildId; }
|
||||
public void setBuildId(Integer buildId) { this.buildId = buildId; }
|
||||
|
||||
public Build getBuild() { return build; }
|
||||
public void setBuild(Build build) { this.build = build; }
|
||||
|
||||
public String getPlatform() { return platform; }
|
||||
public void setPlatform(String platform) { this.platform = platform; }
|
||||
|
||||
public String getCaliber() { return caliber; }
|
||||
public void setCaliber(String caliber) { this.caliber = caliber; }
|
||||
|
||||
public String getBuildClass() { return buildClass; }
|
||||
public void setBuildClass(String buildClass) { this.buildClass = buildClass; }
|
||||
|
||||
public String getCoverImageUrl() { return coverImageUrl; }
|
||||
public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; }
|
||||
|
||||
public String getTagsCsv() { return tagsCsv; }
|
||||
public void setTagsCsv(String tagsCsv) { this.tagsCsv = tagsCsv; }
|
||||
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
package group.goforward.battlbuilder.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* build_profiles
|
||||
* 1:1 with builds (build_id is both PK and FK)
|
||||
* <p>
|
||||
* Dev notes:
|
||||
* - This is the "feed/meta" table for Option B (caliber, class, cover image, tags, etc.)
|
||||
* - Keep it lightweight. Anything social (votes/comments/media) lives elsewhere.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "build_profiles")
|
||||
public class BuildProfile {
|
||||
|
||||
// -----------------------------------------------------
|
||||
// Primary Key = FK to builds.id
|
||||
// -----------------------------------------------------
|
||||
@Id
|
||||
@Column(name = "build_id", nullable = false)
|
||||
private Integer buildId;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@MapsId
|
||||
@JoinColumn(name = "build_id", nullable = false)
|
||||
private Build build;
|
||||
|
||||
// -----------------------------------------------------
|
||||
// Feed metadata fields (MVP)
|
||||
// -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Examples: "AR-15", "AR-10", "AR-9"
|
||||
* (String for now; we can enum later once stable.)
|
||||
*/
|
||||
@Column(name = "platform")
|
||||
private String platform;
|
||||
|
||||
/**
|
||||
* Examples: "5.56", "9mm", ".300 BLK"
|
||||
*/
|
||||
@Column(name = "caliber")
|
||||
private String caliber;
|
||||
|
||||
/**
|
||||
* Expected values for UI: "Rifle" | "Pistol" | "NFA"
|
||||
* (String for now; UI will default if missing.)
|
||||
*/
|
||||
@Column(name = "build_class")
|
||||
private String buildClass;
|
||||
|
||||
/**
|
||||
* Optional hero image used by /builds cards.
|
||||
*/
|
||||
@Column(name = "cover_image_url")
|
||||
private String coverImageUrl;
|
||||
|
||||
/**
|
||||
* MVP tags storage:
|
||||
* - store as comma-separated string: "Duty,NV-Ready,LPVO"
|
||||
* - later: switch to jsonb or join table when needed
|
||||
*/
|
||||
@Column(name = "tags_csv")
|
||||
private String tagsCsv;
|
||||
|
||||
// -----------------------------------------------------
|
||||
// Timestamps (optional but nice for auditing)
|
||||
// -----------------------------------------------------
|
||||
@ColumnDefault("now()")
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@ColumnDefault("now()")
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
if (createdAt == null) createdAt = now;
|
||||
if (updatedAt == null) updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate() {
|
||||
updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// Getters / Setters
|
||||
// -----------------------------------------------------
|
||||
public Integer getBuildId() { return buildId; }
|
||||
public void setBuildId(Integer buildId) { this.buildId = buildId; }
|
||||
|
||||
public Build getBuild() { return build; }
|
||||
public void setBuild(Build build) { this.build = build; }
|
||||
|
||||
public String getPlatform() { return platform; }
|
||||
public void setPlatform(String platform) { this.platform = platform; }
|
||||
|
||||
public String getCaliber() { return caliber; }
|
||||
public void setCaliber(String caliber) { this.caliber = caliber; }
|
||||
|
||||
public String getBuildClass() { return buildClass; }
|
||||
public void setBuildClass(String buildClass) { this.buildClass = buildClass; }
|
||||
|
||||
public String getCoverImageUrl() { return coverImageUrl; }
|
||||
public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; }
|
||||
|
||||
public String getTagsCsv() { return tagsCsv; }
|
||||
public void setTagsCsv(String tagsCsv) { this.tagsCsv = tagsCsv; }
|
||||
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -1,310 +1,310 @@
|
||||
package group.goforward.battlbuilder.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entity representing an email request/queue entry.
|
||||
* Tracks email sending status, delivery, and engagement metrics (opens, clicks).
|
||||
*
|
||||
* @see jakarta.persistence.Entity
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "email_requests")
|
||||
@NamedQueries({
|
||||
@NamedQuery(
|
||||
name = "EmailRequest.findSent",
|
||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT"
|
||||
),
|
||||
@NamedQuery(
|
||||
name = "EmailRequest.findFailed",
|
||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED"
|
||||
),
|
||||
@NamedQuery(
|
||||
name = "EmailRequest.findPending",
|
||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING"
|
||||
)
|
||||
})
|
||||
public class EmailRequest {
|
||||
|
||||
/** The primary key identifier for the email request. */
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** The email address of the recipient. */
|
||||
@Column(nullable = false)
|
||||
private String recipient;
|
||||
|
||||
/** The email subject line. */
|
||||
@Column(nullable = false)
|
||||
private String subject;
|
||||
|
||||
/** The email body content. */
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String body;
|
||||
|
||||
/** The template key used to generate this email (if applicable). */
|
||||
@Column(name = "template_key", length = 100)
|
||||
private String templateKey;
|
||||
|
||||
/** The status of the email (PENDING, SENT, FAILED). */
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private EmailStatus status; // PENDING, SENT, FAILED
|
||||
|
||||
/** The timestamp when the email was sent. */
|
||||
@Column(name = "sent_at")
|
||||
private LocalDateTime sentAt;
|
||||
|
||||
/** The error message if the email failed to send. */
|
||||
@Column(name = "error_message")
|
||||
private String errorMessage;
|
||||
|
||||
/** The timestamp when the email was first opened. */
|
||||
@Column(name = "opened_at")
|
||||
private LocalDateTime openedAt;
|
||||
|
||||
/** The number of times the email has been opened. Defaults to 0. */
|
||||
@Column(name = "open_count", nullable = false)
|
||||
private Integer openCount = 0;
|
||||
|
||||
/** The timestamp when a link in the email was first clicked. */
|
||||
@Column(name = "clicked_at")
|
||||
private LocalDateTime clickedAt;
|
||||
|
||||
/** The number of times links in the email have been clicked. Defaults to 0. */
|
||||
@Column(name = "click_count", nullable = false)
|
||||
private Integer clickCount = 0;
|
||||
|
||||
/** The timestamp when this email request was created. */
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** The timestamp when this email request was last updated. */
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Lifecycle hook called before persisting a new entity.
|
||||
* Initializes timestamps and default values.
|
||||
*/
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
createdAt = now;
|
||||
updatedAt = now;
|
||||
|
||||
if (status == null) status = EmailStatus.PENDING;
|
||||
if (openCount == null) openCount = 0;
|
||||
if (clickCount == null) clickCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook called before updating an existing entity.
|
||||
* Updates the updatedAt timestamp.
|
||||
*/
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
// ===== Getters / Setters =====
|
||||
|
||||
/**
|
||||
* Gets the primary key identifier for the email request.
|
||||
*
|
||||
* @return the email request ID
|
||||
*/
|
||||
public Long getId() { return id; }
|
||||
|
||||
/**
|
||||
* Sets the primary key identifier for the email request.
|
||||
*
|
||||
* @param id the email request ID to set
|
||||
*/
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
/**
|
||||
* Gets the email address of the recipient.
|
||||
*
|
||||
* @return the recipient email address
|
||||
*/
|
||||
public String getRecipient() { return recipient; }
|
||||
|
||||
/**
|
||||
* Sets the email address of the recipient.
|
||||
*
|
||||
* @param recipient the recipient email address to set
|
||||
*/
|
||||
public void setRecipient(String recipient) { this.recipient = recipient; }
|
||||
|
||||
/**
|
||||
* Gets the email subject line.
|
||||
*
|
||||
* @return the subject
|
||||
*/
|
||||
public String getSubject() { return subject; }
|
||||
|
||||
/**
|
||||
* Sets the email subject line.
|
||||
*
|
||||
* @param subject the subject to set
|
||||
*/
|
||||
public void setSubject(String subject) { this.subject = subject; }
|
||||
|
||||
/**
|
||||
* Gets the email body content.
|
||||
*
|
||||
* @return the body, or null if not set
|
||||
*/
|
||||
public String getBody() { return body; }
|
||||
|
||||
/**
|
||||
* Sets the email body content.
|
||||
*
|
||||
* @param body the body to set
|
||||
*/
|
||||
public void setBody(String body) { this.body = body; }
|
||||
|
||||
/**
|
||||
* Gets the template key used to generate this email.
|
||||
*
|
||||
* @return the template key, or null if not set
|
||||
*/
|
||||
public String getTemplateKey() { return templateKey; }
|
||||
|
||||
/**
|
||||
* Sets the template key used to generate this email.
|
||||
*
|
||||
* @param templateKey the template key to set
|
||||
*/
|
||||
public void setTemplateKey(String templateKey) { this.templateKey = templateKey; }
|
||||
|
||||
/**
|
||||
* Gets the status of the email.
|
||||
*
|
||||
* @return the email status (PENDING, SENT, FAILED)
|
||||
*/
|
||||
public EmailStatus getStatus() { return status; }
|
||||
|
||||
/**
|
||||
* Sets the status of the email.
|
||||
*
|
||||
* @param status the email status to set
|
||||
*/
|
||||
public void setStatus(EmailStatus status) { this.status = status; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when the email was sent.
|
||||
*
|
||||
* @return the sent timestamp, or null if not yet sent
|
||||
*/
|
||||
public LocalDateTime getSentAt() { return sentAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when the email was sent.
|
||||
*
|
||||
* @param sentAt the sent timestamp to set
|
||||
*/
|
||||
public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; }
|
||||
|
||||
/**
|
||||
* Gets the error message if the email failed to send.
|
||||
*
|
||||
* @return the error message, or null if no error
|
||||
*/
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
|
||||
/**
|
||||
* Sets the error message if the email failed to send.
|
||||
*
|
||||
* @param errorMessage the error message to set
|
||||
*/
|
||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when the email was first opened.
|
||||
*
|
||||
* @return the opened timestamp, or null if never opened
|
||||
*/
|
||||
public LocalDateTime getOpenedAt() { return openedAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when the email was first opened.
|
||||
*
|
||||
* @param openedAt the opened timestamp to set
|
||||
*/
|
||||
public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; }
|
||||
|
||||
/**
|
||||
* Gets the number of times the email has been opened.
|
||||
*
|
||||
* @return the open count
|
||||
*/
|
||||
public Integer getOpenCount() { return openCount; }
|
||||
|
||||
/**
|
||||
* Sets the number of times the email has been opened.
|
||||
*
|
||||
* @param openCount the open count to set
|
||||
*/
|
||||
public void setOpenCount(Integer openCount) { this.openCount = openCount; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when a link in the email was first clicked.
|
||||
*
|
||||
* @return the clicked timestamp, or null if never clicked
|
||||
*/
|
||||
public LocalDateTime getClickedAt() { return clickedAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when a link in the email was first clicked.
|
||||
*
|
||||
* @param clickedAt the clicked timestamp to set
|
||||
*/
|
||||
public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; }
|
||||
|
||||
/**
|
||||
* Gets the number of times links in the email have been clicked.
|
||||
*
|
||||
* @return the click count
|
||||
*/
|
||||
public Integer getClickCount() { return clickCount; }
|
||||
|
||||
/**
|
||||
* Sets the number of times links in the email have been clicked.
|
||||
*
|
||||
* @param clickCount the click count to set
|
||||
*/
|
||||
public void setClickCount(Integer clickCount) { this.clickCount = clickCount; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when this email request was created.
|
||||
*
|
||||
* @return the creation timestamp
|
||||
*/
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when this email request was created.
|
||||
*
|
||||
* @param createdAt the creation timestamp to set
|
||||
*/
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when this email request was last updated.
|
||||
*
|
||||
* @return the last update timestamp
|
||||
*/
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when this email request was last updated.
|
||||
*
|
||||
* @param updatedAt the last update timestamp to set
|
||||
*/
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
package group.goforward.battlbuilder.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entity representing an email request/queue entry.
|
||||
* Tracks email sending status, delivery, and engagement metrics (opens, clicks).
|
||||
*
|
||||
* @see jakarta.persistence.Entity
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "email_requests")
|
||||
@NamedQueries({
|
||||
@NamedQuery(
|
||||
name = "EmailRequest.findSent",
|
||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT"
|
||||
),
|
||||
@NamedQuery(
|
||||
name = "EmailRequest.findFailed",
|
||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED"
|
||||
),
|
||||
@NamedQuery(
|
||||
name = "EmailRequest.findPending",
|
||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING"
|
||||
)
|
||||
})
|
||||
public class EmailRequest {
|
||||
|
||||
/** The primary key identifier for the email request. */
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** The email address of the recipient. */
|
||||
@Column(nullable = false)
|
||||
private String recipient;
|
||||
|
||||
/** The email subject line. */
|
||||
@Column(nullable = false)
|
||||
private String subject;
|
||||
|
||||
/** The email body content. */
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String body;
|
||||
|
||||
/** The template key used to generate this email (if applicable). */
|
||||
@Column(name = "template_key", length = 100)
|
||||
private String templateKey;
|
||||
|
||||
/** The status of the email (PENDING, SENT, FAILED). */
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private EmailStatus status; // PENDING, SENT, FAILED
|
||||
|
||||
/** The timestamp when the email was sent. */
|
||||
@Column(name = "sent_at")
|
||||
private LocalDateTime sentAt;
|
||||
|
||||
/** The error message if the email failed to send. */
|
||||
@Column(name = "error_message")
|
||||
private String errorMessage;
|
||||
|
||||
/** The timestamp when the email was first opened. */
|
||||
@Column(name = "opened_at")
|
||||
private LocalDateTime openedAt;
|
||||
|
||||
/** The number of times the email has been opened. Defaults to 0. */
|
||||
@Column(name = "open_count", nullable = false)
|
||||
private Integer openCount = 0;
|
||||
|
||||
/** The timestamp when a link in the email was first clicked. */
|
||||
@Column(name = "clicked_at")
|
||||
private LocalDateTime clickedAt;
|
||||
|
||||
/** The number of times links in the email have been clicked. Defaults to 0. */
|
||||
@Column(name = "click_count", nullable = false)
|
||||
private Integer clickCount = 0;
|
||||
|
||||
/** The timestamp when this email request was created. */
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** The timestamp when this email request was last updated. */
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Lifecycle hook called before persisting a new entity.
|
||||
* Initializes timestamps and default values.
|
||||
*/
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
createdAt = now;
|
||||
updatedAt = now;
|
||||
|
||||
if (status == null) status = EmailStatus.PENDING;
|
||||
if (openCount == null) openCount = 0;
|
||||
if (clickCount == null) clickCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook called before updating an existing entity.
|
||||
* Updates the updatedAt timestamp.
|
||||
*/
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
// ===== Getters / Setters =====
|
||||
|
||||
/**
|
||||
* Gets the primary key identifier for the email request.
|
||||
*
|
||||
* @return the email request ID
|
||||
*/
|
||||
public Long getId() { return id; }
|
||||
|
||||
/**
|
||||
* Sets the primary key identifier for the email request.
|
||||
*
|
||||
* @param id the email request ID to set
|
||||
*/
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
/**
|
||||
* Gets the email address of the recipient.
|
||||
*
|
||||
* @return the recipient email address
|
||||
*/
|
||||
public String getRecipient() { return recipient; }
|
||||
|
||||
/**
|
||||
* Sets the email address of the recipient.
|
||||
*
|
||||
* @param recipient the recipient email address to set
|
||||
*/
|
||||
public void setRecipient(String recipient) { this.recipient = recipient; }
|
||||
|
||||
/**
|
||||
* Gets the email subject line.
|
||||
*
|
||||
* @return the subject
|
||||
*/
|
||||
public String getSubject() { return subject; }
|
||||
|
||||
/**
|
||||
* Sets the email subject line.
|
||||
*
|
||||
* @param subject the subject to set
|
||||
*/
|
||||
public void setSubject(String subject) { this.subject = subject; }
|
||||
|
||||
/**
|
||||
* Gets the email body content.
|
||||
*
|
||||
* @return the body, or null if not set
|
||||
*/
|
||||
public String getBody() { return body; }
|
||||
|
||||
/**
|
||||
* Sets the email body content.
|
||||
*
|
||||
* @param body the body to set
|
||||
*/
|
||||
public void setBody(String body) { this.body = body; }
|
||||
|
||||
/**
|
||||
* Gets the template key used to generate this email.
|
||||
*
|
||||
* @return the template key, or null if not set
|
||||
*/
|
||||
public String getTemplateKey() { return templateKey; }
|
||||
|
||||
/**
|
||||
* Sets the template key used to generate this email.
|
||||
*
|
||||
* @param templateKey the template key to set
|
||||
*/
|
||||
public void setTemplateKey(String templateKey) { this.templateKey = templateKey; }
|
||||
|
||||
/**
|
||||
* Gets the status of the email.
|
||||
*
|
||||
* @return the email status (PENDING, SENT, FAILED)
|
||||
*/
|
||||
public EmailStatus getStatus() { return status; }
|
||||
|
||||
/**
|
||||
* Sets the status of the email.
|
||||
*
|
||||
* @param status the email status to set
|
||||
*/
|
||||
public void setStatus(EmailStatus status) { this.status = status; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when the email was sent.
|
||||
*
|
||||
* @return the sent timestamp, or null if not yet sent
|
||||
*/
|
||||
public LocalDateTime getSentAt() { return sentAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when the email was sent.
|
||||
*
|
||||
* @param sentAt the sent timestamp to set
|
||||
*/
|
||||
public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; }
|
||||
|
||||
/**
|
||||
* Gets the error message if the email failed to send.
|
||||
*
|
||||
* @return the error message, or null if no error
|
||||
*/
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
|
||||
/**
|
||||
* Sets the error message if the email failed to send.
|
||||
*
|
||||
* @param errorMessage the error message to set
|
||||
*/
|
||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when the email was first opened.
|
||||
*
|
||||
* @return the opened timestamp, or null if never opened
|
||||
*/
|
||||
public LocalDateTime getOpenedAt() { return openedAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when the email was first opened.
|
||||
*
|
||||
* @param openedAt the opened timestamp to set
|
||||
*/
|
||||
public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; }
|
||||
|
||||
/**
|
||||
* Gets the number of times the email has been opened.
|
||||
*
|
||||
* @return the open count
|
||||
*/
|
||||
public Integer getOpenCount() { return openCount; }
|
||||
|
||||
/**
|
||||
* Sets the number of times the email has been opened.
|
||||
*
|
||||
* @param openCount the open count to set
|
||||
*/
|
||||
public void setOpenCount(Integer openCount) { this.openCount = openCount; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when a link in the email was first clicked.
|
||||
*
|
||||
* @return the clicked timestamp, or null if never clicked
|
||||
*/
|
||||
public LocalDateTime getClickedAt() { return clickedAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when a link in the email was first clicked.
|
||||
*
|
||||
* @param clickedAt the clicked timestamp to set
|
||||
*/
|
||||
public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; }
|
||||
|
||||
/**
|
||||
* Gets the number of times links in the email have been clicked.
|
||||
*
|
||||
* @return the click count
|
||||
*/
|
||||
public Integer getClickCount() { return clickCount; }
|
||||
|
||||
/**
|
||||
* Sets the number of times links in the email have been clicked.
|
||||
*
|
||||
* @param clickCount the click count to set
|
||||
*/
|
||||
public void setClickCount(Integer clickCount) { this.clickCount = clickCount; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when this email request was created.
|
||||
*
|
||||
* @return the creation timestamp
|
||||
*/
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when this email request was created.
|
||||
*
|
||||
* @param createdAt the creation timestamp to set
|
||||
*/
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
/**
|
||||
* Gets the timestamp when this email request was last updated.
|
||||
*
|
||||
* @return the last update timestamp
|
||||
*/
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
|
||||
/**
|
||||
* Sets the timestamp when this email request was last updated.
|
||||
*
|
||||
* @param updatedAt the last update timestamp to set
|
||||
*/
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,28 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.AuthToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
|
||||
|
||||
Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash);
|
||||
|
||||
// ✅ Used for "Invited" badge: active (not expired, not consumed) magic token exists
|
||||
@Query("""
|
||||
select (count(t) > 0) from AuthToken t
|
||||
where lower(t.email) = lower(:email)
|
||||
and t.type = :type
|
||||
and t.expiresAt > :now
|
||||
and t.consumedAt is null
|
||||
""")
|
||||
boolean hasActiveToken(
|
||||
@Param("email") String email,
|
||||
@Param("type") AuthToken.TokenType type,
|
||||
@Param("now") OffsetDateTime now
|
||||
);
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.AuthToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
|
||||
|
||||
Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash);
|
||||
|
||||
// ✅ Used for "Invited" badge: active (not expired, not consumed) magic token exists
|
||||
@Query("""
|
||||
select (count(t) > 0) from AuthToken t
|
||||
where lower(t.email) = lower(:email)
|
||||
and t.type = :type
|
||||
and t.expiresAt > :now
|
||||
and t.consumedAt is null
|
||||
""")
|
||||
boolean hasActiveToken(
|
||||
@Param("email") String email,
|
||||
@Param("type") AuthToken.TokenType type,
|
||||
@Param("now") OffsetDateTime now
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.BuildProfile;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface BuildProfileRepository extends JpaRepository<BuildProfile, Integer> {
|
||||
|
||||
List<BuildProfile> findByBuildIdIn(Collection<Integer> buildIds);
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.BuildProfile;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface BuildProfileRepository extends JpaRepository<BuildProfile, Integer> {
|
||||
|
||||
List<BuildProfile> findByBuildIdIn(Collection<Integer> buildIds);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.CanonicalCategory;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CanonicalCategoryRepository extends JpaRepository<CanonicalCategory, Integer> {
|
||||
|
||||
@Query("""
|
||||
select c
|
||||
from CanonicalCategory c
|
||||
where c.deletedAt is null
|
||||
order by c.name asc
|
||||
""")
|
||||
List<CanonicalCategory> findAllActive();
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.CanonicalCategory;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CanonicalCategoryRepository extends JpaRepository<CanonicalCategory, Integer> {
|
||||
|
||||
@Query("""
|
||||
select c
|
||||
from CanonicalCategory c
|
||||
where c.deletedAt is null
|
||||
order by c.name asc
|
||||
""")
|
||||
List<CanonicalCategory> findAllActive();
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.CategoryMapping;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CategoryMappingRepository extends JpaRepository<CategoryMapping, Integer> {
|
||||
|
||||
// All mappings for a merchant, ordered nicely
|
||||
List<CategoryMapping> findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId);
|
||||
|
||||
// Merchants that actually have mappings (for the dropdown)
|
||||
@Query("""
|
||||
select distinct cm.merchant
|
||||
from CategoryMapping cm
|
||||
order by cm.merchant.name asc
|
||||
""")
|
||||
List<Merchant> findDistinctMerchantsWithMappings();
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.CategoryMapping;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CategoryMappingRepository extends JpaRepository<CategoryMapping, Integer> {
|
||||
|
||||
// All mappings for a merchant, ordered nicely
|
||||
List<CategoryMapping> findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId);
|
||||
|
||||
// Merchants that actually have mappings (for the dropdown)
|
||||
@Query("""
|
||||
select distinct cm.merchant
|
||||
from CategoryMapping cm
|
||||
order by cm.merchant.name asc
|
||||
""")
|
||||
List<Merchant> findDistinctMerchantsWithMappings();
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.EmailRequest;
|
||||
import group.goforward.battlbuilder.model.EmailStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface EmailRequestRepository extends JpaRepository<EmailRequest, Long> {
|
||||
|
||||
List<EmailRequest> findByStatus(EmailStatus status);
|
||||
List<EmailRequest> findByStatusOrderByCreatedAtDesc(EmailStatus status);
|
||||
|
||||
}
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.EmailRequest;
|
||||
import group.goforward.battlbuilder.model.EmailStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface EmailRequestRepository extends JpaRepository<EmailRequest, Long> {
|
||||
|
||||
List<EmailRequest> findByStatus(EmailStatus status);
|
||||
List<EmailRequest> findByStatusOrderByCreatedAtDesc(EmailStatus status);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.EmailTemplate;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface EmailTemplateRepository extends JpaRepository<EmailTemplate, Long> {
|
||||
|
||||
Optional<EmailTemplate> findByTemplateKeyAndEnabledTrue(String templateKey);
|
||||
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.EmailTemplate;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface EmailTemplateRepository extends JpaRepository<EmailTemplate, Long> {
|
||||
|
||||
Optional<EmailTemplate> findByTemplateKeyAndEnabledTrue(String templateKey);
|
||||
|
||||
}
|
||||
@@ -1,63 +1,63 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
|
||||
|
||||
// Pull candidates ordered by platform specificity: exact match first, then ANY/null.
|
||||
@Query("""
|
||||
select m
|
||||
from MerchantCategoryMap m
|
||||
where m.merchant.id = :merchantId
|
||||
and lower(m.rawCategory) = lower(:rawCategory) and m.enabled = true
|
||||
and m.deletedAt is null
|
||||
and (m.platform is null or m.platform = 'ANY' or m.platform = :platform)
|
||||
order by
|
||||
case
|
||||
when m.platform = :platform then 0
|
||||
when m.platform = 'ANY' then 1
|
||||
when m.platform is null then 2
|
||||
else 3
|
||||
end,
|
||||
m.updatedAt desc
|
||||
""")
|
||||
List<MerchantCategoryMap> findCandidates(
|
||||
@Param("merchantId") Integer merchantId,
|
||||
@Param("rawCategory") String rawCategory,
|
||||
@Param("platform") String platform
|
||||
);
|
||||
|
||||
default Optional<MerchantCategoryMap> findBest(Integer merchantId, String rawCategory, String platform) {
|
||||
List<MerchantCategoryMap> candidates = findCandidates(merchantId, rawCategory, platform);
|
||||
return candidates.stream().findFirst();
|
||||
}
|
||||
|
||||
// Optional helper if you want a quick "latest mapping regardless of platform"
|
||||
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
|
||||
Integer merchantId,
|
||||
String rawCategory
|
||||
);
|
||||
|
||||
// Optional: if you still want a role-only lookup list for debugging
|
||||
@Query("""
|
||||
select mcm.canonicalPartRole
|
||||
from MerchantCategoryMap mcm
|
||||
where mcm.merchant.id = :merchantId
|
||||
and mcm.rawCategory = :rawCategory
|
||||
and mcm.enabled = true
|
||||
and mcm.deletedAt is null
|
||||
order by mcm.updatedAt desc
|
||||
""")
|
||||
List<String> findCanonicalPartRoles(
|
||||
@Param("merchantId") Integer merchantId,
|
||||
@Param("rawCategory") String rawCategory
|
||||
);
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
|
||||
|
||||
// Pull candidates ordered by platform specificity: exact match first, then ANY/null.
|
||||
@Query("""
|
||||
select m
|
||||
from MerchantCategoryMap m
|
||||
where m.merchant.id = :merchantId
|
||||
and lower(m.rawCategory) = lower(:rawCategory) and m.enabled = true
|
||||
and m.deletedAt is null
|
||||
and (m.platform is null or m.platform = 'ANY' or m.platform = :platform)
|
||||
order by
|
||||
case
|
||||
when m.platform = :platform then 0
|
||||
when m.platform = 'ANY' then 1
|
||||
when m.platform is null then 2
|
||||
else 3
|
||||
end,
|
||||
m.updatedAt desc
|
||||
""")
|
||||
List<MerchantCategoryMap> findCandidates(
|
||||
@Param("merchantId") Integer merchantId,
|
||||
@Param("rawCategory") String rawCategory,
|
||||
@Param("platform") String platform
|
||||
);
|
||||
|
||||
default Optional<MerchantCategoryMap> findBest(Integer merchantId, String rawCategory, String platform) {
|
||||
List<MerchantCategoryMap> candidates = findCandidates(merchantId, rawCategory, platform);
|
||||
return candidates.stream().findFirst();
|
||||
}
|
||||
|
||||
// Optional helper if you want a quick "latest mapping regardless of platform"
|
||||
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
|
||||
Integer merchantId,
|
||||
String rawCategory
|
||||
);
|
||||
|
||||
// Optional: if you still want a role-only lookup list for debugging
|
||||
@Query("""
|
||||
select mcm.canonicalPartRole
|
||||
from MerchantCategoryMap mcm
|
||||
where mcm.merchant.id = :merchantId
|
||||
and mcm.rawCategory = :rawCategory
|
||||
and mcm.enabled = true
|
||||
and mcm.deletedAt is null
|
||||
order by mcm.updatedAt desc
|
||||
""")
|
||||
List<String> findCanonicalPartRoles(
|
||||
@Param("merchantId") Integer merchantId,
|
||||
@Param("rawCategory") String rawCategory
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartCategory;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
|
||||
|
||||
Optional<PartCategory> findBySlug(String slug);
|
||||
|
||||
List<PartCategory> findAllByOrderByGroupNameAscSortOrderAscNameAsc();
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartCategory;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
|
||||
|
||||
Optional<PartCategory> findBySlug(String slug);
|
||||
|
||||
List<PartCategory> findAllByOrderByGroupNameAscSortOrderAscNameAsc();
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
|
||||
|
||||
// Used by admin screens / lists (case-sensitive, no platform normalization)
|
||||
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
||||
|
||||
// Used by builder/bootstrap flows (case-insensitive)
|
||||
List<PartRoleMapping> findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
||||
|
||||
// Used by resolvers when mapping a single role (case-insensitive)
|
||||
Optional<PartRoleMapping> findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(
|
||||
String platform,
|
||||
String partRole
|
||||
);
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
|
||||
|
||||
// Used by admin screens / lists (case-sensitive, no platform normalization)
|
||||
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
||||
|
||||
// Used by builder/bootstrap flows (case-insensitive)
|
||||
List<PartRoleMapping> findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
||||
|
||||
// Used by resolvers when mapping a single role (case-insensitive)
|
||||
Optional<PartRoleMapping> findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(
|
||||
String platform,
|
||||
String partRole
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartRoleRule;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PartRoleRuleRepository extends JpaRepository<PartRoleRule, Long> {
|
||||
List<PartRoleRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartRoleRule;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PartRoleRuleRepository extends JpaRepository<PartRoleRule, Long> {
|
||||
List<PartRoleRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.PlatformRule;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> {
|
||||
|
||||
// Active rules, highest priority first (tie-breaker: id asc for stability)
|
||||
List<PlatformRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.PlatformRule;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> {
|
||||
|
||||
// Active rules, highest priority first (tie-breaker: id asc for stability)
|
||||
List<PlatformRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,44 @@
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Integer> {
|
||||
|
||||
Optional<User> findByEmailIgnoreCaseAndDeletedAtIsNull(String email);
|
||||
|
||||
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
|
||||
|
||||
Optional<User> findByUuid(UUID uuid);
|
||||
|
||||
boolean existsByRole(String role);
|
||||
|
||||
Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid);
|
||||
|
||||
List<User> findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role);
|
||||
|
||||
// ✅ Pending beta requests (what you described)
|
||||
Page<User> findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc(
|
||||
String role,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
// ✅ Optional: find user by verification token for confirm flow (if you don’t already have it)
|
||||
Optional<User> findByVerificationTokenAndDeletedAtIsNull(String verificationToken);
|
||||
|
||||
// Set Username
|
||||
Optional<User> findByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
|
||||
|
||||
boolean existsByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
|
||||
|
||||
@Query(value = "select * from users where role = :role and is_active = false and deleted_at is null order by created_at asc limit :limit", nativeQuery = true)
|
||||
List<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Integer> {
|
||||
|
||||
Optional<User> findByEmailIgnoreCaseAndDeletedAtIsNull(String email);
|
||||
|
||||
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
|
||||
|
||||
Optional<User> findByUuid(UUID uuid);
|
||||
|
||||
boolean existsByRole(String role);
|
||||
|
||||
Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid);
|
||||
|
||||
List<User> findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role);
|
||||
|
||||
// ✅ Pending beta requests (what you described)
|
||||
Page<User> findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc(
|
||||
String role,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
// ✅ Optional: find user by verification token for confirm flow (if you don’t already have it)
|
||||
Optional<User> findByVerificationTokenAndDeletedAtIsNull(String verificationToken);
|
||||
|
||||
// Set Username
|
||||
Optional<User> findByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
|
||||
|
||||
boolean existsByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
|
||||
|
||||
@Query(value = "select * from users where role = :role and is_active = false and deleted_at is null order by created_at asc limit :limit", nativeQuery = true)
|
||||
List<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
|
||||
}
|
||||
@@ -1,57 +1,57 @@
|
||||
package group.goforward.battlbuilder.repo.catalog.spec;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.model.ProductStatus;
|
||||
import group.goforward.battlbuilder.model.ProductVisibility;
|
||||
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import jakarta.persistence.criteria.JoinType;
|
||||
import java.util.List;
|
||||
|
||||
public class CatalogProductSpecifications {
|
||||
|
||||
private CatalogProductSpecifications() {}
|
||||
|
||||
// Default public catalog rules
|
||||
public static Specification<Product> isCatalogVisible() {
|
||||
return (root, query, cb) -> cb.and(
|
||||
cb.isNull(root.get("deletedAt")),
|
||||
cb.equal(root.get("status"), ProductStatus.ACTIVE),
|
||||
cb.equal(root.get("visibility"), ProductVisibility.PUBLIC),
|
||||
cb.isTrue(root.get("builderEligible"))
|
||||
);
|
||||
}
|
||||
|
||||
public static Specification<Product> platformEquals(String platform) {
|
||||
return (root, query, cb) -> cb.equal(root.get("platform"), platform);
|
||||
}
|
||||
|
||||
public static Specification<Product> partRoleIn(List<String> roles) {
|
||||
return (root, query, cb) -> root.get("partRole").in(roles);
|
||||
}
|
||||
|
||||
public static Specification<Product> brandNameIn(List<String> brandNames) {
|
||||
return (root, query, cb) -> {
|
||||
root.fetch("brand", JoinType.LEFT);
|
||||
query.distinct(true);
|
||||
return root.join("brand", JoinType.LEFT).get("name").in(brandNames);
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<Product> queryLike(String q) {
|
||||
final String like = "%" + q.toLowerCase().trim() + "%";
|
||||
return (root, query, cb) -> {
|
||||
root.fetch("brand", JoinType.LEFT);
|
||||
query.distinct(true);
|
||||
|
||||
var brandJoin = root.join("brand", JoinType.LEFT);
|
||||
return cb.or(
|
||||
cb.like(cb.lower(root.get("name")), like),
|
||||
cb.like(cb.lower(brandJoin.get("name")), like),
|
||||
cb.like(cb.lower(root.get("mpn")), like),
|
||||
cb.like(cb.lower(root.get("upc")), like)
|
||||
);
|
||||
};
|
||||
}
|
||||
package group.goforward.battlbuilder.repo.catalog.spec;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.model.ProductStatus;
|
||||
import group.goforward.battlbuilder.model.ProductVisibility;
|
||||
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import jakarta.persistence.criteria.JoinType;
|
||||
import java.util.List;
|
||||
|
||||
public class CatalogProductSpecifications {
|
||||
|
||||
private CatalogProductSpecifications() {}
|
||||
|
||||
// Default public catalog rules
|
||||
public static Specification<Product> isCatalogVisible() {
|
||||
return (root, query, cb) -> cb.and(
|
||||
cb.isNull(root.get("deletedAt")),
|
||||
cb.equal(root.get("status"), ProductStatus.ACTIVE),
|
||||
cb.equal(root.get("visibility"), ProductVisibility.PUBLIC),
|
||||
cb.isTrue(root.get("builderEligible"))
|
||||
);
|
||||
}
|
||||
|
||||
public static Specification<Product> platformEquals(String platform) {
|
||||
return (root, query, cb) -> cb.equal(root.get("platform"), platform);
|
||||
}
|
||||
|
||||
public static Specification<Product> partRoleIn(List<String> roles) {
|
||||
return (root, query, cb) -> root.get("partRole").in(roles);
|
||||
}
|
||||
|
||||
public static Specification<Product> brandNameIn(List<String> brandNames) {
|
||||
return (root, query, cb) -> {
|
||||
root.fetch("brand", JoinType.LEFT);
|
||||
query.distinct(true);
|
||||
return root.join("brand", JoinType.LEFT).get("name").in(brandNames);
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<Product> queryLike(String q) {
|
||||
final String like = "%" + q.toLowerCase().trim() + "%";
|
||||
return (root, query, cb) -> {
|
||||
root.fetch("brand", JoinType.LEFT);
|
||||
query.distinct(true);
|
||||
|
||||
var brandJoin = root.join("brand", JoinType.LEFT);
|
||||
return cb.or(
|
||||
cb.like(cb.lower(root.get("name")), like),
|
||||
cb.like(cb.lower(brandJoin.get("name")), like),
|
||||
cb.like(cb.lower(root.get("mpn")), like),
|
||||
cb.like(cb.lower(root.get("upc")), like)
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Repositories package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains Spring Data JPA repository interfaces for database
|
||||
* access and persistence operations.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.repo;
|
||||
/**
|
||||
* Repositories package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains Spring Data JPA repository interfaces for database
|
||||
* access and persistence operations.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.repo;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package group.goforward.battlbuilder.repo.projections;
|
||||
|
||||
public interface CatalogRow {
|
||||
Long getId();
|
||||
String getName();
|
||||
String getPlatform();
|
||||
String getPartRole();
|
||||
String getImageUrl(); // or mainImageUrl depending on your schema
|
||||
String getBrand();
|
||||
|
||||
Double getPrice();
|
||||
String getBuyUrl();
|
||||
Boolean getInStock();
|
||||
package group.goforward.battlbuilder.repo.projections;
|
||||
|
||||
public interface CatalogRow {
|
||||
Long getId();
|
||||
String getName();
|
||||
String getPlatform();
|
||||
String getPartRole();
|
||||
String getImageUrl(); // or mainImageUrl depending on your schema
|
||||
String getBrand();
|
||||
|
||||
Double getPrice();
|
||||
String getBuyUrl();
|
||||
Boolean getInStock();
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
package group.goforward.battlbuilder.security;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final UserRepository users;
|
||||
|
||||
public CustomUserDetailsService(UserRepository users) {
|
||||
this.users = users;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
return new CustomUserDetails(user);
|
||||
}
|
||||
package group.goforward.battlbuilder.security;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final UserRepository users;
|
||||
|
||||
public CustomUserDetailsService(UserRepository users) {
|
||||
this.users = users;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
return new CustomUserDetails(user);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,88 @@
|
||||
package group.goforward.battlbuilder.security;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
|
||||
this.jwtService = jwtService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
|
||||
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ If already authenticated with a REAL user, skip.
|
||||
// ✅ If it's anonymous, we should continue and replace it.
|
||||
var existing = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (existing != null
|
||||
&& existing.isAuthenticated()
|
||||
&& !(existing instanceof AnonymousAuthenticationToken)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7);
|
||||
|
||||
if (!jwtService.isTokenValid(token)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
UUID userUuid = jwtService.extractUserUuid(token);
|
||||
if (userUuid == null) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
User user = userRepository.findByUuid(userUuid).orElse(null);
|
||||
if (user == null || !Boolean.TRUE.equals(user.isActive())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
CustomUserDetails userDetails = new CustomUserDetails(user);
|
||||
|
||||
UsernamePasswordAuthenticationToken authToken =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
user.getUuid().toString(), // principal = UUID string
|
||||
null,
|
||||
userDetails.getAuthorities()
|
||||
);
|
||||
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
package group.goforward.battlbuilder.security;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
|
||||
this.jwtService = jwtService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
|
||||
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ If already authenticated with a REAL user, skip.
|
||||
// ✅ If it's anonymous, we should continue and replace it.
|
||||
var existing = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (existing != null
|
||||
&& existing.isAuthenticated()
|
||||
&& !(existing instanceof AnonymousAuthenticationToken)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7);
|
||||
|
||||
if (!jwtService.isTokenValid(token)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
UUID userUuid = jwtService.extractUserUuid(token);
|
||||
if (userUuid == null) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
User user = userRepository.findByUuid(userUuid).orElse(null);
|
||||
if (user == null || !Boolean.TRUE.equals(user.isActive())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
CustomUserDetails userDetails = new CustomUserDetails(user);
|
||||
|
||||
UsernamePasswordAuthenticationToken authToken =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
user.getUuid().toString(), // principal = UUID string
|
||||
null,
|
||||
userDetails.getAuthorities()
|
||||
);
|
||||
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.Brand;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface BrandService {
|
||||
|
||||
List<Brand> findAll();
|
||||
|
||||
Optional<Brand> findById(Integer id);
|
||||
|
||||
Brand save(Brand item);
|
||||
void deleteById(Integer id);
|
||||
}
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.Brand;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface BrandService {
|
||||
|
||||
List<Brand> findAll();
|
||||
|
||||
Optional<Brand> findById(Integer id);
|
||||
|
||||
Brand save(Brand item);
|
||||
void deleteById(Integer id);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.BuildDto;
|
||||
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
|
||||
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
|
||||
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface BuildService {
|
||||
|
||||
List<BuildFeedCardDto> listPublicBuilds(int limit);
|
||||
|
||||
List<BuildSummaryDto> listMyBuilds(int limit);
|
||||
|
||||
BuildDto getMyBuild(UUID uuid);
|
||||
|
||||
BuildDto createMyBuild(UpdateBuildRequest req);
|
||||
|
||||
BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req);
|
||||
|
||||
BuildDto getPublicBuild(UUID uuid);
|
||||
|
||||
void deleteMyBuild(UUID uuid);
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.BuildDto;
|
||||
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
|
||||
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
|
||||
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface BuildService {
|
||||
|
||||
List<BuildFeedCardDto> listPublicBuilds(int limit);
|
||||
|
||||
List<BuildSummaryDto> listMyBuilds(int limit);
|
||||
|
||||
BuildDto getMyBuild(UUID uuid);
|
||||
|
||||
BuildDto createMyBuild(UpdateBuildRequest req);
|
||||
|
||||
BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req);
|
||||
|
||||
BuildDto getPublicBuild(UUID uuid);
|
||||
|
||||
void deleteMyBuild(UUID uuid);
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CatalogQueryService {
|
||||
|
||||
Page<ProductSummaryDto> getOptions(
|
||||
String platform,
|
||||
String partRole,
|
||||
List<String> partRoles,
|
||||
List<String> brands,
|
||||
String q,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request);
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CatalogQueryService {
|
||||
|
||||
Page<ProductSummaryDto> getOptions(
|
||||
String platform,
|
||||
String partRole,
|
||||
List<String> partRoles,
|
||||
List<String> brands,
|
||||
String q,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request);
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||
|
||||
public interface CategoryClassificationService {
|
||||
|
||||
record Result(
|
||||
String platform,
|
||||
String partRole,
|
||||
String rawCategoryKey,
|
||||
PartRoleSource source,
|
||||
String reason
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Legacy convenience: derives rawCategoryKey + platform from row.
|
||||
*/
|
||||
Result classify(Merchant merchant, MerchantFeedRow row);
|
||||
|
||||
/**
|
||||
* Preferred for ETL: caller already computed platform + rawCategoryKey.
|
||||
* This prevents platformResolver overrides from drifting vs mapping selection.
|
||||
*/
|
||||
Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey);
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||
|
||||
public interface CategoryClassificationService {
|
||||
|
||||
record Result(
|
||||
String platform,
|
||||
String partRole,
|
||||
String rawCategoryKey,
|
||||
PartRoleSource source,
|
||||
String reason
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Legacy convenience: derives rawCategoryKey + platform from row.
|
||||
*/
|
||||
Result classify(Merchant merchant, MerchantFeedRow row);
|
||||
|
||||
/**
|
||||
* Preferred for ETL: caller already computed platform + rawCategoryKey.
|
||||
* This prevents platformResolver overrides from drifting vs mapping selection.
|
||||
*/
|
||||
Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey);
|
||||
}
|
||||
@@ -1,72 +1,72 @@
|
||||
// src/main/java/group/goforward/ballistic/service/CategoryMappingRecommendationService.java
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.web.dto.CategoryMappingRecommendationDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class CategoryMappingRecommendationService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
|
||||
public CategoryMappingRecommendationService(ProductRepository productRepository) {
|
||||
this.productRepository = productRepository;
|
||||
}
|
||||
|
||||
public List<CategoryMappingRecommendationDto> listRecommendations() {
|
||||
var groups = productRepository.findUnmappedCategoryGroups();
|
||||
|
||||
return groups.stream()
|
||||
.map(row -> {
|
||||
String merchantName = (String) row.get("merchantName");
|
||||
String rawCategoryKey = (String) row.get("rawCategoryKey");
|
||||
long count = (long) row.get("productCount");
|
||||
|
||||
// Pull one sample product name
|
||||
List<Product> examples = productRepository
|
||||
.findExamplesForCategoryGroup(merchantName, rawCategoryKey);
|
||||
String sampleName = examples.isEmpty()
|
||||
? null
|
||||
: examples.get(0).getName();
|
||||
|
||||
String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName);
|
||||
|
||||
return new CategoryMappingRecommendationDto(
|
||||
merchantName,
|
||||
rawCategoryKey,
|
||||
count,
|
||||
recommendedRole,
|
||||
sampleName
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String inferPartRoleFromRawKey(String rawKey, String sampleName) {
|
||||
String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase();
|
||||
|
||||
if (blob.contains("handguard") || blob.contains("rail")) return "handguard";
|
||||
if (blob.contains("barrel")) return "barrel";
|
||||
if (blob.contains("upper")) return "upper-receiver";
|
||||
if (blob.contains("lower")) return "lower-receiver";
|
||||
if (blob.contains("mag") || blob.contains("magazine")) return "magazine";
|
||||
if (blob.contains("stock") || blob.contains("buttstock")) return "stock";
|
||||
if (blob.contains("grip")) return "grip";
|
||||
if (blob.contains("trigger")) return "trigger";
|
||||
if (blob.contains("sight") || blob.contains("iron sights")) return "sights";
|
||||
if (blob.contains("optic") || blob.contains("scope") || blob.contains("red dot")) return "optic";
|
||||
if (blob.contains("buffer")) return "buffer";
|
||||
if (blob.contains("gas block")) return "gas-block";
|
||||
if (blob.contains("gas tube")) return "gas-tube";
|
||||
if (blob.contains("muzzle")) return "muzzle-device";
|
||||
if (blob.contains("sling")) return "sling";
|
||||
if (blob.contains("bipod")) return "bipod";
|
||||
if (blob.contains("tool")) return "tools";
|
||||
|
||||
return "UNKNOWN";
|
||||
}
|
||||
// src/main/java/group/goforward/ballistic/service/CategoryMappingRecommendationService.java
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.web.dto.CategoryMappingRecommendationDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class CategoryMappingRecommendationService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
|
||||
public CategoryMappingRecommendationService(ProductRepository productRepository) {
|
||||
this.productRepository = productRepository;
|
||||
}
|
||||
|
||||
public List<CategoryMappingRecommendationDto> listRecommendations() {
|
||||
var groups = productRepository.findUnmappedCategoryGroups();
|
||||
|
||||
return groups.stream()
|
||||
.map(row -> {
|
||||
String merchantName = (String) row.get("merchantName");
|
||||
String rawCategoryKey = (String) row.get("rawCategoryKey");
|
||||
long count = (long) row.get("productCount");
|
||||
|
||||
// Pull one sample product name
|
||||
List<Product> examples = productRepository
|
||||
.findExamplesForCategoryGroup(merchantName, rawCategoryKey);
|
||||
String sampleName = examples.isEmpty()
|
||||
? null
|
||||
: examples.get(0).getName();
|
||||
|
||||
String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName);
|
||||
|
||||
return new CategoryMappingRecommendationDto(
|
||||
merchantName,
|
||||
rawCategoryKey,
|
||||
count,
|
||||
recommendedRole,
|
||||
sampleName
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String inferPartRoleFromRawKey(String rawKey, String sampleName) {
|
||||
String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase();
|
||||
|
||||
if (blob.contains("handguard") || blob.contains("rail")) return "handguard";
|
||||
if (blob.contains("barrel")) return "barrel";
|
||||
if (blob.contains("upper")) return "upper-receiver";
|
||||
if (blob.contains("lower")) return "lower-receiver";
|
||||
if (blob.contains("mag") || blob.contains("magazine")) return "magazine";
|
||||
if (blob.contains("stock") || blob.contains("buttstock")) return "stock";
|
||||
if (blob.contains("grip")) return "grip";
|
||||
if (blob.contains("trigger")) return "trigger";
|
||||
if (blob.contains("sight") || blob.contains("iron sights")) return "sights";
|
||||
if (blob.contains("optic") || blob.contains("scope") || blob.contains("red dot")) return "optic";
|
||||
if (blob.contains("buffer")) return "buffer";
|
||||
if (blob.contains("gas block")) return "gas-block";
|
||||
if (blob.contains("gas tube")) return "gas-tube";
|
||||
if (blob.contains("muzzle")) return "muzzle-device";
|
||||
if (blob.contains("sling")) return "sling";
|
||||
if (blob.contains("bipod")) return "bipod";
|
||||
if (blob.contains("tool")) return "tools";
|
||||
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,52 @@
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class CurrentUserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public CurrentUserService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
/** Returns the authenticated User (401 if missing/invalid). */
|
||||
public User requireUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
// No auth, or anonymous auth => 401
|
||||
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
|
||||
// In your setup, JwtAuthenticationFilter sets auth name to UUID string
|
||||
String principal = auth.getName();
|
||||
if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
|
||||
final UUID userUuid;
|
||||
try {
|
||||
userUuid = UUID.fromString(principal);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e);
|
||||
}
|
||||
|
||||
return userRepository.findByUuid(userUuid)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found"));
|
||||
}
|
||||
|
||||
public Integer requireUserId() {
|
||||
return requireUser().getId();
|
||||
}
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class CurrentUserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public CurrentUserService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
/** Returns the authenticated User (401 if missing/invalid). */
|
||||
public User requireUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
// No auth, or anonymous auth => 401
|
||||
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
|
||||
// In your setup, JwtAuthenticationFilter sets auth name to UUID string
|
||||
String principal = auth.getName();
|
||||
if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
|
||||
final UUID userUuid;
|
||||
try {
|
||||
userUuid = UUID.fromString(principal);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e);
|
||||
}
|
||||
|
||||
return userRepository.findByUuid(userUuid)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found"));
|
||||
}
|
||||
|
||||
public Integer requireUserId() {
|
||||
return requireUser().getId();
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,42 @@
|
||||
// src/main/java/group/goforward/ballistic/service/ImportStatusAdminService.java
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.ImportStatus;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.web.dto.ImportStatusByMerchantDto;
|
||||
import group.goforward.battlbuilder.web.dto.ImportStatusSummaryDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ImportStatusAdminService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
|
||||
public ImportStatusAdminService(ProductRepository productRepository) {
|
||||
this.productRepository = productRepository;
|
||||
}
|
||||
|
||||
public List<ImportStatusSummaryDto> summarizeByStatus() {
|
||||
return productRepository.aggregateByImportStatus()
|
||||
.stream()
|
||||
.map(row -> new ImportStatusSummaryDto(
|
||||
(ImportStatus) row.get("status"),
|
||||
(long) row.get("count")
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<ImportStatusByMerchantDto> summarizeByMerchant() {
|
||||
return productRepository.aggregateByMerchantAndStatus()
|
||||
.stream()
|
||||
.map(row -> new ImportStatusByMerchantDto(
|
||||
(String) row.get("merchantName"),
|
||||
(String) row.get("platform"),
|
||||
(ImportStatus) row.get("status"),
|
||||
(long) row.get("count")
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
// src/main/java/group/goforward/ballistic/service/ImportStatusAdminService.java
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.ImportStatus;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.web.dto.ImportStatusByMerchantDto;
|
||||
import group.goforward.battlbuilder.web.dto.ImportStatusSummaryDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ImportStatusAdminService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
|
||||
public ImportStatusAdminService(ProductRepository productRepository) {
|
||||
this.productRepository = productRepository;
|
||||
}
|
||||
|
||||
public List<ImportStatusSummaryDto> summarizeByStatus() {
|
||||
return productRepository.aggregateByImportStatus()
|
||||
.stream()
|
||||
.map(row -> new ImportStatusSummaryDto(
|
||||
(ImportStatus) row.get("status"),
|
||||
(long) row.get("count")
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<ImportStatusByMerchantDto> summarizeByMerchant() {
|
||||
return productRepository.aggregateByMerchantAndStatus()
|
||||
.stream()
|
||||
.map(row -> new ImportStatusByMerchantDto(
|
||||
(String) row.get("merchantName"),
|
||||
(String) row.get("platform"),
|
||||
(ImportStatus) row.get("status"),
|
||||
(long) row.get("count")
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -1,228 +1,228 @@
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.ImportStatus;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||
import group.goforward.battlbuilder.model.CanonicalCategory;
|
||||
import group.goforward.battlbuilder.repo.CanonicalCategoryRepository;
|
||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||
import group.goforward.battlbuilder.repo.MerchantRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.web.dto.MappingOptionsDto;
|
||||
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
|
||||
import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class MappingAdminService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||
private final MerchantRepository merchantRepository;
|
||||
private final CanonicalCategoryRepository canonicalCategoryRepository;
|
||||
private final ReclassificationService reclassificationService;
|
||||
|
||||
public MappingAdminService(
|
||||
ProductRepository productRepository,
|
||||
MerchantCategoryMapRepository merchantCategoryMapRepository,
|
||||
MerchantRepository merchantRepository,
|
||||
CanonicalCategoryRepository canonicalCategoryRepository,
|
||||
ReclassificationService reclassificationService
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||
this.merchantRepository = merchantRepository;
|
||||
this.canonicalCategoryRepository = canonicalCategoryRepository;
|
||||
this.reclassificationService = reclassificationService;
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 1) EXISTING: Role buckets
|
||||
// =========================
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PendingMappingBucketDto> listPendingBuckets() {
|
||||
List<Object[]> rows =
|
||||
productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING);
|
||||
|
||||
return rows.stream()
|
||||
.map(row -> {
|
||||
Integer merchantId = (Integer) row[0];
|
||||
String merchantName = (String) row[1];
|
||||
String rawCategoryKey = (String) row[2];
|
||||
Long count = (Long) row[3];
|
||||
|
||||
return new PendingMappingBucketDto(
|
||||
merchantId,
|
||||
merchantName,
|
||||
rawCategoryKey,
|
||||
count != null ? count : 0L
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Part Role mapping:
|
||||
* Writes merchant_category_map.canonical_part_role and applies to products.
|
||||
*/
|
||||
@Transactional
|
||||
public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
|
||||
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()
|
||||
|| mappedPartRole == null || mappedPartRole.isBlank()) {
|
||||
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
|
||||
}
|
||||
|
||||
Merchant merchant = merchantRepository.findById(merchantId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||
|
||||
// NOTE: this creates a new row every time. If you want "upsert", use findBest() like we do below.
|
||||
MerchantCategoryMap mapping = new MerchantCategoryMap();
|
||||
mapping.setMerchant(merchant);
|
||||
mapping.setRawCategory(rawCategoryKey.trim());
|
||||
mapping.setEnabled(true);
|
||||
|
||||
// SOURCE OF TRUTH (builder slot mapping)
|
||||
mapping.setCanonicalPartRole(mappedPartRole.trim());
|
||||
|
||||
merchantCategoryMapRepository.save(mapping);
|
||||
|
||||
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||
if (rawCategoryKey == null || rawCategoryKey.isBlank())
|
||||
throw new IllegalArgumentException("rawCategoryKey is required");
|
||||
|
||||
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2) NEW: Options endpoint for Catalog UI
|
||||
// ==========================================
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MappingOptionsDto getOptions() {
|
||||
var merchants = merchantRepository.findAll().stream()
|
||||
.map(m -> new MappingOptionsDto.MerchantOptionDto(m.getId(), m.getName()))
|
||||
.toList();
|
||||
|
||||
var categories = canonicalCategoryRepository.findAllActive().stream()
|
||||
.map(c -> new MappingOptionsDto.CanonicalCategoryOptionDto(
|
||||
c.getId(),
|
||||
c.getName(),
|
||||
c.getSlug()
|
||||
))
|
||||
.toList();
|
||||
|
||||
return new MappingOptionsDto(merchants, categories);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 3) NEW: Raw categories list for Catalog mapping table
|
||||
// =====================================================
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<RawCategoryMappingRowDto> listRawCategories(Integer merchantId, String platform, String q, Integer limit) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||
|
||||
String plat = normalizePlatform(platform);
|
||||
String query = (q == null || q.isBlank()) ? null : q.trim();
|
||||
int lim = (limit == null || limit <= 0) ? 500 : Math.min(limit, 2000);
|
||||
|
||||
List<Object[]> rows = productRepository.findRawCategoryMappingRows(merchantId, plat, query, lim);
|
||||
|
||||
return rows.stream().map(r -> new RawCategoryMappingRowDto(
|
||||
(Integer) r[0], // merchantId
|
||||
(String) r[1], // merchantName
|
||||
(String) r[2], // platform
|
||||
(String) r[3], // rawCategoryKey
|
||||
((Number) r[4]).longValue(), // productCount
|
||||
(r[5] == null ? null : ((Number) r[5]).longValue()), // mcmId
|
||||
(Boolean) r[6], // enabled
|
||||
(String) r[7], // canonicalPartRole
|
||||
|
||||
// IMPORTANT: canonicalCategoryId should be Integer, not Long.
|
||||
(r[8] == null ? null : ((Number) r[8]).intValue()), // canonicalCategoryId (Integer)
|
||||
(String) r[9] // canonicalCategoryName
|
||||
)).toList();
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 4) NEW: Upsert catalog mapping
|
||||
// ==========================================================
|
||||
|
||||
public record UpsertCatalogMappingResult(Integer merchantCategoryMapId, int updatedProducts) {}
|
||||
|
||||
@Transactional
|
||||
public UpsertCatalogMappingResult upsertCatalogMapping(
|
||||
Integer merchantId,
|
||||
String platform,
|
||||
String rawCategory,
|
||||
Boolean enabled,
|
||||
Integer canonicalCategoryId // <-- Integer (NOT Long)
|
||||
) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||
if (rawCategory == null || rawCategory.isBlank()) throw new IllegalArgumentException("rawCategory is required");
|
||||
|
||||
String plat = normalizePlatform(platform);
|
||||
String raw = rawCategory.trim();
|
||||
boolean en = (enabled == null) ? true : enabled;
|
||||
|
||||
Merchant merchant = merchantRepository.findById(merchantId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||
|
||||
CanonicalCategory cat = null;
|
||||
if (canonicalCategoryId != null) {
|
||||
cat = canonicalCategoryRepository.findById(canonicalCategoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"CanonicalCategory not found: " + canonicalCategoryId
|
||||
));
|
||||
}
|
||||
|
||||
// Find mapping row (platform-specific first; then ANY/null via your findBest ordering)
|
||||
Optional<MerchantCategoryMap> existing = merchantCategoryMapRepository.findBest(merchantId, raw, plat);
|
||||
MerchantCategoryMap mcm = existing.orElseGet(MerchantCategoryMap::new);
|
||||
|
||||
// Always ensure required fields are set
|
||||
mcm.setMerchant(merchant);
|
||||
mcm.setRawCategory(raw);
|
||||
mcm.setPlatform(plat);
|
||||
mcm.setEnabled(en);
|
||||
|
||||
// Catalog mapping fields (FK + legacy mirror)
|
||||
mcm.setCanonicalCategory(cat); // FK (preferred)
|
||||
mcm.setCanonicalCategoryText(cat == null ? null : cat.getName()); // legacy mirror
|
||||
|
||||
// IMPORTANT: DO NOT clobber canonicalPartRole here
|
||||
|
||||
merchantCategoryMapRepository.save(mcm);
|
||||
|
||||
// Push category FK to products
|
||||
int updated = reclassificationService.applyCatalogCategoryMappingToProducts(
|
||||
merchantId,
|
||||
raw,
|
||||
canonicalCategoryId // can be null to clear
|
||||
);
|
||||
|
||||
return new UpsertCatalogMappingResult(mcm.getId(), updated);
|
||||
}
|
||||
|
||||
// -----------------
|
||||
// Helpers
|
||||
// -----------------
|
||||
|
||||
private String normalizePlatform(String p) {
|
||||
if (p == null) return null;
|
||||
String t = p.trim();
|
||||
if (t.isEmpty()) return null;
|
||||
return t.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.ImportStatus;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||
import group.goforward.battlbuilder.model.CanonicalCategory;
|
||||
import group.goforward.battlbuilder.repo.CanonicalCategoryRepository;
|
||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||
import group.goforward.battlbuilder.repo.MerchantRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.web.dto.MappingOptionsDto;
|
||||
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
|
||||
import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class MappingAdminService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||
private final MerchantRepository merchantRepository;
|
||||
private final CanonicalCategoryRepository canonicalCategoryRepository;
|
||||
private final ReclassificationService reclassificationService;
|
||||
|
||||
public MappingAdminService(
|
||||
ProductRepository productRepository,
|
||||
MerchantCategoryMapRepository merchantCategoryMapRepository,
|
||||
MerchantRepository merchantRepository,
|
||||
CanonicalCategoryRepository canonicalCategoryRepository,
|
||||
ReclassificationService reclassificationService
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||
this.merchantRepository = merchantRepository;
|
||||
this.canonicalCategoryRepository = canonicalCategoryRepository;
|
||||
this.reclassificationService = reclassificationService;
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 1) EXISTING: Role buckets
|
||||
// =========================
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PendingMappingBucketDto> listPendingBuckets() {
|
||||
List<Object[]> rows =
|
||||
productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING);
|
||||
|
||||
return rows.stream()
|
||||
.map(row -> {
|
||||
Integer merchantId = (Integer) row[0];
|
||||
String merchantName = (String) row[1];
|
||||
String rawCategoryKey = (String) row[2];
|
||||
Long count = (Long) row[3];
|
||||
|
||||
return new PendingMappingBucketDto(
|
||||
merchantId,
|
||||
merchantName,
|
||||
rawCategoryKey,
|
||||
count != null ? count : 0L
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Part Role mapping:
|
||||
* Writes merchant_category_map.canonical_part_role and applies to products.
|
||||
*/
|
||||
@Transactional
|
||||
public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
|
||||
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()
|
||||
|| mappedPartRole == null || mappedPartRole.isBlank()) {
|
||||
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
|
||||
}
|
||||
|
||||
Merchant merchant = merchantRepository.findById(merchantId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||
|
||||
// NOTE: this creates a new row every time. If you want "upsert", use findBest() like we do below.
|
||||
MerchantCategoryMap mapping = new MerchantCategoryMap();
|
||||
mapping.setMerchant(merchant);
|
||||
mapping.setRawCategory(rawCategoryKey.trim());
|
||||
mapping.setEnabled(true);
|
||||
|
||||
// SOURCE OF TRUTH (builder slot mapping)
|
||||
mapping.setCanonicalPartRole(mappedPartRole.trim());
|
||||
|
||||
merchantCategoryMapRepository.save(mapping);
|
||||
|
||||
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||
if (rawCategoryKey == null || rawCategoryKey.isBlank())
|
||||
throw new IllegalArgumentException("rawCategoryKey is required");
|
||||
|
||||
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2) NEW: Options endpoint for Catalog UI
|
||||
// ==========================================
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MappingOptionsDto getOptions() {
|
||||
var merchants = merchantRepository.findAll().stream()
|
||||
.map(m -> new MappingOptionsDto.MerchantOptionDto(m.getId(), m.getName()))
|
||||
.toList();
|
||||
|
||||
var categories = canonicalCategoryRepository.findAllActive().stream()
|
||||
.map(c -> new MappingOptionsDto.CanonicalCategoryOptionDto(
|
||||
c.getId(),
|
||||
c.getName(),
|
||||
c.getSlug()
|
||||
))
|
||||
.toList();
|
||||
|
||||
return new MappingOptionsDto(merchants, categories);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 3) NEW: Raw categories list for Catalog mapping table
|
||||
// =====================================================
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<RawCategoryMappingRowDto> listRawCategories(Integer merchantId, String platform, String q, Integer limit) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||
|
||||
String plat = normalizePlatform(platform);
|
||||
String query = (q == null || q.isBlank()) ? null : q.trim();
|
||||
int lim = (limit == null || limit <= 0) ? 500 : Math.min(limit, 2000);
|
||||
|
||||
List<Object[]> rows = productRepository.findRawCategoryMappingRows(merchantId, plat, query, lim);
|
||||
|
||||
return rows.stream().map(r -> new RawCategoryMappingRowDto(
|
||||
(Integer) r[0], // merchantId
|
||||
(String) r[1], // merchantName
|
||||
(String) r[2], // platform
|
||||
(String) r[3], // rawCategoryKey
|
||||
((Number) r[4]).longValue(), // productCount
|
||||
(r[5] == null ? null : ((Number) r[5]).longValue()), // mcmId
|
||||
(Boolean) r[6], // enabled
|
||||
(String) r[7], // canonicalPartRole
|
||||
|
||||
// IMPORTANT: canonicalCategoryId should be Integer, not Long.
|
||||
(r[8] == null ? null : ((Number) r[8]).intValue()), // canonicalCategoryId (Integer)
|
||||
(String) r[9] // canonicalCategoryName
|
||||
)).toList();
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 4) NEW: Upsert catalog mapping
|
||||
// ==========================================================
|
||||
|
||||
public record UpsertCatalogMappingResult(Integer merchantCategoryMapId, int updatedProducts) {}
|
||||
|
||||
@Transactional
|
||||
public UpsertCatalogMappingResult upsertCatalogMapping(
|
||||
Integer merchantId,
|
||||
String platform,
|
||||
String rawCategory,
|
||||
Boolean enabled,
|
||||
Integer canonicalCategoryId // <-- Integer (NOT Long)
|
||||
) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||
if (rawCategory == null || rawCategory.isBlank()) throw new IllegalArgumentException("rawCategory is required");
|
||||
|
||||
String plat = normalizePlatform(platform);
|
||||
String raw = rawCategory.trim();
|
||||
boolean en = (enabled == null) ? true : enabled;
|
||||
|
||||
Merchant merchant = merchantRepository.findById(merchantId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||
|
||||
CanonicalCategory cat = null;
|
||||
if (canonicalCategoryId != null) {
|
||||
cat = canonicalCategoryRepository.findById(canonicalCategoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"CanonicalCategory not found: " + canonicalCategoryId
|
||||
));
|
||||
}
|
||||
|
||||
// Find mapping row (platform-specific first; then ANY/null via your findBest ordering)
|
||||
Optional<MerchantCategoryMap> existing = merchantCategoryMapRepository.findBest(merchantId, raw, plat);
|
||||
MerchantCategoryMap mcm = existing.orElseGet(MerchantCategoryMap::new);
|
||||
|
||||
// Always ensure required fields are set
|
||||
mcm.setMerchant(merchant);
|
||||
mcm.setRawCategory(raw);
|
||||
mcm.setPlatform(plat);
|
||||
mcm.setEnabled(en);
|
||||
|
||||
// Catalog mapping fields (FK + legacy mirror)
|
||||
mcm.setCanonicalCategory(cat); // FK (preferred)
|
||||
mcm.setCanonicalCategoryText(cat == null ? null : cat.getName()); // legacy mirror
|
||||
|
||||
// IMPORTANT: DO NOT clobber canonicalPartRole here
|
||||
|
||||
merchantCategoryMapRepository.save(mcm);
|
||||
|
||||
// Push category FK to products
|
||||
int updated = reclassificationService.applyCatalogCategoryMappingToProducts(
|
||||
merchantId,
|
||||
raw,
|
||||
canonicalCategoryId // can be null to clear
|
||||
);
|
||||
|
||||
return new UpsertCatalogMappingResult(mcm.getId(), updated);
|
||||
}
|
||||
|
||||
// -----------------
|
||||
// Helpers
|
||||
// -----------------
|
||||
|
||||
private String normalizePlatform(String p) {
|
||||
if (p == null) return null;
|
||||
String t = p.trim();
|
||||
if (t.isEmpty()) return null;
|
||||
return t.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
public interface MerchantFeedImportService {
|
||||
|
||||
/**
|
||||
* Full product + offer import for a given merchant.
|
||||
*/
|
||||
void importMerchantFeed(Integer merchantId);
|
||||
|
||||
/**
|
||||
* Offers-only sync (price / stock) for a given merchant.
|
||||
*/
|
||||
void syncOffersOnly(Integer merchantId);
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
public interface MerchantFeedImportService {
|
||||
|
||||
/**
|
||||
* Full product + offer import for a given merchant.
|
||||
*/
|
||||
void importMerchantFeed(Integer merchantId);
|
||||
|
||||
/**
|
||||
* Offers-only sync (price / stock) for a given merchant.
|
||||
*/
|
||||
void syncOffersOnly(Integer merchantId);
|
||||
}
|
||||
@@ -1,35 +1,35 @@
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartCategory;
|
||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class PartCategoryResolverService {
|
||||
|
||||
private final PartRoleMappingRepository partRoleMappingRepository;
|
||||
|
||||
public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) {
|
||||
this.partRoleMappingRepository = partRoleMappingRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PartCategory for a given platform + partRole.
|
||||
* Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
|
||||
*/
|
||||
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
|
||||
if (platform == null || partRole == null) return Optional.empty();
|
||||
|
||||
String p = platform.trim();
|
||||
String r = partRole.trim();
|
||||
|
||||
if (p.isEmpty() || r.isEmpty()) return Optional.empty();
|
||||
|
||||
return partRoleMappingRepository
|
||||
.findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r)
|
||||
.map(PartRoleMapping::getPartCategory);
|
||||
}
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartCategory;
|
||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class PartCategoryResolverService {
|
||||
|
||||
private final PartRoleMappingRepository partRoleMappingRepository;
|
||||
|
||||
public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) {
|
||||
this.partRoleMappingRepository = partRoleMappingRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PartCategory for a given platform + partRole.
|
||||
* Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
|
||||
*/
|
||||
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
|
||||
if (platform == null || partRole == null) return Optional.empty();
|
||||
|
||||
String p = platform.trim();
|
||||
String r = partRole.trim();
|
||||
|
||||
if (p.isEmpty() || r.isEmpty()) return Optional.empty();
|
||||
|
||||
return partRoleMappingRepository
|
||||
.findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r)
|
||||
.map(PartRoleMapping::getPartCategory);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,35 @@
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
|
||||
import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
|
||||
import group.goforward.battlbuilder.web.mapper.PartRoleMappingMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class PartRoleMappingService {
|
||||
|
||||
private final PartRoleMappingRepository repository;
|
||||
|
||||
public PartRoleMappingService(PartRoleMappingRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public List<PartRoleMappingDto> getMappingsForPlatform(String platform) {
|
||||
return repository
|
||||
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
|
||||
.stream()
|
||||
.map(PartRoleMappingMapper::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<PartRoleToCategoryDto> getRoleToCategoryMap(String platform) {
|
||||
return repository
|
||||
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
|
||||
.stream()
|
||||
.map(PartRoleMappingMapper::toRoleMapDto)
|
||||
.toList();
|
||||
}
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
|
||||
import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
|
||||
import group.goforward.battlbuilder.web.mapper.PartRoleMappingMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class PartRoleMappingService {
|
||||
|
||||
private final PartRoleMappingRepository repository;
|
||||
|
||||
public PartRoleMappingService(PartRoleMappingRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public List<PartRoleMappingDto> getMappingsForPlatform(String platform) {
|
||||
return repository
|
||||
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
|
||||
.stream()
|
||||
.map(PartRoleMappingMapper::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<PartRoleToCategoryDto> getRoleToCategoryMap(String platform) {
|
||||
return repository
|
||||
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
|
||||
.stream()
|
||||
.map(PartRoleMappingMapper::toRoleMapDto)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface ProductQueryService {
|
||||
|
||||
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
|
||||
|
||||
List<ProductOfferDto> getOffersForProduct(Integer productId);
|
||||
|
||||
ProductSummaryDto getProductById(Integer productId);
|
||||
|
||||
Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable);
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface ProductQueryService {
|
||||
|
||||
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
|
||||
|
||||
List<ProductOfferDto> getOffersForProduct(Integer productId);
|
||||
|
||||
ProductSummaryDto getProductById(Integer productId);
|
||||
|
||||
Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
public interface ReclassificationService {
|
||||
int reclassifyPendingForMerchant(Integer merchantId);
|
||||
|
||||
// Existing: apply canonical_part_role mapping to products
|
||||
int applyMappingToProducts(Integer merchantId, String rawCategoryKey);
|
||||
|
||||
// NEW: apply canonical_category_id mapping to products
|
||||
int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId);
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
public interface ReclassificationService {
|
||||
int reclassifyPendingForMerchant(Integer merchantId);
|
||||
|
||||
// Existing: apply canonical_part_role mapping to products
|
||||
int applyMappingToProducts(Integer merchantId, String rawCategoryKey);
|
||||
|
||||
// NEW: apply canonical_category_id mapping to products
|
||||
int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
package group.goforward.battlbuilder.service.admin;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
|
||||
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
|
||||
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface AdminProductService {
|
||||
|
||||
Page<ProductAdminRowDto> search(
|
||||
AdminProductSearchRequest request,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
int bulkUpdate(ProductBulkUpdateRequest request);
|
||||
package group.goforward.battlbuilder.service.admin;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
|
||||
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
|
||||
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface AdminProductService {
|
||||
|
||||
Page<ProductAdminRowDto> search(
|
||||
AdminProductSearchRequest request,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
int bulkUpdate(ProductBulkUpdateRequest request);
|
||||
}
|
||||
@@ -1,55 +1,55 @@
|
||||
package group.goforward.battlbuilder.service.admin;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminUserDto;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AdminUserService {
|
||||
|
||||
private static final Set<String> ALLOWED_ROLES = Set.of("USER", "ADMIN");
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public AdminUserService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
public List<AdminUserDto> getAllUsersForAdmin() {
|
||||
return userRepository.findAll()
|
||||
.stream()
|
||||
.map(AdminUserDto::fromUser)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserDto updateUserRole(UUID userUuid, String newRole, Authentication auth) {
|
||||
if (newRole == null || !ALLOWED_ROLES.contains(newRole.toUpperCase())) {
|
||||
throw new IllegalArgumentException("Invalid role: " + newRole);
|
||||
}
|
||||
|
||||
User user = userRepository.findByUuid(userUuid)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
// Optional safety: do not allow demoting yourself (you can loosen this later)
|
||||
String currentEmail = auth != null ? auth.getName() : null;
|
||||
boolean isSelf = currentEmail != null
|
||||
&& currentEmail.equalsIgnoreCase(user.getEmail());
|
||||
|
||||
if (isSelf && !"ADMIN".equalsIgnoreCase(newRole)) {
|
||||
throw new IllegalStateException("You cannot change your own role to non-admin.");
|
||||
}
|
||||
|
||||
user.setRole(newRole.toUpperCase());
|
||||
// updatedAt will be handled by your entity / DB defaults
|
||||
|
||||
return AdminUserDto.fromUser(user);
|
||||
}
|
||||
package group.goforward.battlbuilder.service.admin;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminUserDto;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AdminUserService {
|
||||
|
||||
private static final Set<String> ALLOWED_ROLES = Set.of("USER", "ADMIN");
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public AdminUserService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
public List<AdminUserDto> getAllUsersForAdmin() {
|
||||
return userRepository.findAll()
|
||||
.stream()
|
||||
.map(AdminUserDto::fromUser)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserDto updateUserRole(UUID userUuid, String newRole, Authentication auth) {
|
||||
if (newRole == null || !ALLOWED_ROLES.contains(newRole.toUpperCase())) {
|
||||
throw new IllegalArgumentException("Invalid role: " + newRole);
|
||||
}
|
||||
|
||||
User user = userRepository.findByUuid(userUuid)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
// Optional safety: do not allow demoting yourself (you can loosen this later)
|
||||
String currentEmail = auth != null ? auth.getName() : null;
|
||||
boolean isSelf = currentEmail != null
|
||||
&& currentEmail.equalsIgnoreCase(user.getEmail());
|
||||
|
||||
if (isSelf && !"ADMIN".equalsIgnoreCase(newRole)) {
|
||||
throw new IllegalStateException("You cannot change your own role to non-admin.");
|
||||
}
|
||||
|
||||
user.setRole(newRole.toUpperCase());
|
||||
// updatedAt will be handled by your entity / DB defaults
|
||||
|
||||
return AdminUserDto.fromUser(user);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
package group.goforward.battlbuilder.service.admin;
|
||||
|
||||
import group.goforward.battlbuilder.model.State;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface StatesService {
|
||||
|
||||
List<State> findAll();
|
||||
|
||||
Optional<State> findById(Integer id);
|
||||
|
||||
State save(State item);
|
||||
void deleteById(Integer id);
|
||||
}
|
||||
package group.goforward.battlbuilder.service.admin;
|
||||
|
||||
import group.goforward.battlbuilder.model.State;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface StatesService {
|
||||
|
||||
List<State> findAll();
|
||||
|
||||
Optional<State> findById(Integer id);
|
||||
|
||||
State save(State item);
|
||||
void deleteById(Integer id);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
package group.goforward.battlbuilder.service.admin;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UsersService {
|
||||
|
||||
List<User> findAll();
|
||||
|
||||
Optional<User> findById(Integer id);
|
||||
|
||||
User save(User item);
|
||||
void deleteById(Integer id);
|
||||
}
|
||||
package group.goforward.battlbuilder.service.admin;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UsersService {
|
||||
|
||||
List<User> findAll();
|
||||
|
||||
Optional<User> findById(Integer id);
|
||||
|
||||
User save(User item);
|
||||
void deleteById(Integer id);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Admin service package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains service classes for administrative business logic
|
||||
* and operations.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.service.admin;
|
||||
/**
|
||||
* Admin service package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains service classes for administrative business logic
|
||||
* and operations.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.service.admin;
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
package group.goforward.battlbuilder.service.admin.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.ImportStatus;
|
||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||
import group.goforward.battlbuilder.repo.MerchantRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class AdminDashboardService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final MerchantRepository merchantRepository;
|
||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||
|
||||
public AdminDashboardService(
|
||||
ProductRepository productRepository,
|
||||
MerchantRepository merchantRepository,
|
||||
MerchantCategoryMapRepository merchantCategoryMapRepository
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.merchantRepository = merchantRepository;
|
||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AdminDashboardOverviewDto getOverview() {
|
||||
long totalProducts = productRepository.count();
|
||||
long unmappedProducts = productRepository.countByImportStatus(ImportStatus.PENDING_MAPPING);
|
||||
long mappedProducts = totalProducts - unmappedProducts;
|
||||
|
||||
long merchantCount = merchantRepository.count();
|
||||
long categoryMappings = merchantCategoryMapRepository.count();
|
||||
|
||||
return new AdminDashboardOverviewDto(
|
||||
totalProducts,
|
||||
mappedProducts,
|
||||
unmappedProducts,
|
||||
merchantCount,
|
||||
categoryMappings
|
||||
);
|
||||
}
|
||||
package group.goforward.battlbuilder.service.admin.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.ImportStatus;
|
||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||
import group.goforward.battlbuilder.repo.MerchantRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class AdminDashboardService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final MerchantRepository merchantRepository;
|
||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||
|
||||
public AdminDashboardService(
|
||||
ProductRepository productRepository,
|
||||
MerchantRepository merchantRepository,
|
||||
MerchantCategoryMapRepository merchantCategoryMapRepository
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.merchantRepository = merchantRepository;
|
||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AdminDashboardOverviewDto getOverview() {
|
||||
long totalProducts = productRepository.count();
|
||||
long unmappedProducts = productRepository.countByImportStatus(ImportStatus.PENDING_MAPPING);
|
||||
long mappedProducts = totalProducts - unmappedProducts;
|
||||
|
||||
long merchantCount = merchantRepository.count();
|
||||
long categoryMappings = merchantCategoryMapRepository.count();
|
||||
|
||||
return new AdminDashboardOverviewDto(
|
||||
totalProducts,
|
||||
mappedProducts,
|
||||
unmappedProducts,
|
||||
merchantCount,
|
||||
categoryMappings
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,65 @@
|
||||
package group.goforward.battlbuilder.service.admin.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.service.admin.AdminProductService;
|
||||
import group.goforward.battlbuilder.specs.ProductSpecifications;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
|
||||
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
|
||||
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class AdminProductServiceImpl implements AdminProductService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
|
||||
public AdminProductServiceImpl(ProductRepository productRepository) {
|
||||
this.productRepository = productRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ProductAdminRowDto> search(
|
||||
AdminProductSearchRequest request,
|
||||
Pageable pageable
|
||||
) {
|
||||
Specification<Product> spec =
|
||||
ProductSpecifications.adminSearch(request);
|
||||
|
||||
return productRepository
|
||||
.findAll(spec, pageable)
|
||||
.map(ProductAdminRowDto::fromEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int bulkUpdate(ProductBulkUpdateRequest request) {
|
||||
var products = productRepository.findAllById(request.getProductIds());
|
||||
|
||||
products.forEach(p -> {
|
||||
if (request.getVisibility() != null) {
|
||||
p.setVisibility(request.getVisibility());
|
||||
}
|
||||
if (request.getStatus() != null) {
|
||||
p.setStatus(request.getStatus());
|
||||
}
|
||||
if (request.getBuilderEligible() != null) {
|
||||
p.setBuilderEligible(request.getBuilderEligible());
|
||||
}
|
||||
if (request.getAdminLocked() != null) {
|
||||
p.setAdminLocked(request.getAdminLocked());
|
||||
}
|
||||
if (request.getAdminNote() != null) {
|
||||
p.setAdminNote(request.getAdminNote());
|
||||
}
|
||||
});
|
||||
|
||||
productRepository.saveAll(products);
|
||||
return products.size();
|
||||
}
|
||||
package group.goforward.battlbuilder.service.admin.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.service.admin.AdminProductService;
|
||||
import group.goforward.battlbuilder.specs.ProductSpecifications;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
|
||||
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
|
||||
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class AdminProductServiceImpl implements AdminProductService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
|
||||
public AdminProductServiceImpl(ProductRepository productRepository) {
|
||||
this.productRepository = productRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ProductAdminRowDto> search(
|
||||
AdminProductSearchRequest request,
|
||||
Pageable pageable
|
||||
) {
|
||||
Specification<Product> spec =
|
||||
ProductSpecifications.adminSearch(request);
|
||||
|
||||
return productRepository
|
||||
.findAll(spec, pageable)
|
||||
.map(ProductAdminRowDto::fromEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int bulkUpdate(ProductBulkUpdateRequest request) {
|
||||
var products = productRepository.findAllById(request.getProductIds());
|
||||
|
||||
products.forEach(p -> {
|
||||
if (request.getVisibility() != null) {
|
||||
p.setVisibility(request.getVisibility());
|
||||
}
|
||||
if (request.getStatus() != null) {
|
||||
p.setStatus(request.getStatus());
|
||||
}
|
||||
if (request.getBuilderEligible() != null) {
|
||||
p.setBuilderEligible(request.getBuilderEligible());
|
||||
}
|
||||
if (request.getAdminLocked() != null) {
|
||||
p.setAdminLocked(request.getAdminLocked());
|
||||
}
|
||||
if (request.getAdminNote() != null) {
|
||||
p.setAdminNote(request.getAdminNote());
|
||||
}
|
||||
});
|
||||
|
||||
productRepository.saveAll(products);
|
||||
return products.size();
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,38 @@
|
||||
package group.goforward.battlbuilder.service.admin.impl;
|
||||
|
||||
|
||||
import group.goforward.battlbuilder.model.State;
|
||||
import group.goforward.battlbuilder.repo.StateRepository;
|
||||
import group.goforward.battlbuilder.service.admin.StatesService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class StatesServiceImpl implements StatesService {
|
||||
|
||||
@Autowired
|
||||
private StateRepository repo;
|
||||
|
||||
@Override
|
||||
public List<State> findAll() {
|
||||
return repo.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<State> findById(Integer id) {
|
||||
return repo.findById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public State save(State item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(Integer id) {
|
||||
deleteById(id);
|
||||
}
|
||||
}
|
||||
package group.goforward.battlbuilder.service.admin.impl;
|
||||
|
||||
|
||||
import group.goforward.battlbuilder.model.State;
|
||||
import group.goforward.battlbuilder.repo.StateRepository;
|
||||
import group.goforward.battlbuilder.service.admin.StatesService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class StatesServiceImpl implements StatesService {
|
||||
|
||||
@Autowired
|
||||
private StateRepository repo;
|
||||
|
||||
@Override
|
||||
public List<State> findAll() {
|
||||
return repo.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<State> findById(Integer id) {
|
||||
return repo.findById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public State save(State item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(Integer id) {
|
||||
deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
package group.goforward.battlbuilder.service.admin.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import group.goforward.battlbuilder.service.admin.UsersService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class UsersServiceImpl implements UsersService {
|
||||
|
||||
@Autowired
|
||||
private UserRepository repo;
|
||||
|
||||
@Override
|
||||
public List<User> findAll() {
|
||||
return repo.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<User> findById(Integer id) {
|
||||
return repo.findById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public User save(User item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(Integer id) {
|
||||
deleteById(id);
|
||||
}
|
||||
}
|
||||
package group.goforward.battlbuilder.service.admin.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import group.goforward.battlbuilder.service.admin.UsersService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class UsersServiceImpl implements UsersService {
|
||||
|
||||
@Autowired
|
||||
private UserRepository repo;
|
||||
|
||||
@Override
|
||||
public List<User> findAll() {
|
||||
return repo.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<User> findById(Integer id) {
|
||||
return repo.findById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public User save(User item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(Integer id) {
|
||||
deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Provides the classes necessary for the Spring Services implementations for the Battl.Builder application.
|
||||
* This package includes Services implementations for Spring-Boot application
|
||||
*
|
||||
*
|
||||
* <p>The main entry point for managing the inventory is the
|
||||
* {@link group.goforward.battlbuilder.BattlBuilderApplication} class.</p>
|
||||
*
|
||||
* @since 1.0
|
||||
* @author Don Strawsburg
|
||||
* @version 1.1
|
||||
*/
|
||||
/**
|
||||
* Provides the classes necessary for the Spring Services implementations for the Battl.Builder application.
|
||||
* This package includes Services implementations for Spring-Boot application
|
||||
*
|
||||
*
|
||||
* <p>The main entry point for managing the inventory is the
|
||||
* {@link group.goforward.battlbuilder.BattlBuilderApplication} class.</p>
|
||||
*
|
||||
* @since 1.0
|
||||
* @author Don Strawsburg
|
||||
* @version 1.1
|
||||
*/
|
||||
package group.goforward.battlbuilder.service.admin;
|
||||
@@ -1,29 +1,29 @@
|
||||
package group.goforward.battlbuilder.service.auth;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||
|
||||
public interface BetaAuthService {
|
||||
|
||||
/**
|
||||
* Upsert a beta signup lead and send a confirmation email with a verify token/link.
|
||||
* Should NOT throw to the caller for common cases (e.g. already exists).
|
||||
*/
|
||||
void signup(String email, String useCase);
|
||||
|
||||
/**
|
||||
* Exchanges a "confirm" token for a real JWT session.
|
||||
* This confirms the email (one-time) AND logs the user in immediately.
|
||||
*/
|
||||
AuthResponse confirmAndExchange(String token);
|
||||
|
||||
/**
|
||||
* Exchanges a "magic link" token for a real JWT session.
|
||||
* Used for returning users ("email me a sign-in link").
|
||||
*/
|
||||
AuthResponse exchangeMagicToken(String token);
|
||||
|
||||
void sendPasswordReset(String email);
|
||||
void resetPassword(String token, String newPassword);
|
||||
void sendMagicLoginLink(String email);
|
||||
|
||||
package group.goforward.battlbuilder.service.auth;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||
|
||||
public interface BetaAuthService {
|
||||
|
||||
/**
|
||||
* Upsert a beta signup lead and send a confirmation email with a verify token/link.
|
||||
* Should NOT throw to the caller for common cases (e.g. already exists).
|
||||
*/
|
||||
void signup(String email, String useCase);
|
||||
|
||||
/**
|
||||
* Exchanges a "confirm" token for a real JWT session.
|
||||
* This confirms the email (one-time) AND logs the user in immediately.
|
||||
*/
|
||||
AuthResponse confirmAndExchange(String token);
|
||||
|
||||
/**
|
||||
* Exchanges a "magic link" token for a real JWT session.
|
||||
* Used for returning users ("email me a sign-in link").
|
||||
*/
|
||||
AuthResponse exchangeMagicToken(String token);
|
||||
|
||||
void sendPasswordReset(String email);
|
||||
void resetPassword(String token, String newPassword);
|
||||
void sendMagicLoginLink(String email);
|
||||
|
||||
}
|
||||
@@ -1,321 +1,321 @@
|
||||
package group.goforward.battlbuilder.service.auth.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.AuthToken;
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.AuthTokenRepository;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import group.goforward.battlbuilder.security.JwtService;
|
||||
import group.goforward.battlbuilder.service.auth.BetaAuthService;
|
||||
import group.goforward.battlbuilder.service.utils.EmailService;
|
||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
|
||||
private final AuthTokenRepository tokens;
|
||||
private final UserRepository users;
|
||||
private final JwtService jwtService;
|
||||
private final EmailService emailService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Value("${app.publicBaseUrl:http://localhost:3000}")
|
||||
private String publicBaseUrl;
|
||||
|
||||
@Value("${app.authTokenPepper:change-me}")
|
||||
private String tokenPepper;
|
||||
|
||||
/**
|
||||
* When true:
|
||||
* - Signup captures users (role=BETA, inactive)
|
||||
* - NO tokens are generated
|
||||
* - NO emails are sent
|
||||
*/
|
||||
@Value("${app.beta.captureOnly:true}")
|
||||
private boolean betaCaptureOnly;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public BetaAuthServiceImpl(
|
||||
AuthTokenRepository tokens,
|
||||
UserRepository users,
|
||||
JwtService jwtService,
|
||||
EmailService emailService,
|
||||
PasswordEncoder passwordEncoder
|
||||
) {
|
||||
this.tokens = tokens;
|
||||
this.users = users;
|
||||
this.jwtService = jwtService;
|
||||
this.emailService = emailService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* A: Beta signup (capture lead + optionally email confirm+login token).
|
||||
* The Next page will call /api/auth/beta/confirm and receive AuthResponse.
|
||||
*/
|
||||
@Override
|
||||
public void signup(String rawEmail, String useCase) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
|
||||
// ✅ Create or update a "beta lead" user record
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||
|
||||
if (user == null) {
|
||||
user = new User();
|
||||
user.setUuid(UUID.randomUUID());
|
||||
user.setEmail(email);
|
||||
|
||||
// Treat beta signups as users, but not active / not verified yet
|
||||
user.setRole("BETA");
|
||||
user.setActive(false);
|
||||
user.setDisplayName(null);
|
||||
|
||||
user.setCreatedAt(OffsetDateTime.now());
|
||||
}
|
||||
|
||||
// Optional: stash useCase somewhere if desired
|
||||
// user.setPreferences(mergeUseCase(user.getPreferences(), useCase));
|
||||
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
// 🚫 Capture-only mode: do not create tokens, do not send email
|
||||
if (betaCaptureOnly) return;
|
||||
|
||||
// --- Invite mode (later) ---
|
||||
// 24h confirm token
|
||||
String verifyToken = generateToken();
|
||||
saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24));
|
||||
|
||||
String confirmUrl = publicBaseUrl + "/beta/confirm?token=" + verifyToken;
|
||||
|
||||
String subject = "Your Battl Builders sign-in link";
|
||||
String body = """
|
||||
You're on the list.
|
||||
|
||||
Sign in (and confirm your email) here:
|
||||
%s
|
||||
|
||||
If you didn’t request this, you can ignore this email.
|
||||
""".formatted(confirmUrl);
|
||||
|
||||
emailService.sendEmail(email, subject, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* B: Existing users only — request a magic login link (no signup/confirm).
|
||||
* Caller must always return OK to avoid email enumeration.
|
||||
*/
|
||||
@Override
|
||||
public void sendMagicLoginLink(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
|
||||
// Only send if user exists (but do NOT reveal that)
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||
if (user == null) return;
|
||||
|
||||
boolean isBeta = "BETA".equalsIgnoreCase(user.getRole());
|
||||
|
||||
// If capture-only mode is enabled, do not generate tokens or send email
|
||||
if (betaCaptureOnly) return;
|
||||
|
||||
// Allow magic link requests for:
|
||||
// - active USERs, OR
|
||||
// - BETA users (even if inactive), since they may not be activated yet
|
||||
if (!Boolean.TRUE.equals(user.isActive()) && !isBeta) return;
|
||||
|
||||
// Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired)
|
||||
if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return;
|
||||
|
||||
// 30 minute magic token
|
||||
String magicToken = generateToken();
|
||||
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(30));
|
||||
|
||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
||||
|
||||
String subject = "Your Battl Builders sign-in link";
|
||||
String body = """
|
||||
Here’s your secure sign-in link (expires in 30 minutes):
|
||||
%s
|
||||
|
||||
If you didn’t request this, you can ignore this email.
|
||||
""".formatted(magicUrl);
|
||||
|
||||
emailService.sendEmail(email, subject, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately.
|
||||
*/
|
||||
@Override
|
||||
public AuthResponse confirmAndExchange(String token) {
|
||||
AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token);
|
||||
String email = authToken.getEmail();
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
|
||||
if (user == null) {
|
||||
user = new User();
|
||||
user.setUuid(UUID.randomUUID());
|
||||
user.setEmail(email);
|
||||
user.setDisplayName(null);
|
||||
user.setRole("USER");
|
||||
user.setActive(true);
|
||||
user.setCreatedAt(now);
|
||||
} else {
|
||||
// Promote BETA -> USER on first successful confirm
|
||||
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
||||
user.setRole("USER");
|
||||
}
|
||||
user.setActive(true);
|
||||
}
|
||||
|
||||
user.setLastLoginAt(now);
|
||||
user.incrementLoginCount();
|
||||
user.setUpdatedAt(now);
|
||||
users.save(user);
|
||||
|
||||
String jwt = jwtService.generateToken(user);
|
||||
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes MAGIC_LOGIN token and returns JWT (returning users).
|
||||
* Also promotes BETA->USER and activates the account on first successful login.
|
||||
*/
|
||||
@Override
|
||||
public AuthResponse exchangeMagicToken(String token) {
|
||||
AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token);
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
|
||||
.orElseThrow(() -> new IllegalStateException("User not found for magic token"));
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
|
||||
// Promote/activate beta users on first successful magic login
|
||||
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
||||
user.setRole("USER");
|
||||
}
|
||||
if (!Boolean.TRUE.equals(user.isActive())) {
|
||||
user.setActive(true);
|
||||
}
|
||||
|
||||
user.setLastLoginAt(now);
|
||||
user.incrementLoginCount();
|
||||
user.setUpdatedAt(now);
|
||||
users.save(user);
|
||||
|
||||
String jwt = jwtService.generateToken(user);
|
||||
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Password Reset
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void sendPasswordReset(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||
if (user == null) return;
|
||||
|
||||
// If capture-only mode is enabled, do not generate tokens or send email
|
||||
if (betaCaptureOnly) return;
|
||||
|
||||
String resetToken = generateToken();
|
||||
saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30));
|
||||
|
||||
String resetUrl = publicBaseUrl + "/reset-password?token=" + resetToken;
|
||||
|
||||
String subject = "Reset your Battl Builders password";
|
||||
String body = """
|
||||
Reset your password using this link (expires in 30 minutes):
|
||||
%s
|
||||
|
||||
If you didn’t request this, you can ignore this email.
|
||||
""".formatted(resetUrl);
|
||||
|
||||
emailService.sendEmail(email, subject, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetPassword(String token, String newPassword) {
|
||||
if (newPassword == null || newPassword.trim().length() < 8) {
|
||||
throw new IllegalArgumentException("Password must be at least 8 characters");
|
||||
}
|
||||
|
||||
AuthToken t = consumeToken(AuthToken.TokenType.PASSWORD_RESET, token);
|
||||
String email = t.getEmail();
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword.trim()));
|
||||
user.setPasswordSetAt(OffsetDateTime.now());
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) {
|
||||
AuthToken t = new AuthToken();
|
||||
t.setEmail(email);
|
||||
t.setType(type);
|
||||
t.setTokenHash(hashToken(token));
|
||||
t.setExpiresAt(expiresAt);
|
||||
t.setCreatedAt(OffsetDateTime.now());
|
||||
tokens.save(t);
|
||||
}
|
||||
|
||||
private AuthToken consumeToken(AuthToken.TokenType type, String token) {
|
||||
String hash = hashToken(token);
|
||||
|
||||
AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid token"));
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
|
||||
if (t.isConsumed()) throw new IllegalArgumentException("Token already used");
|
||||
if (t.isExpired(now)) throw new IllegalArgumentException("Token expired");
|
||||
|
||||
t.setConsumedAt(now);
|
||||
tokens.save(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
private String normalizeEmail(String email) {
|
||||
if (email == null) throw new IllegalArgumentException("Email required");
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private String generateToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private String hashToken(String token) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hashed);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to hash token", e);
|
||||
}
|
||||
}
|
||||
package group.goforward.battlbuilder.service.auth.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.AuthToken;
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.AuthTokenRepository;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import group.goforward.battlbuilder.security.JwtService;
|
||||
import group.goforward.battlbuilder.service.auth.BetaAuthService;
|
||||
import group.goforward.battlbuilder.service.utils.EmailService;
|
||||
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
|
||||
private final AuthTokenRepository tokens;
|
||||
private final UserRepository users;
|
||||
private final JwtService jwtService;
|
||||
private final EmailService emailService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Value("${app.publicBaseUrl:http://localhost:3000}")
|
||||
private String publicBaseUrl;
|
||||
|
||||
@Value("${app.authTokenPepper:change-me}")
|
||||
private String tokenPepper;
|
||||
|
||||
/**
|
||||
* When true:
|
||||
* - Signup captures users (role=BETA, inactive)
|
||||
* - NO tokens are generated
|
||||
* - NO emails are sent
|
||||
*/
|
||||
@Value("${app.beta.captureOnly:true}")
|
||||
private boolean betaCaptureOnly;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public BetaAuthServiceImpl(
|
||||
AuthTokenRepository tokens,
|
||||
UserRepository users,
|
||||
JwtService jwtService,
|
||||
EmailService emailService,
|
||||
PasswordEncoder passwordEncoder
|
||||
) {
|
||||
this.tokens = tokens;
|
||||
this.users = users;
|
||||
this.jwtService = jwtService;
|
||||
this.emailService = emailService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* A: Beta signup (capture lead + optionally email confirm+login token).
|
||||
* The Next page will call /api/auth/beta/confirm and receive AuthResponse.
|
||||
*/
|
||||
@Override
|
||||
public void signup(String rawEmail, String useCase) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
|
||||
// ✅ Create or update a "beta lead" user record
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||
|
||||
if (user == null) {
|
||||
user = new User();
|
||||
user.setUuid(UUID.randomUUID());
|
||||
user.setEmail(email);
|
||||
|
||||
// Treat beta signups as users, but not active / not verified yet
|
||||
user.setRole("BETA");
|
||||
user.setActive(false);
|
||||
user.setDisplayName(null);
|
||||
|
||||
user.setCreatedAt(OffsetDateTime.now());
|
||||
}
|
||||
|
||||
// Optional: stash useCase somewhere if desired
|
||||
// user.setPreferences(mergeUseCase(user.getPreferences(), useCase));
|
||||
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
// 🚫 Capture-only mode: do not create tokens, do not send email
|
||||
if (betaCaptureOnly) return;
|
||||
|
||||
// --- Invite mode (later) ---
|
||||
// 24h confirm token
|
||||
String verifyToken = generateToken();
|
||||
saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24));
|
||||
|
||||
String confirmUrl = publicBaseUrl + "/beta/confirm?token=" + verifyToken;
|
||||
|
||||
String subject = "Your Battl Builders sign-in link";
|
||||
String body = """
|
||||
You're on the list.
|
||||
|
||||
Sign in (and confirm your email) here:
|
||||
%s
|
||||
|
||||
If you didn’t request this, you can ignore this email.
|
||||
""".formatted(confirmUrl);
|
||||
|
||||
emailService.sendEmail(email, subject, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* B: Existing users only — request a magic login link (no signup/confirm).
|
||||
* Caller must always return OK to avoid email enumeration.
|
||||
*/
|
||||
@Override
|
||||
public void sendMagicLoginLink(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
|
||||
// Only send if user exists (but do NOT reveal that)
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||
if (user == null) return;
|
||||
|
||||
boolean isBeta = "BETA".equalsIgnoreCase(user.getRole());
|
||||
|
||||
// If capture-only mode is enabled, do not generate tokens or send email
|
||||
if (betaCaptureOnly) return;
|
||||
|
||||
// Allow magic link requests for:
|
||||
// - active USERs, OR
|
||||
// - BETA users (even if inactive), since they may not be activated yet
|
||||
if (!Boolean.TRUE.equals(user.isActive()) && !isBeta) return;
|
||||
|
||||
// Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired)
|
||||
if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return;
|
||||
|
||||
// 30 minute magic token
|
||||
String magicToken = generateToken();
|
||||
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(30));
|
||||
|
||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
||||
|
||||
String subject = "Your Battl Builders sign-in link";
|
||||
String body = """
|
||||
Here’s your secure sign-in link (expires in 30 minutes):
|
||||
%s
|
||||
|
||||
If you didn’t request this, you can ignore this email.
|
||||
""".formatted(magicUrl);
|
||||
|
||||
emailService.sendEmail(email, subject, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately.
|
||||
*/
|
||||
@Override
|
||||
public AuthResponse confirmAndExchange(String token) {
|
||||
AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token);
|
||||
String email = authToken.getEmail();
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
|
||||
if (user == null) {
|
||||
user = new User();
|
||||
user.setUuid(UUID.randomUUID());
|
||||
user.setEmail(email);
|
||||
user.setDisplayName(null);
|
||||
user.setRole("USER");
|
||||
user.setActive(true);
|
||||
user.setCreatedAt(now);
|
||||
} else {
|
||||
// Promote BETA -> USER on first successful confirm
|
||||
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
||||
user.setRole("USER");
|
||||
}
|
||||
user.setActive(true);
|
||||
}
|
||||
|
||||
user.setLastLoginAt(now);
|
||||
user.incrementLoginCount();
|
||||
user.setUpdatedAt(now);
|
||||
users.save(user);
|
||||
|
||||
String jwt = jwtService.generateToken(user);
|
||||
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes MAGIC_LOGIN token and returns JWT (returning users).
|
||||
* Also promotes BETA->USER and activates the account on first successful login.
|
||||
*/
|
||||
@Override
|
||||
public AuthResponse exchangeMagicToken(String token) {
|
||||
AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token);
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
|
||||
.orElseThrow(() -> new IllegalStateException("User not found for magic token"));
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
|
||||
// Promote/activate beta users on first successful magic login
|
||||
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
||||
user.setRole("USER");
|
||||
}
|
||||
if (!Boolean.TRUE.equals(user.isActive())) {
|
||||
user.setActive(true);
|
||||
}
|
||||
|
||||
user.setLastLoginAt(now);
|
||||
user.incrementLoginCount();
|
||||
user.setUpdatedAt(now);
|
||||
users.save(user);
|
||||
|
||||
String jwt = jwtService.generateToken(user);
|
||||
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Password Reset
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void sendPasswordReset(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||
if (user == null) return;
|
||||
|
||||
// If capture-only mode is enabled, do not generate tokens or send email
|
||||
if (betaCaptureOnly) return;
|
||||
|
||||
String resetToken = generateToken();
|
||||
saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30));
|
||||
|
||||
String resetUrl = publicBaseUrl + "/reset-password?token=" + resetToken;
|
||||
|
||||
String subject = "Reset your Battl Builders password";
|
||||
String body = """
|
||||
Reset your password using this link (expires in 30 minutes):
|
||||
%s
|
||||
|
||||
If you didn’t request this, you can ignore this email.
|
||||
""".formatted(resetUrl);
|
||||
|
||||
emailService.sendEmail(email, subject, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetPassword(String token, String newPassword) {
|
||||
if (newPassword == null || newPassword.trim().length() < 8) {
|
||||
throw new IllegalArgumentException("Password must be at least 8 characters");
|
||||
}
|
||||
|
||||
AuthToken t = consumeToken(AuthToken.TokenType.PASSWORD_RESET, token);
|
||||
String email = t.getEmail();
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword.trim()));
|
||||
user.setPasswordSetAt(OffsetDateTime.now());
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) {
|
||||
AuthToken t = new AuthToken();
|
||||
t.setEmail(email);
|
||||
t.setType(type);
|
||||
t.setTokenHash(hashToken(token));
|
||||
t.setExpiresAt(expiresAt);
|
||||
t.setCreatedAt(OffsetDateTime.now());
|
||||
tokens.save(t);
|
||||
}
|
||||
|
||||
private AuthToken consumeToken(AuthToken.TokenType type, String token) {
|
||||
String hash = hashToken(token);
|
||||
|
||||
AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid token"));
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
|
||||
if (t.isConsumed()) throw new IllegalArgumentException("Token already used");
|
||||
if (t.isExpired(now)) throw new IllegalArgumentException("Token expired");
|
||||
|
||||
t.setConsumedAt(now);
|
||||
tokens.save(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
private String normalizeEmail(String email) {
|
||||
if (email == null) throw new IllegalArgumentException("Email required");
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private String generateToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private String hashToken(String token) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hashed);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to hash token", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +1,185 @@
|
||||
package group.goforward.battlbuilder.service.auth.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.AuthToken;
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.AuthTokenRepository;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import group.goforward.battlbuilder.service.utils.TemplatedEmailService;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class BetaInviteService {
|
||||
|
||||
private final UserRepository users;
|
||||
private final AuthTokenRepository tokens;
|
||||
private final TemplatedEmailService templatedEmailService;
|
||||
|
||||
@Value("${app.publicBaseUrl:http://localhost:3000}")
|
||||
private String publicBaseUrl;
|
||||
|
||||
@Value("${app.authTokenPepper:change-me}")
|
||||
private String tokenPepper;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public BetaInviteService(
|
||||
UserRepository users,
|
||||
AuthTokenRepository tokens,
|
||||
TemplatedEmailService templatedEmailService
|
||||
) {
|
||||
this.users = users;
|
||||
this.tokens = tokens;
|
||||
this.templatedEmailService = templatedEmailService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch invite for all pending BETA users (role=BETA, is_active=false, deleted_at is null).
|
||||
*/
|
||||
public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) {
|
||||
|
||||
List<User> betaUsers = (limit > 0)
|
||||
? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit)
|
||||
: users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
|
||||
|
||||
int sent = 0;
|
||||
|
||||
for (User user : betaUsers) {
|
||||
inviteUser(user, tokenMinutes, dryRun);
|
||||
sent++;
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin UI list: all pending beta requests (role=BETA, is_active=false).
|
||||
* Controller expects Page<AdminBetaRequestDto>.
|
||||
*/
|
||||
public Page<AdminBetaRequestDto> listPendingBetaUsers(int page, int size) {
|
||||
int safePage = Math.max(0, page);
|
||||
int safeSize = Math.min(Math.max(1, size), 100);
|
||||
|
||||
List<User> pending = users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
|
||||
|
||||
int from = Math.min(safePage * safeSize, pending.size());
|
||||
int to = Math.min(from + safeSize, pending.size());
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
|
||||
List<AdminBetaRequestDto> dtos = pending.subList(from, to).stream()
|
||||
.map(u -> {
|
||||
AdminBetaRequestDto dto = AdminBetaRequestDto.from(u);
|
||||
dto.invited = tokens.hasActiveToken(
|
||||
u.getEmail(),
|
||||
AuthToken.TokenType.MAGIC_LOGIN,
|
||||
now
|
||||
);
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new PageImpl<>(dtos, PageRequest.of(safePage, safeSize), pending.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a single beta request by userId.
|
||||
*/
|
||||
public AdminInviteResponse inviteSingleBetaUser(Integer userId) {
|
||||
if (userId == null) {
|
||||
return new AdminInviteResponse(false, null, "userId is required");
|
||||
}
|
||||
|
||||
User user = users.findById(userId).orElse(null);
|
||||
if (user == null || user.getDeletedAt() != null) {
|
||||
return new AdminInviteResponse(false, null, "User not found");
|
||||
}
|
||||
|
||||
if (!"BETA".equalsIgnoreCase(user.getRole()) || user.isActive()) {
|
||||
return new AdminInviteResponse(false, user.getEmail(), "User is not a pending beta request");
|
||||
}
|
||||
|
||||
int tokenMinutes = 30; // default for single-invite; feel free to parametrize later
|
||||
String magicUrl = inviteUser(user, tokenMinutes, false);
|
||||
|
||||
return new AdminInviteResponse(true, user.getEmail(), magicUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates token, persists hash, and (optionally) sends email.
|
||||
* Returns the magicUrl for logging / admin response.
|
||||
*/
|
||||
private String inviteUser(User user, int tokenMinutes, boolean dryRun) {
|
||||
String email = user.getEmail();
|
||||
|
||||
String magicToken = generateToken();
|
||||
saveToken(
|
||||
email,
|
||||
AuthToken.TokenType.MAGIC_LOGIN,
|
||||
magicToken,
|
||||
OffsetDateTime.now().plusMinutes(tokenMinutes)
|
||||
);
|
||||
|
||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
||||
|
||||
if (!dryRun) {
|
||||
templatedEmailService.send(
|
||||
"beta_invite",
|
||||
email,
|
||||
Map.of(
|
||||
"minutes", String.valueOf(tokenMinutes),
|
||||
"magicUrl", magicUrl
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return magicUrl;
|
||||
}
|
||||
|
||||
private void saveToken(
|
||||
String email,
|
||||
AuthToken.TokenType type,
|
||||
String token,
|
||||
OffsetDateTime expiresAt
|
||||
) {
|
||||
AuthToken t = new AuthToken();
|
||||
t.setEmail(email);
|
||||
t.setType(type);
|
||||
t.setTokenHash(hashToken(token));
|
||||
t.setExpiresAt(expiresAt);
|
||||
t.setCreatedAt(OffsetDateTime.now());
|
||||
tokens.save(t);
|
||||
}
|
||||
|
||||
private String generateToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private String hashToken(String token) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashed = md.digest(
|
||||
(tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
return HexFormat.of().formatHex(hashed);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to hash token", e);
|
||||
}
|
||||
}
|
||||
package group.goforward.battlbuilder.service.auth.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.AuthToken;
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repo.AuthTokenRepository;
|
||||
import group.goforward.battlbuilder.repo.UserRepository;
|
||||
import group.goforward.battlbuilder.service.utils.TemplatedEmailService;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
|
||||
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class BetaInviteService {
|
||||
|
||||
private final UserRepository users;
|
||||
private final AuthTokenRepository tokens;
|
||||
private final TemplatedEmailService templatedEmailService;
|
||||
|
||||
@Value("${app.publicBaseUrl:http://localhost:3000}")
|
||||
private String publicBaseUrl;
|
||||
|
||||
@Value("${app.authTokenPepper:change-me}")
|
||||
private String tokenPepper;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public BetaInviteService(
|
||||
UserRepository users,
|
||||
AuthTokenRepository tokens,
|
||||
TemplatedEmailService templatedEmailService
|
||||
) {
|
||||
this.users = users;
|
||||
this.tokens = tokens;
|
||||
this.templatedEmailService = templatedEmailService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch invite for all pending BETA users (role=BETA, is_active=false, deleted_at is null).
|
||||
*/
|
||||
public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) {
|
||||
|
||||
List<User> betaUsers = (limit > 0)
|
||||
? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit)
|
||||
: users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
|
||||
|
||||
int sent = 0;
|
||||
|
||||
for (User user : betaUsers) {
|
||||
inviteUser(user, tokenMinutes, dryRun);
|
||||
sent++;
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin UI list: all pending beta requests (role=BETA, is_active=false).
|
||||
* Controller expects Page<AdminBetaRequestDto>.
|
||||
*/
|
||||
public Page<AdminBetaRequestDto> listPendingBetaUsers(int page, int size) {
|
||||
int safePage = Math.max(0, page);
|
||||
int safeSize = Math.min(Math.max(1, size), 100);
|
||||
|
||||
List<User> pending = users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
|
||||
|
||||
int from = Math.min(safePage * safeSize, pending.size());
|
||||
int to = Math.min(from + safeSize, pending.size());
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
|
||||
List<AdminBetaRequestDto> dtos = pending.subList(from, to).stream()
|
||||
.map(u -> {
|
||||
AdminBetaRequestDto dto = AdminBetaRequestDto.from(u);
|
||||
dto.invited = tokens.hasActiveToken(
|
||||
u.getEmail(),
|
||||
AuthToken.TokenType.MAGIC_LOGIN,
|
||||
now
|
||||
);
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new PageImpl<>(dtos, PageRequest.of(safePage, safeSize), pending.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a single beta request by userId.
|
||||
*/
|
||||
public AdminInviteResponse inviteSingleBetaUser(Integer userId) {
|
||||
if (userId == null) {
|
||||
return new AdminInviteResponse(false, null, "userId is required");
|
||||
}
|
||||
|
||||
User user = users.findById(userId).orElse(null);
|
||||
if (user == null || user.getDeletedAt() != null) {
|
||||
return new AdminInviteResponse(false, null, "User not found");
|
||||
}
|
||||
|
||||
if (!"BETA".equalsIgnoreCase(user.getRole()) || user.isActive()) {
|
||||
return new AdminInviteResponse(false, user.getEmail(), "User is not a pending beta request");
|
||||
}
|
||||
|
||||
int tokenMinutes = 30; // default for single-invite; feel free to parametrize later
|
||||
String magicUrl = inviteUser(user, tokenMinutes, false);
|
||||
|
||||
return new AdminInviteResponse(true, user.getEmail(), magicUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates token, persists hash, and (optionally) sends email.
|
||||
* Returns the magicUrl for logging / admin response.
|
||||
*/
|
||||
private String inviteUser(User user, int tokenMinutes, boolean dryRun) {
|
||||
String email = user.getEmail();
|
||||
|
||||
String magicToken = generateToken();
|
||||
saveToken(
|
||||
email,
|
||||
AuthToken.TokenType.MAGIC_LOGIN,
|
||||
magicToken,
|
||||
OffsetDateTime.now().plusMinutes(tokenMinutes)
|
||||
);
|
||||
|
||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
||||
|
||||
if (!dryRun) {
|
||||
templatedEmailService.send(
|
||||
"beta_invite",
|
||||
email,
|
||||
Map.of(
|
||||
"minutes", String.valueOf(tokenMinutes),
|
||||
"magicUrl", magicUrl
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return magicUrl;
|
||||
}
|
||||
|
||||
private void saveToken(
|
||||
String email,
|
||||
AuthToken.TokenType type,
|
||||
String token,
|
||||
OffsetDateTime expiresAt
|
||||
) {
|
||||
AuthToken t = new AuthToken();
|
||||
t.setEmail(email);
|
||||
t.setType(type);
|
||||
t.setTokenHash(hashToken(token));
|
||||
t.setExpiresAt(expiresAt);
|
||||
t.setCreatedAt(OffsetDateTime.now());
|
||||
tokens.save(t);
|
||||
}
|
||||
|
||||
private String generateToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private String hashToken(String token) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashed = md.digest(
|
||||
(tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
return HexFormat.of().formatHex(hashed);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to hash token", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,38 @@
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
|
||||
import group.goforward.battlbuilder.model.Brand;
|
||||
import group.goforward.battlbuilder.repo.BrandRepository;
|
||||
import group.goforward.battlbuilder.service.BrandService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class BrandServiceImpl implements BrandService {
|
||||
|
||||
@Autowired
|
||||
private BrandRepository repo;
|
||||
|
||||
@Override
|
||||
public List<Brand> findAll() {
|
||||
return repo.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Brand> findById(Integer id) {
|
||||
return repo.findById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Brand save(Brand item) {
|
||||
return repo.save(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(Integer id) {
|
||||
deleteById(id);
|
||||
}
|
||||
}
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
|
||||
import group.goforward.battlbuilder.model.Brand;
|
||||
import group.goforward.battlbuilder.repo.BrandRepository;
|
||||
import group.goforward.battlbuilder.service.BrandService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class BrandServiceImpl implements BrandService {
|
||||
|
||||
@Autowired
|
||||
private BrandRepository repo;
|
||||
|
||||
@Override
|
||||
public List<Brand> findAll() {
|
||||
return repo.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Brand> findById(Integer id) {
|
||||
return repo.findById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Brand save(Brand item) {
|
||||
return repo.save(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(Integer id) {
|
||||
deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,209 +1,209 @@
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.model.ProductOffer;
|
||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.repo.catalog.spec.CatalogProductSpecifications;
|
||||
import group.goforward.battlbuilder.service.CatalogQueryService;
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||
|
||||
import org.springframework.data.domain.*;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class CatalogQueryServiceImpl implements CatalogQueryService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductOfferRepository productOfferRepository;
|
||||
|
||||
public CatalogQueryServiceImpl(ProductRepository productRepository,
|
||||
ProductOfferRepository productOfferRepository) {
|
||||
this.productRepository = productRepository;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ProductSummaryDto> getOptions(
|
||||
String platform,
|
||||
String partRole,
|
||||
List<String> partRoles,
|
||||
List<String> brands,
|
||||
String q,
|
||||
Pageable pageable
|
||||
) {
|
||||
pageable = sanitizeCatalogPageable(pageable);
|
||||
|
||||
// Normalize roles: accept partRole OR partRoles
|
||||
List<String> roleList = new ArrayList<>();
|
||||
if (partRole != null && !partRole.isBlank()) roleList.add(partRole);
|
||||
if (partRoles != null && !partRoles.isEmpty()) roleList.addAll(partRoles);
|
||||
roleList = roleList.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
|
||||
|
||||
Specification<Product> spec = Specification.where(CatalogProductSpecifications.isCatalogVisible());
|
||||
|
||||
// platform optional: omit/blank/ALL => universal
|
||||
if (platform != null && !platform.isBlank() && !"ALL".equalsIgnoreCase(platform)) {
|
||||
spec = spec.and(CatalogProductSpecifications.platformEquals(platform));
|
||||
}
|
||||
|
||||
if (!roleList.isEmpty()) {
|
||||
spec = spec.and(CatalogProductSpecifications.partRoleIn(roleList));
|
||||
}
|
||||
|
||||
if (brands != null && !brands.isEmpty()) {
|
||||
spec = spec.and(CatalogProductSpecifications.brandNameIn(brands));
|
||||
}
|
||||
|
||||
if (q != null && !q.isBlank()) {
|
||||
spec = spec.and(CatalogProductSpecifications.queryLike(q));
|
||||
}
|
||||
|
||||
Page<Product> page = productRepository.findAll(spec, pageable);
|
||||
if (page.isEmpty()) {
|
||||
return new PageImpl<>(List.of(), pageable, 0);
|
||||
}
|
||||
|
||||
// Bulk offers for this page (no N+1)
|
||||
List<Integer> productIds = page.getContent().stream().map(Product::getId).toList();
|
||||
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
|
||||
|
||||
List<ProductSummaryDto> dtos = page.getContent().stream().map(p -> {
|
||||
ProductOffer best = bestOfferByProductId.get(p.getId());
|
||||
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
||||
String buyUrl = best != null ? best.getBuyUrl() : null;
|
||||
return ProductMapper.toSummary(p, price, buyUrl);
|
||||
}).toList();
|
||||
|
||||
return new PageImpl<>(dtos, pageable, page.getTotalElements());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request) {
|
||||
List<Integer> ids = request != null ? request.getIds() : null;
|
||||
if (ids == null || ids.isEmpty()) return List.of();
|
||||
|
||||
ids = ids.stream().filter(Objects::nonNull).distinct().toList();
|
||||
|
||||
List<Product> products = productRepository.findByIdIn(ids);
|
||||
if (products.isEmpty()) return List.of();
|
||||
|
||||
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(ids);
|
||||
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
|
||||
|
||||
Map<Integer, Product> productById = products.stream()
|
||||
.collect(Collectors.toMap(Product::getId, p -> p));
|
||||
|
||||
List<ProductSummaryDto> out = new ArrayList<>();
|
||||
for (Integer id : ids) {
|
||||
Product p = productById.get(id);
|
||||
if (p == null) continue;
|
||||
|
||||
ProductOffer best = bestOfferByProductId.get(id);
|
||||
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
||||
String buyUrl = best != null ? best.getBuyUrl() : null;
|
||||
|
||||
out.add(ProductMapper.toSummary(p, price, buyUrl));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<Integer, ProductOffer> pickBestOffers(List<ProductOffer> offers) {
|
||||
Map<Integer, ProductOffer> best = new HashMap<>();
|
||||
if (offers == null || offers.isEmpty()) return best;
|
||||
|
||||
for (ProductOffer o : offers) {
|
||||
if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue;
|
||||
|
||||
Integer pid = o.getProduct().getId();
|
||||
BigDecimal price = o.getEffectivePrice();
|
||||
if (price == null) continue;
|
||||
|
||||
ProductOffer current = best.get(pid);
|
||||
if (current == null) {
|
||||
best.put(pid, o);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- ranking rules (in order) ----
|
||||
// 1) prefer in-stock
|
||||
boolean oStock = Boolean.TRUE.equals(o.getInStock());
|
||||
boolean cStock = Boolean.TRUE.equals(current.getInStock());
|
||||
if (oStock != cStock) {
|
||||
if (oStock) best.put(pid, o);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2) prefer cheaper price
|
||||
BigDecimal currentPrice = current.getEffectivePrice();
|
||||
if (currentPrice == null || price.compareTo(currentPrice) < 0) {
|
||||
best.put(pid, o);
|
||||
continue;
|
||||
}
|
||||
if (price.compareTo(currentPrice) > 0) continue;
|
||||
|
||||
// 3) tie-break: most recently seen
|
||||
OffsetDateTime oSeen = o.getLastSeenAt();
|
||||
OffsetDateTime cSeen = current.getLastSeenAt();
|
||||
|
||||
if (oSeen != null && cSeen != null && oSeen.isAfter(cSeen)) {
|
||||
best.put(pid, o);
|
||||
continue;
|
||||
}
|
||||
if (oSeen != null && cSeen == null) {
|
||||
best.put(pid, o);
|
||||
}
|
||||
|
||||
// 4) tie-break: prefer offer with buyUrl
|
||||
String oUrl = o.getBuyUrl();
|
||||
String cUrl = current.getBuyUrl();
|
||||
if ((oUrl != null && !oUrl.isBlank()) && (cUrl == null || cUrl.isBlank())) {
|
||||
best.put(pid, o);
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private Pageable sanitizeCatalogPageable(Pageable pageable) {
|
||||
if (pageable == null) {
|
||||
return PageRequest.of(0, 24, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
}
|
||||
|
||||
int page = pageable.getPageNumber();
|
||||
int requested = pageable.getPageSize();
|
||||
int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap
|
||||
|
||||
// Default sort if none provided
|
||||
if (pageable.getSort() == null || pageable.getSort().isUnsorted()) {
|
||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
}
|
||||
|
||||
// Only allow safe sorts (for now)
|
||||
Sort.Order first = pageable.getSort().stream().findFirst().orElse(null);
|
||||
if (first == null) {
|
||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
}
|
||||
|
||||
String prop = first.getProperty();
|
||||
Sort.Direction dir = first.getDirection();
|
||||
|
||||
// IMPORTANT:
|
||||
// If you're still using JPA Specifications (Product entity), you can only sort by Product fields.
|
||||
// Once you switch to the native "best offer" query, you can allow "price" and "brand" sorts.
|
||||
return switch (prop) {
|
||||
case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop));
|
||||
default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
};
|
||||
}
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.model.ProductOffer;
|
||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.repo.catalog.spec.CatalogProductSpecifications;
|
||||
import group.goforward.battlbuilder.service.CatalogQueryService;
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||
|
||||
import org.springframework.data.domain.*;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class CatalogQueryServiceImpl implements CatalogQueryService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductOfferRepository productOfferRepository;
|
||||
|
||||
public CatalogQueryServiceImpl(ProductRepository productRepository,
|
||||
ProductOfferRepository productOfferRepository) {
|
||||
this.productRepository = productRepository;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ProductSummaryDto> getOptions(
|
||||
String platform,
|
||||
String partRole,
|
||||
List<String> partRoles,
|
||||
List<String> brands,
|
||||
String q,
|
||||
Pageable pageable
|
||||
) {
|
||||
pageable = sanitizeCatalogPageable(pageable);
|
||||
|
||||
// Normalize roles: accept partRole OR partRoles
|
||||
List<String> roleList = new ArrayList<>();
|
||||
if (partRole != null && !partRole.isBlank()) roleList.add(partRole);
|
||||
if (partRoles != null && !partRoles.isEmpty()) roleList.addAll(partRoles);
|
||||
roleList = roleList.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
|
||||
|
||||
Specification<Product> spec = Specification.where(CatalogProductSpecifications.isCatalogVisible());
|
||||
|
||||
// platform optional: omit/blank/ALL => universal
|
||||
if (platform != null && !platform.isBlank() && !"ALL".equalsIgnoreCase(platform)) {
|
||||
spec = spec.and(CatalogProductSpecifications.platformEquals(platform));
|
||||
}
|
||||
|
||||
if (!roleList.isEmpty()) {
|
||||
spec = spec.and(CatalogProductSpecifications.partRoleIn(roleList));
|
||||
}
|
||||
|
||||
if (brands != null && !brands.isEmpty()) {
|
||||
spec = spec.and(CatalogProductSpecifications.brandNameIn(brands));
|
||||
}
|
||||
|
||||
if (q != null && !q.isBlank()) {
|
||||
spec = spec.and(CatalogProductSpecifications.queryLike(q));
|
||||
}
|
||||
|
||||
Page<Product> page = productRepository.findAll(spec, pageable);
|
||||
if (page.isEmpty()) {
|
||||
return new PageImpl<>(List.of(), pageable, 0);
|
||||
}
|
||||
|
||||
// Bulk offers for this page (no N+1)
|
||||
List<Integer> productIds = page.getContent().stream().map(Product::getId).toList();
|
||||
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
|
||||
|
||||
List<ProductSummaryDto> dtos = page.getContent().stream().map(p -> {
|
||||
ProductOffer best = bestOfferByProductId.get(p.getId());
|
||||
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
||||
String buyUrl = best != null ? best.getBuyUrl() : null;
|
||||
return ProductMapper.toSummary(p, price, buyUrl);
|
||||
}).toList();
|
||||
|
||||
return new PageImpl<>(dtos, pageable, page.getTotalElements());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request) {
|
||||
List<Integer> ids = request != null ? request.getIds() : null;
|
||||
if (ids == null || ids.isEmpty()) return List.of();
|
||||
|
||||
ids = ids.stream().filter(Objects::nonNull).distinct().toList();
|
||||
|
||||
List<Product> products = productRepository.findByIdIn(ids);
|
||||
if (products.isEmpty()) return List.of();
|
||||
|
||||
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(ids);
|
||||
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
|
||||
|
||||
Map<Integer, Product> productById = products.stream()
|
||||
.collect(Collectors.toMap(Product::getId, p -> p));
|
||||
|
||||
List<ProductSummaryDto> out = new ArrayList<>();
|
||||
for (Integer id : ids) {
|
||||
Product p = productById.get(id);
|
||||
if (p == null) continue;
|
||||
|
||||
ProductOffer best = bestOfferByProductId.get(id);
|
||||
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
||||
String buyUrl = best != null ? best.getBuyUrl() : null;
|
||||
|
||||
out.add(ProductMapper.toSummary(p, price, buyUrl));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<Integer, ProductOffer> pickBestOffers(List<ProductOffer> offers) {
|
||||
Map<Integer, ProductOffer> best = new HashMap<>();
|
||||
if (offers == null || offers.isEmpty()) return best;
|
||||
|
||||
for (ProductOffer o : offers) {
|
||||
if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue;
|
||||
|
||||
Integer pid = o.getProduct().getId();
|
||||
BigDecimal price = o.getEffectivePrice();
|
||||
if (price == null) continue;
|
||||
|
||||
ProductOffer current = best.get(pid);
|
||||
if (current == null) {
|
||||
best.put(pid, o);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- ranking rules (in order) ----
|
||||
// 1) prefer in-stock
|
||||
boolean oStock = Boolean.TRUE.equals(o.getInStock());
|
||||
boolean cStock = Boolean.TRUE.equals(current.getInStock());
|
||||
if (oStock != cStock) {
|
||||
if (oStock) best.put(pid, o);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2) prefer cheaper price
|
||||
BigDecimal currentPrice = current.getEffectivePrice();
|
||||
if (currentPrice == null || price.compareTo(currentPrice) < 0) {
|
||||
best.put(pid, o);
|
||||
continue;
|
||||
}
|
||||
if (price.compareTo(currentPrice) > 0) continue;
|
||||
|
||||
// 3) tie-break: most recently seen
|
||||
OffsetDateTime oSeen = o.getLastSeenAt();
|
||||
OffsetDateTime cSeen = current.getLastSeenAt();
|
||||
|
||||
if (oSeen != null && cSeen != null && oSeen.isAfter(cSeen)) {
|
||||
best.put(pid, o);
|
||||
continue;
|
||||
}
|
||||
if (oSeen != null && cSeen == null) {
|
||||
best.put(pid, o);
|
||||
}
|
||||
|
||||
// 4) tie-break: prefer offer with buyUrl
|
||||
String oUrl = o.getBuyUrl();
|
||||
String cUrl = current.getBuyUrl();
|
||||
if ((oUrl != null && !oUrl.isBlank()) && (cUrl == null || cUrl.isBlank())) {
|
||||
best.put(pid, o);
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private Pageable sanitizeCatalogPageable(Pageable pageable) {
|
||||
if (pageable == null) {
|
||||
return PageRequest.of(0, 24, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
}
|
||||
|
||||
int page = pageable.getPageNumber();
|
||||
int requested = pageable.getPageSize();
|
||||
int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap
|
||||
|
||||
// Default sort if none provided
|
||||
if (pageable.getSort() == null || pageable.getSort().isUnsorted()) {
|
||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
}
|
||||
|
||||
// Only allow safe sorts (for now)
|
||||
Sort.Order first = pageable.getSort().stream().findFirst().orElse(null);
|
||||
if (first == null) {
|
||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
}
|
||||
|
||||
String prop = first.getProperty();
|
||||
Sort.Direction dir = first.getDirection();
|
||||
|
||||
// IMPORTANT:
|
||||
// If you're still using JPA Specifications (Product entity), you can only sort by Product fields.
|
||||
// Once you switch to the native "best offer" query, you can allow "price" and "brand" sorts.
|
||||
return switch (prop) {
|
||||
case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop));
|
||||
default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,135 +1,135 @@
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
|
||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||
import group.goforward.battlbuilder.service.CategoryClassificationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class CategoryClassificationServiceImpl implements CategoryClassificationService {
|
||||
|
||||
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||
private final PartRoleResolver partRoleResolver;
|
||||
|
||||
public CategoryClassificationServiceImpl(
|
||||
MerchantCategoryMappingService merchantCategoryMappingService,
|
||||
PartRoleResolver partRoleResolver
|
||||
) {
|
||||
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
||||
this.partRoleResolver = partRoleResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result classify(Merchant merchant, MerchantFeedRow row) {
|
||||
String rawCategoryKey = buildRawCategoryKey(row);
|
||||
String platformFinal = inferPlatform(row);
|
||||
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
||||
return classify(merchant, row, platformFinal, rawCategoryKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) {
|
||||
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
||||
|
||||
// 1) merchant map (authoritative if present)
|
||||
Optional<String> mapped = merchantCategoryMappingService.resolveMappedPartRole(
|
||||
merchant != null ? merchant.getId() : null,
|
||||
rawCategoryKey,
|
||||
platformFinal
|
||||
);
|
||||
|
||||
if (mapped.isPresent()) {
|
||||
String role = normalizePartRole(mapped.get());
|
||||
return new Result(
|
||||
platformFinal,
|
||||
role,
|
||||
rawCategoryKey,
|
||||
PartRoleSource.MERCHANT_MAP,
|
||||
"merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")"
|
||||
);
|
||||
}
|
||||
|
||||
// 2) rules
|
||||
String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey);
|
||||
if (resolved != null && !resolved.isBlank()) {
|
||||
String role = normalizePartRole(resolved);
|
||||
return new Result(
|
||||
platformFinal,
|
||||
role,
|
||||
rawCategoryKey,
|
||||
PartRoleSource.RULES,
|
||||
"PartRoleResolver matched"
|
||||
);
|
||||
}
|
||||
|
||||
// 3) no inference: leave unknown and let it flow to PENDING_MAPPING
|
||||
return new Result(
|
||||
platformFinal,
|
||||
"unknown",
|
||||
rawCategoryKey,
|
||||
PartRoleSource.UNKNOWN,
|
||||
"no mapping or rules match"
|
||||
);
|
||||
}
|
||||
|
||||
private String buildRawCategoryKey(MerchantFeedRow row) {
|
||||
String dept = trimOrNull(row.department());
|
||||
String cat = trimOrNull(row.category());
|
||||
String sub = trimOrNull(row.subCategory());
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (dept != null) sb.append(dept);
|
||||
if (cat != null) {
|
||||
if (!sb.isEmpty()) sb.append(" > ");
|
||||
sb.append(cat);
|
||||
}
|
||||
if (sub != null) {
|
||||
if (!sb.isEmpty()) sb.append(" > ");
|
||||
sb.append(sub);
|
||||
}
|
||||
|
||||
String result = sb.toString();
|
||||
return result.isBlank() ? null : result;
|
||||
}
|
||||
|
||||
private String inferPlatform(MerchantFeedRow row) {
|
||||
String blob = String.join(" ",
|
||||
coalesce(trimOrNull(row.department()), ""),
|
||||
coalesce(trimOrNull(row.category()), ""),
|
||||
coalesce(trimOrNull(row.subCategory()), "")
|
||||
).toLowerCase(Locale.ROOT);
|
||||
|
||||
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
|
||||
if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10";
|
||||
if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9";
|
||||
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
|
||||
|
||||
return "AR-15";
|
||||
}
|
||||
|
||||
|
||||
private String normalizePartRole(String partRole) {
|
||||
if (partRole == null) return "unknown";
|
||||
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
|
||||
return t.isBlank() ? "unknown" : t;
|
||||
}
|
||||
|
||||
private String trimOrNull(String v) {
|
||||
if (v == null) return null;
|
||||
String t = v.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
private String coalesce(String... values) {
|
||||
if (values == null) return null;
|
||||
for (String v : values) {
|
||||
if (v != null && !v.isBlank()) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
|
||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||
import group.goforward.battlbuilder.service.CategoryClassificationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class CategoryClassificationServiceImpl implements CategoryClassificationService {
|
||||
|
||||
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||
private final PartRoleResolver partRoleResolver;
|
||||
|
||||
public CategoryClassificationServiceImpl(
|
||||
MerchantCategoryMappingService merchantCategoryMappingService,
|
||||
PartRoleResolver partRoleResolver
|
||||
) {
|
||||
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
||||
this.partRoleResolver = partRoleResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result classify(Merchant merchant, MerchantFeedRow row) {
|
||||
String rawCategoryKey = buildRawCategoryKey(row);
|
||||
String platformFinal = inferPlatform(row);
|
||||
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
||||
return classify(merchant, row, platformFinal, rawCategoryKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) {
|
||||
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
||||
|
||||
// 1) merchant map (authoritative if present)
|
||||
Optional<String> mapped = merchantCategoryMappingService.resolveMappedPartRole(
|
||||
merchant != null ? merchant.getId() : null,
|
||||
rawCategoryKey,
|
||||
platformFinal
|
||||
);
|
||||
|
||||
if (mapped.isPresent()) {
|
||||
String role = normalizePartRole(mapped.get());
|
||||
return new Result(
|
||||
platformFinal,
|
||||
role,
|
||||
rawCategoryKey,
|
||||
PartRoleSource.MERCHANT_MAP,
|
||||
"merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")"
|
||||
);
|
||||
}
|
||||
|
||||
// 2) rules
|
||||
String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey);
|
||||
if (resolved != null && !resolved.isBlank()) {
|
||||
String role = normalizePartRole(resolved);
|
||||
return new Result(
|
||||
platformFinal,
|
||||
role,
|
||||
rawCategoryKey,
|
||||
PartRoleSource.RULES,
|
||||
"PartRoleResolver matched"
|
||||
);
|
||||
}
|
||||
|
||||
// 3) no inference: leave unknown and let it flow to PENDING_MAPPING
|
||||
return new Result(
|
||||
platformFinal,
|
||||
"unknown",
|
||||
rawCategoryKey,
|
||||
PartRoleSource.UNKNOWN,
|
||||
"no mapping or rules match"
|
||||
);
|
||||
}
|
||||
|
||||
private String buildRawCategoryKey(MerchantFeedRow row) {
|
||||
String dept = trimOrNull(row.department());
|
||||
String cat = trimOrNull(row.category());
|
||||
String sub = trimOrNull(row.subCategory());
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (dept != null) sb.append(dept);
|
||||
if (cat != null) {
|
||||
if (!sb.isEmpty()) sb.append(" > ");
|
||||
sb.append(cat);
|
||||
}
|
||||
if (sub != null) {
|
||||
if (!sb.isEmpty()) sb.append(" > ");
|
||||
sb.append(sub);
|
||||
}
|
||||
|
||||
String result = sb.toString();
|
||||
return result.isBlank() ? null : result;
|
||||
}
|
||||
|
||||
private String inferPlatform(MerchantFeedRow row) {
|
||||
String blob = String.join(" ",
|
||||
coalesce(trimOrNull(row.department()), ""),
|
||||
coalesce(trimOrNull(row.category()), ""),
|
||||
coalesce(trimOrNull(row.subCategory()), "")
|
||||
).toLowerCase(Locale.ROOT);
|
||||
|
||||
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
|
||||
if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10";
|
||||
if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9";
|
||||
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
|
||||
|
||||
return "AR-15";
|
||||
}
|
||||
|
||||
|
||||
private String normalizePartRole(String partRole) {
|
||||
if (partRole == null) return "unknown";
|
||||
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
|
||||
return t.isBlank() ? "unknown" : t;
|
||||
}
|
||||
|
||||
private String trimOrNull(String v) {
|
||||
if (v == null) return null;
|
||||
String t = v.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
private String coalesce(String... values) {
|
||||
if (values == null) return null;
|
||||
for (String v : values) {
|
||||
if (v != null && !v.isBlank()) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,38 @@
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class MerchantCategoryMappingService {
|
||||
|
||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||
|
||||
public MerchantCategoryMappingService(MerchantCategoryMapRepository merchantCategoryMapRepository) {
|
||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||
}
|
||||
|
||||
public Optional<String> resolveMappedPartRole(
|
||||
Integer merchantId,
|
||||
String rawCategoryKey,
|
||||
String platformFinal
|
||||
) {
|
||||
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
List<String> canonicalRoles =
|
||||
merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey);
|
||||
|
||||
if (canonicalRoles == null || canonicalRoles.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return canonicalRoles.stream()
|
||||
.filter(v -> v != null && !v.isBlank())
|
||||
.findFirst();
|
||||
}
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class MerchantCategoryMappingService {
|
||||
|
||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||
|
||||
public MerchantCategoryMappingService(MerchantCategoryMapRepository merchantCategoryMapRepository) {
|
||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||
}
|
||||
|
||||
public Optional<String> resolveMappedPartRole(
|
||||
Integer merchantId,
|
||||
String rawCategoryKey,
|
||||
String platformFinal
|
||||
) {
|
||||
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
List<String> canonicalRoles =
|
||||
merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey);
|
||||
|
||||
if (canonicalRoles == null || canonicalRoles.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return canonicalRoles.stream()
|
||||
.filter(v -> v != null && !v.isBlank())
|
||||
.findFirst();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,174 +1,174 @@
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.model.ProductOffer;
|
||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.service.ProductQueryService;
|
||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ProductQueryServiceImpl implements ProductQueryService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductOfferRepository productOfferRepository;
|
||||
|
||||
public ProductQueryServiceImpl(
|
||||
ProductRepository productRepository,
|
||||
ProductOfferRepository productOfferRepository
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProductSummaryDto> getProducts(String platform, List<String> partRoles) {
|
||||
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
||||
|
||||
List<Product> products;
|
||||
if (partRoles == null || partRoles.isEmpty()) {
|
||||
products = allPlatforms
|
||||
? productRepository.findAllWithBrand()
|
||||
: productRepository.findByPlatformWithBrand(platform);
|
||||
} else {
|
||||
products = allPlatforms
|
||||
? productRepository.findByPartRoleInWithBrand(partRoles)
|
||||
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
||||
}
|
||||
|
||||
if (products.isEmpty()) return List.of();
|
||||
|
||||
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
||||
|
||||
// ✅ canonical repo method
|
||||
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||
|
||||
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
||||
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||
|
||||
return products.stream()
|
||||
.map(p -> {
|
||||
List<ProductOffer> offersForProduct =
|
||||
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
|
||||
|
||||
ProductOffer bestOffer = pickBestOffer(offersForProduct);
|
||||
|
||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||
|
||||
return ProductMapper.toSummary(p, price, buyUrl);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) {
|
||||
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
||||
|
||||
Page<Product> productPage;
|
||||
|
||||
if (partRoles == null || partRoles.isEmpty()) {
|
||||
productPage = allPlatforms
|
||||
? productRepository.findAllWithBrand(pageable)
|
||||
: productRepository.findByPlatformWithBrand(platform, pageable);
|
||||
} else {
|
||||
productPage = allPlatforms
|
||||
? productRepository.findByPartRoleInWithBrand(partRoles, pageable)
|
||||
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable);
|
||||
}
|
||||
|
||||
List<Product> products = productPage.getContent();
|
||||
if (products.isEmpty()) {
|
||||
return Page.empty(pageable);
|
||||
}
|
||||
|
||||
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
||||
|
||||
// Only fetch offers for THIS PAGE of products
|
||||
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||
|
||||
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
||||
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||
|
||||
List<ProductSummaryDto> dtos = products.stream()
|
||||
.map(p -> {
|
||||
List<ProductOffer> offersForProduct =
|
||||
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
|
||||
|
||||
ProductOffer bestOffer = pickBestOffer(offersForProduct);
|
||||
|
||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||
|
||||
return ProductMapper.toSummary(p, price, buyUrl);
|
||||
})
|
||||
.toList();
|
||||
|
||||
return new PageImpl<>(dtos, pageable, productPage.getTotalElements());
|
||||
}
|
||||
|
||||
//
|
||||
// Product Offers
|
||||
//
|
||||
|
||||
@Override
|
||||
public List<ProductOfferDto> getOffersForProduct(Integer productId) {
|
||||
// ✅ canonical repo method
|
||||
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
||||
|
||||
return offers.stream()
|
||||
.map(offer -> {
|
||||
ProductOfferDto dto = new ProductOfferDto();
|
||||
dto.setId(offer.getId().toString());
|
||||
dto.setMerchantName(offer.getMerchant().getName());
|
||||
dto.setPrice(offer.getEffectivePrice());
|
||||
dto.setOriginalPrice(offer.getOriginalPrice());
|
||||
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
|
||||
dto.setBuyUrl(offer.getBuyUrl());
|
||||
dto.setLastUpdated(offer.getLastSeenAt());
|
||||
return dto;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProductSummaryDto getProductById(Integer productId) {
|
||||
Product product = productRepository.findById(productId).orElse(null);
|
||||
if (product == null) return null;
|
||||
|
||||
// ✅ canonical repo method
|
||||
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
||||
ProductOffer bestOffer = pickBestOffer(offers);
|
||||
|
||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||
|
||||
return ProductMapper.toSummary(product, price, buyUrl);
|
||||
}
|
||||
|
||||
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
||||
if (offers == null || offers.isEmpty()) return null;
|
||||
|
||||
// MVP: lowest effective price wins. (Later: prefer in-stock, etc.)
|
||||
return offers.stream()
|
||||
.filter(o -> o.getEffectivePrice() != null)
|
||||
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
||||
.orElse(null);
|
||||
}
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.model.ProductOffer;
|
||||
import group.goforward.battlbuilder.repo.ProductOfferRepository;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.service.ProductQueryService;
|
||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ProductQueryServiceImpl implements ProductQueryService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductOfferRepository productOfferRepository;
|
||||
|
||||
public ProductQueryServiceImpl(
|
||||
ProductRepository productRepository,
|
||||
ProductOfferRepository productOfferRepository
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProductSummaryDto> getProducts(String platform, List<String> partRoles) {
|
||||
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
||||
|
||||
List<Product> products;
|
||||
if (partRoles == null || partRoles.isEmpty()) {
|
||||
products = allPlatforms
|
||||
? productRepository.findAllWithBrand()
|
||||
: productRepository.findByPlatformWithBrand(platform);
|
||||
} else {
|
||||
products = allPlatforms
|
||||
? productRepository.findByPartRoleInWithBrand(partRoles)
|
||||
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
||||
}
|
||||
|
||||
if (products.isEmpty()) return List.of();
|
||||
|
||||
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
||||
|
||||
// ✅ canonical repo method
|
||||
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||
|
||||
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
||||
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||
|
||||
return products.stream()
|
||||
.map(p -> {
|
||||
List<ProductOffer> offersForProduct =
|
||||
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
|
||||
|
||||
ProductOffer bestOffer = pickBestOffer(offersForProduct);
|
||||
|
||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||
|
||||
return ProductMapper.toSummary(p, price, buyUrl);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) {
|
||||
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
||||
|
||||
Page<Product> productPage;
|
||||
|
||||
if (partRoles == null || partRoles.isEmpty()) {
|
||||
productPage = allPlatforms
|
||||
? productRepository.findAllWithBrand(pageable)
|
||||
: productRepository.findByPlatformWithBrand(platform, pageable);
|
||||
} else {
|
||||
productPage = allPlatforms
|
||||
? productRepository.findByPartRoleInWithBrand(partRoles, pageable)
|
||||
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable);
|
||||
}
|
||||
|
||||
List<Product> products = productPage.getContent();
|
||||
if (products.isEmpty()) {
|
||||
return Page.empty(pageable);
|
||||
}
|
||||
|
||||
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
||||
|
||||
// Only fetch offers for THIS PAGE of products
|
||||
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||
|
||||
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
||||
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||
|
||||
List<ProductSummaryDto> dtos = products.stream()
|
||||
.map(p -> {
|
||||
List<ProductOffer> offersForProduct =
|
||||
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
|
||||
|
||||
ProductOffer bestOffer = pickBestOffer(offersForProduct);
|
||||
|
||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||
|
||||
return ProductMapper.toSummary(p, price, buyUrl);
|
||||
})
|
||||
.toList();
|
||||
|
||||
return new PageImpl<>(dtos, pageable, productPage.getTotalElements());
|
||||
}
|
||||
|
||||
//
|
||||
// Product Offers
|
||||
//
|
||||
|
||||
@Override
|
||||
public List<ProductOfferDto> getOffersForProduct(Integer productId) {
|
||||
// ✅ canonical repo method
|
||||
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
||||
|
||||
return offers.stream()
|
||||
.map(offer -> {
|
||||
ProductOfferDto dto = new ProductOfferDto();
|
||||
dto.setId(offer.getId().toString());
|
||||
dto.setMerchantName(offer.getMerchant().getName());
|
||||
dto.setPrice(offer.getEffectivePrice());
|
||||
dto.setOriginalPrice(offer.getOriginalPrice());
|
||||
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
|
||||
dto.setBuyUrl(offer.getBuyUrl());
|
||||
dto.setLastUpdated(offer.getLastSeenAt());
|
||||
return dto;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProductSummaryDto getProductById(Integer productId) {
|
||||
Product product = productRepository.findById(productId).orElse(null);
|
||||
if (product == null) return null;
|
||||
|
||||
// ✅ canonical repo method
|
||||
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
||||
ProductOffer bestOffer = pickBestOffer(offers);
|
||||
|
||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||
|
||||
return ProductMapper.toSummary(product, price, buyUrl);
|
||||
}
|
||||
|
||||
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
||||
if (offers == null || offers.isEmpty()) return null;
|
||||
|
||||
// MVP: lowest effective price wins. (Later: prefer in-stock, etc.)
|
||||
return offers.stream()
|
||||
.filter(o -> o.getEffectivePrice() != null)
|
||||
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -1,175 +1,175 @@
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.ImportStatus;
|
||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.service.ReclassificationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class ReclassificationServiceImpl implements ReclassificationService {
|
||||
|
||||
private static final String CLASSIFIER_VERSION = "v2025-12-28.1";
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||
|
||||
// ✅ Keep ONE constructor. Spring will inject both deps.
|
||||
public ReclassificationServiceImpl(
|
||||
ProductRepository productRepository,
|
||||
MerchantCategoryMappingService merchantCategoryMappingService
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Catalog category FK backfill
|
||||
// ============================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int applyCatalogCategoryMappingToProducts(
|
||||
Integer merchantId,
|
||||
String rawCategoryKey,
|
||||
Integer canonicalCategoryId
|
||||
) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||
if (rawCategoryKey == null || rawCategoryKey.isBlank())
|
||||
throw new IllegalArgumentException("rawCategoryKey is required");
|
||||
|
||||
return productRepository.applyCanonicalCategoryByPrimaryMerchantAndRawCategory(
|
||||
merchantId,
|
||||
rawCategoryKey.trim(),
|
||||
canonicalCategoryId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional helper: bulk reclassify only PENDING_MAPPING for a merchant,
|
||||
* using ONLY merchant_category_map (no rules, no inference).
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int reclassifyPendingForMerchant(Integer merchantId) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
|
||||
|
||||
List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId);
|
||||
if (pending == null || pending.isEmpty()) return 0;
|
||||
|
||||
Instant now = Instant.now();
|
||||
List<Product> toSave = new ArrayList<>();
|
||||
int updated = 0;
|
||||
|
||||
for (Product p : pending) {
|
||||
if (p.getDeletedAt() != null) continue;
|
||||
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
|
||||
|
||||
String rawCategoryKey = p.getRawCategoryKey();
|
||||
if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue;
|
||||
|
||||
String platformFinal = normalizePlatformOrNull(p.getPlatform());
|
||||
|
||||
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
|
||||
merchantId, rawCategoryKey, platformFinal
|
||||
);
|
||||
if (mappedRole.isEmpty()) continue;
|
||||
|
||||
String normalized = normalizePartRole(mappedRole.get());
|
||||
if ("unknown".equals(normalized)) continue;
|
||||
|
||||
String current = normalizePartRole(p.getPartRole());
|
||||
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
|
||||
|
||||
p.setPartRole(normalized);
|
||||
p.setImportStatus(ImportStatus.MAPPED);
|
||||
|
||||
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
|
||||
p.setClassifierVersion(CLASSIFIER_VERSION);
|
||||
p.setClassifiedAt(now);
|
||||
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
|
||||
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
|
||||
|
||||
toSave.add(p);
|
||||
updated++;
|
||||
}
|
||||
|
||||
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by MappingAdminService after creating/updating a mapping.
|
||||
* Applies mapping to all products for merchant+rawCategoryKey.
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
|
||||
if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required");
|
||||
|
||||
List<Product> products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey);
|
||||
if (products == null || products.isEmpty()) return 0;
|
||||
|
||||
Instant now = Instant.now();
|
||||
List<Product> toSave = new ArrayList<>();
|
||||
int updated = 0;
|
||||
|
||||
for (Product p : products) {
|
||||
if (p.getDeletedAt() != null) continue;
|
||||
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
|
||||
|
||||
String platformFinal = normalizePlatformOrNull(p.getPlatform());
|
||||
|
||||
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
|
||||
merchantId, rawCategoryKey, platformFinal
|
||||
);
|
||||
if (mappedRole.isEmpty()) continue;
|
||||
|
||||
String normalized = normalizePartRole(mappedRole.get());
|
||||
if ("unknown".equals(normalized)) continue;
|
||||
|
||||
String current = normalizePartRole(p.getPartRole());
|
||||
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
|
||||
|
||||
p.setPartRole(normalized);
|
||||
p.setImportStatus(ImportStatus.MAPPED);
|
||||
|
||||
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
|
||||
p.setClassifierVersion(CLASSIFIER_VERSION);
|
||||
p.setClassifiedAt(now);
|
||||
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
|
||||
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
|
||||
|
||||
toSave.add(p);
|
||||
updated++;
|
||||
}
|
||||
|
||||
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// -----------------
|
||||
// Helpers
|
||||
// -----------------
|
||||
|
||||
private String normalizePlatformOrNull(String platform) {
|
||||
if (platform == null) return null;
|
||||
String t = platform.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
private String normalizePartRole(String partRole) {
|
||||
if (partRole == null) return "unknown";
|
||||
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
|
||||
return t.isBlank() ? "unknown" : t;
|
||||
}
|
||||
package group.goforward.battlbuilder.service.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.ImportStatus;
|
||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import group.goforward.battlbuilder.service.ReclassificationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class ReclassificationServiceImpl implements ReclassificationService {
|
||||
|
||||
private static final String CLASSIFIER_VERSION = "v2025-12-28.1";
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||
|
||||
// ✅ Keep ONE constructor. Spring will inject both deps.
|
||||
public ReclassificationServiceImpl(
|
||||
ProductRepository productRepository,
|
||||
MerchantCategoryMappingService merchantCategoryMappingService
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Catalog category FK backfill
|
||||
// ============================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int applyCatalogCategoryMappingToProducts(
|
||||
Integer merchantId,
|
||||
String rawCategoryKey,
|
||||
Integer canonicalCategoryId
|
||||
) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||
if (rawCategoryKey == null || rawCategoryKey.isBlank())
|
||||
throw new IllegalArgumentException("rawCategoryKey is required");
|
||||
|
||||
return productRepository.applyCanonicalCategoryByPrimaryMerchantAndRawCategory(
|
||||
merchantId,
|
||||
rawCategoryKey.trim(),
|
||||
canonicalCategoryId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional helper: bulk reclassify only PENDING_MAPPING for a merchant,
|
||||
* using ONLY merchant_category_map (no rules, no inference).
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int reclassifyPendingForMerchant(Integer merchantId) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
|
||||
|
||||
List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId);
|
||||
if (pending == null || pending.isEmpty()) return 0;
|
||||
|
||||
Instant now = Instant.now();
|
||||
List<Product> toSave = new ArrayList<>();
|
||||
int updated = 0;
|
||||
|
||||
for (Product p : pending) {
|
||||
if (p.getDeletedAt() != null) continue;
|
||||
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
|
||||
|
||||
String rawCategoryKey = p.getRawCategoryKey();
|
||||
if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue;
|
||||
|
||||
String platformFinal = normalizePlatformOrNull(p.getPlatform());
|
||||
|
||||
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
|
||||
merchantId, rawCategoryKey, platformFinal
|
||||
);
|
||||
if (mappedRole.isEmpty()) continue;
|
||||
|
||||
String normalized = normalizePartRole(mappedRole.get());
|
||||
if ("unknown".equals(normalized)) continue;
|
||||
|
||||
String current = normalizePartRole(p.getPartRole());
|
||||
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
|
||||
|
||||
p.setPartRole(normalized);
|
||||
p.setImportStatus(ImportStatus.MAPPED);
|
||||
|
||||
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
|
||||
p.setClassifierVersion(CLASSIFIER_VERSION);
|
||||
p.setClassifiedAt(now);
|
||||
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
|
||||
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
|
||||
|
||||
toSave.add(p);
|
||||
updated++;
|
||||
}
|
||||
|
||||
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by MappingAdminService after creating/updating a mapping.
|
||||
* Applies mapping to all products for merchant+rawCategoryKey.
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
|
||||
if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required");
|
||||
|
||||
List<Product> products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey);
|
||||
if (products == null || products.isEmpty()) return 0;
|
||||
|
||||
Instant now = Instant.now();
|
||||
List<Product> toSave = new ArrayList<>();
|
||||
int updated = 0;
|
||||
|
||||
for (Product p : products) {
|
||||
if (p.getDeletedAt() != null) continue;
|
||||
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
|
||||
|
||||
String platformFinal = normalizePlatformOrNull(p.getPlatform());
|
||||
|
||||
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
|
||||
merchantId, rawCategoryKey, platformFinal
|
||||
);
|
||||
if (mappedRole.isEmpty()) continue;
|
||||
|
||||
String normalized = normalizePartRole(mappedRole.get());
|
||||
if ("unknown".equals(normalized)) continue;
|
||||
|
||||
String current = normalizePartRole(p.getPartRole());
|
||||
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
|
||||
|
||||
p.setPartRole(normalized);
|
||||
p.setImportStatus(ImportStatus.MAPPED);
|
||||
|
||||
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
|
||||
p.setClassifierVersion(CLASSIFIER_VERSION);
|
||||
p.setClassifiedAt(now);
|
||||
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
|
||||
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
|
||||
|
||||
toSave.add(p);
|
||||
updated++;
|
||||
}
|
||||
|
||||
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// -----------------
|
||||
// Helpers
|
||||
// -----------------
|
||||
|
||||
private String normalizePlatformOrNull(String platform) {
|
||||
if (platform == null) return null;
|
||||
String t = platform.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
private String normalizePartRole(String partRole) {
|
||||
if (partRole == null) return "unknown";
|
||||
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
|
||||
return t.isBlank() ? "unknown" : t;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
|
||||
/**
|
||||
* Services package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains business logic service classes for product management,
|
||||
* category classification, mapping recommendations, and merchant operations.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
/**
|
||||
* Services package for the BattlBuilder application.
|
||||
* <p>
|
||||
* Contains business logic service classes for product management,
|
||||
* category classification, mapping recommendations, and merchant operations.
|
||||
*
|
||||
* @author Forward Group, LLC
|
||||
* @version 1.0
|
||||
* @since 2025-12-10
|
||||
*/
|
||||
package group.goforward.battlbuilder.service;
|
||||
|
||||
@@ -1,183 +1,183 @@
|
||||
package group.goforward.battlbuilder.service.utils;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import io.minio.BucketExistsArgs;
|
||||
import io.minio.MakeBucketArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class ImageUrlToMinioMigrator {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final MinioClient minioClient;
|
||||
|
||||
private final String bucket;
|
||||
private final String publicBaseUrl;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
|
||||
public ImageUrlToMinioMigrator(ProductRepository productRepository,
|
||||
MinioClient minioClient,
|
||||
@Value("${minio.bucket}") String bucket,
|
||||
@Value("${minio.public-base-url}") String publicBaseUrl) {
|
||||
this.productRepository = productRepository;
|
||||
this.minioClient = minioClient;
|
||||
this.bucket = bucket;
|
||||
this.publicBaseUrl = trimTrailingSlash(publicBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates Product.mainImageUrl URLs to MinIO and rewrites mainImageUrl to the new location.
|
||||
*
|
||||
* @param pageSize batch size for DB paging
|
||||
* @param dryRun if true: download+upload is skipped and DB is not updated
|
||||
* @param maxItems optional cap for safety (null = no cap)
|
||||
* @return count of successfully migrated products
|
||||
*/
|
||||
@Transactional
|
||||
public int migrateMainImages(int pageSize, boolean dryRun, Integer maxItems) {
|
||||
ensureBucketExists();
|
||||
|
||||
int migrated = 0;
|
||||
int page = 0;
|
||||
|
||||
while (true) {
|
||||
if (maxItems != null && migrated >= maxItems) break;
|
||||
|
||||
Page<Product> batch = productRepository
|
||||
.findNeedingBattlImageUrlMigration(PageRequest.of(page, pageSize));
|
||||
if (batch.isEmpty()) break;
|
||||
|
||||
for (Product p : batch.getContent()) {
|
||||
if (maxItems != null && migrated >= maxItems) break;
|
||||
|
||||
String sourceUrl = p.getMainImageUrl();
|
||||
if (sourceUrl == null || sourceUrl.isBlank()) continue;
|
||||
|
||||
// Extra safety: skip if already set (covers any edge cases outside the query)
|
||||
if (p.getBattlImageUrl() != null && !p.getBattlImageUrl().trim().isEmpty()) continue;
|
||||
|
||||
try {
|
||||
if (!dryRun) {
|
||||
String newUrl = uploadFromUrlToMinio(p, sourceUrl);
|
||||
p.setBattlImageUrl(newUrl);
|
||||
productRepository.save(p);
|
||||
}
|
||||
migrated++;
|
||||
} catch (Exception ex) {
|
||||
// fail-soft: continue migrating other products
|
||||
}
|
||||
}
|
||||
|
||||
if (!batch.hasNext()) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
private String uploadFromUrlToMinio(Product p, String sourceUrl) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(sourceUrl))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
|
||||
int status = response.statusCode();
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new IllegalStateException("Failed to download image. HTTP " + status + " url=" + sourceUrl);
|
||||
}
|
||||
|
||||
String contentType = response.headers()
|
||||
.firstValue("content-type")
|
||||
.map(v -> v.split(";", 2)[0].trim())
|
||||
.orElse("application/octet-stream");
|
||||
|
||||
long contentLength = response.headers()
|
||||
.firstValue("content-length")
|
||||
.flatMap(ImageUrlToMinioMigrator::parseLongSafe)
|
||||
.orElse(-1L);
|
||||
|
||||
String ext = extensionForContentType(contentType);
|
||||
|
||||
// Store under a stable key; adjust if you want per-merchant, hashed names, etc.
|
||||
String objectName = "products/" + p.getId() + "/main" + ext;
|
||||
|
||||
try (InputStream in = response.body()) {
|
||||
PutObjectArgs.Builder put = PutObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(objectName)
|
||||
.contentType(contentType);
|
||||
|
||||
if (contentLength >= 0) {
|
||||
put.stream(in, contentLength, -1);
|
||||
} else {
|
||||
put.stream(in, -1, 10L * 1024 * 1024);
|
||||
}
|
||||
|
||||
minioClient.putObject(put.build());
|
||||
}
|
||||
|
||||
return publicBaseUrl + "/" + bucket + "/" + objectName;
|
||||
}
|
||||
|
||||
private void ensureBucketExists() {
|
||||
try {
|
||||
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
|
||||
if (!exists) {
|
||||
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Unable to ensure MinIO bucket exists: " + bucket, e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean looksAlreadyMigrated(String url) {
|
||||
String prefix = publicBaseUrl + "/" + bucket + "/";
|
||||
return url.startsWith(prefix);
|
||||
}
|
||||
|
||||
private static Optional<Long> parseLongSafe(String v) {
|
||||
try {
|
||||
return Optional.of(Long.parseLong(v));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static String extensionForContentType(String contentType) {
|
||||
String ct = contentType.toLowerCase(Locale.ROOT);
|
||||
if (ct.equals("image/jpeg") || ct.equals("image/jpg")) return ".jpg";
|
||||
if (ct.equals("image/png")) return ".png";
|
||||
if (ct.equals("image/webp")) return ".webp";
|
||||
if (ct.equals("image/gif")) return ".gif";
|
||||
if (ct.equals("image/svg+xml")) return ".svg";
|
||||
return ".bin";
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String s) {
|
||||
if (s == null) return "";
|
||||
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
}
|
||||
package group.goforward.battlbuilder.service.utils;
|
||||
|
||||
import group.goforward.battlbuilder.model.Product;
|
||||
import group.goforward.battlbuilder.repo.ProductRepository;
|
||||
import io.minio.BucketExistsArgs;
|
||||
import io.minio.MakeBucketArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class ImageUrlToMinioMigrator {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final MinioClient minioClient;
|
||||
|
||||
private final String bucket;
|
||||
private final String publicBaseUrl;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
|
||||
public ImageUrlToMinioMigrator(ProductRepository productRepository,
|
||||
MinioClient minioClient,
|
||||
@Value("${minio.bucket}") String bucket,
|
||||
@Value("${minio.public-base-url}") String publicBaseUrl) {
|
||||
this.productRepository = productRepository;
|
||||
this.minioClient = minioClient;
|
||||
this.bucket = bucket;
|
||||
this.publicBaseUrl = trimTrailingSlash(publicBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates Product.mainImageUrl URLs to MinIO and rewrites mainImageUrl to the new location.
|
||||
*
|
||||
* @param pageSize batch size for DB paging
|
||||
* @param dryRun if true: download+upload is skipped and DB is not updated
|
||||
* @param maxItems optional cap for safety (null = no cap)
|
||||
* @return count of successfully migrated products
|
||||
*/
|
||||
@Transactional
|
||||
public int migrateMainImages(int pageSize, boolean dryRun, Integer maxItems) {
|
||||
ensureBucketExists();
|
||||
|
||||
int migrated = 0;
|
||||
int page = 0;
|
||||
|
||||
while (true) {
|
||||
if (maxItems != null && migrated >= maxItems) break;
|
||||
|
||||
Page<Product> batch = productRepository
|
||||
.findNeedingBattlImageUrlMigration(PageRequest.of(page, pageSize));
|
||||
if (batch.isEmpty()) break;
|
||||
|
||||
for (Product p : batch.getContent()) {
|
||||
if (maxItems != null && migrated >= maxItems) break;
|
||||
|
||||
String sourceUrl = p.getMainImageUrl();
|
||||
if (sourceUrl == null || sourceUrl.isBlank()) continue;
|
||||
|
||||
// Extra safety: skip if already set (covers any edge cases outside the query)
|
||||
if (p.getBattlImageUrl() != null && !p.getBattlImageUrl().trim().isEmpty()) continue;
|
||||
|
||||
try {
|
||||
if (!dryRun) {
|
||||
String newUrl = uploadFromUrlToMinio(p, sourceUrl);
|
||||
p.setBattlImageUrl(newUrl);
|
||||
productRepository.save(p);
|
||||
}
|
||||
migrated++;
|
||||
} catch (Exception ex) {
|
||||
// fail-soft: continue migrating other products
|
||||
}
|
||||
}
|
||||
|
||||
if (!batch.hasNext()) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
private String uploadFromUrlToMinio(Product p, String sourceUrl) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(sourceUrl))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
|
||||
int status = response.statusCode();
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new IllegalStateException("Failed to download image. HTTP " + status + " url=" + sourceUrl);
|
||||
}
|
||||
|
||||
String contentType = response.headers()
|
||||
.firstValue("content-type")
|
||||
.map(v -> v.split(";", 2)[0].trim())
|
||||
.orElse("application/octet-stream");
|
||||
|
||||
long contentLength = response.headers()
|
||||
.firstValue("content-length")
|
||||
.flatMap(ImageUrlToMinioMigrator::parseLongSafe)
|
||||
.orElse(-1L);
|
||||
|
||||
String ext = extensionForContentType(contentType);
|
||||
|
||||
// Store under a stable key; adjust if you want per-merchant, hashed names, etc.
|
||||
String objectName = "products/" + p.getId() + "/main" + ext;
|
||||
|
||||
try (InputStream in = response.body()) {
|
||||
PutObjectArgs.Builder put = PutObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(objectName)
|
||||
.contentType(contentType);
|
||||
|
||||
if (contentLength >= 0) {
|
||||
put.stream(in, contentLength, -1);
|
||||
} else {
|
||||
put.stream(in, -1, 10L * 1024 * 1024);
|
||||
}
|
||||
|
||||
minioClient.putObject(put.build());
|
||||
}
|
||||
|
||||
return publicBaseUrl + "/" + bucket + "/" + objectName;
|
||||
}
|
||||
|
||||
private void ensureBucketExists() {
|
||||
try {
|
||||
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
|
||||
if (!exists) {
|
||||
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Unable to ensure MinIO bucket exists: " + bucket, e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean looksAlreadyMigrated(String url) {
|
||||
String prefix = publicBaseUrl + "/" + bucket + "/";
|
||||
return url.startsWith(prefix);
|
||||
}
|
||||
|
||||
private static Optional<Long> parseLongSafe(String v) {
|
||||
try {
|
||||
return Optional.of(Long.parseLong(v));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static String extensionForContentType(String contentType) {
|
||||
String ct = contentType.toLowerCase(Locale.ROOT);
|
||||
if (ct.equals("image/jpeg") || ct.equals("image/jpg")) return ".jpg";
|
||||
if (ct.equals("image/png")) return ".png";
|
||||
if (ct.equals("image/webp")) return ".webp";
|
||||
if (ct.equals("image/gif")) return ".gif";
|
||||
if (ct.equals("image/svg+xml")) return ".svg";
|
||||
return ".bin";
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String s) {
|
||||
if (s == null) return "";
|
||||
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
package group.goforward.battlbuilder.service.utils;
|
||||
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@Profile("migrate-images-to-minio")
|
||||
public class MigrateProductImagesToMinioRunner implements CommandLineRunner {
|
||||
|
||||
private final ImageUrlToMinioMigrator migrator;
|
||||
|
||||
public MigrateProductImagesToMinioRunner(ImageUrlToMinioMigrator migrator) {
|
||||
this.migrator = migrator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
// Tune as needed. Start small; you can remove maxItems once you're confident.
|
||||
int migrated = migrator.migrateMainImages(
|
||||
200, // pageSize
|
||||
false, // dryRun
|
||||
1000 // maxItems safety cap
|
||||
);
|
||||
|
||||
System.out.println("Migrated product images: " + migrated);
|
||||
}
|
||||
}
|
||||
package group.goforward.battlbuilder.service.utils;
|
||||
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@Profile("migrate-images-to-minio")
|
||||
public class MigrateProductImagesToMinioRunner implements CommandLineRunner {
|
||||
|
||||
private final ImageUrlToMinioMigrator migrator;
|
||||
|
||||
public MigrateProductImagesToMinioRunner(ImageUrlToMinioMigrator migrator) {
|
||||
this.migrator = migrator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
// Tune as needed. Start small; you can remove maxItems once you're confident.
|
||||
int migrated = migrator.migrateMainImages(
|
||||
200, // pageSize
|
||||
false, // dryRun
|
||||
1000 // maxItems safety cap
|
||||
);
|
||||
|
||||
System.out.println("Migrated product images: " + migrated);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user