This commit is contained in:
2026-01-05 17:06:03 -05:00
parent 323d075982
commit 7c65311fad
118 changed files with 9835 additions and 9761 deletions

View File

@@ -1,87 +1,87 @@
package group.goforward.battlbuilder.catalog.classification; package group.goforward.battlbuilder.catalog.classification;
import group.goforward.battlbuilder.model.PartRoleRule; import group.goforward.battlbuilder.model.PartRoleRule;
import group.goforward.battlbuilder.repo.PartRoleRuleRepository; import group.goforward.battlbuilder.repo.PartRoleRuleRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@Component @Component
public class PartRoleResolver { public class PartRoleResolver {
private static final Logger log = LoggerFactory.getLogger(PartRoleResolver.class); private static final Logger log = LoggerFactory.getLogger(PartRoleResolver.class);
private final PartRoleRuleRepository repo; private final PartRoleRuleRepository repo;
private final List<CompiledRule> rules = new ArrayList<>(); private final List<CompiledRule> rules = new ArrayList<>();
public PartRoleResolver(PartRoleRuleRepository repo) { public PartRoleResolver(PartRoleRuleRepository repo) {
this.repo = repo; this.repo = repo;
} }
@PostConstruct @PostConstruct
public void load() { public void load() {
rules.clear(); rules.clear();
List<PartRoleRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc(); List<PartRoleRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
for (PartRoleRule r : active) { for (PartRoleRule r : active) {
try { try {
rules.add(new CompiledRule( rules.add(new CompiledRule(
r.getId(), r.getId(),
r.getTargetPlatform(), r.getTargetPlatform(),
Pattern.compile(r.getNameRegex(), Pattern.CASE_INSENSITIVE), Pattern.compile(r.getNameRegex(), Pattern.CASE_INSENSITIVE),
normalizeRole(r.getTargetPartRole()) normalizeRole(r.getTargetPartRole())
)); ));
} catch (Exception e) { } catch (Exception e) {
log.warn("Skipping invalid part role rule id={} regex={} err={}", log.warn("Skipping invalid part role rule id={} regex={} err={}",
r.getId(), r.getNameRegex(), e.getMessage()); r.getId(), r.getNameRegex(), e.getMessage());
} }
} }
log.info("Loaded {} part role rules", rules.size()); log.info("Loaded {} part role rules", rules.size());
} }
public String resolve(String platform, String productName, String rawCategoryKey) { public String resolve(String platform, String productName, String rawCategoryKey) {
String p = normalizePlatform(platform); String p = normalizePlatform(platform);
// we match primarily on productName; optionally also include rawCategoryKey in the text blob // we match primarily on productName; optionally also include rawCategoryKey in the text blob
String text = (productName == null ? "" : productName) + String text = (productName == null ? "" : productName) +
" " + " " +
(rawCategoryKey == null ? "" : rawCategoryKey); (rawCategoryKey == null ? "" : rawCategoryKey);
for (CompiledRule r : rules) { for (CompiledRule r : rules) {
if (!r.appliesToPlatform(p)) continue; if (!r.appliesToPlatform(p)) continue;
if (r.pattern.matcher(text).find()) { if (r.pattern.matcher(text).find()) {
return r.targetPartRole; // already normalized return r.targetPartRole; // already normalized
} }
} }
return null; return null;
} }
private static String normalizeRole(String role) { private static String normalizeRole(String role) {
if (role == null) return null; if (role == null) return null;
String t = role.trim(); String t = role.trim();
if (t.isEmpty()) return null; if (t.isEmpty()) return null;
return t.toLowerCase(Locale.ROOT).replace('_','-'); return t.toLowerCase(Locale.ROOT).replace('_','-');
} }
private static String normalizePlatform(String platform) { private static String normalizePlatform(String platform) {
if (platform == null) return null; if (platform == null) return null;
String t = platform.trim(); String t = platform.trim();
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT); return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
} }
private record CompiledRule(Long id, String targetPlatform, Pattern pattern, String targetPartRole) { private record CompiledRule(Long id, String targetPlatform, Pattern pattern, String targetPartRole) {
boolean appliesToPlatform(String platform) { boolean appliesToPlatform(String platform) {
if (targetPlatform == null || targetPlatform.isBlank()) return true; if (targetPlatform == null || targetPlatform.isBlank()) return true;
if (platform == null) return false; if (platform == null) return false;
return targetPlatform.trim().equalsIgnoreCase(platform); return targetPlatform.trim().equalsIgnoreCase(platform);
} }
} }
} }

View File

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

View File

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

View File

@@ -1,245 +1,245 @@
package group.goforward.battlbuilder.classification.admin; package group.goforward.battlbuilder.classification.admin;
import group.goforward.battlbuilder.classification.ClassificationResult; import group.goforward.battlbuilder.classification.ClassificationResult;
import group.goforward.battlbuilder.classification.ProductClassifier; import group.goforward.battlbuilder.classification.ProductClassifier;
import group.goforward.battlbuilder.model.MerchantCategoryMap; import group.goforward.battlbuilder.model.MerchantCategoryMap;
import group.goforward.battlbuilder.model.PartRoleSource; import group.goforward.battlbuilder.model.PartRoleSource;
import group.goforward.battlbuilder.model.Product; import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository; import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.repo.ProductOfferRepository; import group.goforward.battlbuilder.repo.ProductOfferRepository;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.*; import java.util.*;
@Service @Service
public class ClassificationReconcileService { public class ClassificationReconcileService {
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final ProductClassifier productClassifier; private final ProductClassifier productClassifier;
private final ProductOfferRepository productOfferRepository; private final ProductOfferRepository productOfferRepository;
public ClassificationReconcileService(ProductRepository productRepository, public ClassificationReconcileService(ProductRepository productRepository,
ProductClassifier productClassifier, ProductClassifier productClassifier,
ProductOfferRepository productOfferRepository) { ProductOfferRepository productOfferRepository) {
this.productRepository = productRepository; this.productRepository = productRepository;
this.productClassifier = productClassifier; this.productClassifier = productClassifier;
this.productOfferRepository = productOfferRepository; this.productOfferRepository = productOfferRepository;
} }
public ReconcileResponse reconcile(ReconcileRequest req) { public ReconcileResponse reconcile(ReconcileRequest req) {
int limit = req.limit(); int limit = req.limit();
boolean dryRun = req.dryRun(); boolean dryRun = req.dryRun();
// Page in chunks until we hit limit. // Page in chunks until we hit limit.
final int pageSize = Math.min(250, limit); final int pageSize = Math.min(250, limit);
int scanned = 0; int scanned = 0;
int page = 0; int page = 0;
// Counts by reconcile outcome. // Counts by reconcile outcome.
Map<String, Integer> counts = new LinkedHashMap<>(); Map<String, Integer> counts = new LinkedHashMap<>();
counts.put("UNCHANGED", 0); counts.put("UNCHANGED", 0);
counts.put("WOULD_UPDATE", 0); counts.put("WOULD_UPDATE", 0);
counts.put("LOCKED", 0); counts.put("LOCKED", 0);
counts.put("IGNORED", 0); counts.put("IGNORED", 0);
counts.put("UNMAPPED", 0); counts.put("UNMAPPED", 0);
counts.put("CONFLICT", 0); counts.put("CONFLICT", 0);
counts.put("RULE_MATCHED", 0); counts.put("RULE_MATCHED", 0);
// Sample rows to inspect quickly in API response. // Sample rows to inspect quickly in API response.
List<ReconcileDiffRow> samples = new ArrayList<>(); List<ReconcileDiffRow> samples = new ArrayList<>();
// Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1) // Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1)
Map<String, Optional<MerchantCategoryMap>> mappingMemo = new HashMap<>(); Map<String, Optional<MerchantCategoryMap>> mappingMemo = new HashMap<>();
while (scanned < limit) { while (scanned < limit) {
var pageable = PageRequest.of(page, pageSize); var pageable = PageRequest.of(page, pageSize);
// Merchant is inferred via offers; this query limits products to those with offers for req.merchantId if provided. // Merchant is inferred via offers; this query limits products to those with offers for req.merchantId if provided.
var batch = productRepository.pageActiveProductsByOfferMerchant(req.merchantId(), req.platform(), pageable); var batch = productRepository.pageActiveProductsByOfferMerchant(req.merchantId(), req.platform(), pageable);
if (batch.isEmpty()) break; if (batch.isEmpty()) break;
// Avoid N+1: resolve primary merchant for ALL products in this page in one query // Avoid N+1: resolve primary merchant for ALL products in this page in one query
List<Integer> productIds = batch.getContent().stream() List<Integer> productIds = batch.getContent().stream()
.map(Product::getId) .map(Product::getId)
.filter(Objects::nonNull) .filter(Objects::nonNull)
.toList(); .toList();
Map<Integer, Integer> primaryMerchantByProductId = new HashMap<>(); Map<Integer, Integer> primaryMerchantByProductId = new HashMap<>();
if (!productIds.isEmpty()) { if (!productIds.isEmpty()) {
productOfferRepository.findPrimaryMerchantsByFirstSeenForProductIds(productIds) productOfferRepository.findPrimaryMerchantsByFirstSeenForProductIds(productIds)
.forEach(r -> primaryMerchantByProductId.put(r.getProductId(), r.getMerchantId())); .forEach(r -> primaryMerchantByProductId.put(r.getProductId(), r.getMerchantId()));
} }
for (Product p : batch.getContent()) { for (Product p : batch.getContent()) {
if (scanned >= limit) break; if (scanned >= limit) break;
// Optional: skip locked products unless includeLocked=true. // Optional: skip locked products unless includeLocked=true.
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked()); boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
if (!req.includeLocked() && locked) { if (!req.includeLocked() && locked) {
counts.compute("LOCKED", (k, v) -> v + 1); counts.compute("LOCKED", (k, v) -> v + 1);
scanned++; scanned++;
continue; continue;
} }
// Run the classifier (merchant_category_map + other logic inside classifier). // Run the classifier (merchant_category_map + other logic inside classifier).
Integer resolvedMerchantId = primaryMerchantByProductId.get(p.getId()); Integer resolvedMerchantId = primaryMerchantByProductId.get(p.getId());
ClassificationResult resolved = productClassifier.classifyProduct(p, resolvedMerchantId, mappingMemo); ClassificationResult resolved = productClassifier.classifyProduct(p, resolvedMerchantId, mappingMemo);
if (resolved != null && resolved.source() != null && resolved.source().startsWith("rules_")) { if (resolved != null && resolved.source() != null && resolved.source().startsWith("rules_")) {
counts.compute("RULE_MATCHED", (k, v) -> v + 1); counts.compute("RULE_MATCHED", (k, v) -> v + 1);
} }
// Compute diff status (dry-run only right now). // Compute diff status (dry-run only right now).
DiffOutcome outcome = diff(p, resolved); DiffOutcome outcome = diff(p, resolved);
counts.compute(outcome.status, (k, v) -> v + 1); counts.compute(outcome.status, (k, v) -> v + 1);
scanned++; scanned++;
// Keep a small sample set for inspection. // Keep a small sample set for inspection.
boolean interestingStatus = boolean interestingStatus =
outcome.status.equals("WOULD_UPDATE") outcome.status.equals("WOULD_UPDATE")
|| outcome.status.equals("CONFLICT") || outcome.status.equals("CONFLICT")
|| outcome.status.equals("UNMAPPED") || outcome.status.equals("UNMAPPED")
|| outcome.status.equals("IGNORED"); || outcome.status.equals("IGNORED");
boolean ruleHit = boolean ruleHit =
resolved != null resolved != null
&& resolved.source() != null && resolved.source() != null
&& resolved.source().startsWith("rules_"); && resolved.source().startsWith("rules_");
// Keep a small sample set for inspection. // Keep a small sample set for inspection.
// Include rule hits even if the product ends up UNCHANGED, so we can verify rules are working. // Include rule hits even if the product ends up UNCHANGED, so we can verify rules are working.
if (samples.size() < 50 && (interestingStatus || ruleHit)) { if (samples.size() < 50 && (interestingStatus || ruleHit)) {
samples.add(toRow(p, resolved, outcome.status, outcome.meta)); samples.add(toRow(p, resolved, outcome.status, outcome.meta));
} }
} }
if (!batch.hasNext()) break; if (!batch.hasNext()) break;
page++; page++;
} }
// Dry-run only right now—no writes. // Dry-run only right now—no writes.
return new ReconcileResponse(dryRun, scanned, counts, samples); return new ReconcileResponse(dryRun, scanned, counts, samples);
} }
private static class DiffOutcome { private static class DiffOutcome {
final String status; final String status;
final Map<String, Object> meta; final Map<String, Object> meta;
DiffOutcome(String status, Map<String, Object> meta) { DiffOutcome(String status, Map<String, Object> meta) {
this.status = status; this.status = status;
this.meta = meta; this.meta = meta;
} }
} }
/** /**
* Compute reconcile status by comparing: * Compute reconcile status by comparing:
* - existing product role/source/locks * - existing product role/source/locks
* - classifier-resolved role/source/confidence/reason * - classifier-resolved role/source/confidence/reason
*/ */
private DiffOutcome diff(Product p, ClassificationResult resolved) { private DiffOutcome diff(Product p, ClassificationResult resolved) {
String existingRole = trimToNull(p.getPartRole()); String existingRole = trimToNull(p.getPartRole());
String resolvedRole = resolved == null ? null : trimToNull(resolved.partRole()); String resolvedRole = resolved == null ? null : trimToNull(resolved.partRole());
// Respect locks (never propose changes). // Respect locks (never propose changes).
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked()); boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
if (locked) { if (locked) {
return new DiffOutcome("LOCKED", Map.of("note", "partRoleLocked or platformLocked")); return new DiffOutcome("LOCKED", Map.of("note", "partRoleLocked or platformLocked"));
} }
// Treat known "ignored category" decisions as their own outcome bucket. // Treat known "ignored category" decisions as their own outcome bucket.
if (isIgnored(resolved)) { if (isIgnored(resolved)) {
return new DiffOutcome("IGNORED", Map.of( return new DiffOutcome("IGNORED", Map.of(
"reason", resolved.reason(), "reason", resolved.reason(),
"source", resolved.source() "source", resolved.source()
)); ));
} }
// No role resolved = still unmapped (needs merchant_category_map or rule-based inference later). // No role resolved = still unmapped (needs merchant_category_map or rule-based inference later).
if (resolvedRole == null) { if (resolvedRole == null) {
return new DiffOutcome("UNMAPPED", Map.of( return new DiffOutcome("UNMAPPED", Map.of(
"reason", resolved == null ? null : resolved.reason(), "reason", resolved == null ? null : resolved.reason(),
"source", resolved == null ? null : resolved.source(), "source", resolved == null ? null : resolved.source(),
"confidence", resolved == null ? null : resolved.confidence() "confidence", resolved == null ? null : resolved.confidence()
)); ));
} }
// Existing role missing but we resolved one => would update. // Existing role missing but we resolved one => would update.
if (existingRole == null) { if (existingRole == null) {
return new DiffOutcome("WOULD_UPDATE", Map.of("from", null, "to", resolvedRole)); return new DiffOutcome("WOULD_UPDATE", Map.of("from", null, "to", resolvedRole));
} }
// Same role => unchanged. // Same role => unchanged.
if (existingRole.equalsIgnoreCase(resolvedRole)) { if (existingRole.equalsIgnoreCase(resolvedRole)) {
return new DiffOutcome("UNCHANGED", Map.of()); return new DiffOutcome("UNCHANGED", Map.of());
} }
// If existing role came from an override, flag as conflict (don't auto-clobber). // If existing role came from an override, flag as conflict (don't auto-clobber).
PartRoleSource existingSource = p.getPartRoleSource(); PartRoleSource existingSource = p.getPartRoleSource();
if (existingSource == PartRoleSource.OVERRIDE) { if (existingSource == PartRoleSource.OVERRIDE) {
return new DiffOutcome( return new DiffOutcome(
"CONFLICT", "CONFLICT",
Map.of("from", existingRole, "to", resolvedRole, "note", "existing source is OVERRIDE") Map.of("from", existingRole, "to", resolvedRole, "note", "existing source is OVERRIDE")
); );
} }
// Otherwise, its a normal drift that we would update in apply-mode. // Otherwise, its a normal drift that we would update in apply-mode.
return new DiffOutcome("WOULD_UPDATE", Map.of("from", existingRole, "to", resolvedRole)); return new DiffOutcome("WOULD_UPDATE", Map.of("from", existingRole, "to", resolvedRole));
} }
/** /**
* Detect the "non-classifying category ignored" decisions from the classifier. * Detect the "non-classifying category ignored" decisions from the classifier.
* Right now we key off the reason text prefix (fast + simple). * Right now we key off the reason text prefix (fast + simple).
* Later we can formalize this via a dedicated source or meta flag. * Later we can formalize this via a dedicated source or meta flag.
*/ */
private boolean isIgnored(ClassificationResult r) { private boolean isIgnored(ClassificationResult r) {
return r != null && "ignored_category".equalsIgnoreCase(r.source()); return r != null && "ignored_category".equalsIgnoreCase(r.source());
} }
private ReconcileDiffRow toRow(Product p, ClassificationResult resolved, String status, Map<String, Object> meta) { private ReconcileDiffRow toRow(Product p, ClassificationResult resolved, String status, Map<String, Object> meta) {
// Extract the merchant used by classifier (if provided in meta). // Extract the merchant used by classifier (if provided in meta).
Integer resolvedMerchantId = null; Integer resolvedMerchantId = null;
if (resolved != null && resolved.meta() != null) { if (resolved != null && resolved.meta() != null) {
Object v = resolved.meta().get("resolvedMerchantId"); Object v = resolved.meta().get("resolvedMerchantId");
if (v instanceof Integer i) resolvedMerchantId = i; if (v instanceof Integer i) resolvedMerchantId = i;
else if (v instanceof Number n) resolvedMerchantId = n.intValue(); else if (v instanceof Number n) resolvedMerchantId = n.intValue();
else if (v instanceof String s) { else if (v instanceof String s) {
try { resolvedMerchantId = Integer.parseInt(s); } catch (Exception ignored) {} try { resolvedMerchantId = Integer.parseInt(s); } catch (Exception ignored) {}
} }
} }
return new ReconcileDiffRow( return new ReconcileDiffRow(
p.getId(), p.getId(),
p.getName(), p.getName(),
p.getPlatform(), p.getPlatform(),
p.getRawCategoryKey(), p.getRawCategoryKey(),
resolvedMerchantId, resolvedMerchantId,
p.getPartRole(), p.getPartRole(),
p.getPartRoleSource() == null ? null : p.getPartRoleSource().name(), p.getPartRoleSource() == null ? null : p.getPartRoleSource().name(),
p.getPartRoleLocked(), p.getPartRoleLocked(),
p.getPlatformLocked(), p.getPlatformLocked(),
resolved == null ? null : resolved.partRole(), resolved == null ? null : resolved.partRole(),
resolved == null ? null : resolved.source(), resolved == null ? null : resolved.source(),
resolved == null ? 0.0 : resolved.confidence(), resolved == null ? 0.0 : resolved.confidence(),
resolved == null ? null : resolved.reason(), resolved == null ? null : resolved.reason(),
status, status,
meta == null ? Map.of() : meta meta == null ? Map.of() : meta
); );
} }
private static String trimToNull(String s) { private static String trimToNull(String s) {
if (s == null) return null; if (s == null) return null;
String t = s.trim(); String t = s.trim();
return t.isEmpty() ? null : t; return t.isEmpty() ? null : t;
} }
} }

View File

@@ -1,474 +1,474 @@
package group.goforward.battlbuilder.classification.impl; package group.goforward.battlbuilder.classification.impl;
import group.goforward.battlbuilder.classification.ClassificationResult; import group.goforward.battlbuilder.classification.ClassificationResult;
import group.goforward.battlbuilder.classification.ProductClassifier; import group.goforward.battlbuilder.classification.ProductClassifier;
import group.goforward.battlbuilder.model.MerchantCategoryMap; import group.goforward.battlbuilder.model.MerchantCategoryMap;
import group.goforward.battlbuilder.model.Product; import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.repo.ProductOfferRepository; import group.goforward.battlbuilder.repo.ProductOfferRepository;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.HashMap; import java.util.HashMap;
@Component @Component
public class ProductClassifierImpl implements ProductClassifier { public class ProductClassifierImpl implements ProductClassifier {
/** /**
* Bump this whenever you change classification logic. * Bump this whenever you change classification logic.
* Useful for debugging and for "reconcile" runs. * Useful for debugging and for "reconcile" runs.
*/ */
private static final String VERSION = "2025.12.29"; private static final String VERSION = "2025.12.29";
/** /**
* Non-classifying "categories" some merchants use as merchandising labels. * Non-classifying "categories" some merchants use as merchandising labels.
* These are orthogonal to part role (condition/marketing), so we ignore them * These are orthogonal to part role (condition/marketing), so we ignore them
* to avoid polluting merchant_category_map. * to avoid polluting merchant_category_map.
*/ */
// IMPORTANT: // IMPORTANT:
// These tokens represent NON-SEMANTIC merchant categories. // These tokens represent NON-SEMANTIC merchant categories.
// They should NEVER be mapped to part roles. // They should NEVER be mapped to part roles.
// If you're tempted to add them to merchant_category_map, add them here instead. // If you're tempted to add them to merchant_category_map, add them here instead.
private static final Set<String> NON_CLASSIFYING_TOKENS = Set.of( private static final Set<String> NON_CLASSIFYING_TOKENS = Set.of(
// marketing / promos // marketing / promos
"sale", "sale",
"clearance", "clearance",
"deal", "deal",
"special", "special",
"markdown", "markdown",
"promo", "promo",
// merchandising buckets // merchandising buckets
"general", "general",
"apparel", "apparel",
"accessories", "accessories",
"parts", "parts",
"spare parts", "spare parts",
"shop all", "shop all",
"lineup", "lineup",
"collection", "collection",
// bundles / kits / sets // bundles / kits / sets
"bundle", "bundle",
"kit", "kit",
"set", "set",
"builder set", "builder set",
// color / variant groupings // color / variant groupings
"colors", "colors",
"finish", "finish",
"variant", "variant",
// caliber / platform / type filters (not part roles) // caliber / platform / type filters (not part roles)
"bolt action", "bolt action",
"ar15", "ar15",
"ar-15", "ar-15",
"rifles", "rifles",
"creedmoor", "creedmoor",
"winchester", "winchester",
"grendel", "grendel",
"legend", "legend",
// promo shelves // promo shelves
"savings", "savings",
// brand shelves // brand shelves
"magpul", "magpul",
// caliber shelves (not part roles) // caliber shelves (not part roles)
"5.56", "5.56",
"5.56 nato", "5.56 nato",
"223", "223",
".223", ".223",
"223 wylde", "223 wylde",
".223 wylde", ".223 wylde",
"wylde", "wylde",
"nato" "nato"
); );
private final MerchantCategoryMapRepository merchantCategoryMapRepository; private final MerchantCategoryMapRepository merchantCategoryMapRepository;
private final ProductOfferRepository productOfferRepository; private final ProductOfferRepository productOfferRepository;
public ProductClassifierImpl(MerchantCategoryMapRepository merchantCategoryMapRepository, public ProductClassifierImpl(MerchantCategoryMapRepository merchantCategoryMapRepository,
ProductOfferRepository productOfferRepository) { ProductOfferRepository productOfferRepository) {
this.merchantCategoryMapRepository = merchantCategoryMapRepository; this.merchantCategoryMapRepository = merchantCategoryMapRepository;
this.productOfferRepository = productOfferRepository; this.productOfferRepository = productOfferRepository;
} }
@Override @Override
public ClassificationResult classifyProduct(Product product) { public ClassificationResult classifyProduct(Product product) {
// Backwards compatible path (existing callers) // Backwards compatible path (existing callers)
return classifyProduct(product, null); return classifyProduct(product, null);
} }
@Override @Override
public ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId) { public ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId) {
// ===== Guardrails ===== // ===== Guardrails =====
if (product == null || product.getId() == null) { if (product == null || product.getId() == null) {
return ClassificationResult.unknown(VERSION, "Missing product or product.id."); return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
} }
if (isBlank(product.getRawCategoryKey())) { if (isBlank(product.getRawCategoryKey())) {
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product."); return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
} }
// Normalize inputs early so we don't accidentally compare mismatched strings. // Normalize inputs early so we don't accidentally compare mismatched strings.
String rawCategory = normalizeRawCategory(product.getRawCategoryKey()); String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
String platform = normalizePlatform(product.getPlatform()); // may be null String platform = normalizePlatform(product.getPlatform()); // may be null
// Resolve merchant-of-record: // Resolve merchant-of-record:
// - Prefer caller-provided merchantId (batch-resolved in reconcile to avoid N+1) // - Prefer caller-provided merchantId (batch-resolved in reconcile to avoid N+1)
// - Fall back to DB lookup for normal runtime usage // - Fall back to DB lookup for normal runtime usage
Integer merchantId = resolvedMerchantId; Integer merchantId = resolvedMerchantId;
if (merchantId == null) { if (merchantId == null) {
merchantId = productOfferRepository merchantId = productOfferRepository
.findPrimaryMerchantIdByFirstSeen(product.getId()) .findPrimaryMerchantIdByFirstSeen(product.getId())
.orElse(null); .orElse(null);
} }
// ===== Ignore non-classifying categories (e.g. Clearance/Blemished/Caliber shelves) ===== // ===== Ignore non-classifying categories (e.g. Clearance/Blemished/Caliber shelves) =====
if (isNonClassifyingCategory(rawCategory)) { if (isNonClassifyingCategory(rawCategory)) {
return new ClassificationResult( return new ClassificationResult(
null, null,
"ignored_category", "ignored_category",
"Ignored non-classifying merchant category: " + rawCategory, "Ignored non-classifying merchant category: " + rawCategory,
VERSION, VERSION,
0.0, 0.0,
meta( meta(
"resolvedMerchantId", merchantId, "resolvedMerchantId", merchantId,
"rawCategory", rawCategory, "rawCategory", rawCategory,
"platform", platform "platform", platform
) )
); );
} }
// If we need merchant mapping or rules, merchantId is required // If we need merchant mapping or rules, merchantId is required
if (merchantId == null) { if (merchantId == null) {
return ClassificationResult.unknown( return ClassificationResult.unknown(
VERSION, VERSION,
"No offers found for product; cannot determine merchant mapping." "No offers found for product; cannot determine merchant mapping."
); );
} }
// ===== Rule-based split: broad "Gas System" buckets ===== // ===== Rule-based split: broad "Gas System" buckets =====
if (isGasSystemBucket(rawCategory)) { if (isGasSystemBucket(rawCategory)) {
String name = (product.getName() == null) ? "" : product.getName(); String name = (product.getName() == null) ? "" : product.getName();
String n = name.toLowerCase(Locale.ROOT); String n = name.toLowerCase(Locale.ROOT);
if (containsAny(n, "gas block + tube", "gas block and tube", "gas block w/ tube", "gas block with tube", "block/tube", "block + tube")) { if (containsAny(n, "gas block + tube", "gas block and tube", "gas block w/ tube", "gas block with tube", "block/tube", "block + tube")) {
return new ClassificationResult( return new ClassificationResult(
"gas-block-tube-combo", "gas-block-tube-combo",
"rules_gas_system", "rules_gas_system",
"Gas System bucket: inferred gas-block-tube-combo from name.", "Gas System bucket: inferred gas-block-tube-combo from name.",
VERSION, VERSION,
0.92, 0.92,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:combo") meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:combo")
); );
} }
if (containsAny(n, "roll pin", "gas tube roll pin", "gastube roll pin", "tube roll pin")) { if (containsAny(n, "roll pin", "gas tube roll pin", "gastube roll pin", "tube roll pin")) {
return new ClassificationResult( return new ClassificationResult(
"gas-tube-roll-pin", "gas-tube-roll-pin",
"rules_gas_system", "rules_gas_system",
"Gas System bucket: inferred gas-tube-roll-pin from name.", "Gas System bucket: inferred gas-tube-roll-pin from name.",
VERSION, VERSION,
0.90, 0.90,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:roll-pin") meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:roll-pin")
); );
} }
if (containsAny(n, "gas tube", "gastube")) { if (containsAny(n, "gas tube", "gastube")) {
return new ClassificationResult( return new ClassificationResult(
"gas-tube", "gas-tube",
"rules_gas_system", "rules_gas_system",
"Gas System bucket: inferred gas-tube from name.", "Gas System bucket: inferred gas-tube from name.",
VERSION, VERSION,
0.90, 0.90,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:tube") meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:tube")
); );
} }
if (containsAny(n, "gas block", "gasblock")) { if (containsAny(n, "gas block", "gasblock")) {
return new ClassificationResult( return new ClassificationResult(
"gas-block", "gas-block",
"rules_gas_system", "rules_gas_system",
"Gas System bucket: inferred gas-block from name.", "Gas System bucket: inferred gas-block from name.",
VERSION, VERSION,
0.90, 0.90,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:block") meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:block")
); );
} }
return new ClassificationResult( return new ClassificationResult(
null, null,
"rules_gas_system", "rules_gas_system",
"Gas System bucket: no confident keyword match (leave unmapped).", "Gas System bucket: no confident keyword match (leave unmapped).",
VERSION, VERSION,
0.0, 0.0,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:none") meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:none")
); );
} }
// ===== Primary classification: merchant_category_map lookup ===== // ===== Primary classification: merchant_category_map lookup =====
Optional<MerchantCategoryMap> best = Optional<MerchantCategoryMap> best =
merchantCategoryMapRepository.findBest(merchantId, rawCategory, platform); merchantCategoryMapRepository.findBest(merchantId, rawCategory, platform);
if (best.isPresent()) { if (best.isPresent()) {
MerchantCategoryMap map = best.get(); MerchantCategoryMap map = best.get();
String role = trimToNull(map.getCanonicalPartRole()); String role = trimToNull(map.getCanonicalPartRole());
if (role == null) { if (role == null) {
return new ClassificationResult( return new ClassificationResult(
null, null,
"merchant_mapping", "merchant_mapping",
"Mapping found but canonicalPartRole is empty (needs admin mapping).", "Mapping found but canonicalPartRole is empty (needs admin mapping).",
VERSION, VERSION,
0.20, 0.20,
meta( meta(
"resolvedMerchantId", merchantId, "resolvedMerchantId", merchantId,
"rawCategory", rawCategory, "rawCategory", rawCategory,
"platform", platform, "platform", platform,
"mapId", map.getId(), "mapId", map.getId(),
"mapPlatform", map.getPlatform() "mapPlatform", map.getPlatform()
) )
); );
} }
double confidence = double confidence =
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 : platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 : "ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
map.getPlatform() == null ? 0.90 : 0.90; map.getPlatform() == null ? 0.90 : 0.90;
return new ClassificationResult( return new ClassificationResult(
role, role,
"merchant_mapping", "merchant_mapping",
"Mapped via merchant_category_map (canonical_part_role).", "Mapped via merchant_category_map (canonical_part_role).",
VERSION, VERSION,
confidence, confidence,
meta( meta(
"resolvedMerchantId", merchantId, "resolvedMerchantId", merchantId,
"rawCategory", rawCategory, "rawCategory", rawCategory,
"platform", platform, "platform", platform,
"mapId", map.getId(), "mapId", map.getId(),
"mapPlatform", map.getPlatform() "mapPlatform", map.getPlatform()
) )
); );
} }
return new ClassificationResult( return new ClassificationResult(
null, null,
"merchant_mapping", "merchant_mapping",
"No enabled mapping found for resolved merchant/rawCategory/platform.", "No enabled mapping found for resolved merchant/rawCategory/platform.",
VERSION, VERSION,
0.0, 0.0,
meta( meta(
"resolvedMerchantId", merchantId, "resolvedMerchantId", merchantId,
"rawCategory", rawCategory, "rawCategory", rawCategory,
"platform", platform "platform", platform
) )
); );
} }
@Override @Override
public ClassificationResult classifyProduct( public ClassificationResult classifyProduct(
Product product, Product product,
Integer resolvedMerchantId, Integer resolvedMerchantId,
Map<String, Optional<MerchantCategoryMap>> mappingMemo Map<String, Optional<MerchantCategoryMap>> mappingMemo
) { ) {
// Safety: if caller passes null, fall back cleanly // Safety: if caller passes null, fall back cleanly
if (mappingMemo == null) { if (mappingMemo == null) {
mappingMemo = new HashMap<>(); mappingMemo = new HashMap<>();
} }
// ===== Guardrails ===== // ===== Guardrails =====
if (product == null || product.getId() == null) { if (product == null || product.getId() == null) {
return ClassificationResult.unknown(VERSION, "Missing product or product.id."); return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
} }
if (isBlank(product.getRawCategoryKey())) { if (isBlank(product.getRawCategoryKey())) {
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product."); return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
} }
String rawCategory = normalizeRawCategory(product.getRawCategoryKey()); String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
String platform = normalizePlatform(product.getPlatform()); String platform = normalizePlatform(product.getPlatform());
Integer merchantId = resolvedMerchantId; Integer merchantId = resolvedMerchantId;
if (merchantId == null) { if (merchantId == null) {
merchantId = productOfferRepository merchantId = productOfferRepository
.findPrimaryMerchantIdByFirstSeen(product.getId()) .findPrimaryMerchantIdByFirstSeen(product.getId())
.orElse(null); .orElse(null);
} }
// Ignore merchandising/filters // Ignore merchandising/filters
if (isNonClassifyingCategory(rawCategory)) { if (isNonClassifyingCategory(rawCategory)) {
return new ClassificationResult( return new ClassificationResult(
null, null,
"ignored_category", "ignored_category",
"Ignored non-classifying merchant category: " + rawCategory, "Ignored non-classifying merchant category: " + rawCategory,
VERSION, VERSION,
0.0, 0.0,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform) meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
); );
} }
if (merchantId == null) { if (merchantId == null) {
return ClassificationResult.unknown( return ClassificationResult.unknown(
VERSION, VERSION,
"No offers found for product; cannot determine merchant mapping." "No offers found for product; cannot determine merchant mapping."
); );
} }
// ===== Gas System rules (unchanged) ===== // ===== Gas System rules (unchanged) =====
if (isGasSystemBucket(rawCategory)) { if (isGasSystemBucket(rawCategory)) {
// reuse your existing logic by calling the 2-arg version // reuse your existing logic by calling the 2-arg version
return classifyProduct(product, merchantId); return classifyProduct(product, merchantId);
} }
// ===== Memoized merchant_category_map lookup ===== // ===== Memoized merchant_category_map lookup =====
// Key is normalized to maximize cache hits across equivalent strings. // Key is normalized to maximize cache hits across equivalent strings.
String memoKey = String memoKey =
merchantId + "|" + merchantId + "|" +
(platform == null ? "null" : platform.toUpperCase(Locale.ROOT)) + "|" + (platform == null ? "null" : platform.toUpperCase(Locale.ROOT)) + "|" +
rawCategory.toLowerCase(Locale.ROOT).trim(); rawCategory.toLowerCase(Locale.ROOT).trim();
final Integer mid = merchantId; final Integer mid = merchantId;
final String rc = rawCategory; final String rc = rawCategory;
final String pl = platform; final String pl = platform;
Optional<MerchantCategoryMap> best = mappingMemo.computeIfAbsent( Optional<MerchantCategoryMap> best = mappingMemo.computeIfAbsent(
memoKey, memoKey,
k -> merchantCategoryMapRepository.findBest(mid, rc, pl) k -> merchantCategoryMapRepository.findBest(mid, rc, pl)
); );
if (best.isPresent()) { if (best.isPresent()) {
MerchantCategoryMap map = best.get(); MerchantCategoryMap map = best.get();
String role = trimToNull(map.getCanonicalPartRole()); String role = trimToNull(map.getCanonicalPartRole());
if (role == null) { if (role == null) {
return new ClassificationResult( return new ClassificationResult(
null, null,
"merchant_mapping", "merchant_mapping",
"Mapping found but canonicalPartRole is empty (needs admin mapping).", "Mapping found but canonicalPartRole is empty (needs admin mapping).",
VERSION, VERSION,
0.20, 0.20,
meta( meta(
"resolvedMerchantId", merchantId, "resolvedMerchantId", merchantId,
"rawCategory", rawCategory, "rawCategory", rawCategory,
"platform", platform, "platform", platform,
"mapId", map.getId(), "mapId", map.getId(),
"mapPlatform", map.getPlatform() "mapPlatform", map.getPlatform()
) )
); );
} }
double confidence = double confidence =
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 : platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 : "ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
map.getPlatform() == null ? 0.90 : 0.90; map.getPlatform() == null ? 0.90 : 0.90;
return new ClassificationResult( return new ClassificationResult(
role, role,
"merchant_mapping", "merchant_mapping",
"Mapped via merchant_category_map (canonical_part_role).", "Mapped via merchant_category_map (canonical_part_role).",
VERSION, VERSION,
confidence, confidence,
meta( meta(
"resolvedMerchantId", merchantId, "resolvedMerchantId", merchantId,
"rawCategory", rawCategory, "rawCategory", rawCategory,
"platform", platform, "platform", platform,
"mapId", map.getId(), "mapId", map.getId(),
"mapPlatform", map.getPlatform() "mapPlatform", map.getPlatform()
) )
); );
} }
return new ClassificationResult( return new ClassificationResult(
null, null,
"merchant_mapping", "merchant_mapping",
"No enabled mapping found for resolved merchant/rawCategory/platform.", "No enabled mapping found for resolved merchant/rawCategory/platform.",
VERSION, VERSION,
0.0, 0.0,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform) meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
); );
} }
/** /**
* Returns true if this raw category is a merchandising/condition label rather than a taxonomy path. * Returns true if this raw category is a merchandising/condition label rather than a taxonomy path.
*/ */
private boolean isNonClassifyingCategory(String rawCategory) { private boolean isNonClassifyingCategory(String rawCategory) {
if (rawCategory == null) return false; if (rawCategory == null) return false;
String v = rawCategory.toLowerCase(Locale.ROOT).trim(); String v = rawCategory.toLowerCase(Locale.ROOT).trim();
// If it's a breadcrumb/taxonomy path, DO NOT treat it as a merchandising label. // If it's a breadcrumb/taxonomy path, DO NOT treat it as a merchandising label.
// Example: "Gunsmithing > ... > Gun Care & Accessories > Ar-15 Complete Uppers" // Example: "Gunsmithing > ... > Gun Care & Accessories > Ar-15 Complete Uppers"
if (v.contains(">")) return false; if (v.contains(">")) return false;
// For flat categories, apply token matching. // For flat categories, apply token matching.
return NON_CLASSIFYING_TOKENS.stream().anyMatch(v::contains); return NON_CLASSIFYING_TOKENS.stream().anyMatch(v::contains);
} }
/** /**
* Normalize merchant-provided category strings. * Normalize merchant-provided category strings.
* Keep it light: trim + collapse whitespace. * Keep it light: trim + collapse whitespace.
* (If needed later: unify case or canonicalize separators.) * (If needed later: unify case or canonicalize separators.)
*/ */
private String normalizeRawCategory(String raw) { private String normalizeRawCategory(String raw) {
if (raw == null) return null; if (raw == null) return null;
return raw.trim().replaceAll("\\s+", " "); return raw.trim().replaceAll("\\s+", " ");
} }
/** /**
* Normalize platform values. We keep it consistent with expected inputs like "AR-15". * Normalize platform values. We keep it consistent with expected inputs like "AR-15".
* Uppercasing is okay because "AR-15" remains "AR-15". * Uppercasing is okay because "AR-15" remains "AR-15".
*/ */
private String normalizePlatform(String p) { private String normalizePlatform(String p) {
if (p == null) return null; if (p == null) return null;
String t = p.trim(); String t = p.trim();
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT); return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
} }
private boolean isBlank(String s) { private boolean isBlank(String s) {
return s == null || s.trim().isEmpty(); return s == null || s.trim().isEmpty();
} }
private String trimToNull(String s) { private String trimToNull(String s) {
if (s == null) return null; if (s == null) return null;
String t = s.trim(); String t = s.trim();
return t.isEmpty() ? null : t; return t.isEmpty() ? null : t;
} }
private boolean isGasSystemBucket(String rawCategory) { private boolean isGasSystemBucket(String rawCategory) {
if (rawCategory == null) return false; if (rawCategory == null) return false;
String v = rawCategory.toLowerCase(Locale.ROOT); String v = rawCategory.toLowerCase(Locale.ROOT);
return v.contains("gas system"); return v.contains("gas system");
} }
private boolean containsAny(String haystack, String... needles) { private boolean containsAny(String haystack, String... needles) {
if (haystack == null) return false; if (haystack == null) return false;
for (String n : needles) { for (String n : needles) {
if (n != null && !n.isBlank() && haystack.contains(n)) return true; if (n != null && !n.isBlank() && haystack.contains(n)) return true;
} }
return false; return false;
} }
/** /**
* Safe metadata builder. * Safe metadata builder.
* IMPORTANT: Map.of(...) throws if any key/value is null; this helper skips null entries. * IMPORTANT: Map.of(...) throws if any key/value is null; this helper skips null entries.
*/ */
private static Map<String, Object> meta(Object... kv) { private static Map<String, Object> meta(Object... kv) {
Map<String, Object> m = new LinkedHashMap<>(); Map<String, Object> m = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) { for (int i = 0; i < kv.length; i += 2) {
String k = (String) kv[i]; String k = (String) kv[i];
Object v = kv[i + 1]; Object v = kv[i + 1];
if (k != null && v != null) m.put(k, v); if (k != null && v != null) m.put(k, v);
} }
return m; return m;
} }
} }

View File

@@ -1,45 +1,45 @@
package group.goforward.battlbuilder.cli; package group.goforward.battlbuilder.cli;
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService; import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
@Profile("!prod") @Profile("!prod")
@ConditionalOnProperty( @ConditionalOnProperty(
name = "app.beta.invite.run", name = "app.beta.invite.run",
havingValue = "true" havingValue = "true"
) )
public class BetaInviteCliRunner implements CommandLineRunner { public class BetaInviteCliRunner implements CommandLineRunner {
private final BetaInviteService inviteService; private final BetaInviteService inviteService;
@Value("${app.beta.invite.limit:0}") @Value("${app.beta.invite.limit:0}")
private int limit; private int limit;
@Value("${app.beta.invite.dryRun:true}") @Value("${app.beta.invite.dryRun:true}")
private boolean dryRun; private boolean dryRun;
@Value("${app.beta.invite.tokenMinutes:30}") @Value("${app.beta.invite.tokenMinutes:30}")
private int tokenMinutes; private int tokenMinutes;
public BetaInviteCliRunner(BetaInviteService inviteService) { public BetaInviteCliRunner(BetaInviteService inviteService) {
this.inviteService = inviteService; this.inviteService = inviteService;
} }
@Override @Override
public void run(String... args) { public void run(String... args) {
int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun); int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
System.out.println( System.out.println(
"✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun "✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun
); );
// Exit so it behaves like a CLI command // Exit so it behaves like a CLI command
System.exit(0); System.exit(0);
} }
} }

View File

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

View File

@@ -1,22 +1,22 @@
package group.goforward.battlbuilder.config; package group.goforward.battlbuilder.config;
import io.minio.MinioClient; import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@Configuration @Configuration
public class MinioConfig { public class MinioConfig {
@Bean @Bean
public MinioClient minioClient( public MinioClient minioClient(
@Value("${minio.endpoint}") String endpoint, @Value("${minio.endpoint}") String endpoint,
@Value("${minio.access-key}") String accessKey, @Value("${minio.access-key}") String accessKey,
@Value("${minio.secret-key}") String secretKey @Value("${minio.secret-key}") String secretKey
) { ) {
return MinioClient.builder() return MinioClient.builder()
.endpoint(endpoint) .endpoint(endpoint)
.credentials(accessKey, secretKey) .credentials(accessKey, secretKey)
.build(); .build();
} }
} }

View File

@@ -1,17 +1,17 @@
/* /*
package group.goforward.battlbuilder.configuration; package group.goforward.battlbuilder.configuration;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration @Configuration
public class PasswordConfig { public class PasswordConfig {
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
// // BCrypt default password // // BCrypt default password
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
}*/ }*/

View File

@@ -1,93 +1,93 @@
package group.goforward.battlbuilder.config; package group.goforward.battlbuilder.config;
import group.goforward.battlbuilder.security.JwtAuthenticationFilter; import group.goforward.battlbuilder.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List; import java.util.List;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.jwtAuthenticationFilter = jwtAuthenticationFilter;
} }
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(c -> c.configurationSource(corsConfigurationSource())) .cors(c -> c.configurationSource(corsConfigurationSource()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// ---------------------------- // ----------------------------
// Public // Public
// ---------------------------- // ----------------------------
.requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll() .requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/products/gunbuilder/**").permitAll() .requestMatchers("/api/products/gunbuilder/**").permitAll()
// Public builds feed + public build detail (1 path segment only) // Public builds feed + public build detail (1 path segment only)
.requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/builds/*").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/builds/*").permitAll()
// ---------------------------- // ----------------------------
// Protected // Protected
// ---------------------------- // ----------------------------
.requestMatchers("/api/v1/builds/me/**").authenticated() .requestMatchers("/api/v1/builds/me/**").authenticated()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
// Everything else (adjust later as you lock down) // Everything else (adjust later as you lock down)
.anyRequest().permitAll() .anyRequest().permitAll()
) )
// run JWT before AnonymousAuth sets principal="anonymousUser" // run JWT before AnonymousAuth sets principal="anonymousUser"
.addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class);
return http.build(); return http.build();
} }
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration(); CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("http://localhost:3000")); cfg.setAllowedOrigins(List.of("http://localhost:3000"));
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type")); cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
cfg.setExposedHeaders(List.of("Authorization")); cfg.setExposedHeaders(List.of("Authorization"));
cfg.setAllowCredentials(true); cfg.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg); source.registerCorsConfiguration("/**", cfg);
return source; return source;
} }
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
@Bean @Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
throws Exception { throws Exception {
return configuration.getAuthenticationManager(); return configuration.getAuthenticationManager();
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,55 @@
package group.goforward.battlbuilder.controller; package group.goforward.battlbuilder.controller;
import group.goforward.battlbuilder.repo.PartCategoryRepository; import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto; import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@RestController /**
@RequestMapping({"/api/categories", "/api/v1/categories"}) * REST controller for managing part categories.
@CrossOrigin // you can tighten origins later *
public class CategoryController { * This controller provides endpoints for retrieving and interacting with
* part category data through its associated repository. Part categories are
private final PartCategoryRepository partCategories; * sorted based on their group name, sort order, and name in ascending order.
*
public CategoryController(PartCategoryRepository partCategories) { * Annotations:
this.partCategories = partCategories; * - {@code @RestController}: Indicates that this class is a REST controller.
} * - {@code @RequestMapping}: Maps HTTP requests to specific endpoints. Supported
* paths are "/api/categories" and "/api/v1/categories".
@GetMapping * - {@code @CrossOrigin}: Enables cross-origin requests.
public List<PartCategoryDto> list() { *
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc() * Constructor:
.stream() * - {@code CategoryController(PartCategoryRepository partCategories)}: Initializes
.map(pc -> new PartCategoryDto( * the controller with the specified repository for accessing part category data.
pc.getId(), *
pc.getSlug(), * Methods:
pc.getName(), * - {@code List<PartCategoryDto> list()}: Retrieves a list of part categories from
pc.getDescription(), * the repository, sorts them, and maps them to DTO objects for output.
pc.getGroupName(), */
pc.getSortOrder() @RestController
)) @RequestMapping({"/api/categories", "/api/v1/categories"})
.toList(); @CrossOrigin // you can tighten origins later
} public class CategoryController {
private final PartCategoryRepository partCategories;
public CategoryController(PartCategoryRepository partCategories) {
this.partCategories = partCategories;
}
@GetMapping
public List<PartCategoryDto> list() {
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream()
.map(pc -> new PartCategoryDto(
pc.getId(),
pc.getSlug(),
pc.getName(),
pc.getDescription(),
pc.getGroupName(),
pc.getSortOrder()
))
.toList();
}
} }

View File

@@ -1,48 +1,60 @@
package group.goforward.battlbuilder.controller; package group.goforward.battlbuilder.controller;
import group.goforward.battlbuilder.repo.EmailRequestRepository; import group.goforward.battlbuilder.repo.EmailRequestRepository;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.net.URI; import java.net.URI;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@RestController /**
@RequestMapping("/api/email") * The EmailTrackingController handles tracking of email-related events such as
public class EmailTrackingController { * email opens and link clicks. This controller provides endpoints to record
* these events and return appropriate responses.
// 1x1 transparent GIF *
private static final byte[] PIXEL = new byte[] { * The tracking of email opens is achieved through a transparent 1x1 GIF image,
71,73,70,56,57,97,1,0,1,0,-128,0,0,0,0,0,-1,-1,-1,33,-7,4,1,0,0,0,0,44,0,0,0,0,1,0,1,0,0,2,2,68,1,0,59 * and link clicks are redirected to the intended URL while capturing relevant metadata.
}; *
* Request mappings:
private final EmailRequestRepository repo; * 1. "/api/email/open/{id}" - Tracks email open events.
* 2. "/api/email/click/{id}" - Tracks email link click events.
public EmailTrackingController(EmailRequestRepository repo) { */
this.repo = repo; @RestController
} @RequestMapping("/api/email")
public class EmailTrackingController {
@GetMapping(value = "/open/{id}", produces = "image/gif")
public ResponseEntity<byte[]> open(@PathVariable Long id) { // 1x1 transparent GIF
repo.findById(id).ifPresent(r -> { private static final byte[] PIXEL = new byte[] {
if (r.getOpenedAt() == null) r.setOpenedAt(LocalDateTime.now()); 71,73,70,56,57,97,1,0,1,0,-128,0,0,0,0,0,-1,-1,-1,33,-7,4,1,0,0,0,0,44,0,0,0,0,1,0,1,0,0,2,2,68,1,0,59
r.setOpenCount((r.getOpenCount() == null ? 0 : r.getOpenCount()) + 1); };
repo.save(r);
}); private final EmailRequestRepository repo;
return ResponseEntity.ok() public EmailTrackingController(EmailRequestRepository repo) {
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") this.repo = repo;
.body(PIXEL); }
}
@GetMapping(value = "/open/{id}", produces = "image/gif")
@GetMapping("/click/{id}") public ResponseEntity<byte[]> open(@PathVariable Long id) {
public ResponseEntity<Void> click(@PathVariable Long id, @RequestParam String url) { repo.findById(id).ifPresent(r -> {
repo.findById(id).ifPresent(r -> { if (r.getOpenedAt() == null) r.setOpenedAt(LocalDateTime.now());
if (r.getClickedAt() == null) r.setClickedAt(LocalDateTime.now()); r.setOpenCount((r.getOpenCount() == null ? 0 : r.getOpenCount()) + 1);
r.setClickCount((r.getClickCount() == null ? 0 : r.getClickCount()) + 1); repo.save(r);
repo.save(r); });
});
return ResponseEntity.ok()
return ResponseEntity.status(302).location(URI.create(url)).build(); .header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
} .body(PIXEL);
}
@GetMapping("/click/{id}")
public ResponseEntity<Void> click(@PathVariable Long id, @RequestParam String url) {
repo.findById(id).ifPresent(r -> {
if (r.getClickedAt() == null) r.setClickedAt(LocalDateTime.now());
r.setClickCount((r.getClickCount() == null ? 0 : r.getClickCount()) + 1);
repo.save(r);
});
return ResponseEntity.status(302).location(URI.create(url)).build();
}
} }

View File

@@ -4,6 +4,15 @@ import group.goforward.battlbuilder.service.MerchantFeedImportService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
/**
* REST controller responsible for handling import operations for merchants.
* Supports full product and offer imports as well as offers-only synchronization.
*
* Mapped to the following base endpoints:
* /api/admin/imports and /api/v1/admin/imports
*
* Cross-origin requests are permitted from http://localhost:3000.
*/
@RestController @RestController
@RequestMapping({"/api/admin/imports", "/api/v1/admin/imports"}) @RequestMapping({"/api/admin/imports", "/api/v1/admin/imports"})

View File

@@ -8,6 +8,12 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
/**
* REST controller for debugging merchant-related functionalities in the admin API.
* Provides endpoints for retrieving merchant data for administrative purposes.
*
* Mapped to both "/api/admin" and "/api/v1/admin" base paths.
*/
@RestController @RestController
@RequestMapping({"/api/admin", "/api/v1/admin"}) @RequestMapping({"/api/admin", "/api/v1/admin"})
public class MerchantDebugController { public class MerchantDebugController {

View File

@@ -1,31 +1,31 @@
//package group.goforward.battlbuilder.controller; //package group.goforward.battlbuilder.controller;
// //
//import group.goforward.battlbuilder.service.PartRoleMappingService; //import group.goforward.battlbuilder.service.PartRoleMappingService;
//import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto; //import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
//import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto; //import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
//import org.springframework.web.bind.annotation.*; //import org.springframework.web.bind.annotation.*;
// //
//import java.util.List; //import java.util.List;
// //
//@RestController //@RestController
//@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"}) //@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"})
//public class PartRoleMappingController { //public class PartRoleMappingController {
// //
// private final PartRoleMappingService service; // private final PartRoleMappingService service;
// //
// public PartRoleMappingController(PartRoleMappingService service) { // public PartRoleMappingController(PartRoleMappingService service) {
// this.service = service; // this.service = service;
// } // }
// //
// // Full view for admin UI // // Full view for admin UI
// @GetMapping("/{platform}") // @GetMapping("/{platform}")
// public List<PartRoleMappingDto> getMappings(@PathVariable String platform) { // public List<PartRoleMappingDto> getMappings(@PathVariable String platform) {
// return service.getMappingsForPlatform(platform); // return service.getMappingsForPlatform(platform);
// } // }
// //
// // Thin mapping for the builder // // Thin mapping for the builder
// @GetMapping("/{platform}/map") // @GetMapping("/{platform}/map")
// public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) { // public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) {
// return service.getRoleToCategoryMap(platform); // return service.getRoleToCategoryMap(platform);
// } // }
//} //}

View File

@@ -1,51 +1,51 @@
package group.goforward.battlbuilder.controller.admin; package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService; import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto; import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse; import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/v1/admin/beta") @RequestMapping("/api/v1/admin/beta")
public class AdminBetaInviteController { public class AdminBetaInviteController {
private final BetaInviteService betaInviteService; private final BetaInviteService betaInviteService;
public AdminBetaInviteController(BetaInviteService betaInviteService) { public AdminBetaInviteController(BetaInviteService betaInviteService) {
this.betaInviteService = betaInviteService; this.betaInviteService = betaInviteService;
} }
/** /**
* //api/v1/admin/beta/invites/send?limit=25&dryRun=true&tokenMinutes=30 * //api/v1/admin/beta/invites/send?limit=25&dryRun=true&tokenMinutes=30
* @param limit * @param limit
* @param dryRun * @param dryRun
* @param tokenMinutes * @param tokenMinutes
* @return * @return
*/ */
@PostMapping("/invites/send") @PostMapping("/invites/send")
public InviteBatchResponse sendInvites( public InviteBatchResponse sendInvites(
@RequestParam(defaultValue = "0") int limit, @RequestParam(defaultValue = "0") int limit,
@RequestParam(defaultValue = "true") boolean dryRun, @RequestParam(defaultValue = "true") boolean dryRun,
@RequestParam(defaultValue = "30") int tokenMinutes @RequestParam(defaultValue = "30") int tokenMinutes
) { ) {
int processed = betaInviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun); int processed = betaInviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit); return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit);
} }
@GetMapping("/requests") @GetMapping("/requests")
public Page<AdminBetaRequestDto> listBetaRequests( public Page<AdminBetaRequestDto> listBetaRequests(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size @RequestParam(defaultValue = "25") int size
) { ) {
return betaInviteService.listPendingBetaUsers(page, size); return betaInviteService.listPendingBetaUsers(page, size);
} }
@PostMapping("/requests/{userId}/invite") @PostMapping("/requests/{userId}/invite")
public AdminInviteResponse inviteSingle(@PathVariable Integer userId) { public AdminInviteResponse inviteSingle(@PathVariable Integer userId) {
return betaInviteService.inviteSingleBetaUser(userId); return betaInviteService.inviteSingleBetaUser(userId);
} }
public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {} public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {}
} }

View File

@@ -1,40 +1,40 @@
package group.goforward.battlbuilder.controller.admin; package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.model.PartCategory; import group.goforward.battlbuilder.model.PartCategory;
import group.goforward.battlbuilder.repo.PartCategoryRepository; import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto; import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/api/admin/categories") @RequestMapping("/api/admin/categories")
@CrossOrigin @CrossOrigin
public class AdminCategoryController { public class AdminCategoryController {
private final PartCategoryRepository partCategories; private final PartCategoryRepository partCategories;
public AdminCategoryController(PartCategoryRepository partCategories) { public AdminCategoryController(PartCategoryRepository partCategories) {
this.partCategories = partCategories; this.partCategories = partCategories;
} }
@GetMapping @GetMapping
public List<PartCategoryDto> listCategories() { public List<PartCategoryDto> listCategories() {
return partCategories return partCategories
.findAllByOrderByGroupNameAscSortOrderAscNameAsc() .findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream() .stream()
.map(this::toDto) .map(this::toDto)
.toList(); .toList();
} }
private PartCategoryDto toDto(PartCategory entity) { private PartCategoryDto toDto(PartCategory entity) {
return new PartCategoryDto( return new PartCategoryDto(
entity.getId(), entity.getId(),
entity.getSlug(), entity.getSlug(),
entity.getName(), entity.getName(),
entity.getDescription(), entity.getDescription(),
entity.getGroupName(), entity.getGroupName(),
entity.getSortOrder() entity.getSortOrder()
); );
} }
} }

View File

@@ -1,25 +1,25 @@
package group.goforward.battlbuilder.controller.admin; package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.service.admin.impl.AdminDashboardService; import group.goforward.battlbuilder.service.admin.impl.AdminDashboardService;
import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto; import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("/api/admin/dashboard") @RequestMapping("/api/admin/dashboard")
public class AdminDashboardController { public class AdminDashboardController {
private final AdminDashboardService adminDashboardService; private final AdminDashboardService adminDashboardService;
public AdminDashboardController(AdminDashboardService adminDashboardService) { public AdminDashboardController(AdminDashboardService adminDashboardService) {
this.adminDashboardService = adminDashboardService; this.adminDashboardService = adminDashboardService;
} }
@GetMapping("/overview") @GetMapping("/overview")
public ResponseEntity<AdminDashboardOverviewDto> getOverview() { public ResponseEntity<AdminDashboardOverviewDto> getOverview() {
AdminDashboardOverviewDto dto = adminDashboardService.getOverview(); AdminDashboardOverviewDto dto = adminDashboardService.getOverview();
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }
} }

View File

@@ -1,34 +1,34 @@
package group.goforward.battlbuilder.controller.admin; package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.repo.PartCategoryRepository; import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto; import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/api/admin/part-categories") @RequestMapping("/api/admin/part-categories")
@CrossOrigin // keep it loose for now, you can tighten origins later @CrossOrigin // keep it loose for now, you can tighten origins later
public class AdminPartCategoryController { public class AdminPartCategoryController {
private final PartCategoryRepository partCategories; private final PartCategoryRepository partCategories;
public AdminPartCategoryController(PartCategoryRepository partCategories) { public AdminPartCategoryController(PartCategoryRepository partCategories) {
this.partCategories = partCategories; this.partCategories = partCategories;
} }
@GetMapping @GetMapping
public List<PartCategoryDto> list() { public List<PartCategoryDto> list() {
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc() return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream() .stream()
.map(pc -> new PartCategoryDto( .map(pc -> new PartCategoryDto(
pc.getId(), pc.getId(),
pc.getSlug(), pc.getSlug(),
pc.getName(), pc.getName(),
pc.getDescription(), pc.getDescription(),
pc.getGroupName(), pc.getGroupName(),
pc.getSortOrder() pc.getSortOrder()
)) ))
.toList(); .toList();
} }
} }

View File

@@ -1,124 +1,124 @@
package group.goforward.battlbuilder.controller.admin; package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.model.PartCategory; import group.goforward.battlbuilder.model.PartCategory;
import group.goforward.battlbuilder.model.PartRoleMapping; import group.goforward.battlbuilder.model.PartRoleMapping;
import group.goforward.battlbuilder.repo.PartCategoryRepository; import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.repo.PartRoleMappingRepository; import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
import group.goforward.battlbuilder.web.dto.admin.AdminPartRoleMappingDto; import group.goforward.battlbuilder.web.dto.admin.AdminPartRoleMappingDto;
import group.goforward.battlbuilder.web.dto.admin.CreatePartRoleMappingRequest; import group.goforward.battlbuilder.web.dto.admin.CreatePartRoleMappingRequest;
import group.goforward.battlbuilder.web.dto.admin.UpdatePartRoleMappingRequest; import group.goforward.battlbuilder.web.dto.admin.UpdatePartRoleMappingRequest;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/api/admin/part-role-mappings") @RequestMapping("/api/admin/part-role-mappings")
@CrossOrigin @CrossOrigin
public class AdminPartRoleMappingController { public class AdminPartRoleMappingController {
private final PartRoleMappingRepository partRoleMappingRepository; private final PartRoleMappingRepository partRoleMappingRepository;
private final PartCategoryRepository partCategoryRepository; private final PartCategoryRepository partCategoryRepository;
public AdminPartRoleMappingController( public AdminPartRoleMappingController(
PartRoleMappingRepository partRoleMappingRepository, PartRoleMappingRepository partRoleMappingRepository,
PartCategoryRepository partCategoryRepository PartCategoryRepository partCategoryRepository
) { ) {
this.partRoleMappingRepository = partRoleMappingRepository; this.partRoleMappingRepository = partRoleMappingRepository;
this.partCategoryRepository = partCategoryRepository; this.partCategoryRepository = partCategoryRepository;
} }
// GET /api/admin/part-role-mappings?platform=AR-15 // GET /api/admin/part-role-mappings?platform=AR-15
@GetMapping @GetMapping
public List<AdminPartRoleMappingDto> list( public List<AdminPartRoleMappingDto> list(
@RequestParam(name = "platform", required = false) String platform @RequestParam(name = "platform", required = false) String platform
) { ) {
List<PartRoleMapping> mappings; List<PartRoleMapping> mappings;
if (platform != null && !platform.isBlank()) { if (platform != null && !platform.isBlank()) {
mappings = partRoleMappingRepository mappings = partRoleMappingRepository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform); .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform);
} else { } else {
mappings = partRoleMappingRepository.findAll(); mappings = partRoleMappingRepository.findAll();
} }
return mappings.stream() return mappings.stream()
.map(this::toDto) .map(this::toDto)
.toList(); .toList();
} }
// POST /api/admin/part-role-mappings // POST /api/admin/part-role-mappings
@PostMapping @PostMapping
public AdminPartRoleMappingDto create( public AdminPartRoleMappingDto create(
@RequestBody CreatePartRoleMappingRequest request @RequestBody CreatePartRoleMappingRequest request
) { ) {
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
.orElseThrow(() -> new ResponseStatusException( .orElseThrow(() -> new ResponseStatusException(
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
"PartCategory not found for slug: " + request.categorySlug() "PartCategory not found for slug: " + request.categorySlug()
)); ));
PartRoleMapping mapping = new PartRoleMapping(); PartRoleMapping mapping = new PartRoleMapping();
mapping.setPlatform(request.platform()); mapping.setPlatform(request.platform());
mapping.setPartRole(request.partRole()); mapping.setPartRole(request.partRole());
mapping.setPartCategory(category); mapping.setPartCategory(category);
mapping.setNotes(request.notes()); mapping.setNotes(request.notes());
mapping = partRoleMappingRepository.save(mapping); mapping = partRoleMappingRepository.save(mapping);
return toDto(mapping); return toDto(mapping);
} }
// PUT /api/admin/part-role-mappings/{id} // PUT /api/admin/part-role-mappings/{id}
@PutMapping("/{id}") @PutMapping("/{id}")
public AdminPartRoleMappingDto update( public AdminPartRoleMappingDto update(
@PathVariable Integer id, @PathVariable Integer id,
@RequestBody UpdatePartRoleMappingRequest request @RequestBody UpdatePartRoleMappingRequest request
) { ) {
PartRoleMapping mapping = partRoleMappingRepository.findById(id) PartRoleMapping mapping = partRoleMappingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"));
if (request.platform() != null) { if (request.platform() != null) {
mapping.setPlatform(request.platform()); mapping.setPlatform(request.platform());
} }
if (request.partRole() != null) { if (request.partRole() != null) {
mapping.setPartRole(request.partRole()); mapping.setPartRole(request.partRole());
} }
if (request.categorySlug() != null) { if (request.categorySlug() != null) {
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
.orElseThrow(() -> new ResponseStatusException( .orElseThrow(() -> new ResponseStatusException(
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
"PartCategory not found for slug: " + request.categorySlug() "PartCategory not found for slug: " + request.categorySlug()
)); ));
mapping.setPartCategory(category); mapping.setPartCategory(category);
} }
if (request.notes() != null) { if (request.notes() != null) {
mapping.setNotes(request.notes()); mapping.setNotes(request.notes());
} }
mapping = partRoleMappingRepository.save(mapping); mapping = partRoleMappingRepository.save(mapping);
return toDto(mapping); return toDto(mapping);
} }
// DELETE /api/admin/part-role-mappings/{id} // DELETE /api/admin/part-role-mappings/{id}
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Integer id) { public void delete(@PathVariable Integer id) {
if (!partRoleMappingRepository.existsById(id)) { if (!partRoleMappingRepository.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found");
} }
partRoleMappingRepository.deleteById(id); partRoleMappingRepository.deleteById(id);
} }
private AdminPartRoleMappingDto toDto(PartRoleMapping mapping) { private AdminPartRoleMappingDto toDto(PartRoleMapping mapping) {
PartCategory cat = mapping.getPartCategory(); PartCategory cat = mapping.getPartCategory();
return new AdminPartRoleMappingDto( return new AdminPartRoleMappingDto(
mapping.getId(), mapping.getId(),
mapping.getPlatform(), mapping.getPlatform(),
mapping.getPartRole(), mapping.getPartRole(),
cat != null ? cat.getSlug() : null, cat != null ? cat.getSlug() : null,
cat != null ? cat.getGroupName() : null, cat != null ? cat.getGroupName() : null,
mapping.getNotes() mapping.getNotes()
); );
} }
} }

View File

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

View File

@@ -11,6 +11,27 @@ import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
/**
* REST controller for managing brand entities.
* Provides endpoints for performing CRUD operations on brands.
*
* The controller exposes REST APIs to:
* - Retrieve a list of all brands.
* - Fetch a single brand by its ID.
* - Create a new brand.
* - Delete a brand by its ID.
*
* This controller interacts with the persistence layer through `BrandRepository` and with the
* business logic through `BrandService`.
*
* Mapped base endpoints:
* - `/api/v1/brands`
* - `/api/brands`
*
* Dependencies:
* - `BrandRepository` for direct database access.
* - `BrandService` for handling the business logic of brands.
*/
@RestController @RestController
@RequestMapping({"/api/v1/brands", "/api/brands"}) @RequestMapping({"/api/v1/brands", "/api/brands"})
public class BrandController { public class BrandController {

View File

@@ -1,96 +1,96 @@
package group.goforward.battlbuilder.controller.api.v1; package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.service.BuildService; import group.goforward.battlbuilder.service.BuildService;
import group.goforward.battlbuilder.web.dto.BuildDto; import group.goforward.battlbuilder.web.dto.BuildDto;
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
import group.goforward.battlbuilder.web.dto.BuildSummaryDto; import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@CrossOrigin @CrossOrigin
@RestController @RestController
@RequestMapping("/api/v1/builds") @RequestMapping("/api/v1/builds")
public class BuildV1Controller { public class BuildV1Controller {
private final BuildService buildService; private final BuildService buildService;
public BuildV1Controller(BuildService buildService) { public BuildV1Controller(BuildService buildService) {
this.buildService = buildService; this.buildService = buildService;
} }
/** /**
* Public builds feed for /builds page. * Public builds feed for /builds page.
* GET /api/v1/builds?limit=50 * GET /api/v1/builds?limit=50
*/ */
@GetMapping @GetMapping
public ResponseEntity<List<BuildFeedCardDto>> listPublicBuilds( public ResponseEntity<List<BuildFeedCardDto>> listPublicBuilds(
@RequestParam(name = "limit", required = false, defaultValue = "50") Integer limit @RequestParam(name = "limit", required = false, defaultValue = "50") Integer limit
) { ) {
return ResponseEntity.ok(buildService.listPublicBuilds(limit == null ? 50 : limit)); return ResponseEntity.ok(buildService.listPublicBuilds(limit == null ? 50 : limit));
} }
/** /**
* Public build detail for /builds/{uuid} * Public build detail for /builds/{uuid}
* GET /api/v1/builds/{uuid} * GET /api/v1/builds/{uuid}
*/ */
@GetMapping("/{uuid}") @GetMapping("/{uuid}")
public ResponseEntity<BuildDto> getPublicBuild(@PathVariable("uuid") UUID uuid) { public ResponseEntity<BuildDto> getPublicBuild(@PathVariable("uuid") UUID uuid) {
return ResponseEntity.ok(buildService.getPublicBuild(uuid)); return ResponseEntity.ok(buildService.getPublicBuild(uuid));
} }
/** /**
* Vault builds (authenticated user). * Vault builds (authenticated user).
* GET /api/v1/builds/me?limit=100 * GET /api/v1/builds/me?limit=100
*/ */
@GetMapping("/me") @GetMapping("/me")
public ResponseEntity<List<BuildSummaryDto>> listMyBuilds( public ResponseEntity<List<BuildSummaryDto>> listMyBuilds(
@RequestParam(name = "limit", required = false, defaultValue = "100") Integer limit @RequestParam(name = "limit", required = false, defaultValue = "100") Integer limit
) { ) {
return ResponseEntity.ok(buildService.listMyBuilds(limit == null ? 100 : limit)); return ResponseEntity.ok(buildService.listMyBuilds(limit == null ? 100 : limit));
} }
/** /**
* Load a single build (Vault edit + Builder ?load=uuid). * Load a single build (Vault edit + Builder ?load=uuid).
* GET /api/v1/builds/me/{uuid} * GET /api/v1/builds/me/{uuid}
*/ */
@GetMapping("/me/{uuid}") @GetMapping("/me/{uuid}")
public ResponseEntity<BuildDto> getMyBuild(@PathVariable("uuid") UUID uuid) { public ResponseEntity<BuildDto> getMyBuild(@PathVariable("uuid") UUID uuid) {
return ResponseEntity.ok(buildService.getMyBuild(uuid)); return ResponseEntity.ok(buildService.getMyBuild(uuid));
} }
/** /**
* Create a NEW build in Vault (Save As…). * Create a NEW build in Vault (Save As…).
* POST /api/v1/builds/me * POST /api/v1/builds/me
*/ */
@PostMapping("/me") @PostMapping("/me")
public ResponseEntity<BuildDto> createMyBuild(@RequestBody UpdateBuildRequest req) { public ResponseEntity<BuildDto> createMyBuild(@RequestBody UpdateBuildRequest req) {
return ResponseEntity.ok(buildService.createMyBuild(req)); return ResponseEntity.ok(buildService.createMyBuild(req));
} }
/** /**
* Update build (authenticated user; must own build eventually). * Update build (authenticated user; must own build eventually).
* PUT /api/v1/builds/me/{uuid} * PUT /api/v1/builds/me/{uuid}
*/ */
@PutMapping("/me/{uuid}") @PutMapping("/me/{uuid}")
public ResponseEntity<BuildDto> updateMyBuild( public ResponseEntity<BuildDto> updateMyBuild(
@PathVariable("uuid") UUID uuid, @PathVariable("uuid") UUID uuid,
@RequestBody UpdateBuildRequest req @RequestBody UpdateBuildRequest req
) { ) {
return ResponseEntity.ok(buildService.updateMyBuild(uuid, req)); return ResponseEntity.ok(buildService.updateMyBuild(uuid, req));
} }
/** /**
* Delete a build (authenticated user; must own build). * Delete a build (authenticated user; must own build).
* DELETE /api/v1/builds/me/{uuid} * DELETE /api/v1/builds/me/{uuid}
*/ */
@DeleteMapping("/me/{uuid}") @DeleteMapping("/me/{uuid}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteMyBuild(@PathVariable("uuid") UUID uuid) { public void deleteMyBuild(@PathVariable("uuid") UUID uuid) {
buildService.deleteMyBuild(uuid); buildService.deleteMyBuild(uuid);
} }
} }

View File

@@ -1,60 +1,60 @@
package group.goforward.battlbuilder.controller.api.v1; package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.service.CatalogQueryService; import group.goforward.battlbuilder.service.CatalogQueryService;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto; import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1/catalog") @RequestMapping("/api/v1/catalog")
@CrossOrigin // tighten later @CrossOrigin // tighten later
public class CatalogController { public class CatalogController {
private final CatalogQueryService catalogQueryService; private final CatalogQueryService catalogQueryService;
public CatalogController(CatalogQueryService catalogQueryService) { public CatalogController(CatalogQueryService catalogQueryService) {
this.catalogQueryService = catalogQueryService; this.catalogQueryService = catalogQueryService;
} }
@GetMapping("/options") @GetMapping("/options")
public Page<ProductSummaryDto> getOptions( public Page<ProductSummaryDto> getOptions(
@RequestParam(required = false) String platform, @RequestParam(required = false) String platform,
@RequestParam(required = false) String partRole, @RequestParam(required = false) String partRole,
@RequestParam(required = false) List<String> partRoles, @RequestParam(required = false) List<String> partRoles,
@RequestParam(required = false, name = "brand") List<String> brands, @RequestParam(required = false, name = "brand") List<String> brands,
@RequestParam(required = false) String q, @RequestParam(required = false) String q,
Pageable pageable Pageable pageable
) { ) {
Pageable safe = sanitizeCatalogPageable(pageable); Pageable safe = sanitizeCatalogPageable(pageable);
return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe); return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe);
} }
private Pageable sanitizeCatalogPageable(Pageable pageable) { private Pageable sanitizeCatalogPageable(Pageable pageable) {
int page = Math.max(0, pageable.getPageNumber()); int page = Math.max(0, pageable.getPageNumber());
// hard cap to keep UI snappy + protect DB // hard cap to keep UI snappy + protect DB
int requested = pageable.getPageSize(); int requested = pageable.getPageSize();
int size = Math.min(Math.max(requested, 1), 48); // 48 max int size = Math.min(Math.max(requested, 1), 48); // 48 max
// default sort if none provided // default sort if none provided
Sort sort = pageable.getSort().isSorted() Sort sort = pageable.getSort().isSorted()
? pageable.getSort() ? pageable.getSort()
: Sort.by(Sort.Direction.DESC, "updatedAt"); : Sort.by(Sort.Direction.DESC, "updatedAt");
return PageRequest.of(page, size, sort); return PageRequest.of(page, size, sort);
} }
@PostMapping("/products/by-ids") @PostMapping("/products/by-ids")
public List<ProductSummaryDto> getProductsByIds(@RequestBody CatalogProductIdsRequest request) { public List<ProductSummaryDto> getProductsByIds(@RequestBody CatalogProductIdsRequest request) {
return catalogQueryService.getProductsByIds(request); return catalogQueryService.getProductsByIds(request);
} }
} }

View File

@@ -1,152 +1,152 @@
package group.goforward.battlbuilder.controller.api.v1; package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.common.ApiResponse; import group.goforward.battlbuilder.common.ApiResponse;
import group.goforward.battlbuilder.dto.EmailRequestDto; import group.goforward.battlbuilder.dto.EmailRequestDto;
import group.goforward.battlbuilder.model.EmailRequest; import group.goforward.battlbuilder.model.EmailRequest;
import group.goforward.battlbuilder.model.EmailStatus; import group.goforward.battlbuilder.model.EmailStatus;
import group.goforward.battlbuilder.repo.EmailRequestRepository; import group.goforward.battlbuilder.repo.EmailRequestRepository;
import group.goforward.battlbuilder.service.utils.EmailService; import group.goforward.battlbuilder.service.utils.EmailService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/api/email") @RequestMapping("/api/email")
public class EmailController { public class EmailController {
private static final EmailStatus EMAIL_STATUS_SENT = EmailStatus.SENT; private static final EmailStatus EMAIL_STATUS_SENT = EmailStatus.SENT;
private final EmailService emailService; private final EmailService emailService;
private final EmailRequestRepository emailRequestRepository; private final EmailRequestRepository emailRequestRepository;
@Autowired @Autowired
public EmailController(EmailService emailService, EmailRequestRepository emailRequestRepository) { public EmailController(EmailService emailService, EmailRequestRepository emailRequestRepository) {
this.emailService = emailService; this.emailService = emailService;
this.emailRequestRepository = emailRequestRepository; this.emailRequestRepository = emailRequestRepository;
} }
@GetMapping("/statuses") @GetMapping("/statuses")
public ResponseEntity<ApiResponse<List<String>>> getEmailStatuses() { public ResponseEntity<ApiResponse<List<String>>> getEmailStatuses() {
List<String> statuses = Arrays.stream(EmailStatus.values()) List<String> statuses = Arrays.stream(EmailStatus.values())
.map(Enum::name) .map(Enum::name)
.toList(); .toList();
return ResponseEntity.ok( return ResponseEntity.ok(
ApiResponse.success(statuses, "Email statuses retrieved successfully") ApiResponse.success(statuses, "Email statuses retrieved successfully")
); );
} }
@GetMapping @GetMapping
public ResponseEntity<ApiResponse<List<EmailRequest>>> getAllEmailRequests() { public ResponseEntity<ApiResponse<List<EmailRequest>>> getAllEmailRequests() {
try { try {
List<EmailRequest> emailRequests = emailRequestRepository.findAll(); List<EmailRequest> emailRequests = emailRequestRepository.findAll();
return ResponseEntity.ok( return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Email requests retrieved successfully") ApiResponse.success(emailRequests, "Email requests retrieved successfully")
); );
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.status(500).body( return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving email requests: " + e.getMessage(), null) ApiResponse.error("Error retrieving email requests: " + e.getMessage(), null)
); );
} }
} }
@GetMapping("/allSent") @GetMapping("/allSent")
public ResponseEntity<ApiResponse<List<EmailRequest>>> getNotSentEmailRequests() { public ResponseEntity<ApiResponse<List<EmailRequest>>> getNotSentEmailRequests() {
try { try {
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.SENT); List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.SENT);
return ResponseEntity.ok( return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Not sent email requests retrieved successfully") ApiResponse.success(emailRequests, "Not sent email requests retrieved successfully")
); );
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.status(500).body( return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving not sent email requests: " + e.getMessage(), null) ApiResponse.error("Error retrieving not sent email requests: " + e.getMessage(), null)
); );
} }
} }
@GetMapping("/allFailed") @GetMapping("/allFailed")
public ResponseEntity<ApiResponse<List<EmailRequest>>> getFailedEmailRequests() { public ResponseEntity<ApiResponse<List<EmailRequest>>> getFailedEmailRequests() {
try { try {
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.FAILED); List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.FAILED);
return ResponseEntity.ok( return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Failed email requests retrieved successfully") ApiResponse.success(emailRequests, "Failed email requests retrieved successfully")
); );
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.status(500).body( return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving failed email requests: " + e.getMessage(), null) ApiResponse.error("Error retrieving failed email requests: " + e.getMessage(), null)
); );
} }
} }
@GetMapping("/allPending") @GetMapping("/allPending")
public ResponseEntity<ApiResponse<List<EmailRequest>>> getPendingEmailRequests() { public ResponseEntity<ApiResponse<List<EmailRequest>>> getPendingEmailRequests() {
try { try {
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.PENDING); List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.PENDING);
return ResponseEntity.ok( return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Pending email requests retrieved successfully") ApiResponse.success(emailRequests, "Pending email requests retrieved successfully")
); );
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.status(500).body( return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving Pending email requests: " + e.getMessage(), null) ApiResponse.error("Error retrieving Pending email requests: " + e.getMessage(), null)
); );
} }
} }
@PostMapping("/send") @PostMapping("/send")
public ResponseEntity<ApiResponse<EmailRequest>> sendEmail(@RequestBody EmailRequestDto emailDto) { public ResponseEntity<ApiResponse<EmailRequest>> sendEmail(@RequestBody EmailRequestDto emailDto) {
try { try {
EmailRequest emailRequest = emailService.sendEmail( EmailRequest emailRequest = emailService.sendEmail(
emailDto.getRecipient(), emailDto.getRecipient(),
emailDto.getSubject(), emailDto.getSubject(),
emailDto.getBody() emailDto.getBody()
); );
return buildEmailResponse(emailRequest); return buildEmailResponse(emailRequest);
} catch (Exception e) { } catch (Exception e) {
return buildErrorResponse(e.getMessage()); return buildErrorResponse(e.getMessage());
} }
} }
@DeleteMapping("/delete/{id}") @DeleteMapping("/delete/{id}")
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) { public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
return emailRequestRepository.findById(Long.valueOf(id)) return emailRequestRepository.findById(Long.valueOf(id))
.map(item -> { .map(item -> {
emailRequestRepository.deleteById(Long.valueOf(id)); emailRequestRepository.deleteById(Long.valueOf(id));
return ResponseEntity.noContent().<Void>build(); return ResponseEntity.noContent().<Void>build();
}) })
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }
// Replace /delete/{id} with a RESTful DELETE /{id} // Replace /delete/{id} with a RESTful DELETE /{id}
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteEmailRequest(@PathVariable Long id) { public void deleteEmailRequest(@PathVariable Long id) {
if (!emailRequestRepository.existsById(id)) { if (!emailRequestRepository.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email request not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email request not found");
} }
emailRequestRepository.deleteById(id); emailRequestRepository.deleteById(id);
} }
private ResponseEntity<ApiResponse<EmailRequest>> buildEmailResponse(EmailRequest emailRequest) { private ResponseEntity<ApiResponse<EmailRequest>> buildEmailResponse(EmailRequest emailRequest) {
if (EMAIL_STATUS_SENT.equals(emailRequest.getStatus())) { if (EMAIL_STATUS_SENT.equals(emailRequest.getStatus())) {
return ResponseEntity.ok( return ResponseEntity.ok(
ApiResponse.success(emailRequest, "Email sent successfully") ApiResponse.success(emailRequest, "Email sent successfully")
); );
} else { } else {
String errorMessage = "Failed to send email: " + emailRequest.getErrorMessage(); String errorMessage = "Failed to send email: " + emailRequest.getErrorMessage();
return ResponseEntity.status(500).body( return ResponseEntity.status(500).body(
ApiResponse.error(errorMessage, emailRequest) ApiResponse.error(errorMessage, emailRequest)
); );
} }
} }
private ResponseEntity<ApiResponse<EmailRequest>> buildErrorResponse(String exceptionMessage) { private ResponseEntity<ApiResponse<EmailRequest>> buildErrorResponse(String exceptionMessage) {
String errorMessage = "Error processing email request: " + exceptionMessage; String errorMessage = "Error processing email request: " + exceptionMessage;
return ResponseEntity.status(500).body( return ResponseEntity.status(500).body(
ApiResponse.error(errorMessage, null) ApiResponse.error(errorMessage, null)
); );
} }
} }

View File

@@ -1,231 +1,231 @@
package group.goforward.battlbuilder.controller.api.v1; package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.model.User; import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository; import group.goforward.battlbuilder.repo.UserRepository;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.springframework.http.HttpStatus.*; import static org.springframework.http.HttpStatus.*;
@RestController @RestController
@RequestMapping({"/api/v1/users/me", "/api/users/me"}) @RequestMapping({"/api/v1/users/me", "/api/users/me"})
@CrossOrigin @CrossOrigin
public class MeController { public class MeController {
private final UserRepository users; private final UserRepository users;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
public MeController(UserRepository users, PasswordEncoder passwordEncoder) { public MeController(UserRepository users, PasswordEncoder passwordEncoder) {
this.users = users; this.users = users;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
} }
// ----------------------------- // -----------------------------
// Helpers // Helpers
// ----------------------------- // -----------------------------
private Authentication requireAuth() { private Authentication requireAuth() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) { if (auth == null || !auth.isAuthenticated()) {
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
} }
// Spring may set "anonymousUser" as a principal when not logged in // Spring may set "anonymousUser" as a principal when not logged in
Object principal = auth.getPrincipal(); Object principal = auth.getPrincipal();
if (principal == null || "anonymousUser".equals(principal)) { if (principal == null || "anonymousUser".equals(principal)) {
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
} }
return auth; return auth;
} }
private Optional<UUID> tryParseUuid(String s) { private Optional<UUID> tryParseUuid(String s) {
try { try {
return Optional.of(UUID.fromString(s)); return Optional.of(UUID.fromString(s));
} catch (Exception ignored) { } catch (Exception ignored) {
return Optional.empty(); return Optional.empty();
} }
} }
private User requireUser() { private User requireUser() {
Authentication auth = requireAuth(); Authentication auth = requireAuth();
Object principal = auth.getPrincipal(); Object principal = auth.getPrincipal();
// Case 1: principal is a String (we commonly set this to UUID string) // Case 1: principal is a String (we commonly set this to UUID string)
if (principal instanceof String s) { if (principal instanceof String s) {
// Prefer UUID lookup // Prefer UUID lookup
Optional<UUID> uuid = tryParseUuid(s); Optional<UUID> uuid = tryParseUuid(s);
if (uuid.isPresent()) { if (uuid.isPresent()) {
return users.findByUuidAndDeletedAtIsNull(uuid.get()) return users.findByUuidAndDeletedAtIsNull(uuid.get())
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
} }
// Fallback to email lookup // Fallback to email lookup
String email = s.trim().toLowerCase(); String email = s.trim().toLowerCase();
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
} }
// Case 2: principal is a UserDetails (often username=email) // Case 2: principal is a UserDetails (often username=email)
if (principal instanceof UserDetails ud) { if (principal instanceof UserDetails ud) {
String username = ud.getUsername(); String username = ud.getUsername();
if (username == null) { if (username == null) {
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
} }
// Try UUID first, then email // Try UUID first, then email
Optional<UUID> uuid = tryParseUuid(username); Optional<UUID> uuid = tryParseUuid(username);
if (uuid.isPresent()) { if (uuid.isPresent()) {
return users.findByUuidAndDeletedAtIsNull(uuid.get()) return users.findByUuidAndDeletedAtIsNull(uuid.get())
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
} }
String email = username.trim().toLowerCase(); String email = username.trim().toLowerCase();
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
} }
// Anything else: unsupported principal type // Anything else: unsupported principal type
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated"); throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
} }
private Map<String, Object> toMeResponse(User user) { private Map<String, Object> toMeResponse(User user) {
Map<String, Object> out = new java.util.HashMap<>(); Map<String, Object> out = new java.util.HashMap<>();
out.put("email", user.getEmail()); out.put("email", user.getEmail());
out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName()); out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName());
out.put("username", user.getUsername() == null ? "" : user.getUsername()); out.put("username", user.getUsername() == null ? "" : user.getUsername());
out.put("role", user.getRole() == null ? "USER" : user.getRole()); out.put("role", user.getRole() == null ? "USER" : user.getRole());
out.put("uuid", String.valueOf(user.getUuid())); out.put("uuid", String.valueOf(user.getUuid()));
out.put("passwordSetAt", user.getPasswordSetAt() == null ? null : user.getPasswordSetAt().toString()); out.put("passwordSetAt", user.getPasswordSetAt() == null ? null : user.getPasswordSetAt().toString());
return out; return out;
} }
private String normalizeUsername(String raw) { private String normalizeUsername(String raw) {
if (raw == null) return null; if (raw == null) return null;
String s = raw.trim().toLowerCase(); String s = raw.trim().toLowerCase();
return s.isBlank() ? null : s; return s.isBlank() ? null : s;
} }
private boolean isReservedUsername(String u) { private boolean isReservedUsername(String u) {
return switch (u) { return switch (u) {
case "admin", "support", "battl", "battlbuilders", "builder", case "admin", "support", "battl", "battlbuilders", "builder",
"api", "login", "register", "account", "privacy", "tos" -> true; "api", "login", "register", "account", "privacy", "tos" -> true;
default -> false; default -> false;
}; };
} }
// ----------------------------- // -----------------------------
// Routes // Routes
// ----------------------------- // -----------------------------
@GetMapping @GetMapping
public ResponseEntity<?> me() { public ResponseEntity<?> me() {
User user = requireUser(); User user = requireUser();
return ResponseEntity.ok(toMeResponse(user)); return ResponseEntity.ok(toMeResponse(user));
} }
@PatchMapping @PatchMapping
public ResponseEntity<?> updateMe(@RequestBody Map<String, Object> body) { public ResponseEntity<?> updateMe(@RequestBody Map<String, Object> body) {
User user = requireUser(); User user = requireUser();
String displayName = null; String displayName = null;
if (body != null && body.get("displayName") != null) { if (body != null && body.get("displayName") != null) {
displayName = String.valueOf(body.get("displayName")).trim(); displayName = String.valueOf(body.get("displayName")).trim();
} }
String username = null; String username = null;
if (body != null && body.get("username") != null) { if (body != null && body.get("username") != null) {
username = normalizeUsername(String.valueOf(body.get("username"))); username = normalizeUsername(String.valueOf(body.get("username")));
} }
if ((displayName == null || displayName.isBlank()) && (username == null)) { if ((displayName == null || displayName.isBlank()) && (username == null)) {
throw new ResponseStatusException(BAD_REQUEST, "username or displayName is required"); throw new ResponseStatusException(BAD_REQUEST, "username or displayName is required");
} }
// display name is flexible // display name is flexible
if (displayName != null && !displayName.isBlank()) { if (displayName != null && !displayName.isBlank()) {
user.setDisplayName(displayName); user.setDisplayName(displayName);
} }
// username is strict + unique // username is strict + unique
if (username != null) { if (username != null) {
if (username.length() < 3 || username.length() > 20) { if (username.length() < 3 || username.length() > 20) {
throw new ResponseStatusException(BAD_REQUEST, "Username must be 320 characters"); throw new ResponseStatusException(BAD_REQUEST, "Username must be 320 characters");
} }
if (!username.matches("^[a-z0-9_]+$")) { if (!username.matches("^[a-z0-9_]+$")) {
throw new ResponseStatusException(BAD_REQUEST, "Username can only contain a-z, 0-9, underscore"); throw new ResponseStatusException(BAD_REQUEST, "Username can only contain a-z, 0-9, underscore");
} }
if (isReservedUsername(username)) { if (isReservedUsername(username)) {
throw new ResponseStatusException(BAD_REQUEST, "That username is reserved"); throw new ResponseStatusException(BAD_REQUEST, "That username is reserved");
} }
users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username).ifPresent(existing -> { users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username).ifPresent(existing -> {
if (!existing.getId().equals(user.getId())) { if (!existing.getId().equals(user.getId())) {
throw new ResponseStatusException(CONFLICT, "Username already taken"); throw new ResponseStatusException(CONFLICT, "Username already taken");
} }
}); });
user.setUsername(username); user.setUsername(username);
} }
user.setUpdatedAt(OffsetDateTime.now()); user.setUpdatedAt(OffsetDateTime.now());
users.save(user); users.save(user);
return ResponseEntity.ok(toMeResponse(user)); return ResponseEntity.ok(toMeResponse(user));
} }
@PostMapping("/password") @PostMapping("/password")
public ResponseEntity<?> setPassword(@RequestBody Map<String, Object> body) { public ResponseEntity<?> setPassword(@RequestBody Map<String, Object> body) {
User user = requireUser(); User user = requireUser();
String password = null; String password = null;
if (body != null && body.get("password") != null) { if (body != null && body.get("password") != null) {
password = String.valueOf(body.get("password")); password = String.valueOf(body.get("password"));
} }
if (password == null || password.length() < 8) { if (password == null || password.length() < 8) {
throw new ResponseStatusException(BAD_REQUEST, "Password must be at least 8 characters"); throw new ResponseStatusException(BAD_REQUEST, "Password must be at least 8 characters");
} }
user.setPasswordHash(passwordEncoder.encode(password)); user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW
user.setUpdatedAt(OffsetDateTime.now()); user.setUpdatedAt(OffsetDateTime.now());
users.save(user); users.save(user);
return ResponseEntity.ok(Map.of("ok", true, "passwordSetAt", user.getPasswordSetAt().toString())); return ResponseEntity.ok(Map.of("ok", true, "passwordSetAt", user.getPasswordSetAt().toString()));
} }
@GetMapping("/username-available") @GetMapping("/username-available")
public ResponseEntity<?> usernameAvailable(@RequestParam("username") String usernameRaw) { public ResponseEntity<?> usernameAvailable(@RequestParam("username") String usernameRaw) {
String username = normalizeUsername(usernameRaw); String username = normalizeUsername(usernameRaw);
// Soft fail // Soft fail
if (username == null) return ResponseEntity.ok(Map.of("available", false)); if (username == null) return ResponseEntity.ok(Map.of("available", false));
if (username.length() < 3 || username.length() > 20) { if (username.length() < 3 || username.length() > 20) {
return ResponseEntity.ok(Map.of("available", false)); return ResponseEntity.ok(Map.of("available", false));
} }
if (!username.matches("^[a-z0-9_]+$")) { if (!username.matches("^[a-z0-9_]+$")) {
return ResponseEntity.ok(Map.of("available", false)); return ResponseEntity.ok(Map.of("available", false));
} }
if (isReservedUsername(username)) { if (isReservedUsername(username)) {
return ResponseEntity.ok(Map.of("available", false)); return ResponseEntity.ok(Map.of("available", false));
} }
User me = requireUser(); User me = requireUser();
boolean available = users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username) boolean available = users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username)
.map(existing -> existing.getId().equals(me.getId())) // if it's mine, treat as available .map(existing -> existing.getId().equals(me.getId())) // if it's mine, treat as available
.orElse(true); .orElse(true);
return ResponseEntity.ok(Map.of("available", available)); return ResponseEntity.ok(Map.of("available", available));
} }
} }

View File

@@ -1,49 +1,49 @@
package group.goforward.battlbuilder.controller.api.v1; package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.service.ProductQueryService; import group.goforward.battlbuilder.service.ProductQueryService;
import group.goforward.battlbuilder.web.dto.ProductOfferDto; import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto; import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault; import org.springframework.data.web.PageableDefault;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/api/v1/products") @RequestMapping("/api/v1/products")
@CrossOrigin @CrossOrigin
public class ProductV1Controller { public class ProductV1Controller {
private final ProductQueryService productQueryService; private final ProductQueryService productQueryService;
public ProductV1Controller(ProductQueryService productQueryService) { public ProductV1Controller(ProductQueryService productQueryService) {
this.productQueryService = productQueryService; this.productQueryService = productQueryService;
} }
@GetMapping @GetMapping
@Cacheable( @Cacheable(
value = "gunbuilderProductsV1", value = "gunbuilderProductsV1",
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize" key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
) )
public Page<ProductSummaryDto> getProducts( public Page<ProductSummaryDto> getProducts(
@RequestParam(defaultValue = "AR-15") String platform, @RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List<String> partRoles, @RequestParam(required = false, name = "partRoles") List<String> partRoles,
@PageableDefault(size = 50) Pageable pageable @PageableDefault(size = 50) Pageable pageable
) { ) {
return productQueryService.getProductsPage(platform, partRoles, pageable); return productQueryService.getProductsPage(platform, partRoles, pageable);
} }
@GetMapping("/{id}/offers") @GetMapping("/{id}/offers")
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) { public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
return productQueryService.getOffersForProduct(productId); return productQueryService.getOffersForProduct(productId);
} }
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) { public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
ProductSummaryDto dto = productQueryService.getProductById(productId); ProductSummaryDto dto = productQueryService.getProductById(productId);
return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build(); return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build();
} }
} }

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
package group.goforward.battlbuilder.enrichment; package group.goforward.battlbuilder.enrichment;
/** /**
* Enum representing the source of an enrichment. * Enum representing the source of an enrichment.
*/ */
public enum EnrichmentSource { public enum EnrichmentSource {
AI, AI,
RULES, RULES,
HUMAN HUMAN
} }

View File

@@ -1,19 +1,19 @@
package group.goforward.battlbuilder.enrichment; package group.goforward.battlbuilder.enrichment;
/** /**
* Status of an enrichment in the system. * Status of an enrichment in the system.
* *
* <p>Possible values: * <p>Possible values:
* <ul> * <ul>
* <li>PENDING_REVIEW - awaiting review</li> * <li>PENDING_REVIEW - awaiting review</li>
* <li>APPROVED - approved to apply</li> * <li>APPROVED - approved to apply</li>
* <li>REJECTED - rejected and will not be applied</li> * <li>REJECTED - rejected and will not be applied</li>
* <li>APPLIED - enrichment has been applied</li> * <li>APPLIED - enrichment has been applied</li>
* </ul> * </ul>
*/ */
public enum EnrichmentStatus { public enum EnrichmentStatus {
PENDING_REVIEW, PENDING_REVIEW,
APPROVED, APPROVED,
REJECTED, REJECTED,
APPLIED APPLIED
} }

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.enrichment; package group.goforward.battlbuilder.enrichment;
/** /**
* Enum representing different types of enrichment that can be applied to products. * Enum representing different types of enrichment that can be applied to products.
*/ */
public enum EnrichmentType { public enum EnrichmentType {
CALIBER, CALIBER,
CALIBER_GROUP, CALIBER_GROUP,
BARREL_LENGTH, BARREL_LENGTH,
GAS_SYSTEM, GAS_SYSTEM,
HANDGUARD_LENGTH, HANDGUARD_LENGTH,
CONFIGURATION, CONFIGURATION,
PART_ROLE PART_ROLE
} }

View File

@@ -1,103 +1,103 @@
package group.goforward.battlbuilder.enrichment.ai; package group.goforward.battlbuilder.enrichment.ai;
import group.goforward.battlbuilder.enrichment.*; import group.goforward.battlbuilder.enrichment.*;
import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult; import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository; import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy; import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
import group.goforward.battlbuilder.model.Product; import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository; import group.goforward.battlbuilder.repo.ProductRepository;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@Service @Service
public class AiEnrichmentOrchestrator { public class AiEnrichmentOrchestrator {
private final EnrichmentModelClient modelClient; private final EnrichmentModelClient modelClient;
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final ProductEnrichmentRepository enrichmentRepository; private final ProductEnrichmentRepository enrichmentRepository;
@Value("${ai.minConfidence:0.75}") @Value("${ai.minConfidence:0.75}")
private BigDecimal minConfidence; private BigDecimal minConfidence;
public AiEnrichmentOrchestrator( public AiEnrichmentOrchestrator(
EnrichmentModelClient modelClient, EnrichmentModelClient modelClient,
ProductRepository productRepository, ProductRepository productRepository,
ProductEnrichmentRepository enrichmentRepository ProductEnrichmentRepository enrichmentRepository
) { ) {
this.modelClient = modelClient; this.modelClient = modelClient;
this.productRepository = productRepository; this.productRepository = productRepository;
this.enrichmentRepository = enrichmentRepository; this.enrichmentRepository = enrichmentRepository;
} }
public int runCaliber(int limit) { public int runCaliber(int limit) {
// pick candidates: caliber missing // pick candidates: caliber missing
List<Product> candidates = productRepository.findProductsMissingCaliber(limit); List<Product> candidates = productRepository.findProductsMissingCaliber(limit);
int created = 0; int created = 0;
for (Product p : candidates) { for (Product p : candidates) {
CaliberExtractionResult r = modelClient.extractCaliber(p); CaliberExtractionResult r = modelClient.extractCaliber(p);
if (r == null || !r.isUsable(minConfidence)) { if (r == null || !r.isUsable(minConfidence)) {
continue; continue;
} }
// Optional: avoid duplicates for same product/type/status // Optional: avoid duplicates for same product/type/status
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus( boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
p.getId(), EnrichmentType.CALIBER, EnrichmentStatus.PENDING_REVIEW p.getId(), EnrichmentType.CALIBER, EnrichmentStatus.PENDING_REVIEW
); );
if (exists) continue; if (exists) continue;
ProductEnrichment pe = new ProductEnrichment(); ProductEnrichment pe = new ProductEnrichment();
pe.setProductId(p.getId()); pe.setProductId(p.getId());
pe.setEnrichmentType(EnrichmentType.CALIBER); pe.setEnrichmentType(EnrichmentType.CALIBER);
pe.setSource(EnrichmentSource.AI); pe.setSource(EnrichmentSource.AI);
pe.setStatus(EnrichmentStatus.PENDING_REVIEW); pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
pe.setSchemaVersion(1); pe.setSchemaVersion(1);
pe.setAttributes(Map.of("caliber", r.caliber())); pe.setAttributes(Map.of("caliber", r.caliber()));
pe.setConfidence(r.confidence()); pe.setConfidence(r.confidence());
pe.setRationale(r.reason()); pe.setRationale(r.reason());
pe.setMeta(Map.of("provider", modelClient.providerName())); pe.setMeta(Map.of("provider", modelClient.providerName()));
enrichmentRepository.save(pe); enrichmentRepository.save(pe);
created++; created++;
} }
return created; return created;
} }
public int runCaliberGroup(int limit) { public int runCaliberGroup(int limit) {
List<Product> candidates = productRepository.findProductsMissingCaliberGroup(limit); List<Product> candidates = productRepository.findProductsMissingCaliberGroup(limit);
int created = 0; int created = 0;
for (Product p : candidates) { for (Product p : candidates) {
String group = CaliberTaxonomy.groupForCaliber(p.getCaliber()); String group = CaliberTaxonomy.groupForCaliber(p.getCaliber());
if (group == null || group.isBlank()) continue; if (group == null || group.isBlank()) continue;
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus( boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
p.getId(), EnrichmentType.CALIBER_GROUP, EnrichmentStatus.PENDING_REVIEW p.getId(), EnrichmentType.CALIBER_GROUP, EnrichmentStatus.PENDING_REVIEW
); );
if (exists) continue; if (exists) continue;
ProductEnrichment pe = new ProductEnrichment(); ProductEnrichment pe = new ProductEnrichment();
pe.setProductId(p.getId()); pe.setProductId(p.getId());
pe.setEnrichmentType(EnrichmentType.CALIBER_GROUP); pe.setEnrichmentType(EnrichmentType.CALIBER_GROUP);
pe.setSource(EnrichmentSource.RULES); // derived rules pe.setSource(EnrichmentSource.RULES); // derived rules
pe.setStatus(EnrichmentStatus.PENDING_REVIEW); pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
pe.setSchemaVersion(1); pe.setSchemaVersion(1);
pe.setAttributes(java.util.Map.of("caliberGroup", group)); pe.setAttributes(java.util.Map.of("caliberGroup", group));
pe.setConfidence(new java.math.BigDecimal("1.00")); pe.setConfidence(new java.math.BigDecimal("1.00"));
pe.setRationale("Derived caliberGroup from product.caliber via CaliberTaxonomy"); pe.setRationale("Derived caliberGroup from product.caliber via CaliberTaxonomy");
pe.setMeta(java.util.Map.of("provider", "TAXONOMY")); pe.setMeta(java.util.Map.of("provider", "TAXONOMY"));
enrichmentRepository.save(pe); enrichmentRepository.save(pe);
created++; created++;
} }
return created; return created;
} }
} }

View File

@@ -1,153 +1,153 @@
package group.goforward.battlbuilder.enrichment.controller; package group.goforward.battlbuilder.enrichment.controller;
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy; import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
import group.goforward.battlbuilder.enrichment.EnrichmentStatus; import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
import group.goforward.battlbuilder.enrichment.EnrichmentType; import group.goforward.battlbuilder.enrichment.EnrichmentType;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import group.goforward.battlbuilder.enrichment.ai.AiEnrichmentOrchestrator; import group.goforward.battlbuilder.enrichment.ai.AiEnrichmentOrchestrator;
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository; import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
import group.goforward.battlbuilder.enrichment.service.CaliberEnrichmentService; import group.goforward.battlbuilder.enrichment.service.CaliberEnrichmentService;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/api/admin/enrichment") @RequestMapping("/api/admin/enrichment")
public class AdminEnrichmentController { public class AdminEnrichmentController {
private final CaliberEnrichmentService caliberEnrichmentService; private final CaliberEnrichmentService caliberEnrichmentService;
private final ProductEnrichmentRepository enrichmentRepository; private final ProductEnrichmentRepository enrichmentRepository;
private final AiEnrichmentOrchestrator aiEnrichmentOrchestrator; private final AiEnrichmentOrchestrator aiEnrichmentOrchestrator;
public AdminEnrichmentController( public AdminEnrichmentController(
CaliberEnrichmentService caliberEnrichmentService, CaliberEnrichmentService caliberEnrichmentService,
ProductEnrichmentRepository enrichmentRepository, ProductEnrichmentRepository enrichmentRepository,
AiEnrichmentOrchestrator aiEnrichmentOrchestrator AiEnrichmentOrchestrator aiEnrichmentOrchestrator
) { ) {
this.caliberEnrichmentService = caliberEnrichmentService; this.caliberEnrichmentService = caliberEnrichmentService;
this.enrichmentRepository = enrichmentRepository; this.enrichmentRepository = enrichmentRepository;
this.aiEnrichmentOrchestrator = aiEnrichmentOrchestrator; this.aiEnrichmentOrchestrator = aiEnrichmentOrchestrator;
} }
@PostMapping("/run") @PostMapping("/run")
public ResponseEntity<?> run( public ResponseEntity<?> run(
@RequestParam EnrichmentType type, @RequestParam EnrichmentType type,
@RequestParam(defaultValue = "200") int limit @RequestParam(defaultValue = "200") int limit
) { ) {
if (type != EnrichmentType.CALIBER) { if (type != EnrichmentType.CALIBER) {
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0."); return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
} }
return ResponseEntity.ok(caliberEnrichmentService.runRules(limit)); return ResponseEntity.ok(caliberEnrichmentService.runRules(limit));
} }
// ✅ NEW: Run AI enrichment // ✅ NEW: Run AI enrichment
@PostMapping("/ai/run") @PostMapping("/ai/run")
public ResponseEntity<?> runAi( public ResponseEntity<?> runAi(
@RequestParam EnrichmentType type, @RequestParam EnrichmentType type,
@RequestParam(defaultValue = "200") int limit @RequestParam(defaultValue = "200") int limit
) { ) {
if (type != EnrichmentType.CALIBER) { if (type != EnrichmentType.CALIBER) {
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0."); return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
} }
// This should create ProductEnrichment rows with source=AI and status=PENDING_REVIEW // This should create ProductEnrichment rows with source=AI and status=PENDING_REVIEW
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliber(limit)); return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliber(limit));
} }
@GetMapping("/queue") @GetMapping("/queue")
public ResponseEntity<List<ProductEnrichment>> queue( public ResponseEntity<List<ProductEnrichment>> queue(
@RequestParam(defaultValue = "CALIBER") EnrichmentType type, @RequestParam(defaultValue = "CALIBER") EnrichmentType type,
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status, @RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
@RequestParam(defaultValue = "100") int limit @RequestParam(defaultValue = "100") int limit
) { ) {
var items = enrichmentRepository var items = enrichmentRepository
.findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(type, status, PageRequest.of(0, limit)); .findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(type, status, PageRequest.of(0, limit));
return ResponseEntity.ok(items); return ResponseEntity.ok(items);
} }
@GetMapping("/queue2") @GetMapping("/queue2")
public ResponseEntity<?> queue2( public ResponseEntity<?> queue2(
@RequestParam(defaultValue = "CALIBER") EnrichmentType type, @RequestParam(defaultValue = "CALIBER") EnrichmentType type,
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status, @RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
@RequestParam(defaultValue = "100") int limit @RequestParam(defaultValue = "100") int limit
) { ) {
return ResponseEntity.ok( return ResponseEntity.ok(
enrichmentRepository.queueWithProduct(type, status, PageRequest.of(0, limit)) enrichmentRepository.queueWithProduct(type, status, PageRequest.of(0, limit))
); );
} }
@PostMapping("/{id}/approve") @PostMapping("/{id}/approve")
public ResponseEntity<?> approve(@PathVariable Long id) { public ResponseEntity<?> approve(@PathVariable Long id) {
var e = enrichmentRepository.findById(id) var e = enrichmentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); .orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
e.setStatus(EnrichmentStatus.APPROVED); e.setStatus(EnrichmentStatus.APPROVED);
enrichmentRepository.save(e); enrichmentRepository.save(e);
return ResponseEntity.ok(e); return ResponseEntity.ok(e);
} }
@PostMapping("/{id}/reject") @PostMapping("/{id}/reject")
public ResponseEntity<?> reject(@PathVariable Long id) { public ResponseEntity<?> reject(@PathVariable Long id) {
var e = enrichmentRepository.findById(id) var e = enrichmentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); .orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
e.setStatus(EnrichmentStatus.REJECTED); e.setStatus(EnrichmentStatus.REJECTED);
enrichmentRepository.save(e); enrichmentRepository.save(e);
return ResponseEntity.ok(e); return ResponseEntity.ok(e);
} }
@PostMapping("/{id}/apply") @PostMapping("/{id}/apply")
@Transactional @Transactional
public ResponseEntity<?> apply(@PathVariable Long id) { public ResponseEntity<?> apply(@PathVariable Long id) {
var e = enrichmentRepository.findById(id) var e = enrichmentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); .orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
if (e.getStatus() != EnrichmentStatus.APPROVED) { if (e.getStatus() != EnrichmentStatus.APPROVED) {
return ResponseEntity.badRequest().body("Enrichment must be APPROVED before applying."); return ResponseEntity.badRequest().body("Enrichment must be APPROVED before applying.");
} }
if (e.getEnrichmentType() == EnrichmentType.CALIBER) { if (e.getEnrichmentType() == EnrichmentType.CALIBER) {
Object caliberObj = e.getAttributes().get("caliber"); Object caliberObj = e.getAttributes().get("caliber");
if (!(caliberObj instanceof String caliber) || caliber.trim().isEmpty()) { if (!(caliberObj instanceof String caliber) || caliber.trim().isEmpty()) {
return ResponseEntity.badRequest().body("Missing attributes.caliber"); return ResponseEntity.badRequest().body("Missing attributes.caliber");
} }
String canonical = CaliberTaxonomy.normalizeCaliber(caliber.trim()); String canonical = CaliberTaxonomy.normalizeCaliber(caliber.trim());
int updated = enrichmentRepository.applyCaliberIfBlank(e.getProductId(), canonical); int updated = enrichmentRepository.applyCaliberIfBlank(e.getProductId(), canonical);
if (updated == 0) { if (updated == 0) {
return ResponseEntity.badRequest().body("Product caliber already set (or product not found). Not applied."); return ResponseEntity.badRequest().body("Product caliber already set (or product not found). Not applied.");
} }
// Bonus safety: set group if blank // Bonus safety: set group if blank
String group = CaliberTaxonomy.groupForCaliber(canonical); String group = CaliberTaxonomy.groupForCaliber(canonical);
if (group != null && !group.isBlank()) { if (group != null && !group.isBlank()) {
enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group); enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group);
} }
} else if (e.getEnrichmentType() == EnrichmentType.CALIBER_GROUP) { } else if (e.getEnrichmentType() == EnrichmentType.CALIBER_GROUP) {
Object groupObj = e.getAttributes().get("caliberGroup"); Object groupObj = e.getAttributes().get("caliberGroup");
if (!(groupObj instanceof String group) || group.trim().isEmpty()) { if (!(groupObj instanceof String group) || group.trim().isEmpty()) {
return ResponseEntity.badRequest().body("Missing attributes.caliberGroup"); return ResponseEntity.badRequest().body("Missing attributes.caliberGroup");
} }
int updated = enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group.trim()); int updated = enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group.trim());
if (updated == 0) { if (updated == 0) {
return ResponseEntity.badRequest().body("Product caliber_group already set (or product not found). Not applied."); return ResponseEntity.badRequest().body("Product caliber_group already set (or product not found). Not applied.");
} }
} else { } else {
return ResponseEntity.badRequest().body("Unsupported enrichment type in v0."); return ResponseEntity.badRequest().body("Unsupported enrichment type in v0.");
} }
e.setStatus(EnrichmentStatus.APPLIED); e.setStatus(EnrichmentStatus.APPLIED);
enrichmentRepository.save(e); enrichmentRepository.save(e);
return ResponseEntity.ok(e); return ResponseEntity.ok(e);
} }
@PostMapping("/groups/run") @PostMapping("/groups/run")
public ResponseEntity<?> runGroups(@RequestParam(defaultValue = "200") int limit) { public ResponseEntity<?> runGroups(@RequestParam(defaultValue = "200") int limit) {
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliberGroup(limit)); return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliberGroup(limit));
} }
} }

View File

@@ -1,95 +1,95 @@
package group.goforward.battlbuilder.enrichment.model; package group.goforward.battlbuilder.enrichment.model;
import group.goforward.battlbuilder.enrichment.EnrichmentSource; import group.goforward.battlbuilder.enrichment.EnrichmentSource;
import group.goforward.battlbuilder.enrichment.EnrichmentStatus; import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
import group.goforward.battlbuilder.enrichment.EnrichmentType; import group.goforward.battlbuilder.enrichment.EnrichmentType;
import jakarta.persistence.*; import jakarta.persistence.*;
import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes; import org.hibernate.type.SqlTypes;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@Entity @Entity
@Table(name = "product_enrichments") @Table(name = "product_enrichments")
public class ProductEnrichment { public class ProductEnrichment {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@Column(name = "product_id", nullable = false) @Column(name = "product_id", nullable = false)
private Integer productId; private Integer productId;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "enrichment_type", nullable = false) @Column(name = "enrichment_type", nullable = false)
private EnrichmentType enrichmentType; private EnrichmentType enrichmentType;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "source", nullable = false) @Column(name = "source", nullable = false)
private EnrichmentSource source = EnrichmentSource.AI; private EnrichmentSource source = EnrichmentSource.AI;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false) @Column(name = "status", nullable = false)
private EnrichmentStatus status = EnrichmentStatus.PENDING_REVIEW; private EnrichmentStatus status = EnrichmentStatus.PENDING_REVIEW;
@Column(name = "schema_version", nullable = false) @Column(name = "schema_version", nullable = false)
private Integer schemaVersion = 1; private Integer schemaVersion = 1;
@JdbcTypeCode(SqlTypes.JSON) @JdbcTypeCode(SqlTypes.JSON)
@Column(name = "attributes", nullable = false, columnDefinition = "jsonb") @Column(name = "attributes", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> attributes = new HashMap<>(); private Map<String, Object> attributes = new HashMap<>();
@Column(name = "confidence", precision = 4, scale = 3) @Column(name = "confidence", precision = 4, scale = 3)
private BigDecimal confidence; private BigDecimal confidence;
@Column(name = "rationale") @Column(name = "rationale")
private String rationale; private String rationale;
@JdbcTypeCode(SqlTypes.JSON) @JdbcTypeCode(SqlTypes.JSON)
@Column(name = "meta", nullable = false, columnDefinition = "jsonb") @Column(name = "meta", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> meta = new HashMap<>(); private Map<String, Object> meta = new HashMap<>();
// DB sets these defaults; we keep them read-only to avoid fighting the trigger/defaults // DB sets these defaults; we keep them read-only to avoid fighting the trigger/defaults
@Column(name = "created_at", insertable = false, updatable = false) @Column(name = "created_at", insertable = false, updatable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@Column(name = "updated_at", insertable = false, updatable = false) @Column(name = "updated_at", insertable = false, updatable = false)
private OffsetDateTime updatedAt; private OffsetDateTime updatedAt;
// --- getters/setters (generate via IDE) --- // --- getters/setters (generate via IDE) ---
public Long getId() { return id; } public Long getId() { return id; }
public Integer getProductId() { return productId; } public Integer getProductId() { return productId; }
public void setProductId(Integer productId) { this.productId = productId; } public void setProductId(Integer productId) { this.productId = productId; }
public EnrichmentType getEnrichmentType() { return enrichmentType; } public EnrichmentType getEnrichmentType() { return enrichmentType; }
public void setEnrichmentType(EnrichmentType enrichmentType) { this.enrichmentType = enrichmentType; } public void setEnrichmentType(EnrichmentType enrichmentType) { this.enrichmentType = enrichmentType; }
public EnrichmentSource getSource() { return source; } public EnrichmentSource getSource() { return source; }
public void setSource(EnrichmentSource source) { this.source = source; } public void setSource(EnrichmentSource source) { this.source = source; }
public EnrichmentStatus getStatus() { return status; } public EnrichmentStatus getStatus() { return status; }
public void setStatus(EnrichmentStatus status) { this.status = status; } public void setStatus(EnrichmentStatus status) { this.status = status; }
public Integer getSchemaVersion() { return schemaVersion; } public Integer getSchemaVersion() { return schemaVersion; }
public void setSchemaVersion(Integer schemaVersion) { this.schemaVersion = schemaVersion; } public void setSchemaVersion(Integer schemaVersion) { this.schemaVersion = schemaVersion; }
public Map<String, Object> getAttributes() { return attributes; } public Map<String, Object> getAttributes() { return attributes; }
public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; } public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
public BigDecimal getConfidence() { return confidence; } public BigDecimal getConfidence() { return confidence; }
public void setConfidence(BigDecimal confidence) { this.confidence = confidence; } public void setConfidence(BigDecimal confidence) { this.confidence = confidence; }
public String getRationale() { return rationale; } public String getRationale() { return rationale; }
public void setRationale(String rationale) { this.rationale = rationale; } public void setRationale(String rationale) { this.rationale = rationale; }
public Map<String, Object> getMeta() { return meta; } public Map<String, Object> getMeta() { return meta; }
public void setMeta(Map<String, Object> meta) { this.meta = meta; } public void setMeta(Map<String, Object> meta) { this.meta = meta; }
public OffsetDateTime getCreatedAt() { return createdAt; } public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; } public OffsetDateTime getUpdatedAt() { return updatedAt; }
} }

View File

@@ -1,94 +1,94 @@
package group.goforward.battlbuilder.enrichment.repo; package group.goforward.battlbuilder.enrichment.repo;
import group.goforward.battlbuilder.enrichment.EnrichmentStatus; import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
import group.goforward.battlbuilder.enrichment.EnrichmentType; import group.goforward.battlbuilder.enrichment.EnrichmentType;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
import group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem; import group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface ProductEnrichmentRepository extends JpaRepository<ProductEnrichment, Long> { public interface ProductEnrichmentRepository extends JpaRepository<ProductEnrichment, Long> {
boolean existsByProductIdAndEnrichmentTypeAndStatus( boolean existsByProductIdAndEnrichmentTypeAndStatus(
Integer productId, Integer productId,
EnrichmentType enrichmentType, EnrichmentType enrichmentType,
EnrichmentStatus status EnrichmentStatus status
); );
@Query(""" @Query("""
select e from ProductEnrichment e select e from ProductEnrichment e
where e.productId = :productId where e.productId = :productId
and e.enrichmentType = :type and e.enrichmentType = :type
and e.status in ('PENDING_REVIEW','APPROVED') and e.status in ('PENDING_REVIEW','APPROVED')
""") """)
Optional<ProductEnrichment> findActive(Integer productId, EnrichmentType type); Optional<ProductEnrichment> findActive(Integer productId, EnrichmentType type);
List<ProductEnrichment> findByEnrichmentTypeAndStatusOrderByCreatedAtDesc( List<ProductEnrichment> findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(
EnrichmentType type, EnrichmentType type,
EnrichmentStatus status, EnrichmentStatus status,
Pageable pageable Pageable pageable
); );
@Query(""" @Query("""
select new group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem( select new group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem(
e.id, e.id,
e.productId, e.productId,
p.name, p.name,
p.slug, p.slug,
p.mainImageUrl, p.mainImageUrl,
b.name, b.name,
e.enrichmentType, e.enrichmentType,
e.source, e.source,
e.status, e.status,
e.schemaVersion, e.schemaVersion,
e.attributes, e.attributes,
e.confidence, e.confidence,
e.rationale, e.rationale,
e.createdAt, e.createdAt,
p.caliber, p.caliber,
p.caliberGroup p.caliberGroup
) )
from ProductEnrichment e from ProductEnrichment e
join Product p on p.id = e.productId join Product p on p.id = e.productId
join p.brand b join p.brand b
where e.enrichmentType = :type where e.enrichmentType = :type
and e.status = :status and e.status = :status
order by e.createdAt desc order by e.createdAt desc
""") """)
List<EnrichmentQueueItem> queueWithProduct( List<EnrichmentQueueItem> queueWithProduct(
EnrichmentType type, EnrichmentType type,
EnrichmentStatus status, EnrichmentStatus status,
Pageable pageable Pageable pageable
); );
@Modifying @Modifying
@Query(""" @Query("""
update Product p update Product p
set p.caliber = :caliber set p.caliber = :caliber
where p.id = :productId where p.id = :productId
and (p.caliber is null or trim(p.caliber) = '') and (p.caliber is null or trim(p.caliber) = '')
""") """)
int applyCaliberIfBlank( int applyCaliberIfBlank(
@Param("productId") Integer productId, @Param("productId") Integer productId,
@Param("caliber") String caliber @Param("caliber") String caliber
); );
@Modifying @Modifying
@Query(""" @Query("""
update Product p update Product p
set p.caliberGroup = :caliberGroup set p.caliberGroup = :caliberGroup
where p.id = :productId where p.id = :productId
and (p.caliberGroup is null or trim(p.caliberGroup) = '') and (p.caliberGroup is null or trim(p.caliberGroup) = '')
""") """)
int applyCaliberGroupIfBlank( int applyCaliberGroupIfBlank(
@Param("productId") Integer productId, @Param("productId") Integer productId,
@Param("caliberGroup") String caliberGroup @Param("caliberGroup") String caliberGroup
); );
} }

View File

@@ -1,86 +1,86 @@
package group.goforward.battlbuilder.enrichment.service; package group.goforward.battlbuilder.enrichment.service;
import group.goforward.battlbuilder.enrichment.*; import group.goforward.battlbuilder.enrichment.*;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment; import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository; import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext; import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class CaliberEnrichmentService { public class CaliberEnrichmentService {
private final ProductEnrichmentRepository enrichmentRepository; private final ProductEnrichmentRepository enrichmentRepository;
@PersistenceContext @PersistenceContext
private EntityManager em; private EntityManager em;
private final CaliberRuleExtractor extractor = new CaliberRuleExtractor(); private final CaliberRuleExtractor extractor = new CaliberRuleExtractor();
public CaliberEnrichmentService(ProductEnrichmentRepository enrichmentRepository) { public CaliberEnrichmentService(ProductEnrichmentRepository enrichmentRepository) {
this.enrichmentRepository = enrichmentRepository; this.enrichmentRepository = enrichmentRepository;
} }
public record RunResult(int scanned, int created) {} public record RunResult(int scanned, int created) {}
/** /**
* Creates PENDING_REVIEW caliber enrichments for products that don't already have an active one. * Creates PENDING_REVIEW caliber enrichments for products that don't already have an active one.
*/ */
@Transactional @Transactional
public RunResult runRules(int limit) { public RunResult runRules(int limit) {
// Adjust Product entity package if needed: // Adjust Product entity package if needed:
// IMPORTANT: Product must be a mapped @Entity named "Product" // IMPORTANT: Product must be a mapped @Entity named "Product"
List<Object[]> rows = em.createQuery(""" List<Object[]> rows = em.createQuery("""
select p.id, p.name, p.description select p.id, p.name, p.description
from Product p from Product p
where p.deletedAt is null where p.deletedAt is null
and not exists ( and not exists (
select 1 from ProductEnrichment e select 1 from ProductEnrichment e
where e.productId = p.id where e.productId = p.id
and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER
and e.status in ('PENDING_REVIEW','APPROVED') and e.status in ('PENDING_REVIEW','APPROVED')
) )
order by p.id desc order by p.id desc
""", Object[].class) """, Object[].class)
.setMaxResults(limit) .setMaxResults(limit)
.getResultList(); .getResultList();
int created = 0; int created = 0;
for (Object[] r : rows) { for (Object[] r : rows) {
Integer productId = (Integer) r[0]; Integer productId = (Integer) r[0];
String name = (String) r[1]; String name = (String) r[1];
String description = (String) r[2]; String description = (String) r[2];
Optional<CaliberRuleExtractor.Result> res = extractor.extract(name, description); Optional<CaliberRuleExtractor.Result> res = extractor.extract(name, description);
if (res.isEmpty()) continue; if (res.isEmpty()) continue;
var result = res.get(); var result = res.get();
ProductEnrichment e = new ProductEnrichment(); ProductEnrichment e = new ProductEnrichment();
e.setProductId(productId); e.setProductId(productId);
e.setEnrichmentType(EnrichmentType.CALIBER); e.setEnrichmentType(EnrichmentType.CALIBER);
e.setSource(EnrichmentSource.RULES); e.setSource(EnrichmentSource.RULES);
e.setStatus(EnrichmentStatus.PENDING_REVIEW); e.setStatus(EnrichmentStatus.PENDING_REVIEW);
e.setSchemaVersion(1); e.setSchemaVersion(1);
var attrs = new HashMap<String, Object>(); var attrs = new HashMap<String, Object>();
attrs.put("caliber", result.caliber()); attrs.put("caliber", result.caliber());
e.setAttributes(attrs); e.setAttributes(attrs);
e.setConfidence(BigDecimal.valueOf(result.confidence())); e.setConfidence(BigDecimal.valueOf(result.confidence()));
e.setRationale(result.rationale()); e.setRationale(result.rationale());
enrichmentRepository.save(e); enrichmentRepository.save(e);
created++; created++;
} }
return new RunResult(rows.size(), created); return new RunResult(rows.size(), created);
} }
} }

View File

@@ -1,35 +1,35 @@
package group.goforward.battlbuilder.enrichment.taxonomies; package group.goforward.battlbuilder.enrichment.taxonomies;
import java.util.Locale; import java.util.Locale;
public final class CaliberTaxonomy { public final class CaliberTaxonomy {
private CaliberTaxonomy() {} private CaliberTaxonomy() {}
public static String normalizeCaliber(String raw) { public static String normalizeCaliber(String raw) {
if (raw == null) return null; if (raw == null) return null;
String s = raw.trim(); String s = raw.trim();
// Canonicalize common variants // Canonicalize common variants
String l = s.toLowerCase(Locale.ROOT); String l = s.toLowerCase(Locale.ROOT);
if (l.contains("223 wylde") || l.contains(".223 wylde")) return ".223 Wylde"; if (l.contains("223 wylde") || l.contains(".223 wylde")) return ".223 Wylde";
if (l.contains("5.56") || l.contains("5,56") || l.contains("5.56x45") || l.contains("5.56x45mm")) return "5.56 NATO"; if (l.contains("5.56") || l.contains("5,56") || l.contains("5.56x45") || l.contains("5.56x45mm")) return "5.56 NATO";
if (l.contains("223") || l.contains(".223") || l.contains("223 rem") || l.contains("223 remington")) return ".223 Remington"; if (l.contains("223") || l.contains(".223") || l.contains("223 rem") || l.contains("223 remington")) return ".223 Remington";
if (l.contains("300 blackout") || l.contains("300 blk") || l.contains("300 aac")) return "300 BLK"; if (l.contains("300 blackout") || l.contains("300 blk") || l.contains("300 aac")) return "300 BLK";
// fallback: return trimmed original (you can tighten later) // fallback: return trimmed original (you can tighten later)
return s; return s;
} }
public static String groupForCaliber(String caliberCanonical) { public static String groupForCaliber(String caliberCanonical) {
if (caliberCanonical == null) return null; if (caliberCanonical == null) return null;
String l = caliberCanonical.toLowerCase(Locale.ROOT); String l = caliberCanonical.toLowerCase(Locale.ROOT);
if (l.contains("223") || l.contains("5.56") || l.contains("wylde")) return "223/5.56"; if (l.contains("223") || l.contains("5.56") || l.contains("wylde")) return "223/5.56";
if (l.contains("300 blk") || l.contains("300 blackout") || l.contains("300 aac")) return "300 BLK"; if (l.contains("300 blk") || l.contains("300 blackout") || l.contains("300 aac")) return "300 BLK";
// TODO add more buckets: 308/7.62, 6.5 CM, 9mm, etc. // TODO add more buckets: 308/7.62, 6.5 CM, 9mm, etc.
return null; return null;
} }
} }

View File

@@ -1,174 +1,174 @@
package group.goforward.battlbuilder.model; package group.goforward.battlbuilder.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
/** /**
* Entity representing an authentication token. * Entity representing an authentication token.
* Tokens are used for beta verification, magic login links, and password resets. * Tokens are used for beta verification, magic login links, and password resets.
* Tokens are hashed before storage and can be consumed and expired. * Tokens are hashed before storage and can be consumed and expired.
* *
* @see jakarta.persistence.Entity * @see jakarta.persistence.Entity
*/ */
@Entity @Entity
@Table( @Table(
name = "auth_tokens", name = "auth_tokens",
indexes = { indexes = {
@Index(name = "idx_auth_tokens_email", columnList = "email"), @Index(name = "idx_auth_tokens_email", columnList = "email"),
@Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash") @Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash")
} }
) )
public class AuthToken { public class AuthToken {
/** /**
* Enumeration of token types. * Enumeration of token types.
*/ */
public enum TokenType { public enum TokenType {
/** Token for beta access verification. */ /** Token for beta access verification. */
BETA_VERIFY, BETA_VERIFY,
/** Token for magic link login. */ /** Token for magic link login. */
MAGIC_LOGIN, MAGIC_LOGIN,
/** Token for password reset. */ /** Token for password reset. */
PASSWORD_RESET PASSWORD_RESET
} }
/** The primary key identifier for the token. */ /** The primary key identifier for the token. */
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
/** The email address associated with this token. */ /** The email address associated with this token. */
@Column(nullable = false) @Column(nullable = false)
private String email; private String email;
/** The type of token. */ /** The type of token. */
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32) @Column(nullable = false, length = 32)
private TokenType type; private TokenType type;
/** The hashed token value. */ /** The hashed token value. */
@Column(name = "token_hash", nullable = false, length = 64) @Column(name = "token_hash", nullable = false, length = 64)
private String tokenHash; private String tokenHash;
/** The timestamp when this token expires. */ /** The timestamp when this token expires. */
@Column(name = "expires_at", nullable = false) @Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt; private OffsetDateTime expiresAt;
/** The timestamp when this token was consumed/used. */ /** The timestamp when this token was consumed/used. */
@Column(name = "consumed_at") @Column(name = "consumed_at")
private OffsetDateTime consumedAt; private OffsetDateTime consumedAt;
/** The timestamp when this token was created. */ /** The timestamp when this token was created. */
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
// getters/setters // getters/setters
/** /**
* Gets the primary key identifier for the token. * Gets the primary key identifier for the token.
* *
* @return the token ID * @return the token ID
*/ */
public Long getId() { return id; } public Long getId() { return id; }
/** /**
* Gets the email address associated with this token. * Gets the email address associated with this token.
* *
* @return the email address * @return the email address
*/ */
public String getEmail() { return email; } public String getEmail() { return email; }
/** /**
* Sets the email address associated with this token. * Sets the email address associated with this token.
* *
* @param email the email address to set * @param email the email address to set
*/ */
public void setEmail(String email) { this.email = email; } public void setEmail(String email) { this.email = email; }
/** /**
* Gets the type of token. * Gets the type of token.
* *
* @return the token type * @return the token type
*/ */
public TokenType getType() { return type; } public TokenType getType() { return type; }
/** /**
* Sets the type of token. * Sets the type of token.
* *
* @param type the token type to set * @param type the token type to set
*/ */
public void setType(TokenType type) { this.type = type; } public void setType(TokenType type) { this.type = type; }
/** /**
* Gets the hashed token value. * Gets the hashed token value.
* *
* @return the token hash * @return the token hash
*/ */
public String getTokenHash() { return tokenHash; } public String getTokenHash() { return tokenHash; }
/** /**
* Sets the hashed token value. * Sets the hashed token value.
* *
* @param tokenHash the token hash to set * @param tokenHash the token hash to set
*/ */
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; } public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
/** /**
* Gets the timestamp when this token expires. * Gets the timestamp when this token expires.
* *
* @return the expiration timestamp * @return the expiration timestamp
*/ */
public OffsetDateTime getExpiresAt() { return expiresAt; } public OffsetDateTime getExpiresAt() { return expiresAt; }
/** /**
* Sets the timestamp when this token expires. * Sets the timestamp when this token expires.
* *
* @param expiresAt the expiration timestamp to set * @param expiresAt the expiration timestamp to set
*/ */
public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; } public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; }
/** /**
* Gets the timestamp when this token was consumed/used. * Gets the timestamp when this token was consumed/used.
* *
* @return the consumed timestamp, or null if not yet consumed * @return the consumed timestamp, or null if not yet consumed
*/ */
public OffsetDateTime getConsumedAt() { return consumedAt; } public OffsetDateTime getConsumedAt() { return consumedAt; }
/** /**
* Sets the timestamp when this token was consumed/used. * Sets the timestamp when this token was consumed/used.
* *
* @param consumedAt the consumed timestamp to set * @param consumedAt the consumed timestamp to set
*/ */
public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; } public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; }
/** /**
* Gets the timestamp when this token was created. * Gets the timestamp when this token was created.
* *
* @return the creation timestamp * @return the creation timestamp
*/ */
public OffsetDateTime getCreatedAt() { return createdAt; } public OffsetDateTime getCreatedAt() { return createdAt; }
/** /**
* Sets the timestamp when this token was created. * Sets the timestamp when this token was created.
* *
* @param createdAt the creation timestamp to set * @param createdAt the creation timestamp to set
*/ */
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
/** /**
* Checks if this token has been consumed/used. * Checks if this token has been consumed/used.
* *
* @return true if the token has been consumed, false otherwise * @return true if the token has been consumed, false otherwise
*/ */
@Transient @Transient
public boolean isConsumed() { return consumedAt != null; } public boolean isConsumed() { return consumedAt != null; }
/** /**
* Checks if this token has expired at the given time. * Checks if this token has expired at the given time.
* *
* @param now the current time to check against * @param now the current time to check against
* @return true if the token has expired, false otherwise * @return true if the token has expired, false otherwise
*/ */
@Transient @Transient
public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); } public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); }
} }

View File

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

View File

@@ -1,310 +1,310 @@
package group.goforward.battlbuilder.model; package group.goforward.battlbuilder.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* Entity representing an email request/queue entry. * Entity representing an email request/queue entry.
* Tracks email sending status, delivery, and engagement metrics (opens, clicks). * Tracks email sending status, delivery, and engagement metrics (opens, clicks).
* *
* @see jakarta.persistence.Entity * @see jakarta.persistence.Entity
*/ */
@Entity @Entity
@Table(name = "email_requests") @Table(name = "email_requests")
@NamedQueries({ @NamedQueries({
@NamedQuery( @NamedQuery(
name = "EmailRequest.findSent", name = "EmailRequest.findSent",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT" query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT"
), ),
@NamedQuery( @NamedQuery(
name = "EmailRequest.findFailed", name = "EmailRequest.findFailed",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED" query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED"
), ),
@NamedQuery( @NamedQuery(
name = "EmailRequest.findPending", name = "EmailRequest.findPending",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING" query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING"
) )
}) })
public class EmailRequest { public class EmailRequest {
/** The primary key identifier for the email request. */ /** The primary key identifier for the email request. */
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
/** The email address of the recipient. */ /** The email address of the recipient. */
@Column(nullable = false) @Column(nullable = false)
private String recipient; private String recipient;
/** The email subject line. */ /** The email subject line. */
@Column(nullable = false) @Column(nullable = false)
private String subject; private String subject;
/** The email body content. */ /** The email body content. */
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String body; private String body;
/** The template key used to generate this email (if applicable). */ /** The template key used to generate this email (if applicable). */
@Column(name = "template_key", length = 100) @Column(name = "template_key", length = 100)
private String templateKey; private String templateKey;
/** The status of the email (PENDING, SENT, FAILED). */ /** The status of the email (PENDING, SENT, FAILED). */
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false) @Column(nullable = false)
private EmailStatus status; // PENDING, SENT, FAILED private EmailStatus status; // PENDING, SENT, FAILED
/** The timestamp when the email was sent. */ /** The timestamp when the email was sent. */
@Column(name = "sent_at") @Column(name = "sent_at")
private LocalDateTime sentAt; private LocalDateTime sentAt;
/** The error message if the email failed to send. */ /** The error message if the email failed to send. */
@Column(name = "error_message") @Column(name = "error_message")
private String errorMessage; private String errorMessage;
/** The timestamp when the email was first opened. */ /** The timestamp when the email was first opened. */
@Column(name = "opened_at") @Column(name = "opened_at")
private LocalDateTime openedAt; private LocalDateTime openedAt;
/** The number of times the email has been opened. Defaults to 0. */ /** The number of times the email has been opened. Defaults to 0. */
@Column(name = "open_count", nullable = false) @Column(name = "open_count", nullable = false)
private Integer openCount = 0; private Integer openCount = 0;
/** The timestamp when a link in the email was first clicked. */ /** The timestamp when a link in the email was first clicked. */
@Column(name = "clicked_at") @Column(name = "clicked_at")
private LocalDateTime clickedAt; private LocalDateTime clickedAt;
/** The number of times links in the email have been clicked. Defaults to 0. */ /** The number of times links in the email have been clicked. Defaults to 0. */
@Column(name = "click_count", nullable = false) @Column(name = "click_count", nullable = false)
private Integer clickCount = 0; private Integer clickCount = 0;
/** The timestamp when this email request was created. */ /** The timestamp when this email request was created. */
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** The timestamp when this email request was last updated. */ /** The timestamp when this email request was last updated. */
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
/** /**
* Lifecycle hook called before persisting a new entity. * Lifecycle hook called before persisting a new entity.
* Initializes timestamps and default values. * Initializes timestamps and default values.
*/ */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
createdAt = now; createdAt = now;
updatedAt = now; updatedAt = now;
if (status == null) status = EmailStatus.PENDING; if (status == null) status = EmailStatus.PENDING;
if (openCount == null) openCount = 0; if (openCount == null) openCount = 0;
if (clickCount == null) clickCount = 0; if (clickCount == null) clickCount = 0;
} }
/** /**
* Lifecycle hook called before updating an existing entity. * Lifecycle hook called before updating an existing entity.
* Updates the updatedAt timestamp. * Updates the updatedAt timestamp.
*/ */
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
} }
// ===== Getters / Setters ===== // ===== Getters / Setters =====
/** /**
* Gets the primary key identifier for the email request. * Gets the primary key identifier for the email request.
* *
* @return the email request ID * @return the email request ID
*/ */
public Long getId() { return id; } public Long getId() { return id; }
/** /**
* Sets the primary key identifier for the email request. * Sets the primary key identifier for the email request.
* *
* @param id the email request ID to set * @param id the email request ID to set
*/ */
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
/** /**
* Gets the email address of the recipient. * Gets the email address of the recipient.
* *
* @return the recipient email address * @return the recipient email address
*/ */
public String getRecipient() { return recipient; } public String getRecipient() { return recipient; }
/** /**
* Sets the email address of the recipient. * Sets the email address of the recipient.
* *
* @param recipient the recipient email address to set * @param recipient the recipient email address to set
*/ */
public void setRecipient(String recipient) { this.recipient = recipient; } public void setRecipient(String recipient) { this.recipient = recipient; }
/** /**
* Gets the email subject line. * Gets the email subject line.
* *
* @return the subject * @return the subject
*/ */
public String getSubject() { return subject; } public String getSubject() { return subject; }
/** /**
* Sets the email subject line. * Sets the email subject line.
* *
* @param subject the subject to set * @param subject the subject to set
*/ */
public void setSubject(String subject) { this.subject = subject; } public void setSubject(String subject) { this.subject = subject; }
/** /**
* Gets the email body content. * Gets the email body content.
* *
* @return the body, or null if not set * @return the body, or null if not set
*/ */
public String getBody() { return body; } public String getBody() { return body; }
/** /**
* Sets the email body content. * Sets the email body content.
* *
* @param body the body to set * @param body the body to set
*/ */
public void setBody(String body) { this.body = body; } public void setBody(String body) { this.body = body; }
/** /**
* Gets the template key used to generate this email. * Gets the template key used to generate this email.
* *
* @return the template key, or null if not set * @return the template key, or null if not set
*/ */
public String getTemplateKey() { return templateKey; } public String getTemplateKey() { return templateKey; }
/** /**
* Sets the template key used to generate this email. * Sets the template key used to generate this email.
* *
* @param templateKey the template key to set * @param templateKey the template key to set
*/ */
public void setTemplateKey(String templateKey) { this.templateKey = templateKey; } public void setTemplateKey(String templateKey) { this.templateKey = templateKey; }
/** /**
* Gets the status of the email. * Gets the status of the email.
* *
* @return the email status (PENDING, SENT, FAILED) * @return the email status (PENDING, SENT, FAILED)
*/ */
public EmailStatus getStatus() { return status; } public EmailStatus getStatus() { return status; }
/** /**
* Sets the status of the email. * Sets the status of the email.
* *
* @param status the email status to set * @param status the email status to set
*/ */
public void setStatus(EmailStatus status) { this.status = status; } public void setStatus(EmailStatus status) { this.status = status; }
/** /**
* Gets the timestamp when the email was sent. * Gets the timestamp when the email was sent.
* *
* @return the sent timestamp, or null if not yet sent * @return the sent timestamp, or null if not yet sent
*/ */
public LocalDateTime getSentAt() { return sentAt; } public LocalDateTime getSentAt() { return sentAt; }
/** /**
* Sets the timestamp when the email was sent. * Sets the timestamp when the email was sent.
* *
* @param sentAt the sent timestamp to set * @param sentAt the sent timestamp to set
*/ */
public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; } public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; }
/** /**
* Gets the error message if the email failed to send. * Gets the error message if the email failed to send.
* *
* @return the error message, or null if no error * @return the error message, or null if no error
*/ */
public String getErrorMessage() { return errorMessage; } public String getErrorMessage() { return errorMessage; }
/** /**
* Sets the error message if the email failed to send. * Sets the error message if the email failed to send.
* *
* @param errorMessage the error message to set * @param errorMessage the error message to set
*/ */
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
/** /**
* Gets the timestamp when the email was first opened. * Gets the timestamp when the email was first opened.
* *
* @return the opened timestamp, or null if never opened * @return the opened timestamp, or null if never opened
*/ */
public LocalDateTime getOpenedAt() { return openedAt; } public LocalDateTime getOpenedAt() { return openedAt; }
/** /**
* Sets the timestamp when the email was first opened. * Sets the timestamp when the email was first opened.
* *
* @param openedAt the opened timestamp to set * @param openedAt the opened timestamp to set
*/ */
public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; } public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; }
/** /**
* Gets the number of times the email has been opened. * Gets the number of times the email has been opened.
* *
* @return the open count * @return the open count
*/ */
public Integer getOpenCount() { return openCount; } public Integer getOpenCount() { return openCount; }
/** /**
* Sets the number of times the email has been opened. * Sets the number of times the email has been opened.
* *
* @param openCount the open count to set * @param openCount the open count to set
*/ */
public void setOpenCount(Integer openCount) { this.openCount = openCount; } public void setOpenCount(Integer openCount) { this.openCount = openCount; }
/** /**
* Gets the timestamp when a link in the email was first clicked. * Gets the timestamp when a link in the email was first clicked.
* *
* @return the clicked timestamp, or null if never clicked * @return the clicked timestamp, or null if never clicked
*/ */
public LocalDateTime getClickedAt() { return clickedAt; } public LocalDateTime getClickedAt() { return clickedAt; }
/** /**
* Sets the timestamp when a link in the email was first clicked. * Sets the timestamp when a link in the email was first clicked.
* *
* @param clickedAt the clicked timestamp to set * @param clickedAt the clicked timestamp to set
*/ */
public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; } public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; }
/** /**
* Gets the number of times links in the email have been clicked. * Gets the number of times links in the email have been clicked.
* *
* @return the click count * @return the click count
*/ */
public Integer getClickCount() { return clickCount; } public Integer getClickCount() { return clickCount; }
/** /**
* Sets the number of times links in the email have been clicked. * Sets the number of times links in the email have been clicked.
* *
* @param clickCount the click count to set * @param clickCount the click count to set
*/ */
public void setClickCount(Integer clickCount) { this.clickCount = clickCount; } public void setClickCount(Integer clickCount) { this.clickCount = clickCount; }
/** /**
* Gets the timestamp when this email request was created. * Gets the timestamp when this email request was created.
* *
* @return the creation timestamp * @return the creation timestamp
*/ */
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
/** /**
* Sets the timestamp when this email request was created. * Sets the timestamp when this email request was created.
* *
* @param createdAt the creation timestamp to set * @param createdAt the creation timestamp to set
*/ */
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
/** /**
* Gets the timestamp when this email request was last updated. * Gets the timestamp when this email request was last updated.
* *
* @return the last update timestamp * @return the last update timestamp
*/ */
public LocalDateTime getUpdatedAt() { return updatedAt; } public LocalDateTime getUpdatedAt() { return updatedAt; }
/** /**
* Sets the timestamp when this email request was last updated. * Sets the timestamp when this email request was last updated.
* *
* @param updatedAt the last update timestamp to set * @param updatedAt the last update timestamp to set
*/ */
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,28 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.AuthToken; import group.goforward.battlbuilder.model.AuthToken;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Optional; import java.util.Optional;
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> { public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash); Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash);
// ✅ Used for "Invited" badge: active (not expired, not consumed) magic token exists // ✅ Used for "Invited" badge: active (not expired, not consumed) magic token exists
@Query(""" @Query("""
select (count(t) > 0) from AuthToken t select (count(t) > 0) from AuthToken t
where lower(t.email) = lower(:email) where lower(t.email) = lower(:email)
and t.type = :type and t.type = :type
and t.expiresAt > :now and t.expiresAt > :now
and t.consumedAt is null and t.consumedAt is null
""") """)
boolean hasActiveToken( boolean hasActiveToken(
@Param("email") String email, @Param("email") String email,
@Param("type") AuthToken.TokenType type, @Param("type") AuthToken.TokenType type,
@Param("now") OffsetDateTime now @Param("now") OffsetDateTime now
); );
} }

View File

@@ -1,12 +1,12 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.BuildProfile; import group.goforward.battlbuilder.model.BuildProfile;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
public interface BuildProfileRepository extends JpaRepository<BuildProfile, Integer> { public interface BuildProfileRepository extends JpaRepository<BuildProfile, Integer> {
List<BuildProfile> findByBuildIdIn(Collection<Integer> buildIds); List<BuildProfile> findByBuildIdIn(Collection<Integer> buildIds);
} }

View File

@@ -1,18 +1,18 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.CanonicalCategory; import group.goforward.battlbuilder.model.CanonicalCategory;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import java.util.List; import java.util.List;
public interface CanonicalCategoryRepository extends JpaRepository<CanonicalCategory, Integer> { public interface CanonicalCategoryRepository extends JpaRepository<CanonicalCategory, Integer> {
@Query(""" @Query("""
select c select c
from CanonicalCategory c from CanonicalCategory c
where c.deletedAt is null where c.deletedAt is null
order by c.name asc order by c.name asc
""") """)
List<CanonicalCategory> findAllActive(); List<CanonicalCategory> findAllActive();
} }

View File

@@ -1,22 +1,22 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.CategoryMapping; import group.goforward.battlbuilder.model.CategoryMapping;
import group.goforward.battlbuilder.model.Merchant; import group.goforward.battlbuilder.model.Merchant;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import java.util.List; import java.util.List;
public interface CategoryMappingRepository extends JpaRepository<CategoryMapping, Integer> { public interface CategoryMappingRepository extends JpaRepository<CategoryMapping, Integer> {
// All mappings for a merchant, ordered nicely // All mappings for a merchant, ordered nicely
List<CategoryMapping> findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId); List<CategoryMapping> findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId);
// Merchants that actually have mappings (for the dropdown) // Merchants that actually have mappings (for the dropdown)
@Query(""" @Query("""
select distinct cm.merchant select distinct cm.merchant
from CategoryMapping cm from CategoryMapping cm
order by cm.merchant.name asc order by cm.merchant.name asc
""") """)
List<Merchant> findDistinctMerchantsWithMappings(); List<Merchant> findDistinctMerchantsWithMappings();
} }

View File

@@ -1,16 +1,16 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.EmailRequest; import group.goforward.battlbuilder.model.EmailRequest;
import group.goforward.battlbuilder.model.EmailStatus; import group.goforward.battlbuilder.model.EmailStatus;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@Repository @Repository
public interface EmailRequestRepository extends JpaRepository<EmailRequest, Long> { public interface EmailRequestRepository extends JpaRepository<EmailRequest, Long> {
List<EmailRequest> findByStatus(EmailStatus status); List<EmailRequest> findByStatus(EmailStatus status);
List<EmailRequest> findByStatusOrderByCreatedAtDesc(EmailStatus status); List<EmailRequest> findByStatusOrderByCreatedAtDesc(EmailStatus status);
} }

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.EmailTemplate; import group.goforward.battlbuilder.model.EmailTemplate;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
public interface EmailTemplateRepository extends JpaRepository<EmailTemplate, Long> { public interface EmailTemplateRepository extends JpaRepository<EmailTemplate, Long> {
Optional<EmailTemplate> findByTemplateKeyAndEnabledTrue(String templateKey); Optional<EmailTemplate> findByTemplateKeyAndEnabledTrue(String templateKey);
} }

View File

@@ -1,63 +1,63 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.MerchantCategoryMap; import group.goforward.battlbuilder.model.MerchantCategoryMap;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> { public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
// Pull candidates ordered by platform specificity: exact match first, then ANY/null. // Pull candidates ordered by platform specificity: exact match first, then ANY/null.
@Query(""" @Query("""
select m select m
from MerchantCategoryMap m from MerchantCategoryMap m
where m.merchant.id = :merchantId where m.merchant.id = :merchantId
and lower(m.rawCategory) = lower(:rawCategory) and m.enabled = true and lower(m.rawCategory) = lower(:rawCategory) and m.enabled = true
and m.deletedAt is null and m.deletedAt is null
and (m.platform is null or m.platform = 'ANY' or m.platform = :platform) and (m.platform is null or m.platform = 'ANY' or m.platform = :platform)
order by order by
case case
when m.platform = :platform then 0 when m.platform = :platform then 0
when m.platform = 'ANY' then 1 when m.platform = 'ANY' then 1
when m.platform is null then 2 when m.platform is null then 2
else 3 else 3
end, end,
m.updatedAt desc m.updatedAt desc
""") """)
List<MerchantCategoryMap> findCandidates( List<MerchantCategoryMap> findCandidates(
@Param("merchantId") Integer merchantId, @Param("merchantId") Integer merchantId,
@Param("rawCategory") String rawCategory, @Param("rawCategory") String rawCategory,
@Param("platform") String platform @Param("platform") String platform
); );
default Optional<MerchantCategoryMap> findBest(Integer merchantId, String rawCategory, String platform) { default Optional<MerchantCategoryMap> findBest(Integer merchantId, String rawCategory, String platform) {
List<MerchantCategoryMap> candidates = findCandidates(merchantId, rawCategory, platform); List<MerchantCategoryMap> candidates = findCandidates(merchantId, rawCategory, platform);
return candidates.stream().findFirst(); return candidates.stream().findFirst();
} }
// Optional helper if you want a quick "latest mapping regardless of platform" // Optional helper if you want a quick "latest mapping regardless of platform"
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc( Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
Integer merchantId, Integer merchantId,
String rawCategory String rawCategory
); );
// Optional: if you still want a role-only lookup list for debugging // Optional: if you still want a role-only lookup list for debugging
@Query(""" @Query("""
select mcm.canonicalPartRole select mcm.canonicalPartRole
from MerchantCategoryMap mcm from MerchantCategoryMap mcm
where mcm.merchant.id = :merchantId where mcm.merchant.id = :merchantId
and mcm.rawCategory = :rawCategory and mcm.rawCategory = :rawCategory
and mcm.enabled = true and mcm.enabled = true
and mcm.deletedAt is null and mcm.deletedAt is null
order by mcm.updatedAt desc order by mcm.updatedAt desc
""") """)
List<String> findCanonicalPartRoles( List<String> findCanonicalPartRoles(
@Param("merchantId") Integer merchantId, @Param("merchantId") Integer merchantId,
@Param("rawCategory") String rawCategory @Param("rawCategory") String rawCategory
); );
} }

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PartCategory; import group.goforward.battlbuilder.model.PartCategory;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> { public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
Optional<PartCategory> findBySlug(String slug); Optional<PartCategory> findBySlug(String slug);
List<PartCategory> findAllByOrderByGroupNameAscSortOrderAscNameAsc(); List<PartCategory> findAllByOrderByGroupNameAscSortOrderAscNameAsc();
} }

View File

@@ -1,22 +1,22 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PartRoleMapping; import group.goforward.battlbuilder.model.PartRoleMapping;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> { public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
// Used by admin screens / lists (case-sensitive, no platform normalization) // Used by admin screens / lists (case-sensitive, no platform normalization)
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform); List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
// Used by builder/bootstrap flows (case-insensitive) // Used by builder/bootstrap flows (case-insensitive)
List<PartRoleMapping> findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform); List<PartRoleMapping> findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
// Used by resolvers when mapping a single role (case-insensitive) // Used by resolvers when mapping a single role (case-insensitive)
Optional<PartRoleMapping> findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull( Optional<PartRoleMapping> findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(
String platform, String platform,
String partRole String partRole
); );
} }

View File

@@ -1,10 +1,10 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PartRoleRule; import group.goforward.battlbuilder.model.PartRoleRule;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
public interface PartRoleRuleRepository extends JpaRepository<PartRoleRule, Long> { public interface PartRoleRuleRepository extends JpaRepository<PartRoleRule, Long> {
List<PartRoleRule> findAllByActiveTrueOrderByPriorityDescIdAsc(); List<PartRoleRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
} }

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PlatformRule; import group.goforward.battlbuilder.model.PlatformRule;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@Repository @Repository
public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> { public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> {
// Active rules, highest priority first (tie-breaker: id asc for stability) // Active rules, highest priority first (tie-breaker: id asc for stability)
List<PlatformRule> findAllByActiveTrueOrderByPriorityDescIdAsc(); List<PlatformRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
} }

View File

@@ -1,44 +1,44 @@
package group.goforward.battlbuilder.repo; package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.User; import group.goforward.battlbuilder.model.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface UserRepository extends JpaRepository<User, Integer> { public interface UserRepository extends JpaRepository<User, Integer> {
Optional<User> findByEmailIgnoreCaseAndDeletedAtIsNull(String email); Optional<User> findByEmailIgnoreCaseAndDeletedAtIsNull(String email);
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email); boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
Optional<User> findByUuid(UUID uuid); Optional<User> findByUuid(UUID uuid);
boolean existsByRole(String role); boolean existsByRole(String role);
Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid); Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid);
List<User> findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role); List<User> findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role);
// ✅ Pending beta requests (what you described) // ✅ Pending beta requests (what you described)
Page<User> findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc( Page<User> findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc(
String role, String role,
Pageable pageable Pageable pageable
); );
// ✅ Optional: find user by verification token for confirm flow (if you dont already have it) // ✅ Optional: find user by verification token for confirm flow (if you dont already have it)
Optional<User> findByVerificationTokenAndDeletedAtIsNull(String verificationToken); Optional<User> findByVerificationTokenAndDeletedAtIsNull(String verificationToken);
// Set Username // Set Username
Optional<User> findByUsernameIgnoreCaseAndDeletedAtIsNull(String username); Optional<User> findByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
boolean existsByUsernameIgnoreCaseAndDeletedAtIsNull(String username); boolean existsByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
@Query(value = "select * from users where role = :role and is_active = false and deleted_at is null order by created_at asc limit :limit", nativeQuery = true) @Query(value = "select * from users where role = :role and is_active = false and deleted_at is null order by created_at asc limit :limit", nativeQuery = true)
List<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit); List<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
} }

View File

@@ -1,57 +1,57 @@
package group.goforward.battlbuilder.repo.catalog.spec; package group.goforward.battlbuilder.repo.catalog.spec;
import group.goforward.battlbuilder.model.Product; import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductStatus; import group.goforward.battlbuilder.model.ProductStatus;
import group.goforward.battlbuilder.model.ProductVisibility; import group.goforward.battlbuilder.model.ProductVisibility;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.JoinType;
import java.util.List; import java.util.List;
public class CatalogProductSpecifications { public class CatalogProductSpecifications {
private CatalogProductSpecifications() {} private CatalogProductSpecifications() {}
// Default public catalog rules // Default public catalog rules
public static Specification<Product> isCatalogVisible() { public static Specification<Product> isCatalogVisible() {
return (root, query, cb) -> cb.and( return (root, query, cb) -> cb.and(
cb.isNull(root.get("deletedAt")), cb.isNull(root.get("deletedAt")),
cb.equal(root.get("status"), ProductStatus.ACTIVE), cb.equal(root.get("status"), ProductStatus.ACTIVE),
cb.equal(root.get("visibility"), ProductVisibility.PUBLIC), cb.equal(root.get("visibility"), ProductVisibility.PUBLIC),
cb.isTrue(root.get("builderEligible")) cb.isTrue(root.get("builderEligible"))
); );
} }
public static Specification<Product> platformEquals(String platform) { public static Specification<Product> platformEquals(String platform) {
return (root, query, cb) -> cb.equal(root.get("platform"), platform); return (root, query, cb) -> cb.equal(root.get("platform"), platform);
} }
public static Specification<Product> partRoleIn(List<String> roles) { public static Specification<Product> partRoleIn(List<String> roles) {
return (root, query, cb) -> root.get("partRole").in(roles); return (root, query, cb) -> root.get("partRole").in(roles);
} }
public static Specification<Product> brandNameIn(List<String> brandNames) { public static Specification<Product> brandNameIn(List<String> brandNames) {
return (root, query, cb) -> { return (root, query, cb) -> {
root.fetch("brand", JoinType.LEFT); root.fetch("brand", JoinType.LEFT);
query.distinct(true); query.distinct(true);
return root.join("brand", JoinType.LEFT).get("name").in(brandNames); return root.join("brand", JoinType.LEFT).get("name").in(brandNames);
}; };
} }
public static Specification<Product> queryLike(String q) { public static Specification<Product> queryLike(String q) {
final String like = "%" + q.toLowerCase().trim() + "%"; final String like = "%" + q.toLowerCase().trim() + "%";
return (root, query, cb) -> { return (root, query, cb) -> {
root.fetch("brand", JoinType.LEFT); root.fetch("brand", JoinType.LEFT);
query.distinct(true); query.distinct(true);
var brandJoin = root.join("brand", JoinType.LEFT); var brandJoin = root.join("brand", JoinType.LEFT);
return cb.or( return cb.or(
cb.like(cb.lower(root.get("name")), like), cb.like(cb.lower(root.get("name")), like),
cb.like(cb.lower(brandJoin.get("name")), like), cb.like(cb.lower(brandJoin.get("name")), like),
cb.like(cb.lower(root.get("mpn")), like), cb.like(cb.lower(root.get("mpn")), like),
cb.like(cb.lower(root.get("upc")), like) cb.like(cb.lower(root.get("upc")), like)
); );
}; };
} }
} }

View File

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

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.repo.projections; package group.goforward.battlbuilder.repo.projections;
public interface CatalogRow { public interface CatalogRow {
Long getId(); Long getId();
String getName(); String getName();
String getPlatform(); String getPlatform();
String getPartRole(); String getPartRole();
String getImageUrl(); // or mainImageUrl depending on your schema String getImageUrl(); // or mainImageUrl depending on your schema
String getBrand(); String getBrand();
Double getPrice(); Double getPrice();
String getBuyUrl(); String getBuyUrl();
Boolean getInStock(); Boolean getInStock();
} }

View File

@@ -1,25 +1,25 @@
package group.goforward.battlbuilder.security; package group.goforward.battlbuilder.security;
import group.goforward.battlbuilder.model.User; import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository; import group.goforward.battlbuilder.repo.UserRepository;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class CustomUserDetailsService implements UserDetailsService { public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository users; private final UserRepository users;
public CustomUserDetailsService(UserRepository users) { public CustomUserDetailsService(UserRepository users) {
this.users = users; this.users = users;
} }
@Override @Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found")); .orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new CustomUserDetails(user); return new CustomUserDetails(user);
} }
} }

View File

@@ -1,88 +1,88 @@
package group.goforward.battlbuilder.security; package group.goforward.battlbuilder.security;
import group.goforward.battlbuilder.model.User; import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository; import group.goforward.battlbuilder.repo.UserRepository;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.UUID; import java.util.UUID;
@Component @Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService; private final JwtService jwtService;
private final UserRepository userRepository; private final UserRepository userRepository;
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) { public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
this.jwtService = jwtService; this.jwtService = jwtService;
this.userRepository = userRepository; this.userRepository = userRepository;
} }
@Override @Override
protected void doFilterInternal( protected void doFilterInternal(
HttpServletRequest request, HttpServletRequest request,
HttpServletResponse response, HttpServletResponse response,
FilterChain filterChain FilterChain filterChain
) throws ServletException, IOException { ) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization"); String authHeader = request.getHeader("Authorization");
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) { if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
// ✅ If already authenticated with a REAL user, skip. // ✅ If already authenticated with a REAL user, skip.
// ✅ If it's anonymous, we should continue and replace it. // ✅ If it's anonymous, we should continue and replace it.
var existing = SecurityContextHolder.getContext().getAuthentication(); var existing = SecurityContextHolder.getContext().getAuthentication();
if (existing != null if (existing != null
&& existing.isAuthenticated() && existing.isAuthenticated()
&& !(existing instanceof AnonymousAuthenticationToken)) { && !(existing instanceof AnonymousAuthenticationToken)) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
String token = authHeader.substring(7); String token = authHeader.substring(7);
if (!jwtService.isTokenValid(token)) { if (!jwtService.isTokenValid(token)) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
UUID userUuid = jwtService.extractUserUuid(token); UUID userUuid = jwtService.extractUserUuid(token);
if (userUuid == null) { if (userUuid == null) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
User user = userRepository.findByUuid(userUuid).orElse(null); User user = userRepository.findByUuid(userUuid).orElse(null);
if (user == null || !Boolean.TRUE.equals(user.isActive())) { if (user == null || !Boolean.TRUE.equals(user.isActive())) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
CustomUserDetails userDetails = new CustomUserDetails(user); CustomUserDetails userDetails = new CustomUserDetails(user);
UsernamePasswordAuthenticationToken authToken = UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken( new UsernamePasswordAuthenticationToken(
user.getUuid().toString(), // principal = UUID string user.getUuid().toString(), // principal = UUID string
null, null,
userDetails.getAuthorities() userDetails.getAuthorities()
); );
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken); SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
} }

View File

@@ -1,16 +1,16 @@
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.Brand; import group.goforward.battlbuilder.model.Brand;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface BrandService { public interface BrandService {
List<Brand> findAll(); List<Brand> findAll();
Optional<Brand> findById(Integer id); Optional<Brand> findById(Integer id);
Brand save(Brand item); Brand save(Brand item);
void deleteById(Integer id); void deleteById(Integer id);
} }

View File

@@ -1,26 +1,26 @@
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.web.dto.BuildDto; import group.goforward.battlbuilder.web.dto.BuildDto;
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
import group.goforward.battlbuilder.web.dto.BuildSummaryDto; import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
public interface BuildService { public interface BuildService {
List<BuildFeedCardDto> listPublicBuilds(int limit); List<BuildFeedCardDto> listPublicBuilds(int limit);
List<BuildSummaryDto> listMyBuilds(int limit); List<BuildSummaryDto> listMyBuilds(int limit);
BuildDto getMyBuild(UUID uuid); BuildDto getMyBuild(UUID uuid);
BuildDto createMyBuild(UpdateBuildRequest req); BuildDto createMyBuild(UpdateBuildRequest req);
BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req); BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req);
BuildDto getPublicBuild(UUID uuid); BuildDto getPublicBuild(UUID uuid);
void deleteMyBuild(UUID uuid); void deleteMyBuild(UUID uuid);
} }

View File

@@ -1,23 +1,23 @@
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto; import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import java.util.List; import java.util.List;
public interface CatalogQueryService { public interface CatalogQueryService {
Page<ProductSummaryDto> getOptions( Page<ProductSummaryDto> getOptions(
String platform, String platform,
String partRole, String partRole,
List<String> partRoles, List<String> partRoles,
List<String> brands, List<String> brands,
String q, String q,
Pageable pageable Pageable pageable
); );
List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request); List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request);
} }

View File

@@ -1,27 +1,27 @@
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.imports.MerchantFeedRow; import group.goforward.battlbuilder.imports.MerchantFeedRow;
import group.goforward.battlbuilder.model.Merchant; import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.PartRoleSource; import group.goforward.battlbuilder.model.PartRoleSource;
public interface CategoryClassificationService { public interface CategoryClassificationService {
record Result( record Result(
String platform, String platform,
String partRole, String partRole,
String rawCategoryKey, String rawCategoryKey,
PartRoleSource source, PartRoleSource source,
String reason String reason
) {} ) {}
/** /**
* Legacy convenience: derives rawCategoryKey + platform from row. * Legacy convenience: derives rawCategoryKey + platform from row.
*/ */
Result classify(Merchant merchant, MerchantFeedRow row); Result classify(Merchant merchant, MerchantFeedRow row);
/** /**
* Preferred for ETL: caller already computed platform + rawCategoryKey. * Preferred for ETL: caller already computed platform + rawCategoryKey.
* This prevents platformResolver overrides from drifting vs mapping selection. * This prevents platformResolver overrides from drifting vs mapping selection.
*/ */
Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey); Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey);
} }

View File

@@ -1,72 +1,72 @@
// src/main/java/group/goforward/ballistic/service/CategoryMappingRecommendationService.java // src/main/java/group/goforward/ballistic/service/CategoryMappingRecommendationService.java
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.Product; import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository; import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.CategoryMappingRecommendationDto; import group.goforward.battlbuilder.web.dto.CategoryMappingRecommendationDto;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@Service @Service
public class CategoryMappingRecommendationService { public class CategoryMappingRecommendationService {
private final ProductRepository productRepository; private final ProductRepository productRepository;
public CategoryMappingRecommendationService(ProductRepository productRepository) { public CategoryMappingRecommendationService(ProductRepository productRepository) {
this.productRepository = productRepository; this.productRepository = productRepository;
} }
public List<CategoryMappingRecommendationDto> listRecommendations() { public List<CategoryMappingRecommendationDto> listRecommendations() {
var groups = productRepository.findUnmappedCategoryGroups(); var groups = productRepository.findUnmappedCategoryGroups();
return groups.stream() return groups.stream()
.map(row -> { .map(row -> {
String merchantName = (String) row.get("merchantName"); String merchantName = (String) row.get("merchantName");
String rawCategoryKey = (String) row.get("rawCategoryKey"); String rawCategoryKey = (String) row.get("rawCategoryKey");
long count = (long) row.get("productCount"); long count = (long) row.get("productCount");
// Pull one sample product name // Pull one sample product name
List<Product> examples = productRepository List<Product> examples = productRepository
.findExamplesForCategoryGroup(merchantName, rawCategoryKey); .findExamplesForCategoryGroup(merchantName, rawCategoryKey);
String sampleName = examples.isEmpty() String sampleName = examples.isEmpty()
? null ? null
: examples.get(0).getName(); : examples.get(0).getName();
String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName); String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName);
return new CategoryMappingRecommendationDto( return new CategoryMappingRecommendationDto(
merchantName, merchantName,
rawCategoryKey, rawCategoryKey,
count, count,
recommendedRole, recommendedRole,
sampleName sampleName
); );
}) })
.toList(); .toList();
} }
private String inferPartRoleFromRawKey(String rawKey, String sampleName) { private String inferPartRoleFromRawKey(String rawKey, String sampleName) {
String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase(); String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase();
if (blob.contains("handguard") || blob.contains("rail")) return "handguard"; if (blob.contains("handguard") || blob.contains("rail")) return "handguard";
if (blob.contains("barrel")) return "barrel"; if (blob.contains("barrel")) return "barrel";
if (blob.contains("upper")) return "upper-receiver"; if (blob.contains("upper")) return "upper-receiver";
if (blob.contains("lower")) return "lower-receiver"; if (blob.contains("lower")) return "lower-receiver";
if (blob.contains("mag") || blob.contains("magazine")) return "magazine"; if (blob.contains("mag") || blob.contains("magazine")) return "magazine";
if (blob.contains("stock") || blob.contains("buttstock")) return "stock"; if (blob.contains("stock") || blob.contains("buttstock")) return "stock";
if (blob.contains("grip")) return "grip"; if (blob.contains("grip")) return "grip";
if (blob.contains("trigger")) return "trigger"; if (blob.contains("trigger")) return "trigger";
if (blob.contains("sight") || blob.contains("iron sights")) return "sights"; if (blob.contains("sight") || blob.contains("iron sights")) return "sights";
if (blob.contains("optic") || blob.contains("scope") || blob.contains("red dot")) return "optic"; if (blob.contains("optic") || blob.contains("scope") || blob.contains("red dot")) return "optic";
if (blob.contains("buffer")) return "buffer"; if (blob.contains("buffer")) return "buffer";
if (blob.contains("gas block")) return "gas-block"; if (blob.contains("gas block")) return "gas-block";
if (blob.contains("gas tube")) return "gas-tube"; if (blob.contains("gas tube")) return "gas-tube";
if (blob.contains("muzzle")) return "muzzle-device"; if (blob.contains("muzzle")) return "muzzle-device";
if (blob.contains("sling")) return "sling"; if (blob.contains("sling")) return "sling";
if (blob.contains("bipod")) return "bipod"; if (blob.contains("bipod")) return "bipod";
if (blob.contains("tool")) return "tools"; if (blob.contains("tool")) return "tools";
return "UNKNOWN"; return "UNKNOWN";
} }
} }

View File

@@ -1,52 +1,52 @@
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.User; import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository; import group.goforward.battlbuilder.repo.UserRepository;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.UUID; import java.util.UUID;
@Service @Service
public class CurrentUserService { public class CurrentUserService {
private final UserRepository userRepository; private final UserRepository userRepository;
public CurrentUserService(UserRepository userRepository) { public CurrentUserService(UserRepository userRepository) {
this.userRepository = userRepository; this.userRepository = userRepository;
} }
/** Returns the authenticated User (401 if missing/invalid). */ /** Returns the authenticated User (401 if missing/invalid). */
public User requireUser() { public User requireUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// No auth, or anonymous auth => 401 // No auth, or anonymous auth => 401
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
} }
// In your setup, JwtAuthenticationFilter sets auth name to UUID string // In your setup, JwtAuthenticationFilter sets auth name to UUID string
String principal = auth.getName(); String principal = auth.getName();
if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) { if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
} }
final UUID userUuid; final UUID userUuid;
try { try {
userUuid = UUID.fromString(principal); userUuid = UUID.fromString(principal);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e);
} }
return userRepository.findByUuid(userUuid) return userRepository.findByUuid(userUuid)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found"));
} }
public Integer requireUserId() { public Integer requireUserId() {
return requireUser().getId(); return requireUser().getId();
} }
} }

View File

@@ -1,42 +1,42 @@
// src/main/java/group/goforward/ballistic/service/ImportStatusAdminService.java // src/main/java/group/goforward/ballistic/service/ImportStatusAdminService.java
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.ImportStatus; import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.repo.ProductRepository; import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.ImportStatusByMerchantDto; import group.goforward.battlbuilder.web.dto.ImportStatusByMerchantDto;
import group.goforward.battlbuilder.web.dto.ImportStatusSummaryDto; import group.goforward.battlbuilder.web.dto.ImportStatusSummaryDto;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@Service @Service
public class ImportStatusAdminService { public class ImportStatusAdminService {
private final ProductRepository productRepository; private final ProductRepository productRepository;
public ImportStatusAdminService(ProductRepository productRepository) { public ImportStatusAdminService(ProductRepository productRepository) {
this.productRepository = productRepository; this.productRepository = productRepository;
} }
public List<ImportStatusSummaryDto> summarizeByStatus() { public List<ImportStatusSummaryDto> summarizeByStatus() {
return productRepository.aggregateByImportStatus() return productRepository.aggregateByImportStatus()
.stream() .stream()
.map(row -> new ImportStatusSummaryDto( .map(row -> new ImportStatusSummaryDto(
(ImportStatus) row.get("status"), (ImportStatus) row.get("status"),
(long) row.get("count") (long) row.get("count")
)) ))
.toList(); .toList();
} }
public List<ImportStatusByMerchantDto> summarizeByMerchant() { public List<ImportStatusByMerchantDto> summarizeByMerchant() {
return productRepository.aggregateByMerchantAndStatus() return productRepository.aggregateByMerchantAndStatus()
.stream() .stream()
.map(row -> new ImportStatusByMerchantDto( .map(row -> new ImportStatusByMerchantDto(
(String) row.get("merchantName"), (String) row.get("merchantName"),
(String) row.get("platform"), (String) row.get("platform"),
(ImportStatus) row.get("status"), (ImportStatus) row.get("status"),
(long) row.get("count") (long) row.get("count")
)) ))
.toList(); .toList();
} }
} }

View File

@@ -1,228 +1,228 @@
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.ImportStatus; import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.Merchant; import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.MerchantCategoryMap; import group.goforward.battlbuilder.model.MerchantCategoryMap;
import group.goforward.battlbuilder.model.CanonicalCategory; import group.goforward.battlbuilder.model.CanonicalCategory;
import group.goforward.battlbuilder.repo.CanonicalCategoryRepository; import group.goforward.battlbuilder.repo.CanonicalCategoryRepository;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.repo.MerchantRepository; import group.goforward.battlbuilder.repo.MerchantRepository;
import group.goforward.battlbuilder.repo.ProductRepository; import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.MappingOptionsDto; import group.goforward.battlbuilder.web.dto.MappingOptionsDto;
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto; import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto; import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class MappingAdminService { public class MappingAdminService {
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final MerchantCategoryMapRepository merchantCategoryMapRepository; private final MerchantCategoryMapRepository merchantCategoryMapRepository;
private final MerchantRepository merchantRepository; private final MerchantRepository merchantRepository;
private final CanonicalCategoryRepository canonicalCategoryRepository; private final CanonicalCategoryRepository canonicalCategoryRepository;
private final ReclassificationService reclassificationService; private final ReclassificationService reclassificationService;
public MappingAdminService( public MappingAdminService(
ProductRepository productRepository, ProductRepository productRepository,
MerchantCategoryMapRepository merchantCategoryMapRepository, MerchantCategoryMapRepository merchantCategoryMapRepository,
MerchantRepository merchantRepository, MerchantRepository merchantRepository,
CanonicalCategoryRepository canonicalCategoryRepository, CanonicalCategoryRepository canonicalCategoryRepository,
ReclassificationService reclassificationService ReclassificationService reclassificationService
) { ) {
this.productRepository = productRepository; this.productRepository = productRepository;
this.merchantCategoryMapRepository = merchantCategoryMapRepository; this.merchantCategoryMapRepository = merchantCategoryMapRepository;
this.merchantRepository = merchantRepository; this.merchantRepository = merchantRepository;
this.canonicalCategoryRepository = canonicalCategoryRepository; this.canonicalCategoryRepository = canonicalCategoryRepository;
this.reclassificationService = reclassificationService; this.reclassificationService = reclassificationService;
} }
// ========================= // =========================
// 1) EXISTING: Role buckets // 1) EXISTING: Role buckets
// ========================= // =========================
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<PendingMappingBucketDto> listPendingBuckets() { public List<PendingMappingBucketDto> listPendingBuckets() {
List<Object[]> rows = List<Object[]> rows =
productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING); productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING);
return rows.stream() return rows.stream()
.map(row -> { .map(row -> {
Integer merchantId = (Integer) row[0]; Integer merchantId = (Integer) row[0];
String merchantName = (String) row[1]; String merchantName = (String) row[1];
String rawCategoryKey = (String) row[2]; String rawCategoryKey = (String) row[2];
Long count = (Long) row[3]; Long count = (Long) row[3];
return new PendingMappingBucketDto( return new PendingMappingBucketDto(
merchantId, merchantId,
merchantName, merchantName,
rawCategoryKey, rawCategoryKey,
count != null ? count : 0L count != null ? count : 0L
); );
}) })
.toList(); .toList();
} }
/** /**
* Part Role mapping: * Part Role mapping:
* Writes merchant_category_map.canonical_part_role and applies to products. * Writes merchant_category_map.canonical_part_role and applies to products.
*/ */
@Transactional @Transactional
public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) { public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank() if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()
|| mappedPartRole == null || mappedPartRole.isBlank()) { || mappedPartRole == null || mappedPartRole.isBlank()) {
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required"); throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
} }
Merchant merchant = merchantRepository.findById(merchantId) Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
// NOTE: this creates a new row every time. If you want "upsert", use findBest() like we do below. // NOTE: this creates a new row every time. If you want "upsert", use findBest() like we do below.
MerchantCategoryMap mapping = new MerchantCategoryMap(); MerchantCategoryMap mapping = new MerchantCategoryMap();
mapping.setMerchant(merchant); mapping.setMerchant(merchant);
mapping.setRawCategory(rawCategoryKey.trim()); mapping.setRawCategory(rawCategoryKey.trim());
mapping.setEnabled(true); mapping.setEnabled(true);
// SOURCE OF TRUTH (builder slot mapping) // SOURCE OF TRUTH (builder slot mapping)
mapping.setCanonicalPartRole(mappedPartRole.trim()); mapping.setCanonicalPartRole(mappedPartRole.trim());
merchantCategoryMapRepository.save(mapping); merchantCategoryMapRepository.save(mapping);
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey); return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
} }
@Transactional @Transactional
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) { public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
if (rawCategoryKey == null || rawCategoryKey.isBlank()) if (rawCategoryKey == null || rawCategoryKey.isBlank())
throw new IllegalArgumentException("rawCategoryKey is required"); throw new IllegalArgumentException("rawCategoryKey is required");
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey); return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
} }
// ========================================== // ==========================================
// 2) NEW: Options endpoint for Catalog UI // 2) NEW: Options endpoint for Catalog UI
// ========================================== // ==========================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
public MappingOptionsDto getOptions() { public MappingOptionsDto getOptions() {
var merchants = merchantRepository.findAll().stream() var merchants = merchantRepository.findAll().stream()
.map(m -> new MappingOptionsDto.MerchantOptionDto(m.getId(), m.getName())) .map(m -> new MappingOptionsDto.MerchantOptionDto(m.getId(), m.getName()))
.toList(); .toList();
var categories = canonicalCategoryRepository.findAllActive().stream() var categories = canonicalCategoryRepository.findAllActive().stream()
.map(c -> new MappingOptionsDto.CanonicalCategoryOptionDto( .map(c -> new MappingOptionsDto.CanonicalCategoryOptionDto(
c.getId(), c.getId(),
c.getName(), c.getName(),
c.getSlug() c.getSlug()
)) ))
.toList(); .toList();
return new MappingOptionsDto(merchants, categories); return new MappingOptionsDto(merchants, categories);
} }
// ===================================================== // =====================================================
// 3) NEW: Raw categories list for Catalog mapping table // 3) NEW: Raw categories list for Catalog mapping table
// ===================================================== // =====================================================
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<RawCategoryMappingRowDto> listRawCategories(Integer merchantId, String platform, String q, Integer limit) { public List<RawCategoryMappingRowDto> listRawCategories(Integer merchantId, String platform, String q, Integer limit) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
String plat = normalizePlatform(platform); String plat = normalizePlatform(platform);
String query = (q == null || q.isBlank()) ? null : q.trim(); String query = (q == null || q.isBlank()) ? null : q.trim();
int lim = (limit == null || limit <= 0) ? 500 : Math.min(limit, 2000); int lim = (limit == null || limit <= 0) ? 500 : Math.min(limit, 2000);
List<Object[]> rows = productRepository.findRawCategoryMappingRows(merchantId, plat, query, lim); List<Object[]> rows = productRepository.findRawCategoryMappingRows(merchantId, plat, query, lim);
return rows.stream().map(r -> new RawCategoryMappingRowDto( return rows.stream().map(r -> new RawCategoryMappingRowDto(
(Integer) r[0], // merchantId (Integer) r[0], // merchantId
(String) r[1], // merchantName (String) r[1], // merchantName
(String) r[2], // platform (String) r[2], // platform
(String) r[3], // rawCategoryKey (String) r[3], // rawCategoryKey
((Number) r[4]).longValue(), // productCount ((Number) r[4]).longValue(), // productCount
(r[5] == null ? null : ((Number) r[5]).longValue()), // mcmId (r[5] == null ? null : ((Number) r[5]).longValue()), // mcmId
(Boolean) r[6], // enabled (Boolean) r[6], // enabled
(String) r[7], // canonicalPartRole (String) r[7], // canonicalPartRole
// IMPORTANT: canonicalCategoryId should be Integer, not Long. // IMPORTANT: canonicalCategoryId should be Integer, not Long.
(r[8] == null ? null : ((Number) r[8]).intValue()), // canonicalCategoryId (Integer) (r[8] == null ? null : ((Number) r[8]).intValue()), // canonicalCategoryId (Integer)
(String) r[9] // canonicalCategoryName (String) r[9] // canonicalCategoryName
)).toList(); )).toList();
} }
// ========================================================== // ==========================================================
// 4) NEW: Upsert catalog mapping // 4) NEW: Upsert catalog mapping
// ========================================================== // ==========================================================
public record UpsertCatalogMappingResult(Integer merchantCategoryMapId, int updatedProducts) {} public record UpsertCatalogMappingResult(Integer merchantCategoryMapId, int updatedProducts) {}
@Transactional @Transactional
public UpsertCatalogMappingResult upsertCatalogMapping( public UpsertCatalogMappingResult upsertCatalogMapping(
Integer merchantId, Integer merchantId,
String platform, String platform,
String rawCategory, String rawCategory,
Boolean enabled, Boolean enabled,
Integer canonicalCategoryId // <-- Integer (NOT Long) Integer canonicalCategoryId // <-- Integer (NOT Long)
) { ) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
if (rawCategory == null || rawCategory.isBlank()) throw new IllegalArgumentException("rawCategory is required"); if (rawCategory == null || rawCategory.isBlank()) throw new IllegalArgumentException("rawCategory is required");
String plat = normalizePlatform(platform); String plat = normalizePlatform(platform);
String raw = rawCategory.trim(); String raw = rawCategory.trim();
boolean en = (enabled == null) ? true : enabled; boolean en = (enabled == null) ? true : enabled;
Merchant merchant = merchantRepository.findById(merchantId) Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
CanonicalCategory cat = null; CanonicalCategory cat = null;
if (canonicalCategoryId != null) { if (canonicalCategoryId != null) {
cat = canonicalCategoryRepository.findById(canonicalCategoryId) cat = canonicalCategoryRepository.findById(canonicalCategoryId)
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
"CanonicalCategory not found: " + canonicalCategoryId "CanonicalCategory not found: " + canonicalCategoryId
)); ));
} }
// Find mapping row (platform-specific first; then ANY/null via your findBest ordering) // Find mapping row (platform-specific first; then ANY/null via your findBest ordering)
Optional<MerchantCategoryMap> existing = merchantCategoryMapRepository.findBest(merchantId, raw, plat); Optional<MerchantCategoryMap> existing = merchantCategoryMapRepository.findBest(merchantId, raw, plat);
MerchantCategoryMap mcm = existing.orElseGet(MerchantCategoryMap::new); MerchantCategoryMap mcm = existing.orElseGet(MerchantCategoryMap::new);
// Always ensure required fields are set // Always ensure required fields are set
mcm.setMerchant(merchant); mcm.setMerchant(merchant);
mcm.setRawCategory(raw); mcm.setRawCategory(raw);
mcm.setPlatform(plat); mcm.setPlatform(plat);
mcm.setEnabled(en); mcm.setEnabled(en);
// Catalog mapping fields (FK + legacy mirror) // Catalog mapping fields (FK + legacy mirror)
mcm.setCanonicalCategory(cat); // FK (preferred) mcm.setCanonicalCategory(cat); // FK (preferred)
mcm.setCanonicalCategoryText(cat == null ? null : cat.getName()); // legacy mirror mcm.setCanonicalCategoryText(cat == null ? null : cat.getName()); // legacy mirror
// IMPORTANT: DO NOT clobber canonicalPartRole here // IMPORTANT: DO NOT clobber canonicalPartRole here
merchantCategoryMapRepository.save(mcm); merchantCategoryMapRepository.save(mcm);
// Push category FK to products // Push category FK to products
int updated = reclassificationService.applyCatalogCategoryMappingToProducts( int updated = reclassificationService.applyCatalogCategoryMappingToProducts(
merchantId, merchantId,
raw, raw,
canonicalCategoryId // can be null to clear canonicalCategoryId // can be null to clear
); );
return new UpsertCatalogMappingResult(mcm.getId(), updated); return new UpsertCatalogMappingResult(mcm.getId(), updated);
} }
// ----------------- // -----------------
// Helpers // Helpers
// ----------------- // -----------------
private String normalizePlatform(String p) { private String normalizePlatform(String p) {
if (p == null) return null; if (p == null) return null;
String t = p.trim(); String t = p.trim();
if (t.isEmpty()) return null; if (t.isEmpty()) return null;
return t.toUpperCase(Locale.ROOT); return t.toUpperCase(Locale.ROOT);
} }
} }

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
public interface MerchantFeedImportService { public interface MerchantFeedImportService {
/** /**
* Full product + offer import for a given merchant. * Full product + offer import for a given merchant.
*/ */
void importMerchantFeed(Integer merchantId); void importMerchantFeed(Integer merchantId);
/** /**
* Offers-only sync (price / stock) for a given merchant. * Offers-only sync (price / stock) for a given merchant.
*/ */
void syncOffersOnly(Integer merchantId); void syncOffersOnly(Integer merchantId);
} }

View File

@@ -1,35 +1,35 @@
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.PartCategory; import group.goforward.battlbuilder.model.PartCategory;
import group.goforward.battlbuilder.model.PartRoleMapping; import group.goforward.battlbuilder.model.PartRoleMapping;
import group.goforward.battlbuilder.repo.PartRoleMappingRepository; import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class PartCategoryResolverService { public class PartCategoryResolverService {
private final PartRoleMappingRepository partRoleMappingRepository; private final PartRoleMappingRepository partRoleMappingRepository;
public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) { public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) {
this.partRoleMappingRepository = partRoleMappingRepository; this.partRoleMappingRepository = partRoleMappingRepository;
} }
/** /**
* Resolve a PartCategory for a given platform + partRole. * Resolve a PartCategory for a given platform + partRole.
* Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower" * Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
*/ */
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) { public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
if (platform == null || partRole == null) return Optional.empty(); if (platform == null || partRole == null) return Optional.empty();
String p = platform.trim(); String p = platform.trim();
String r = partRole.trim(); String r = partRole.trim();
if (p.isEmpty() || r.isEmpty()) return Optional.empty(); if (p.isEmpty() || r.isEmpty()) return Optional.empty();
return partRoleMappingRepository return partRoleMappingRepository
.findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r) .findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r)
.map(PartRoleMapping::getPartCategory); .map(PartRoleMapping::getPartCategory);
} }
} }

View File

@@ -1,35 +1,35 @@
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.repo.PartRoleMappingRepository; import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto; import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto; import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
import group.goforward.battlbuilder.web.mapper.PartRoleMappingMapper; import group.goforward.battlbuilder.web.mapper.PartRoleMappingMapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@Service @Service
public class PartRoleMappingService { public class PartRoleMappingService {
private final PartRoleMappingRepository repository; private final PartRoleMappingRepository repository;
public PartRoleMappingService(PartRoleMappingRepository repository) { public PartRoleMappingService(PartRoleMappingRepository repository) {
this.repository = repository; this.repository = repository;
} }
public List<PartRoleMappingDto> getMappingsForPlatform(String platform) { public List<PartRoleMappingDto> getMappingsForPlatform(String platform) {
return repository return repository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform) .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
.stream() .stream()
.map(PartRoleMappingMapper::toDto) .map(PartRoleMappingMapper::toDto)
.toList(); .toList();
} }
public List<PartRoleToCategoryDto> getRoleToCategoryMap(String platform) { public List<PartRoleToCategoryDto> getRoleToCategoryMap(String platform) {
return repository return repository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform) .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
.stream() .stream()
.map(PartRoleMappingMapper::toRoleMapDto) .map(PartRoleMappingMapper::toRoleMapDto)
.toList(); .toList();
} }
} }

View File

@@ -1,20 +1,20 @@
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.web.dto.ProductOfferDto; import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto; import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import java.util.List; import java.util.List;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
public interface ProductQueryService { public interface ProductQueryService {
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles); List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
List<ProductOfferDto> getOffersForProduct(Integer productId); List<ProductOfferDto> getOffersForProduct(Integer productId);
ProductSummaryDto getProductById(Integer productId); ProductSummaryDto getProductById(Integer productId);
Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable); Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable);
} }

View File

@@ -1,11 +1,11 @@
package group.goforward.battlbuilder.service; package group.goforward.battlbuilder.service;
public interface ReclassificationService { public interface ReclassificationService {
int reclassifyPendingForMerchant(Integer merchantId); int reclassifyPendingForMerchant(Integer merchantId);
// Existing: apply canonical_part_role mapping to products // Existing: apply canonical_part_role mapping to products
int applyMappingToProducts(Integer merchantId, String rawCategoryKey); int applyMappingToProducts(Integer merchantId, String rawCategoryKey);
// NEW: apply canonical_category_id mapping to products // NEW: apply canonical_category_id mapping to products
int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId); int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId);
} }

View File

@@ -1,18 +1,18 @@
package group.goforward.battlbuilder.service.admin; package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest; import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto; import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest; import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
public interface AdminProductService { public interface AdminProductService {
Page<ProductAdminRowDto> search( Page<ProductAdminRowDto> search(
AdminProductSearchRequest request, AdminProductSearchRequest request,
Pageable pageable Pageable pageable
); );
int bulkUpdate(ProductBulkUpdateRequest request); int bulkUpdate(ProductBulkUpdateRequest request);
} }

View File

@@ -1,55 +1,55 @@
package group.goforward.battlbuilder.service.admin; package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.model.User; import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository; import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.web.dto.admin.AdminUserDto; import group.goforward.battlbuilder.web.dto.admin.AdminUserDto;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Service @Service
public class AdminUserService { public class AdminUserService {
private static final Set<String> ALLOWED_ROLES = Set.of("USER", "ADMIN"); private static final Set<String> ALLOWED_ROLES = Set.of("USER", "ADMIN");
private final UserRepository userRepository; private final UserRepository userRepository;
public AdminUserService(UserRepository userRepository) { public AdminUserService(UserRepository userRepository) {
this.userRepository = userRepository; this.userRepository = userRepository;
} }
public List<AdminUserDto> getAllUsersForAdmin() { public List<AdminUserDto> getAllUsersForAdmin() {
return userRepository.findAll() return userRepository.findAll()
.stream() .stream()
.map(AdminUserDto::fromUser) .map(AdminUserDto::fromUser)
.toList(); .toList();
} }
@Transactional @Transactional
public AdminUserDto updateUserRole(UUID userUuid, String newRole, Authentication auth) { public AdminUserDto updateUserRole(UUID userUuid, String newRole, Authentication auth) {
if (newRole == null || !ALLOWED_ROLES.contains(newRole.toUpperCase())) { if (newRole == null || !ALLOWED_ROLES.contains(newRole.toUpperCase())) {
throw new IllegalArgumentException("Invalid role: " + newRole); throw new IllegalArgumentException("Invalid role: " + newRole);
} }
User user = userRepository.findByUuid(userUuid) User user = userRepository.findByUuid(userUuid)
.orElseThrow(() -> new IllegalArgumentException("User not found")); .orElseThrow(() -> new IllegalArgumentException("User not found"));
// Optional safety: do not allow demoting yourself (you can loosen this later) // Optional safety: do not allow demoting yourself (you can loosen this later)
String currentEmail = auth != null ? auth.getName() : null; String currentEmail = auth != null ? auth.getName() : null;
boolean isSelf = currentEmail != null boolean isSelf = currentEmail != null
&& currentEmail.equalsIgnoreCase(user.getEmail()); && currentEmail.equalsIgnoreCase(user.getEmail());
if (isSelf && !"ADMIN".equalsIgnoreCase(newRole)) { if (isSelf && !"ADMIN".equalsIgnoreCase(newRole)) {
throw new IllegalStateException("You cannot change your own role to non-admin."); throw new IllegalStateException("You cannot change your own role to non-admin.");
} }
user.setRole(newRole.toUpperCase()); user.setRole(newRole.toUpperCase());
// updatedAt will be handled by your entity / DB defaults // updatedAt will be handled by your entity / DB defaults
return AdminUserDto.fromUser(user); return AdminUserDto.fromUser(user);
} }
} }

View File

@@ -1,16 +1,16 @@
package group.goforward.battlbuilder.service.admin; package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.model.State; import group.goforward.battlbuilder.model.State;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface StatesService { public interface StatesService {
List<State> findAll(); List<State> findAll();
Optional<State> findById(Integer id); Optional<State> findById(Integer id);
State save(State item); State save(State item);
void deleteById(Integer id); void deleteById(Integer id);
} }

View File

@@ -1,16 +1,16 @@
package group.goforward.battlbuilder.service.admin; package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.model.User; import group.goforward.battlbuilder.model.User;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface UsersService { public interface UsersService {
List<User> findAll(); List<User> findAll();
Optional<User> findById(Integer id); Optional<User> findById(Integer id);
User save(User item); User save(User item);
void deleteById(Integer id); void deleteById(Integer id);
} }

View File

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

View File

@@ -1,45 +1,45 @@
package group.goforward.battlbuilder.service.admin.impl; package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.ImportStatus; import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.repo.MerchantRepository; import group.goforward.battlbuilder.repo.MerchantRepository;
import group.goforward.battlbuilder.repo.ProductRepository; import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto; import group.goforward.battlbuilder.web.dto.admin.AdminDashboardOverviewDto;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@Service @Service
public class AdminDashboardService { public class AdminDashboardService {
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final MerchantRepository merchantRepository; private final MerchantRepository merchantRepository;
private final MerchantCategoryMapRepository merchantCategoryMapRepository; private final MerchantCategoryMapRepository merchantCategoryMapRepository;
public AdminDashboardService( public AdminDashboardService(
ProductRepository productRepository, ProductRepository productRepository,
MerchantRepository merchantRepository, MerchantRepository merchantRepository,
MerchantCategoryMapRepository merchantCategoryMapRepository MerchantCategoryMapRepository merchantCategoryMapRepository
) { ) {
this.productRepository = productRepository; this.productRepository = productRepository;
this.merchantRepository = merchantRepository; this.merchantRepository = merchantRepository;
this.merchantCategoryMapRepository = merchantCategoryMapRepository; this.merchantCategoryMapRepository = merchantCategoryMapRepository;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public AdminDashboardOverviewDto getOverview() { public AdminDashboardOverviewDto getOverview() {
long totalProducts = productRepository.count(); long totalProducts = productRepository.count();
long unmappedProducts = productRepository.countByImportStatus(ImportStatus.PENDING_MAPPING); long unmappedProducts = productRepository.countByImportStatus(ImportStatus.PENDING_MAPPING);
long mappedProducts = totalProducts - unmappedProducts; long mappedProducts = totalProducts - unmappedProducts;
long merchantCount = merchantRepository.count(); long merchantCount = merchantRepository.count();
long categoryMappings = merchantCategoryMapRepository.count(); long categoryMappings = merchantCategoryMapRepository.count();
return new AdminDashboardOverviewDto( return new AdminDashboardOverviewDto(
totalProducts, totalProducts,
mappedProducts, mappedProducts,
unmappedProducts, unmappedProducts,
merchantCount, merchantCount,
categoryMappings categoryMappings
); );
} }
} }

View File

@@ -1,65 +1,65 @@
package group.goforward.battlbuilder.service.admin.impl; package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.Product; import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository; import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.service.admin.AdminProductService; import group.goforward.battlbuilder.service.admin.AdminProductService;
import group.goforward.battlbuilder.specs.ProductSpecifications; import group.goforward.battlbuilder.specs.ProductSpecifications;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest; import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto; import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest; import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@Service @Service
@Transactional @Transactional
public class AdminProductServiceImpl implements AdminProductService { public class AdminProductServiceImpl implements AdminProductService {
private final ProductRepository productRepository; private final ProductRepository productRepository;
public AdminProductServiceImpl(ProductRepository productRepository) { public AdminProductServiceImpl(ProductRepository productRepository) {
this.productRepository = productRepository; this.productRepository = productRepository;
} }
@Override @Override
public Page<ProductAdminRowDto> search( public Page<ProductAdminRowDto> search(
AdminProductSearchRequest request, AdminProductSearchRequest request,
Pageable pageable Pageable pageable
) { ) {
Specification<Product> spec = Specification<Product> spec =
ProductSpecifications.adminSearch(request); ProductSpecifications.adminSearch(request);
return productRepository return productRepository
.findAll(spec, pageable) .findAll(spec, pageable)
.map(ProductAdminRowDto::fromEntity); .map(ProductAdminRowDto::fromEntity);
} }
@Override @Override
public int bulkUpdate(ProductBulkUpdateRequest request) { public int bulkUpdate(ProductBulkUpdateRequest request) {
var products = productRepository.findAllById(request.getProductIds()); var products = productRepository.findAllById(request.getProductIds());
products.forEach(p -> { products.forEach(p -> {
if (request.getVisibility() != null) { if (request.getVisibility() != null) {
p.setVisibility(request.getVisibility()); p.setVisibility(request.getVisibility());
} }
if (request.getStatus() != null) { if (request.getStatus() != null) {
p.setStatus(request.getStatus()); p.setStatus(request.getStatus());
} }
if (request.getBuilderEligible() != null) { if (request.getBuilderEligible() != null) {
p.setBuilderEligible(request.getBuilderEligible()); p.setBuilderEligible(request.getBuilderEligible());
} }
if (request.getAdminLocked() != null) { if (request.getAdminLocked() != null) {
p.setAdminLocked(request.getAdminLocked()); p.setAdminLocked(request.getAdminLocked());
} }
if (request.getAdminNote() != null) { if (request.getAdminNote() != null) {
p.setAdminNote(request.getAdminNote()); p.setAdminNote(request.getAdminNote());
} }
}); });
productRepository.saveAll(products); productRepository.saveAll(products);
return products.size(); return products.size();
} }
} }

View File

@@ -1,38 +1,38 @@
package group.goforward.battlbuilder.service.admin.impl; package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.State; import group.goforward.battlbuilder.model.State;
import group.goforward.battlbuilder.repo.StateRepository; import group.goforward.battlbuilder.repo.StateRepository;
import group.goforward.battlbuilder.service.admin.StatesService; import group.goforward.battlbuilder.service.admin.StatesService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class StatesServiceImpl implements StatesService { public class StatesServiceImpl implements StatesService {
@Autowired @Autowired
private StateRepository repo; private StateRepository repo;
@Override @Override
public List<State> findAll() { public List<State> findAll() {
return repo.findAll(); return repo.findAll();
} }
@Override @Override
public Optional<State> findById(Integer id) { public Optional<State> findById(Integer id) {
return repo.findById(id); return repo.findById(id);
} }
@Override @Override
public State save(State item) { public State save(State item) {
return null; return null;
} }
@Override @Override
public void deleteById(Integer id) { public void deleteById(Integer id) {
deleteById(id); deleteById(id);
} }
} }

View File

@@ -1,37 +1,37 @@
package group.goforward.battlbuilder.service.admin.impl; package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.User; import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository; import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.service.admin.UsersService; import group.goforward.battlbuilder.service.admin.UsersService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class UsersServiceImpl implements UsersService { public class UsersServiceImpl implements UsersService {
@Autowired @Autowired
private UserRepository repo; private UserRepository repo;
@Override @Override
public List<User> findAll() { public List<User> findAll() {
return repo.findAll(); return repo.findAll();
} }
@Override @Override
public Optional<User> findById(Integer id) { public Optional<User> findById(Integer id) {
return repo.findById(id); return repo.findById(id);
} }
@Override @Override
public User save(User item) { public User save(User item) {
return null; return null;
} }
@Override @Override
public void deleteById(Integer id) { public void deleteById(Integer id) {
deleteById(id); deleteById(id);
} }
} }

View File

@@ -1,13 +1,13 @@
/** /**
* Provides the classes necessary for the Spring Services implementations for the Battl.Builder application. * Provides the classes necessary for the Spring Services implementations for the Battl.Builder application.
* This package includes Services implementations for Spring-Boot application * This package includes Services implementations for Spring-Boot application
* *
* *
* <p>The main entry point for managing the inventory is the * <p>The main entry point for managing the inventory is the
* {@link group.goforward.battlbuilder.BattlBuilderApplication} class.</p> * {@link group.goforward.battlbuilder.BattlBuilderApplication} class.</p>
* *
* @since 1.0 * @since 1.0
* @author Don Strawsburg * @author Don Strawsburg
* @version 1.1 * @version 1.1
*/ */
package group.goforward.battlbuilder.service.admin; package group.goforward.battlbuilder.service.admin;

View File

@@ -1,29 +1,29 @@
package group.goforward.battlbuilder.service.auth; package group.goforward.battlbuilder.service.auth;
import group.goforward.battlbuilder.web.dto.auth.AuthResponse; import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
public interface BetaAuthService { public interface BetaAuthService {
/** /**
* Upsert a beta signup lead and send a confirmation email with a verify token/link. * Upsert a beta signup lead and send a confirmation email with a verify token/link.
* Should NOT throw to the caller for common cases (e.g. already exists). * Should NOT throw to the caller for common cases (e.g. already exists).
*/ */
void signup(String email, String useCase); void signup(String email, String useCase);
/** /**
* Exchanges a "confirm" token for a real JWT session. * Exchanges a "confirm" token for a real JWT session.
* This confirms the email (one-time) AND logs the user in immediately. * This confirms the email (one-time) AND logs the user in immediately.
*/ */
AuthResponse confirmAndExchange(String token); AuthResponse confirmAndExchange(String token);
/** /**
* Exchanges a "magic link" token for a real JWT session. * Exchanges a "magic link" token for a real JWT session.
* Used for returning users ("email me a sign-in link"). * Used for returning users ("email me a sign-in link").
*/ */
AuthResponse exchangeMagicToken(String token); AuthResponse exchangeMagicToken(String token);
void sendPasswordReset(String email); void sendPasswordReset(String email);
void resetPassword(String token, String newPassword); void resetPassword(String token, String newPassword);
void sendMagicLoginLink(String email); void sendMagicLoginLink(String email);
} }

View File

@@ -1,321 +1,321 @@
package group.goforward.battlbuilder.service.auth.impl; package group.goforward.battlbuilder.service.auth.impl;
import group.goforward.battlbuilder.model.AuthToken; import group.goforward.battlbuilder.model.AuthToken;
import group.goforward.battlbuilder.model.User; import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.AuthTokenRepository; import group.goforward.battlbuilder.repo.AuthTokenRepository;
import group.goforward.battlbuilder.repo.UserRepository; import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.security.JwtService; import group.goforward.battlbuilder.security.JwtService;
import group.goforward.battlbuilder.service.auth.BetaAuthService; import group.goforward.battlbuilder.service.auth.BetaAuthService;
import group.goforward.battlbuilder.service.utils.EmailService; import group.goforward.battlbuilder.service.utils.EmailService;
import group.goforward.battlbuilder.web.dto.auth.AuthResponse; import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.UUID; import java.util.UUID;
@Service @Service
public class BetaAuthServiceImpl implements BetaAuthService { public class BetaAuthServiceImpl implements BetaAuthService {
private final AuthTokenRepository tokens; private final AuthTokenRepository tokens;
private final UserRepository users; private final UserRepository users;
private final JwtService jwtService; private final JwtService jwtService;
private final EmailService emailService; private final EmailService emailService;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
@Value("${app.publicBaseUrl:http://localhost:3000}") @Value("${app.publicBaseUrl:http://localhost:3000}")
private String publicBaseUrl; private String publicBaseUrl;
@Value("${app.authTokenPepper:change-me}") @Value("${app.authTokenPepper:change-me}")
private String tokenPepper; private String tokenPepper;
/** /**
* When true: * When true:
* - Signup captures users (role=BETA, inactive) * - Signup captures users (role=BETA, inactive)
* - NO tokens are generated * - NO tokens are generated
* - NO emails are sent * - NO emails are sent
*/ */
@Value("${app.beta.captureOnly:true}") @Value("${app.beta.captureOnly:true}")
private boolean betaCaptureOnly; private boolean betaCaptureOnly;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
public BetaAuthServiceImpl( public BetaAuthServiceImpl(
AuthTokenRepository tokens, AuthTokenRepository tokens,
UserRepository users, UserRepository users,
JwtService jwtService, JwtService jwtService,
EmailService emailService, EmailService emailService,
PasswordEncoder passwordEncoder PasswordEncoder passwordEncoder
) { ) {
this.tokens = tokens; this.tokens = tokens;
this.users = users; this.users = users;
this.jwtService = jwtService; this.jwtService = jwtService;
this.emailService = emailService; this.emailService = emailService;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
} }
/** /**
* A: Beta signup (capture lead + optionally email confirm+login token). * A: Beta signup (capture lead + optionally email confirm+login token).
* The Next page will call /api/auth/beta/confirm and receive AuthResponse. * The Next page will call /api/auth/beta/confirm and receive AuthResponse.
*/ */
@Override @Override
public void signup(String rawEmail, String useCase) { public void signup(String rawEmail, String useCase) {
String email = normalizeEmail(rawEmail); String email = normalizeEmail(rawEmail);
// ✅ Create or update a "beta lead" user record // ✅ Create or update a "beta lead" user record
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) { if (user == null) {
user = new User(); user = new User();
user.setUuid(UUID.randomUUID()); user.setUuid(UUID.randomUUID());
user.setEmail(email); user.setEmail(email);
// Treat beta signups as users, but not active / not verified yet // Treat beta signups as users, but not active / not verified yet
user.setRole("BETA"); user.setRole("BETA");
user.setActive(false); user.setActive(false);
user.setDisplayName(null); user.setDisplayName(null);
user.setCreatedAt(OffsetDateTime.now()); user.setCreatedAt(OffsetDateTime.now());
} }
// Optional: stash useCase somewhere if desired // Optional: stash useCase somewhere if desired
// user.setPreferences(mergeUseCase(user.getPreferences(), useCase)); // user.setPreferences(mergeUseCase(user.getPreferences(), useCase));
user.setUpdatedAt(OffsetDateTime.now()); user.setUpdatedAt(OffsetDateTime.now());
users.save(user); users.save(user);
// 🚫 Capture-only mode: do not create tokens, do not send email // 🚫 Capture-only mode: do not create tokens, do not send email
if (betaCaptureOnly) return; if (betaCaptureOnly) return;
// --- Invite mode (later) --- // --- Invite mode (later) ---
// 24h confirm token // 24h confirm token
String verifyToken = generateToken(); String verifyToken = generateToken();
saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24)); saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24));
String confirmUrl = publicBaseUrl + "/beta/confirm?token=" + verifyToken; String confirmUrl = publicBaseUrl + "/beta/confirm?token=" + verifyToken;
String subject = "Your Battl Builders sign-in link"; String subject = "Your Battl Builders sign-in link";
String body = """ String body = """
You're on the list. You're on the list.
Sign in (and confirm your email) here: Sign in (and confirm your email) here:
%s %s
If you didnt request this, you can ignore this email. If you didnt request this, you can ignore this email.
""".formatted(confirmUrl); """.formatted(confirmUrl);
emailService.sendEmail(email, subject, body); emailService.sendEmail(email, subject, body);
} }
/** /**
* B: Existing users only — request a magic login link (no signup/confirm). * B: Existing users only — request a magic login link (no signup/confirm).
* Caller must always return OK to avoid email enumeration. * Caller must always return OK to avoid email enumeration.
*/ */
@Override @Override
public void sendMagicLoginLink(String rawEmail) { public void sendMagicLoginLink(String rawEmail) {
String email = normalizeEmail(rawEmail); String email = normalizeEmail(rawEmail);
// Only send if user exists (but do NOT reveal that) // Only send if user exists (but do NOT reveal that)
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) return; if (user == null) return;
boolean isBeta = "BETA".equalsIgnoreCase(user.getRole()); boolean isBeta = "BETA".equalsIgnoreCase(user.getRole());
// If capture-only mode is enabled, do not generate tokens or send email // If capture-only mode is enabled, do not generate tokens or send email
if (betaCaptureOnly) return; if (betaCaptureOnly) return;
// Allow magic link requests for: // Allow magic link requests for:
// - active USERs, OR // - active USERs, OR
// - BETA users (even if inactive), since they may not be activated yet // - BETA users (even if inactive), since they may not be activated yet
if (!Boolean.TRUE.equals(user.isActive()) && !isBeta) return; if (!Boolean.TRUE.equals(user.isActive()) && !isBeta) return;
// Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired) // Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired)
if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return; if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return;
// 30 minute magic token // 30 minute magic token
String magicToken = generateToken(); String magicToken = generateToken();
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(30)); saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(30));
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken; String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
String subject = "Your Battl Builders sign-in link"; String subject = "Your Battl Builders sign-in link";
String body = """ String body = """
Heres your secure sign-in link (expires in 30 minutes): Heres your secure sign-in link (expires in 30 minutes):
%s %s
If you didnt request this, you can ignore this email. If you didnt request this, you can ignore this email.
""".formatted(magicUrl); """.formatted(magicUrl);
emailService.sendEmail(email, subject, body); emailService.sendEmail(email, subject, body);
} }
/** /**
* Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately. * Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately.
*/ */
@Override @Override
public AuthResponse confirmAndExchange(String token) { public AuthResponse confirmAndExchange(String token) {
AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token); AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token);
String email = authToken.getEmail(); String email = authToken.getEmail();
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
OffsetDateTime now = OffsetDateTime.now(); OffsetDateTime now = OffsetDateTime.now();
if (user == null) { if (user == null) {
user = new User(); user = new User();
user.setUuid(UUID.randomUUID()); user.setUuid(UUID.randomUUID());
user.setEmail(email); user.setEmail(email);
user.setDisplayName(null); user.setDisplayName(null);
user.setRole("USER"); user.setRole("USER");
user.setActive(true); user.setActive(true);
user.setCreatedAt(now); user.setCreatedAt(now);
} else { } else {
// Promote BETA -> USER on first successful confirm // Promote BETA -> USER on first successful confirm
if ("BETA".equalsIgnoreCase(user.getRole())) { if ("BETA".equalsIgnoreCase(user.getRole())) {
user.setRole("USER"); user.setRole("USER");
} }
user.setActive(true); user.setActive(true);
} }
user.setLastLoginAt(now); user.setLastLoginAt(now);
user.incrementLoginCount(); user.incrementLoginCount();
user.setUpdatedAt(now); user.setUpdatedAt(now);
users.save(user); users.save(user);
String jwt = jwtService.generateToken(user); String jwt = jwtService.generateToken(user);
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole()); return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
} }
/** /**
* Consumes MAGIC_LOGIN token and returns JWT (returning users). * Consumes MAGIC_LOGIN token and returns JWT (returning users).
* Also promotes BETA->USER and activates the account on first successful login. * Also promotes BETA->USER and activates the account on first successful login.
*/ */
@Override @Override
public AuthResponse exchangeMagicToken(String token) { public AuthResponse exchangeMagicToken(String token) {
AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token); AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token);
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail()) User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
.orElseThrow(() -> new IllegalStateException("User not found for magic token")); .orElseThrow(() -> new IllegalStateException("User not found for magic token"));
OffsetDateTime now = OffsetDateTime.now(); OffsetDateTime now = OffsetDateTime.now();
// Promote/activate beta users on first successful magic login // Promote/activate beta users on first successful magic login
if ("BETA".equalsIgnoreCase(user.getRole())) { if ("BETA".equalsIgnoreCase(user.getRole())) {
user.setRole("USER"); user.setRole("USER");
} }
if (!Boolean.TRUE.equals(user.isActive())) { if (!Boolean.TRUE.equals(user.isActive())) {
user.setActive(true); user.setActive(true);
} }
user.setLastLoginAt(now); user.setLastLoginAt(now);
user.incrementLoginCount(); user.incrementLoginCount();
user.setUpdatedAt(now); user.setUpdatedAt(now);
users.save(user); users.save(user);
String jwt = jwtService.generateToken(user); String jwt = jwtService.generateToken(user);
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole()); return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Password Reset // Password Reset
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@Override @Override
public void sendPasswordReset(String rawEmail) { public void sendPasswordReset(String rawEmail) {
String email = normalizeEmail(rawEmail); String email = normalizeEmail(rawEmail);
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) return; if (user == null) return;
// If capture-only mode is enabled, do not generate tokens or send email // If capture-only mode is enabled, do not generate tokens or send email
if (betaCaptureOnly) return; if (betaCaptureOnly) return;
String resetToken = generateToken(); String resetToken = generateToken();
saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30)); saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30));
String resetUrl = publicBaseUrl + "/reset-password?token=" + resetToken; String resetUrl = publicBaseUrl + "/reset-password?token=" + resetToken;
String subject = "Reset your Battl Builders password"; String subject = "Reset your Battl Builders password";
String body = """ String body = """
Reset your password using this link (expires in 30 minutes): Reset your password using this link (expires in 30 minutes):
%s %s
If you didnt request this, you can ignore this email. If you didnt request this, you can ignore this email.
""".formatted(resetUrl); """.formatted(resetUrl);
emailService.sendEmail(email, subject, body); emailService.sendEmail(email, subject, body);
} }
@Override @Override
public void resetPassword(String token, String newPassword) { public void resetPassword(String token, String newPassword) {
if (newPassword == null || newPassword.trim().length() < 8) { if (newPassword == null || newPassword.trim().length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters"); throw new IllegalArgumentException("Password must be at least 8 characters");
} }
AuthToken t = consumeToken(AuthToken.TokenType.PASSWORD_RESET, token); AuthToken t = consumeToken(AuthToken.TokenType.PASSWORD_RESET, token);
String email = t.getEmail(); String email = t.getEmail();
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email) User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new IllegalArgumentException("User not found")); .orElseThrow(() -> new IllegalArgumentException("User not found"));
user.setPasswordHash(passwordEncoder.encode(newPassword.trim())); user.setPasswordHash(passwordEncoder.encode(newPassword.trim()));
user.setPasswordSetAt(OffsetDateTime.now()); user.setPasswordSetAt(OffsetDateTime.now());
user.setUpdatedAt(OffsetDateTime.now()); user.setUpdatedAt(OffsetDateTime.now());
users.save(user); users.save(user);
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) { private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) {
AuthToken t = new AuthToken(); AuthToken t = new AuthToken();
t.setEmail(email); t.setEmail(email);
t.setType(type); t.setType(type);
t.setTokenHash(hashToken(token)); t.setTokenHash(hashToken(token));
t.setExpiresAt(expiresAt); t.setExpiresAt(expiresAt);
t.setCreatedAt(OffsetDateTime.now()); t.setCreatedAt(OffsetDateTime.now());
tokens.save(t); tokens.save(t);
} }
private AuthToken consumeToken(AuthToken.TokenType type, String token) { private AuthToken consumeToken(AuthToken.TokenType type, String token) {
String hash = hashToken(token); String hash = hashToken(token);
AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash) AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash)
.orElseThrow(() -> new IllegalArgumentException("Invalid token")); .orElseThrow(() -> new IllegalArgumentException("Invalid token"));
OffsetDateTime now = OffsetDateTime.now(); OffsetDateTime now = OffsetDateTime.now();
if (t.isConsumed()) throw new IllegalArgumentException("Token already used"); if (t.isConsumed()) throw new IllegalArgumentException("Token already used");
if (t.isExpired(now)) throw new IllegalArgumentException("Token expired"); if (t.isExpired(now)) throw new IllegalArgumentException("Token expired");
t.setConsumedAt(now); t.setConsumedAt(now);
tokens.save(t); tokens.save(t);
return t; return t;
} }
private String normalizeEmail(String email) { private String normalizeEmail(String email) {
if (email == null) throw new IllegalArgumentException("Email required"); if (email == null) throw new IllegalArgumentException("Email required");
return email.trim().toLowerCase(); return email.trim().toLowerCase();
} }
private String generateToken() { private String generateToken() {
byte[] bytes = new byte[32]; byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes); secureRandom.nextBytes(bytes);
return HexFormat.of().formatHex(bytes); return HexFormat.of().formatHex(bytes);
} }
private String hashToken(String token) { private String hashToken(String token) {
try { try {
MessageDigest md = MessageDigest.getInstance("SHA-256"); MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)); byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hashed); return HexFormat.of().formatHex(hashed);
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("Failed to hash token", e); throw new RuntimeException("Failed to hash token", e);
} }
} }
} }

View File

@@ -1,185 +1,185 @@
package group.goforward.battlbuilder.service.auth.impl; package group.goforward.battlbuilder.service.auth.impl;
import group.goforward.battlbuilder.model.AuthToken; import group.goforward.battlbuilder.model.AuthToken;
import group.goforward.battlbuilder.model.User; import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.AuthTokenRepository; import group.goforward.battlbuilder.repo.AuthTokenRepository;
import group.goforward.battlbuilder.repo.UserRepository; import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.service.utils.TemplatedEmailService; import group.goforward.battlbuilder.service.utils.TemplatedEmailService;
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto; import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse; import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
public class BetaInviteService { public class BetaInviteService {
private final UserRepository users; private final UserRepository users;
private final AuthTokenRepository tokens; private final AuthTokenRepository tokens;
private final TemplatedEmailService templatedEmailService; private final TemplatedEmailService templatedEmailService;
@Value("${app.publicBaseUrl:http://localhost:3000}") @Value("${app.publicBaseUrl:http://localhost:3000}")
private String publicBaseUrl; private String publicBaseUrl;
@Value("${app.authTokenPepper:change-me}") @Value("${app.authTokenPepper:change-me}")
private String tokenPepper; private String tokenPepper;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
public BetaInviteService( public BetaInviteService(
UserRepository users, UserRepository users,
AuthTokenRepository tokens, AuthTokenRepository tokens,
TemplatedEmailService templatedEmailService TemplatedEmailService templatedEmailService
) { ) {
this.users = users; this.users = users;
this.tokens = tokens; this.tokens = tokens;
this.templatedEmailService = templatedEmailService; this.templatedEmailService = templatedEmailService;
} }
/** /**
* Batch invite for all pending BETA users (role=BETA, is_active=false, deleted_at is null). * Batch invite for all pending BETA users (role=BETA, is_active=false, deleted_at is null).
*/ */
public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) { public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) {
List<User> betaUsers = (limit > 0) List<User> betaUsers = (limit > 0)
? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit) ? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit)
: users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA"); : users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
int sent = 0; int sent = 0;
for (User user : betaUsers) { for (User user : betaUsers) {
inviteUser(user, tokenMinutes, dryRun); inviteUser(user, tokenMinutes, dryRun);
sent++; sent++;
} }
return sent; return sent;
} }
/** /**
* Admin UI list: all pending beta requests (role=BETA, is_active=false). * Admin UI list: all pending beta requests (role=BETA, is_active=false).
* Controller expects Page<AdminBetaRequestDto>. * Controller expects Page<AdminBetaRequestDto>.
*/ */
public Page<AdminBetaRequestDto> listPendingBetaUsers(int page, int size) { public Page<AdminBetaRequestDto> listPendingBetaUsers(int page, int size) {
int safePage = Math.max(0, page); int safePage = Math.max(0, page);
int safeSize = Math.min(Math.max(1, size), 100); int safeSize = Math.min(Math.max(1, size), 100);
List<User> pending = users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA"); List<User> pending = users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
int from = Math.min(safePage * safeSize, pending.size()); int from = Math.min(safePage * safeSize, pending.size());
int to = Math.min(from + safeSize, pending.size()); int to = Math.min(from + safeSize, pending.size());
OffsetDateTime now = OffsetDateTime.now(); OffsetDateTime now = OffsetDateTime.now();
List<AdminBetaRequestDto> dtos = pending.subList(from, to).stream() List<AdminBetaRequestDto> dtos = pending.subList(from, to).stream()
.map(u -> { .map(u -> {
AdminBetaRequestDto dto = AdminBetaRequestDto.from(u); AdminBetaRequestDto dto = AdminBetaRequestDto.from(u);
dto.invited = tokens.hasActiveToken( dto.invited = tokens.hasActiveToken(
u.getEmail(), u.getEmail(),
AuthToken.TokenType.MAGIC_LOGIN, AuthToken.TokenType.MAGIC_LOGIN,
now now
); );
return dto; return dto;
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
return new PageImpl<>(dtos, PageRequest.of(safePage, safeSize), pending.size()); return new PageImpl<>(dtos, PageRequest.of(safePage, safeSize), pending.size());
} }
/** /**
* Invite a single beta request by userId. * Invite a single beta request by userId.
*/ */
public AdminInviteResponse inviteSingleBetaUser(Integer userId) { public AdminInviteResponse inviteSingleBetaUser(Integer userId) {
if (userId == null) { if (userId == null) {
return new AdminInviteResponse(false, null, "userId is required"); return new AdminInviteResponse(false, null, "userId is required");
} }
User user = users.findById(userId).orElse(null); User user = users.findById(userId).orElse(null);
if (user == null || user.getDeletedAt() != null) { if (user == null || user.getDeletedAt() != null) {
return new AdminInviteResponse(false, null, "User not found"); return new AdminInviteResponse(false, null, "User not found");
} }
if (!"BETA".equalsIgnoreCase(user.getRole()) || user.isActive()) { if (!"BETA".equalsIgnoreCase(user.getRole()) || user.isActive()) {
return new AdminInviteResponse(false, user.getEmail(), "User is not a pending beta request"); return new AdminInviteResponse(false, user.getEmail(), "User is not a pending beta request");
} }
int tokenMinutes = 30; // default for single-invite; feel free to parametrize later int tokenMinutes = 30; // default for single-invite; feel free to parametrize later
String magicUrl = inviteUser(user, tokenMinutes, false); String magicUrl = inviteUser(user, tokenMinutes, false);
return new AdminInviteResponse(true, user.getEmail(), magicUrl); return new AdminInviteResponse(true, user.getEmail(), magicUrl);
} }
/** /**
* Creates token, persists hash, and (optionally) sends email. * Creates token, persists hash, and (optionally) sends email.
* Returns the magicUrl for logging / admin response. * Returns the magicUrl for logging / admin response.
*/ */
private String inviteUser(User user, int tokenMinutes, boolean dryRun) { private String inviteUser(User user, int tokenMinutes, boolean dryRun) {
String email = user.getEmail(); String email = user.getEmail();
String magicToken = generateToken(); String magicToken = generateToken();
saveToken( saveToken(
email, email,
AuthToken.TokenType.MAGIC_LOGIN, AuthToken.TokenType.MAGIC_LOGIN,
magicToken, magicToken,
OffsetDateTime.now().plusMinutes(tokenMinutes) OffsetDateTime.now().plusMinutes(tokenMinutes)
); );
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken; String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
if (!dryRun) { if (!dryRun) {
templatedEmailService.send( templatedEmailService.send(
"beta_invite", "beta_invite",
email, email,
Map.of( Map.of(
"minutes", String.valueOf(tokenMinutes), "minutes", String.valueOf(tokenMinutes),
"magicUrl", magicUrl "magicUrl", magicUrl
) )
); );
} }
return magicUrl; return magicUrl;
} }
private void saveToken( private void saveToken(
String email, String email,
AuthToken.TokenType type, AuthToken.TokenType type,
String token, String token,
OffsetDateTime expiresAt OffsetDateTime expiresAt
) { ) {
AuthToken t = new AuthToken(); AuthToken t = new AuthToken();
t.setEmail(email); t.setEmail(email);
t.setType(type); t.setType(type);
t.setTokenHash(hashToken(token)); t.setTokenHash(hashToken(token));
t.setExpiresAt(expiresAt); t.setExpiresAt(expiresAt);
t.setCreatedAt(OffsetDateTime.now()); t.setCreatedAt(OffsetDateTime.now());
tokens.save(t); tokens.save(t);
} }
private String generateToken() { private String generateToken() {
byte[] bytes = new byte[32]; byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes); secureRandom.nextBytes(bytes);
return HexFormat.of().formatHex(bytes); return HexFormat.of().formatHex(bytes);
} }
private String hashToken(String token) { private String hashToken(String token) {
try { try {
MessageDigest md = MessageDigest.getInstance("SHA-256"); MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hashed = md.digest( byte[] hashed = md.digest(
(tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8) (tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)
); );
return HexFormat.of().formatHex(hashed); return HexFormat.of().formatHex(hashed);
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("Failed to hash token", e); throw new RuntimeException("Failed to hash token", e);
} }
} }
} }

View File

@@ -1,38 +1,38 @@
package group.goforward.battlbuilder.service.impl; package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.Brand; import group.goforward.battlbuilder.model.Brand;
import group.goforward.battlbuilder.repo.BrandRepository; import group.goforward.battlbuilder.repo.BrandRepository;
import group.goforward.battlbuilder.service.BrandService; import group.goforward.battlbuilder.service.BrandService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class BrandServiceImpl implements BrandService { public class BrandServiceImpl implements BrandService {
@Autowired @Autowired
private BrandRepository repo; private BrandRepository repo;
@Override @Override
public List<Brand> findAll() { public List<Brand> findAll() {
return repo.findAll(); return repo.findAll();
} }
@Override @Override
public Optional<Brand> findById(Integer id) { public Optional<Brand> findById(Integer id) {
return repo.findById(id); return repo.findById(id);
} }
@Override @Override
public Brand save(Brand item) { public Brand save(Brand item) {
return repo.save(item); return repo.save(item);
} }
@Override @Override
public void deleteById(Integer id) { public void deleteById(Integer id) {
deleteById(id); deleteById(id);
} }
} }

View File

@@ -1,209 +1,209 @@
package group.goforward.battlbuilder.service.impl; package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.Product; import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductOffer; import group.goforward.battlbuilder.model.ProductOffer;
import group.goforward.battlbuilder.repo.ProductOfferRepository; import group.goforward.battlbuilder.repo.ProductOfferRepository;
import group.goforward.battlbuilder.repo.ProductRepository; import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.repo.catalog.spec.CatalogProductSpecifications; import group.goforward.battlbuilder.repo.catalog.spec.CatalogProductSpecifications;
import group.goforward.battlbuilder.service.CatalogQueryService; import group.goforward.battlbuilder.service.CatalogQueryService;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto; import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest; import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
import group.goforward.battlbuilder.web.mapper.ProductMapper; import group.goforward.battlbuilder.web.mapper.ProductMapper;
import org.springframework.data.domain.*; import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
public class CatalogQueryServiceImpl implements CatalogQueryService { public class CatalogQueryServiceImpl implements CatalogQueryService {
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final ProductOfferRepository productOfferRepository; private final ProductOfferRepository productOfferRepository;
public CatalogQueryServiceImpl(ProductRepository productRepository, public CatalogQueryServiceImpl(ProductRepository productRepository,
ProductOfferRepository productOfferRepository) { ProductOfferRepository productOfferRepository) {
this.productRepository = productRepository; this.productRepository = productRepository;
this.productOfferRepository = productOfferRepository; this.productOfferRepository = productOfferRepository;
} }
@Override @Override
public Page<ProductSummaryDto> getOptions( public Page<ProductSummaryDto> getOptions(
String platform, String platform,
String partRole, String partRole,
List<String> partRoles, List<String> partRoles,
List<String> brands, List<String> brands,
String q, String q,
Pageable pageable Pageable pageable
) { ) {
pageable = sanitizeCatalogPageable(pageable); pageable = sanitizeCatalogPageable(pageable);
// Normalize roles: accept partRole OR partRoles // Normalize roles: accept partRole OR partRoles
List<String> roleList = new ArrayList<>(); List<String> roleList = new ArrayList<>();
if (partRole != null && !partRole.isBlank()) roleList.add(partRole); if (partRole != null && !partRole.isBlank()) roleList.add(partRole);
if (partRoles != null && !partRoles.isEmpty()) roleList.addAll(partRoles); if (partRoles != null && !partRoles.isEmpty()) roleList.addAll(partRoles);
roleList = roleList.stream().filter(s -> s != null && !s.isBlank()).distinct().toList(); roleList = roleList.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
Specification<Product> spec = Specification.where(CatalogProductSpecifications.isCatalogVisible()); Specification<Product> spec = Specification.where(CatalogProductSpecifications.isCatalogVisible());
// platform optional: omit/blank/ALL => universal // platform optional: omit/blank/ALL => universal
if (platform != null && !platform.isBlank() && !"ALL".equalsIgnoreCase(platform)) { if (platform != null && !platform.isBlank() && !"ALL".equalsIgnoreCase(platform)) {
spec = spec.and(CatalogProductSpecifications.platformEquals(platform)); spec = spec.and(CatalogProductSpecifications.platformEquals(platform));
} }
if (!roleList.isEmpty()) { if (!roleList.isEmpty()) {
spec = spec.and(CatalogProductSpecifications.partRoleIn(roleList)); spec = spec.and(CatalogProductSpecifications.partRoleIn(roleList));
} }
if (brands != null && !brands.isEmpty()) { if (brands != null && !brands.isEmpty()) {
spec = spec.and(CatalogProductSpecifications.brandNameIn(brands)); spec = spec.and(CatalogProductSpecifications.brandNameIn(brands));
} }
if (q != null && !q.isBlank()) { if (q != null && !q.isBlank()) {
spec = spec.and(CatalogProductSpecifications.queryLike(q)); spec = spec.and(CatalogProductSpecifications.queryLike(q));
} }
Page<Product> page = productRepository.findAll(spec, pageable); Page<Product> page = productRepository.findAll(spec, pageable);
if (page.isEmpty()) { if (page.isEmpty()) {
return new PageImpl<>(List.of(), pageable, 0); return new PageImpl<>(List.of(), pageable, 0);
} }
// Bulk offers for this page (no N+1) // Bulk offers for this page (no N+1)
List<Integer> productIds = page.getContent().stream().map(Product::getId).toList(); List<Integer> productIds = page.getContent().stream().map(Product::getId).toList();
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(productIds); List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(productIds);
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers); Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
List<ProductSummaryDto> dtos = page.getContent().stream().map(p -> { List<ProductSummaryDto> dtos = page.getContent().stream().map(p -> {
ProductOffer best = bestOfferByProductId.get(p.getId()); ProductOffer best = bestOfferByProductId.get(p.getId());
BigDecimal price = best != null ? best.getEffectivePrice() : null; BigDecimal price = best != null ? best.getEffectivePrice() : null;
String buyUrl = best != null ? best.getBuyUrl() : null; String buyUrl = best != null ? best.getBuyUrl() : null;
return ProductMapper.toSummary(p, price, buyUrl); return ProductMapper.toSummary(p, price, buyUrl);
}).toList(); }).toList();
return new PageImpl<>(dtos, pageable, page.getTotalElements()); return new PageImpl<>(dtos, pageable, page.getTotalElements());
} }
@Override @Override
public List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request) { public List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request) {
List<Integer> ids = request != null ? request.getIds() : null; List<Integer> ids = request != null ? request.getIds() : null;
if (ids == null || ids.isEmpty()) return List.of(); if (ids == null || ids.isEmpty()) return List.of();
ids = ids.stream().filter(Objects::nonNull).distinct().toList(); ids = ids.stream().filter(Objects::nonNull).distinct().toList();
List<Product> products = productRepository.findByIdIn(ids); List<Product> products = productRepository.findByIdIn(ids);
if (products.isEmpty()) return List.of(); if (products.isEmpty()) return List.of();
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(ids); List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(ids);
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers); Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
Map<Integer, Product> productById = products.stream() Map<Integer, Product> productById = products.stream()
.collect(Collectors.toMap(Product::getId, p -> p)); .collect(Collectors.toMap(Product::getId, p -> p));
List<ProductSummaryDto> out = new ArrayList<>(); List<ProductSummaryDto> out = new ArrayList<>();
for (Integer id : ids) { for (Integer id : ids) {
Product p = productById.get(id); Product p = productById.get(id);
if (p == null) continue; if (p == null) continue;
ProductOffer best = bestOfferByProductId.get(id); ProductOffer best = bestOfferByProductId.get(id);
BigDecimal price = best != null ? best.getEffectivePrice() : null; BigDecimal price = best != null ? best.getEffectivePrice() : null;
String buyUrl = best != null ? best.getBuyUrl() : null; String buyUrl = best != null ? best.getBuyUrl() : null;
out.add(ProductMapper.toSummary(p, price, buyUrl)); out.add(ProductMapper.toSummary(p, price, buyUrl));
} }
return out; return out;
} }
private Map<Integer, ProductOffer> pickBestOffers(List<ProductOffer> offers) { private Map<Integer, ProductOffer> pickBestOffers(List<ProductOffer> offers) {
Map<Integer, ProductOffer> best = new HashMap<>(); Map<Integer, ProductOffer> best = new HashMap<>();
if (offers == null || offers.isEmpty()) return best; if (offers == null || offers.isEmpty()) return best;
for (ProductOffer o : offers) { for (ProductOffer o : offers) {
if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue; if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue;
Integer pid = o.getProduct().getId(); Integer pid = o.getProduct().getId();
BigDecimal price = o.getEffectivePrice(); BigDecimal price = o.getEffectivePrice();
if (price == null) continue; if (price == null) continue;
ProductOffer current = best.get(pid); ProductOffer current = best.get(pid);
if (current == null) { if (current == null) {
best.put(pid, o); best.put(pid, o);
continue; continue;
} }
// ---- ranking rules (in order) ---- // ---- ranking rules (in order) ----
// 1) prefer in-stock // 1) prefer in-stock
boolean oStock = Boolean.TRUE.equals(o.getInStock()); boolean oStock = Boolean.TRUE.equals(o.getInStock());
boolean cStock = Boolean.TRUE.equals(current.getInStock()); boolean cStock = Boolean.TRUE.equals(current.getInStock());
if (oStock != cStock) { if (oStock != cStock) {
if (oStock) best.put(pid, o); if (oStock) best.put(pid, o);
continue; continue;
} }
// 2) prefer cheaper price // 2) prefer cheaper price
BigDecimal currentPrice = current.getEffectivePrice(); BigDecimal currentPrice = current.getEffectivePrice();
if (currentPrice == null || price.compareTo(currentPrice) < 0) { if (currentPrice == null || price.compareTo(currentPrice) < 0) {
best.put(pid, o); best.put(pid, o);
continue; continue;
} }
if (price.compareTo(currentPrice) > 0) continue; if (price.compareTo(currentPrice) > 0) continue;
// 3) tie-break: most recently seen // 3) tie-break: most recently seen
OffsetDateTime oSeen = o.getLastSeenAt(); OffsetDateTime oSeen = o.getLastSeenAt();
OffsetDateTime cSeen = current.getLastSeenAt(); OffsetDateTime cSeen = current.getLastSeenAt();
if (oSeen != null && cSeen != null && oSeen.isAfter(cSeen)) { if (oSeen != null && cSeen != null && oSeen.isAfter(cSeen)) {
best.put(pid, o); best.put(pid, o);
continue; continue;
} }
if (oSeen != null && cSeen == null) { if (oSeen != null && cSeen == null) {
best.put(pid, o); best.put(pid, o);
} }
// 4) tie-break: prefer offer with buyUrl // 4) tie-break: prefer offer with buyUrl
String oUrl = o.getBuyUrl(); String oUrl = o.getBuyUrl();
String cUrl = current.getBuyUrl(); String cUrl = current.getBuyUrl();
if ((oUrl != null && !oUrl.isBlank()) && (cUrl == null || cUrl.isBlank())) { if ((oUrl != null && !oUrl.isBlank()) && (cUrl == null || cUrl.isBlank())) {
best.put(pid, o); best.put(pid, o);
} }
} }
return best; return best;
} }
private Pageable sanitizeCatalogPageable(Pageable pageable) { private Pageable sanitizeCatalogPageable(Pageable pageable) {
if (pageable == null) { if (pageable == null) {
return PageRequest.of(0, 24, Sort.by(Sort.Direction.DESC, "updatedAt")); return PageRequest.of(0, 24, Sort.by(Sort.Direction.DESC, "updatedAt"));
} }
int page = pageable.getPageNumber(); int page = pageable.getPageNumber();
int requested = pageable.getPageSize(); int requested = pageable.getPageSize();
int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap
// Default sort if none provided // Default sort if none provided
if (pageable.getSort() == null || pageable.getSort().isUnsorted()) { if (pageable.getSort() == null || pageable.getSort().isUnsorted()) {
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
} }
// Only allow safe sorts (for now) // Only allow safe sorts (for now)
Sort.Order first = pageable.getSort().stream().findFirst().orElse(null); Sort.Order first = pageable.getSort().stream().findFirst().orElse(null);
if (first == null) { if (first == null) {
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
} }
String prop = first.getProperty(); String prop = first.getProperty();
Sort.Direction dir = first.getDirection(); Sort.Direction dir = first.getDirection();
// IMPORTANT: // IMPORTANT:
// If you're still using JPA Specifications (Product entity), you can only sort by Product fields. // If you're still using JPA Specifications (Product entity), you can only sort by Product fields.
// Once you switch to the native "best offer" query, you can allow "price" and "brand" sorts. // Once you switch to the native "best offer" query, you can allow "price" and "brand" sorts.
return switch (prop) { return switch (prop) {
case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop)); case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop));
default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
}; };
} }
} }

View File

@@ -1,135 +1,135 @@
package group.goforward.battlbuilder.service.impl; package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver; import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
import group.goforward.battlbuilder.imports.MerchantFeedRow; import group.goforward.battlbuilder.imports.MerchantFeedRow;
import group.goforward.battlbuilder.model.Merchant; import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.PartRoleSource; import group.goforward.battlbuilder.model.PartRoleSource;
import group.goforward.battlbuilder.service.CategoryClassificationService; import group.goforward.battlbuilder.service.CategoryClassificationService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class CategoryClassificationServiceImpl implements CategoryClassificationService { public class CategoryClassificationServiceImpl implements CategoryClassificationService {
private final MerchantCategoryMappingService merchantCategoryMappingService; private final MerchantCategoryMappingService merchantCategoryMappingService;
private final PartRoleResolver partRoleResolver; private final PartRoleResolver partRoleResolver;
public CategoryClassificationServiceImpl( public CategoryClassificationServiceImpl(
MerchantCategoryMappingService merchantCategoryMappingService, MerchantCategoryMappingService merchantCategoryMappingService,
PartRoleResolver partRoleResolver PartRoleResolver partRoleResolver
) { ) {
this.merchantCategoryMappingService = merchantCategoryMappingService; this.merchantCategoryMappingService = merchantCategoryMappingService;
this.partRoleResolver = partRoleResolver; this.partRoleResolver = partRoleResolver;
} }
@Override @Override
public Result classify(Merchant merchant, MerchantFeedRow row) { public Result classify(Merchant merchant, MerchantFeedRow row) {
String rawCategoryKey = buildRawCategoryKey(row); String rawCategoryKey = buildRawCategoryKey(row);
String platformFinal = inferPlatform(row); String platformFinal = inferPlatform(row);
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15"; if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
return classify(merchant, row, platformFinal, rawCategoryKey); return classify(merchant, row, platformFinal, rawCategoryKey);
} }
@Override @Override
public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) { public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) {
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15"; if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
// 1) merchant map (authoritative if present) // 1) merchant map (authoritative if present)
Optional<String> mapped = merchantCategoryMappingService.resolveMappedPartRole( Optional<String> mapped = merchantCategoryMappingService.resolveMappedPartRole(
merchant != null ? merchant.getId() : null, merchant != null ? merchant.getId() : null,
rawCategoryKey, rawCategoryKey,
platformFinal platformFinal
); );
if (mapped.isPresent()) { if (mapped.isPresent()) {
String role = normalizePartRole(mapped.get()); String role = normalizePartRole(mapped.get());
return new Result( return new Result(
platformFinal, platformFinal,
role, role,
rawCategoryKey, rawCategoryKey,
PartRoleSource.MERCHANT_MAP, PartRoleSource.MERCHANT_MAP,
"merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")" "merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")"
); );
} }
// 2) rules // 2) rules
String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey); String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey);
if (resolved != null && !resolved.isBlank()) { if (resolved != null && !resolved.isBlank()) {
String role = normalizePartRole(resolved); String role = normalizePartRole(resolved);
return new Result( return new Result(
platformFinal, platformFinal,
role, role,
rawCategoryKey, rawCategoryKey,
PartRoleSource.RULES, PartRoleSource.RULES,
"PartRoleResolver matched" "PartRoleResolver matched"
); );
} }
// 3) no inference: leave unknown and let it flow to PENDING_MAPPING // 3) no inference: leave unknown and let it flow to PENDING_MAPPING
return new Result( return new Result(
platformFinal, platformFinal,
"unknown", "unknown",
rawCategoryKey, rawCategoryKey,
PartRoleSource.UNKNOWN, PartRoleSource.UNKNOWN,
"no mapping or rules match" "no mapping or rules match"
); );
} }
private String buildRawCategoryKey(MerchantFeedRow row) { private String buildRawCategoryKey(MerchantFeedRow row) {
String dept = trimOrNull(row.department()); String dept = trimOrNull(row.department());
String cat = trimOrNull(row.category()); String cat = trimOrNull(row.category());
String sub = trimOrNull(row.subCategory()); String sub = trimOrNull(row.subCategory());
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
if (dept != null) sb.append(dept); if (dept != null) sb.append(dept);
if (cat != null) { if (cat != null) {
if (!sb.isEmpty()) sb.append(" > "); if (!sb.isEmpty()) sb.append(" > ");
sb.append(cat); sb.append(cat);
} }
if (sub != null) { if (sub != null) {
if (!sb.isEmpty()) sb.append(" > "); if (!sb.isEmpty()) sb.append(" > ");
sb.append(sub); sb.append(sub);
} }
String result = sb.toString(); String result = sb.toString();
return result.isBlank() ? null : result; return result.isBlank() ? null : result;
} }
private String inferPlatform(MerchantFeedRow row) { private String inferPlatform(MerchantFeedRow row) {
String blob = String.join(" ", String blob = String.join(" ",
coalesce(trimOrNull(row.department()), ""), coalesce(trimOrNull(row.department()), ""),
coalesce(trimOrNull(row.category()), ""), coalesce(trimOrNull(row.category()), ""),
coalesce(trimOrNull(row.subCategory()), "") coalesce(trimOrNull(row.subCategory()), "")
).toLowerCase(Locale.ROOT); ).toLowerCase(Locale.ROOT);
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15"; if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10"; if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10";
if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9"; if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9";
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47"; if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
return "AR-15"; return "AR-15";
} }
private String normalizePartRole(String partRole) { private String normalizePartRole(String partRole) {
if (partRole == null) return "unknown"; if (partRole == null) return "unknown";
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-'); String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
return t.isBlank() ? "unknown" : t; return t.isBlank() ? "unknown" : t;
} }
private String trimOrNull(String v) { private String trimOrNull(String v) {
if (v == null) return null; if (v == null) return null;
String t = v.trim(); String t = v.trim();
return t.isEmpty() ? null : t; return t.isEmpty() ? null : t;
} }
private String coalesce(String... values) { private String coalesce(String... values) {
if (values == null) return null; if (values == null) return null;
for (String v : values) { for (String v : values) {
if (v != null && !v.isBlank()) return v; if (v != null && !v.isBlank()) return v;
} }
return null; return null;
} }
} }

View File

@@ -1,38 +1,38 @@
package group.goforward.battlbuilder.service.impl; package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository; import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class MerchantCategoryMappingService { public class MerchantCategoryMappingService {
private final MerchantCategoryMapRepository merchantCategoryMapRepository; private final MerchantCategoryMapRepository merchantCategoryMapRepository;
public MerchantCategoryMappingService(MerchantCategoryMapRepository merchantCategoryMapRepository) { public MerchantCategoryMappingService(MerchantCategoryMapRepository merchantCategoryMapRepository) {
this.merchantCategoryMapRepository = merchantCategoryMapRepository; this.merchantCategoryMapRepository = merchantCategoryMapRepository;
} }
public Optional<String> resolveMappedPartRole( public Optional<String> resolveMappedPartRole(
Integer merchantId, Integer merchantId,
String rawCategoryKey, String rawCategoryKey,
String platformFinal String platformFinal
) { ) {
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) { if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) {
return Optional.empty(); return Optional.empty();
} }
List<String> canonicalRoles = List<String> canonicalRoles =
merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey); merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey);
if (canonicalRoles == null || canonicalRoles.isEmpty()) { if (canonicalRoles == null || canonicalRoles.isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
return canonicalRoles.stream() return canonicalRoles.stream()
.filter(v -> v != null && !v.isBlank()) .filter(v -> v != null && !v.isBlank())
.findFirst(); .findFirst();
} }
} }

View File

@@ -1,174 +1,174 @@
package group.goforward.battlbuilder.service.impl; package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.Product; import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductOffer; import group.goforward.battlbuilder.model.ProductOffer;
import group.goforward.battlbuilder.repo.ProductOfferRepository; import group.goforward.battlbuilder.repo.ProductOfferRepository;
import group.goforward.battlbuilder.repo.ProductRepository; import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.service.ProductQueryService; import group.goforward.battlbuilder.service.ProductQueryService;
import group.goforward.battlbuilder.web.dto.ProductOfferDto; import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto; import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.mapper.ProductMapper; import group.goforward.battlbuilder.web.mapper.ProductMapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import java.util.Collections; import java.util.Collections;
import java.util.Map; import java.util.Map;
import java.util.List; import java.util.List;
@Service @Service
public class ProductQueryServiceImpl implements ProductQueryService { public class ProductQueryServiceImpl implements ProductQueryService {
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final ProductOfferRepository productOfferRepository; private final ProductOfferRepository productOfferRepository;
public ProductQueryServiceImpl( public ProductQueryServiceImpl(
ProductRepository productRepository, ProductRepository productRepository,
ProductOfferRepository productOfferRepository ProductOfferRepository productOfferRepository
) { ) {
this.productRepository = productRepository; this.productRepository = productRepository;
this.productOfferRepository = productOfferRepository; this.productOfferRepository = productOfferRepository;
} }
@Override @Override
public List<ProductSummaryDto> getProducts(String platform, List<String> partRoles) { public List<ProductSummaryDto> getProducts(String platform, List<String> partRoles) {
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL"); final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
List<Product> products; List<Product> products;
if (partRoles == null || partRoles.isEmpty()) { if (partRoles == null || partRoles.isEmpty()) {
products = allPlatforms products = allPlatforms
? productRepository.findAllWithBrand() ? productRepository.findAllWithBrand()
: productRepository.findByPlatformWithBrand(platform); : productRepository.findByPlatformWithBrand(platform);
} else { } else {
products = allPlatforms products = allPlatforms
? productRepository.findByPartRoleInWithBrand(partRoles) ? productRepository.findByPartRoleInWithBrand(partRoles)
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles); : productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
} }
if (products.isEmpty()) return List.of(); if (products.isEmpty()) return List.of();
List<Integer> productIds = products.stream().map(Product::getId).toList(); List<Integer> productIds = products.stream().map(Product::getId).toList();
// ✅ canonical repo method // ✅ canonical repo method
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds); List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream() Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null) .filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
.collect(Collectors.groupingBy(o -> o.getProduct().getId())); .collect(Collectors.groupingBy(o -> o.getProduct().getId()));
return products.stream() return products.stream()
.map(p -> { .map(p -> {
List<ProductOffer> offersForProduct = List<ProductOffer> offersForProduct =
offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
ProductOffer bestOffer = pickBestOffer(offersForProduct); ProductOffer bestOffer = pickBestOffer(offersForProduct);
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
return ProductMapper.toSummary(p, price, buyUrl); return ProductMapper.toSummary(p, price, buyUrl);
}) })
.toList(); .toList();
} }
@Override @Override
public Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) { public Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) {
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL"); final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
Page<Product> productPage; Page<Product> productPage;
if (partRoles == null || partRoles.isEmpty()) { if (partRoles == null || partRoles.isEmpty()) {
productPage = allPlatforms productPage = allPlatforms
? productRepository.findAllWithBrand(pageable) ? productRepository.findAllWithBrand(pageable)
: productRepository.findByPlatformWithBrand(platform, pageable); : productRepository.findByPlatformWithBrand(platform, pageable);
} else { } else {
productPage = allPlatforms productPage = allPlatforms
? productRepository.findByPartRoleInWithBrand(partRoles, pageable) ? productRepository.findByPartRoleInWithBrand(partRoles, pageable)
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable); : productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable);
} }
List<Product> products = productPage.getContent(); List<Product> products = productPage.getContent();
if (products.isEmpty()) { if (products.isEmpty()) {
return Page.empty(pageable); return Page.empty(pageable);
} }
List<Integer> productIds = products.stream().map(Product::getId).toList(); List<Integer> productIds = products.stream().map(Product::getId).toList();
// Only fetch offers for THIS PAGE of products // Only fetch offers for THIS PAGE of products
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds); List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream() Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null) .filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
.collect(Collectors.groupingBy(o -> o.getProduct().getId())); .collect(Collectors.groupingBy(o -> o.getProduct().getId()));
List<ProductSummaryDto> dtos = products.stream() List<ProductSummaryDto> dtos = products.stream()
.map(p -> { .map(p -> {
List<ProductOffer> offersForProduct = List<ProductOffer> offersForProduct =
offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
ProductOffer bestOffer = pickBestOffer(offersForProduct); ProductOffer bestOffer = pickBestOffer(offersForProduct);
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
return ProductMapper.toSummary(p, price, buyUrl); return ProductMapper.toSummary(p, price, buyUrl);
}) })
.toList(); .toList();
return new PageImpl<>(dtos, pageable, productPage.getTotalElements()); return new PageImpl<>(dtos, pageable, productPage.getTotalElements());
} }
// //
// Product Offers // Product Offers
// //
@Override @Override
public List<ProductOfferDto> getOffersForProduct(Integer productId) { public List<ProductOfferDto> getOffersForProduct(Integer productId) {
// ✅ canonical repo method // ✅ canonical repo method
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId); List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
return offers.stream() return offers.stream()
.map(offer -> { .map(offer -> {
ProductOfferDto dto = new ProductOfferDto(); ProductOfferDto dto = new ProductOfferDto();
dto.setId(offer.getId().toString()); dto.setId(offer.getId().toString());
dto.setMerchantName(offer.getMerchant().getName()); dto.setMerchantName(offer.getMerchant().getName());
dto.setPrice(offer.getEffectivePrice()); dto.setPrice(offer.getEffectivePrice());
dto.setOriginalPrice(offer.getOriginalPrice()); dto.setOriginalPrice(offer.getOriginalPrice());
dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
dto.setBuyUrl(offer.getBuyUrl()); dto.setBuyUrl(offer.getBuyUrl());
dto.setLastUpdated(offer.getLastSeenAt()); dto.setLastUpdated(offer.getLastSeenAt());
return dto; return dto;
}) })
.toList(); .toList();
} }
@Override @Override
public ProductSummaryDto getProductById(Integer productId) { public ProductSummaryDto getProductById(Integer productId) {
Product product = productRepository.findById(productId).orElse(null); Product product = productRepository.findById(productId).orElse(null);
if (product == null) return null; if (product == null) return null;
// ✅ canonical repo method // ✅ canonical repo method
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId); List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
ProductOffer bestOffer = pickBestOffer(offers); ProductOffer bestOffer = pickBestOffer(offers);
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
return ProductMapper.toSummary(product, price, buyUrl); return ProductMapper.toSummary(product, price, buyUrl);
} }
private ProductOffer pickBestOffer(List<ProductOffer> offers) { private ProductOffer pickBestOffer(List<ProductOffer> offers) {
if (offers == null || offers.isEmpty()) return null; if (offers == null || offers.isEmpty()) return null;
// MVP: lowest effective price wins. (Later: prefer in-stock, etc.) // MVP: lowest effective price wins. (Later: prefer in-stock, etc.)
return offers.stream() return offers.stream()
.filter(o -> o.getEffectivePrice() != null) .filter(o -> o.getEffectivePrice() != null)
.min(Comparator.comparing(ProductOffer::getEffectivePrice)) .min(Comparator.comparing(ProductOffer::getEffectivePrice))
.orElse(null); .orElse(null);
} }
} }

View File

@@ -1,175 +1,175 @@
package group.goforward.battlbuilder.service.impl; package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.ImportStatus; import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.PartRoleSource; import group.goforward.battlbuilder.model.PartRoleSource;
import group.goforward.battlbuilder.model.Product; import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository; import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.service.ReclassificationService; import group.goforward.battlbuilder.service.ReclassificationService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class ReclassificationServiceImpl implements ReclassificationService { public class ReclassificationServiceImpl implements ReclassificationService {
private static final String CLASSIFIER_VERSION = "v2025-12-28.1"; private static final String CLASSIFIER_VERSION = "v2025-12-28.1";
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final MerchantCategoryMappingService merchantCategoryMappingService; private final MerchantCategoryMappingService merchantCategoryMappingService;
// ✅ Keep ONE constructor. Spring will inject both deps. // ✅ Keep ONE constructor. Spring will inject both deps.
public ReclassificationServiceImpl( public ReclassificationServiceImpl(
ProductRepository productRepository, ProductRepository productRepository,
MerchantCategoryMappingService merchantCategoryMappingService MerchantCategoryMappingService merchantCategoryMappingService
) { ) {
this.productRepository = productRepository; this.productRepository = productRepository;
this.merchantCategoryMappingService = merchantCategoryMappingService; this.merchantCategoryMappingService = merchantCategoryMappingService;
} }
// ============================ // ============================
// Catalog category FK backfill // Catalog category FK backfill
// ============================ // ============================
@Override @Override
@Transactional @Transactional
public int applyCatalogCategoryMappingToProducts( public int applyCatalogCategoryMappingToProducts(
Integer merchantId, Integer merchantId,
String rawCategoryKey, String rawCategoryKey,
Integer canonicalCategoryId Integer canonicalCategoryId
) { ) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
if (rawCategoryKey == null || rawCategoryKey.isBlank()) if (rawCategoryKey == null || rawCategoryKey.isBlank())
throw new IllegalArgumentException("rawCategoryKey is required"); throw new IllegalArgumentException("rawCategoryKey is required");
return productRepository.applyCanonicalCategoryByPrimaryMerchantAndRawCategory( return productRepository.applyCanonicalCategoryByPrimaryMerchantAndRawCategory(
merchantId, merchantId,
rawCategoryKey.trim(), rawCategoryKey.trim(),
canonicalCategoryId canonicalCategoryId
); );
} }
/** /**
* Optional helper: bulk reclassify only PENDING_MAPPING for a merchant, * Optional helper: bulk reclassify only PENDING_MAPPING for a merchant,
* using ONLY merchant_category_map (no rules, no inference). * using ONLY merchant_category_map (no rules, no inference).
*/ */
@Override @Override
@Transactional @Transactional
public int reclassifyPendingForMerchant(Integer merchantId) { public int reclassifyPendingForMerchant(Integer merchantId) {
if (merchantId == null) throw new IllegalArgumentException("merchantId required"); if (merchantId == null) throw new IllegalArgumentException("merchantId required");
List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId); List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId);
if (pending == null || pending.isEmpty()) return 0; if (pending == null || pending.isEmpty()) return 0;
Instant now = Instant.now(); Instant now = Instant.now();
List<Product> toSave = new ArrayList<>(); List<Product> toSave = new ArrayList<>();
int updated = 0; int updated = 0;
for (Product p : pending) { for (Product p : pending) {
if (p.getDeletedAt() != null) continue; if (p.getDeletedAt() != null) continue;
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue; if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
String rawCategoryKey = p.getRawCategoryKey(); String rawCategoryKey = p.getRawCategoryKey();
if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue; if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue;
String platformFinal = normalizePlatformOrNull(p.getPlatform()); String platformFinal = normalizePlatformOrNull(p.getPlatform());
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole( Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
merchantId, rawCategoryKey, platformFinal merchantId, rawCategoryKey, platformFinal
); );
if (mappedRole.isEmpty()) continue; if (mappedRole.isEmpty()) continue;
String normalized = normalizePartRole(mappedRole.get()); String normalized = normalizePartRole(mappedRole.get());
if ("unknown".equals(normalized)) continue; if ("unknown".equals(normalized)) continue;
String current = normalizePartRole(p.getPartRole()); String current = normalizePartRole(p.getPartRole());
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue; if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
p.setPartRole(normalized); p.setPartRole(normalized);
p.setImportStatus(ImportStatus.MAPPED); p.setImportStatus(ImportStatus.MAPPED);
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP); p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
p.setClassifierVersion(CLASSIFIER_VERSION); p.setClassifierVersion(CLASSIFIER_VERSION);
p.setClassifiedAt(now); p.setClassifiedAt(now);
p.setClassificationReason("merchant_category_map: " + rawCategoryKey + p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
(platformFinal != null ? (" (" + platformFinal + ")") : "")); (platformFinal != null ? (" (" + platformFinal + ")") : ""));
toSave.add(p); toSave.add(p);
updated++; updated++;
} }
if (!toSave.isEmpty()) productRepository.saveAll(toSave); if (!toSave.isEmpty()) productRepository.saveAll(toSave);
return updated; return updated;
} }
/** /**
* Called by MappingAdminService after creating/updating a mapping. * Called by MappingAdminService after creating/updating a mapping.
* Applies mapping to all products for merchant+rawCategoryKey. * Applies mapping to all products for merchant+rawCategoryKey.
*/ */
@Override @Override
@Transactional @Transactional
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) { public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
if (merchantId == null) throw new IllegalArgumentException("merchantId required"); if (merchantId == null) throw new IllegalArgumentException("merchantId required");
if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required"); if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required");
List<Product> products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey); List<Product> products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey);
if (products == null || products.isEmpty()) return 0; if (products == null || products.isEmpty()) return 0;
Instant now = Instant.now(); Instant now = Instant.now();
List<Product> toSave = new ArrayList<>(); List<Product> toSave = new ArrayList<>();
int updated = 0; int updated = 0;
for (Product p : products) { for (Product p : products) {
if (p.getDeletedAt() != null) continue; if (p.getDeletedAt() != null) continue;
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue; if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
String platformFinal = normalizePlatformOrNull(p.getPlatform()); String platformFinal = normalizePlatformOrNull(p.getPlatform());
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole( Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
merchantId, rawCategoryKey, platformFinal merchantId, rawCategoryKey, platformFinal
); );
if (mappedRole.isEmpty()) continue; if (mappedRole.isEmpty()) continue;
String normalized = normalizePartRole(mappedRole.get()); String normalized = normalizePartRole(mappedRole.get());
if ("unknown".equals(normalized)) continue; if ("unknown".equals(normalized)) continue;
String current = normalizePartRole(p.getPartRole()); String current = normalizePartRole(p.getPartRole());
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue; if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
p.setPartRole(normalized); p.setPartRole(normalized);
p.setImportStatus(ImportStatus.MAPPED); p.setImportStatus(ImportStatus.MAPPED);
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP); p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
p.setClassifierVersion(CLASSIFIER_VERSION); p.setClassifierVersion(CLASSIFIER_VERSION);
p.setClassifiedAt(now); p.setClassifiedAt(now);
p.setClassificationReason("merchant_category_map: " + rawCategoryKey + p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
(platformFinal != null ? (" (" + platformFinal + ")") : "")); (platformFinal != null ? (" (" + platformFinal + ")") : ""));
toSave.add(p); toSave.add(p);
updated++; updated++;
} }
if (!toSave.isEmpty()) productRepository.saveAll(toSave); if (!toSave.isEmpty()) productRepository.saveAll(toSave);
return updated; return updated;
} }
// ----------------- // -----------------
// Helpers // Helpers
// ----------------- // -----------------
private String normalizePlatformOrNull(String platform) { private String normalizePlatformOrNull(String platform) {
if (platform == null) return null; if (platform == null) return null;
String t = platform.trim(); String t = platform.trim();
return t.isEmpty() ? null : t; return t.isEmpty() ? null : t;
} }
private String normalizePartRole(String partRole) { private String normalizePartRole(String partRole) {
if (partRole == null) return "unknown"; if (partRole == null) return "unknown";
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-'); String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
return t.isBlank() ? "unknown" : t; return t.isBlank() ? "unknown" : t;
} }
} }

View File

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

View File

@@ -1,183 +1,183 @@
package group.goforward.battlbuilder.service.utils; package group.goforward.battlbuilder.service.utils;
import group.goforward.battlbuilder.model.Product; import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository; import group.goforward.battlbuilder.repo.ProductRepository;
import io.minio.BucketExistsArgs; import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs; import io.minio.MakeBucketArgs;
import io.minio.MinioClient; import io.minio.MinioClient;
import io.minio.PutObjectArgs; import io.minio.PutObjectArgs;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.io.InputStream; import java.io.InputStream;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.time.Duration; import java.time.Duration;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class ImageUrlToMinioMigrator { public class ImageUrlToMinioMigrator {
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final MinioClient minioClient; private final MinioClient minioClient;
private final String bucket; private final String bucket;
private final String publicBaseUrl; private final String publicBaseUrl;
private final HttpClient httpClient = HttpClient.newBuilder() private final HttpClient httpClient = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL) .followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(15)) .connectTimeout(Duration.ofSeconds(15))
.build(); .build();
public ImageUrlToMinioMigrator(ProductRepository productRepository, public ImageUrlToMinioMigrator(ProductRepository productRepository,
MinioClient minioClient, MinioClient minioClient,
@Value("${minio.bucket}") String bucket, @Value("${minio.bucket}") String bucket,
@Value("${minio.public-base-url}") String publicBaseUrl) { @Value("${minio.public-base-url}") String publicBaseUrl) {
this.productRepository = productRepository; this.productRepository = productRepository;
this.minioClient = minioClient; this.minioClient = minioClient;
this.bucket = bucket; this.bucket = bucket;
this.publicBaseUrl = trimTrailingSlash(publicBaseUrl); this.publicBaseUrl = trimTrailingSlash(publicBaseUrl);
} }
/** /**
* Migrates Product.mainImageUrl URLs to MinIO and rewrites mainImageUrl to the new location. * Migrates Product.mainImageUrl URLs to MinIO and rewrites mainImageUrl to the new location.
* *
* @param pageSize batch size for DB paging * @param pageSize batch size for DB paging
* @param dryRun if true: download+upload is skipped and DB is not updated * @param dryRun if true: download+upload is skipped and DB is not updated
* @param maxItems optional cap for safety (null = no cap) * @param maxItems optional cap for safety (null = no cap)
* @return count of successfully migrated products * @return count of successfully migrated products
*/ */
@Transactional @Transactional
public int migrateMainImages(int pageSize, boolean dryRun, Integer maxItems) { public int migrateMainImages(int pageSize, boolean dryRun, Integer maxItems) {
ensureBucketExists(); ensureBucketExists();
int migrated = 0; int migrated = 0;
int page = 0; int page = 0;
while (true) { while (true) {
if (maxItems != null && migrated >= maxItems) break; if (maxItems != null && migrated >= maxItems) break;
Page<Product> batch = productRepository Page<Product> batch = productRepository
.findNeedingBattlImageUrlMigration(PageRequest.of(page, pageSize)); .findNeedingBattlImageUrlMigration(PageRequest.of(page, pageSize));
if (batch.isEmpty()) break; if (batch.isEmpty()) break;
for (Product p : batch.getContent()) { for (Product p : batch.getContent()) {
if (maxItems != null && migrated >= maxItems) break; if (maxItems != null && migrated >= maxItems) break;
String sourceUrl = p.getMainImageUrl(); String sourceUrl = p.getMainImageUrl();
if (sourceUrl == null || sourceUrl.isBlank()) continue; if (sourceUrl == null || sourceUrl.isBlank()) continue;
// Extra safety: skip if already set (covers any edge cases outside the query) // Extra safety: skip if already set (covers any edge cases outside the query)
if (p.getBattlImageUrl() != null && !p.getBattlImageUrl().trim().isEmpty()) continue; if (p.getBattlImageUrl() != null && !p.getBattlImageUrl().trim().isEmpty()) continue;
try { try {
if (!dryRun) { if (!dryRun) {
String newUrl = uploadFromUrlToMinio(p, sourceUrl); String newUrl = uploadFromUrlToMinio(p, sourceUrl);
p.setBattlImageUrl(newUrl); p.setBattlImageUrl(newUrl);
productRepository.save(p); productRepository.save(p);
} }
migrated++; migrated++;
} catch (Exception ex) { } catch (Exception ex) {
// fail-soft: continue migrating other products // fail-soft: continue migrating other products
} }
} }
if (!batch.hasNext()) break; if (!batch.hasNext()) break;
page++; page++;
} }
return migrated; return migrated;
} }
private String uploadFromUrlToMinio(Product p, String sourceUrl) throws Exception { private String uploadFromUrlToMinio(Product p, String sourceUrl) throws Exception {
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(sourceUrl)) .uri(URI.create(sourceUrl))
.timeout(Duration.ofSeconds(60)) .timeout(Duration.ofSeconds(60))
.GET() .GET()
.build(); .build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
int status = response.statusCode(); int status = response.statusCode();
if (status < 200 || status >= 300) { if (status < 200 || status >= 300) {
throw new IllegalStateException("Failed to download image. HTTP " + status + " url=" + sourceUrl); throw new IllegalStateException("Failed to download image. HTTP " + status + " url=" + sourceUrl);
} }
String contentType = response.headers() String contentType = response.headers()
.firstValue("content-type") .firstValue("content-type")
.map(v -> v.split(";", 2)[0].trim()) .map(v -> v.split(";", 2)[0].trim())
.orElse("application/octet-stream"); .orElse("application/octet-stream");
long contentLength = response.headers() long contentLength = response.headers()
.firstValue("content-length") .firstValue("content-length")
.flatMap(ImageUrlToMinioMigrator::parseLongSafe) .flatMap(ImageUrlToMinioMigrator::parseLongSafe)
.orElse(-1L); .orElse(-1L);
String ext = extensionForContentType(contentType); String ext = extensionForContentType(contentType);
// Store under a stable key; adjust if you want per-merchant, hashed names, etc. // Store under a stable key; adjust if you want per-merchant, hashed names, etc.
String objectName = "products/" + p.getId() + "/main" + ext; String objectName = "products/" + p.getId() + "/main" + ext;
try (InputStream in = response.body()) { try (InputStream in = response.body()) {
PutObjectArgs.Builder put = PutObjectArgs.builder() PutObjectArgs.Builder put = PutObjectArgs.builder()
.bucket(bucket) .bucket(bucket)
.object(objectName) .object(objectName)
.contentType(contentType); .contentType(contentType);
if (contentLength >= 0) { if (contentLength >= 0) {
put.stream(in, contentLength, -1); put.stream(in, contentLength, -1);
} else { } else {
put.stream(in, -1, 10L * 1024 * 1024); put.stream(in, -1, 10L * 1024 * 1024);
} }
minioClient.putObject(put.build()); minioClient.putObject(put.build());
} }
return publicBaseUrl + "/" + bucket + "/" + objectName; return publicBaseUrl + "/" + bucket + "/" + objectName;
} }
private void ensureBucketExists() { private void ensureBucketExists() {
try { try {
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) { if (!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
} }
} catch (Exception e) { } catch (Exception e) {
throw new IllegalStateException("Unable to ensure MinIO bucket exists: " + bucket, e); throw new IllegalStateException("Unable to ensure MinIO bucket exists: " + bucket, e);
} }
} }
private boolean looksAlreadyMigrated(String url) { private boolean looksAlreadyMigrated(String url) {
String prefix = publicBaseUrl + "/" + bucket + "/"; String prefix = publicBaseUrl + "/" + bucket + "/";
return url.startsWith(prefix); return url.startsWith(prefix);
} }
private static Optional<Long> parseLongSafe(String v) { private static Optional<Long> parseLongSafe(String v) {
try { try {
return Optional.of(Long.parseLong(v)); return Optional.of(Long.parseLong(v));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
} }
private static String extensionForContentType(String contentType) { private static String extensionForContentType(String contentType) {
String ct = contentType.toLowerCase(Locale.ROOT); String ct = contentType.toLowerCase(Locale.ROOT);
if (ct.equals("image/jpeg") || ct.equals("image/jpg")) return ".jpg"; if (ct.equals("image/jpeg") || ct.equals("image/jpg")) return ".jpg";
if (ct.equals("image/png")) return ".png"; if (ct.equals("image/png")) return ".png";
if (ct.equals("image/webp")) return ".webp"; if (ct.equals("image/webp")) return ".webp";
if (ct.equals("image/gif")) return ".gif"; if (ct.equals("image/gif")) return ".gif";
if (ct.equals("image/svg+xml")) return ".svg"; if (ct.equals("image/svg+xml")) return ".svg";
return ".bin"; return ".bin";
} }
private static String trimTrailingSlash(String s) { private static String trimTrailingSlash(String s) {
if (s == null) return ""; if (s == null) return "";
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
} }
} }

View File

@@ -1,28 +1,28 @@
package group.goforward.battlbuilder.service.utils; package group.goforward.battlbuilder.service.utils;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
@Profile("migrate-images-to-minio") @Profile("migrate-images-to-minio")
public class MigrateProductImagesToMinioRunner implements CommandLineRunner { public class MigrateProductImagesToMinioRunner implements CommandLineRunner {
private final ImageUrlToMinioMigrator migrator; private final ImageUrlToMinioMigrator migrator;
public MigrateProductImagesToMinioRunner(ImageUrlToMinioMigrator migrator) { public MigrateProductImagesToMinioRunner(ImageUrlToMinioMigrator migrator) {
this.migrator = migrator; this.migrator = migrator;
} }
@Override @Override
public void run(String... args) { public void run(String... args) {
// Tune as needed. Start small; you can remove maxItems once you're confident. // Tune as needed. Start small; you can remove maxItems once you're confident.
int migrated = migrator.migrateMainImages( int migrated = migrator.migrateMainImages(
200, // pageSize 200, // pageSize
false, // dryRun false, // dryRun
1000 // maxItems safety cap 1000 // maxItems safety cap
); );
System.out.println("Migrated product images: " + migrated); System.out.println("Migrated product images: " + migrated);
} }
} }

Some files were not shown because too many files have changed in this diff Show More