diff --git a/src/main/java/group/goforward/battlbuilder/controllers/BuildV1Controller.java b/src/main/java/group/goforward/battlbuilder/controllers/BuildV1Controller.java new file mode 100644 index 0000000..1f3c9fe --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/controllers/BuildV1Controller.java @@ -0,0 +1,76 @@ +package group.goforward.battlbuilder.controllers; + +import group.goforward.battlbuilder.services.BuildService; +import group.goforward.battlbuilder.web.dto.BuildDto; +import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; +import group.goforward.battlbuilder.web.dto.BuildSummaryDto; +import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@CrossOrigin +@RestController +@RequestMapping("/api/v1/builds") +public class BuildV1Controller { + + private final BuildService buildService; + + public BuildV1Controller(BuildService buildService) { + this.buildService = buildService; + } + + /** + * Public builds feed for /builds page. + * GET /api/v1/builds?limit=50 + */ + @GetMapping + public ResponseEntity> listPublicBuilds( + @RequestParam(name = "limit", required = false, defaultValue = "50") Integer limit + ) { + return ResponseEntity.ok(buildService.listPublicBuilds(limit == null ? 50 : limit)); + } + + /** + * Vault builds (authenticated user). + * GET /api/v1/builds/me?limit=100 + */ + @GetMapping("/me") + public ResponseEntity> listMyBuilds( + @RequestParam(name = "limit", required = false, defaultValue = "100") Integer limit + ) { + return ResponseEntity.ok(buildService.listMyBuilds(limit == null ? 100 : limit)); + } + + /** + * Load a single build (Vault edit + Builder ?load=uuid). + * GET /api/v1/builds/me/{uuid} + */ + @GetMapping("/me/{uuid}") + public ResponseEntity getMyBuild(@PathVariable("uuid") UUID uuid) { + return ResponseEntity.ok(buildService.getMyBuild(uuid)); + } + + /** + * Create a NEW build in Vault (Save As…). + * POST /api/v1/builds/me + */ + @PostMapping("/me") + public ResponseEntity createMyBuild(@RequestBody UpdateBuildRequest req) { + return ResponseEntity.ok(buildService.createMyBuild(req)); + } + + /** + * Update build (authenticated user; must own build eventually). + * PUT /api/v1/builds/me/{uuid} + */ + @PutMapping("/me/{uuid}") + public ResponseEntity updateMyBuild( + @PathVariable("uuid") UUID uuid, + @RequestBody UpdateBuildRequest req + ) { + return ResponseEntity.ok(buildService.updateMyBuild(uuid, req)); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java b/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java index 7291c87..0ec8825 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java @@ -1,172 +1,56 @@ package group.goforward.battlbuilder.controllers; -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.model.ProductOffer; -import group.goforward.battlbuilder.repos.ProductOfferRepository; -import group.goforward.battlbuilder.repos.ProductRepository; import group.goforward.battlbuilder.web.dto.ProductOfferDto; import group.goforward.battlbuilder.web.dto.ProductSummaryDto; -import group.goforward.battlbuilder.web.mapper.ProductMapper; -import org.springframework.cache.annotation.Cacheable; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.math.BigDecimal; -import java.util.*; -import java.util.stream.Collectors; +import java.util.List; +/** + * LEGACY CONTROLLER (Deprecated) + * + * Do not add new features here. + * Canonical API lives in ProductV1Controller (/api/v1/products). + * + * This exists only to keep older clients working temporarily. + * Disable by default using: + * app.api.legacy.enabled=false + * + * NOTE: + * Even when disabled, Spring still compiles this class. So it must not reference + * missing services/methods. + */ +@Deprecated @RestController @RequestMapping("/api/products") @CrossOrigin +@ConditionalOnProperty(name = "app.api.legacy.enabled", havingValue = "true", matchIfMissing = false) public class ProductController { - private final ProductRepository productRepository; - private final ProductOfferRepository productOfferRepository; + private static final String MSG = + "Legacy endpoint disabled. Use /api/v1/products instead."; - public ProductController( - ProductRepository productRepository, - ProductOfferRepository productOfferRepository - ) { - this.productRepository = productRepository; - this.productOfferRepository = productOfferRepository; - } - - /** - * List products for the builder, filterable by platform + partRoles. - * - * Examples: - * - GET /api/products?platform=AR-15 - * - GET /api/products?platform=AR-15&partRoles=upper-receiver&partRoles=upper - * - GET /api/products?platform=ALL (no platform filter) - * - GET /api/products?platform=ALL&partRoles=magazine - */ @GetMapping - @Cacheable( - value = "gunbuilderProducts", - key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())" - ) - public List getProducts( + public ResponseEntity getProducts( @RequestParam(defaultValue = "AR-15") String platform, @RequestParam(required = false, name = "partRoles") List partRoles ) { - final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL"); - - long started = System.currentTimeMillis(); - System.out.println("getProducts: start, platform=" + platform + - ", allPlatforms=" + allPlatforms + - ", partRoles=" + (partRoles == null ? "null" : partRoles)); - - // 1) Load products (with brand pre-fetched) - long tProductsStart = System.currentTimeMillis(); - List products; - - if (partRoles == null || partRoles.isEmpty()) { - products = allPlatforms - ? productRepository.findAllWithBrand() - : productRepository.findByPlatformWithBrand(platform); - } else { - products = allPlatforms - ? productRepository.findByPartRoleInWithBrand(partRoles) - : productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles); - } - - long tProductsEnd = System.currentTimeMillis(); - System.out.println("getProducts: loaded products: " + - products.size() + " in " + (tProductsEnd - tProductsStart) + " ms"); - - if (products.isEmpty()) { - long took = System.currentTimeMillis() - started; - System.out.println("getProducts: 0 products in " + took + " ms"); - return List.of(); - } - - // 2) Load offers for these product IDs - long tOffersStart = System.currentTimeMillis(); - List productIds = products.stream() - .map(Product::getId) - .toList(); - - List allOffers = - productOfferRepository.findByProductIdIn(productIds); - - long tOffersEnd = System.currentTimeMillis(); - System.out.println("getProducts: loaded offers: " + - allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms"); - - Map> offersByProductId = allOffers.stream() - .collect(Collectors.groupingBy(o -> o.getProduct().getId())); - - // 3) Map to DTOs with price and buyUrl - long tMapStart = System.currentTimeMillis(); - List result = products.stream() - .map(p -> { - List offersForProduct = - offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); - - ProductOffer bestOffer = pickBestOffer(offersForProduct); - - BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; - String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; - - return ProductMapper.toSummary(p, price, buyUrl); - }) - .toList(); - - long tMapEnd = System.currentTimeMillis(); - long took = System.currentTimeMillis() - started; - - System.out.println("getProducts: mapping to DTOs took " + - (tMapEnd - tMapStart) + " ms"); - System.out.println("getProducts: TOTAL " + took + " ms (" + - "products=" + (tProductsEnd - tProductsStart) + " ms, " + - "offers=" + (tOffersEnd - tOffersStart) + " ms, " + - "map=" + (tMapEnd - tMapStart) + " ms)"); - - return result; + // Legacy disabled by design (Option B cleanup) + return ResponseEntity.status(410).body(MSG); } @GetMapping("/{id}/offers") - public List getOffersForProduct(@PathVariable("id") Integer productId) { - List offers = productOfferRepository.findByProductId(productId); - - return offers.stream() - .map(offer -> { - ProductOfferDto dto = new ProductOfferDto(); - dto.setId(offer.getId().toString()); - dto.setMerchantName(offer.getMerchant().getName()); - dto.setPrice(offer.getEffectivePrice()); - dto.setOriginalPrice(offer.getOriginalPrice()); - dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); - dto.setBuyUrl(offer.getBuyUrl()); - dto.setLastUpdated(offer.getLastSeenAt()); - return dto; - }) - .toList(); - } - - private ProductOffer pickBestOffer(List offers) { - if (offers == null || offers.isEmpty()) return null; - - // Right now: lowest price wins, regardless of stock - return offers.stream() - .filter(o -> o.getEffectivePrice() != null) - .min(Comparator.comparing(ProductOffer::getEffectivePrice)) - .orElse(null); + public ResponseEntity getOffersForProduct(@PathVariable("id") Integer productId) { + return ResponseEntity.status(410).body(MSG); } @GetMapping("/{id}") - public ResponseEntity getProductById(@PathVariable("id") Integer productId) { - return productRepository.findById(productId) - .map(product -> { - List offers = productOfferRepository.findByProductId(productId); - ProductOffer bestOffer = pickBestOffer(offers); - - BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; - String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; - - return ProductMapper.toSummary(product, price, buyUrl); - }) - .map(ResponseEntity::ok) - .orElseGet(() -> ResponseEntity.notFound().build()); + public ResponseEntity getProductById(@PathVariable("id") Integer productId) { + return ResponseEntity.status(410).body(MSG); } + + // If you *really* need typed responses for an old client, we can re-add + // a real service layer once we align on the actual ProductQueryService API. } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java b/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java index cff9406..ef0a082 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java @@ -1,703 +1,45 @@ package group.goforward.battlbuilder.controllers; -import group.goforward.battlbuilder.model.Build; -import group.goforward.battlbuilder.model.BuildItem; -import group.goforward.battlbuilder.model.Product; -import group.goforward.battlbuilder.model.ProductOffer; -import group.goforward.battlbuilder.repos.BuildItemRepository; -import group.goforward.battlbuilder.repos.BuildRepository; -import group.goforward.battlbuilder.repos.ProductOfferRepository; -import group.goforward.battlbuilder.repos.ProductRepository; -import group.goforward.battlbuilder.web.dto.BuildCreateRequest; -import group.goforward.battlbuilder.web.dto.BuildDto; -import group.goforward.battlbuilder.web.dto.BuildItemDto; -import group.goforward.battlbuilder.web.dto.ProductDto; +import group.goforward.battlbuilder.services.ProductQueryService; import group.goforward.battlbuilder.web.dto.ProductOfferDto; +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; +import org.springframework.cache.annotation.Cacheable; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -import java.util.*; -import java.util.stream.Collectors; +import java.util.List; -@CrossOrigin @RestController @RequestMapping("/api/v1/products") +@CrossOrigin public class ProductV1Controller { - private final ProductRepository productRepository; - private final ProductOfferRepository productOfferRepository; + private final ProductQueryService productQueryService; - // ✅ Builds - private final BuildRepository buildRepository; - private final BuildItemRepository buildItemRepository; - - public ProductV1Controller( - ProductRepository productRepository, - ProductOfferRepository productOfferRepository, - BuildRepository buildRepository, - BuildItemRepository buildItemRepository - ) { - this.productRepository = productRepository; - this.productOfferRepository = productOfferRepository; - this.buildRepository = buildRepository; - this.buildItemRepository = buildItemRepository; + public ProductV1Controller(ProductQueryService productQueryService) { + this.productQueryService = productQueryService; } - // ========================================================================= - // PRODUCTS - // ========================================================================= - - /** - * List products (v1 summary contract) - * Keep this lightweight for grids/lists. - */ @GetMapping - public List listProducts( - @RequestParam(name = "platform", required = false) String platform, - @RequestParam(name = "partRoles", required = false) List partRoles + @Cacheable( + value = "gunbuilderProductsV1", + key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())" + ) + public List getProducts( + @RequestParam(defaultValue = "AR-15") String platform, + @RequestParam(required = false, name = "partRoles") List partRoles ) { - boolean allPlatforms = - (platform == null || platform.isBlank() || platform.equalsIgnoreCase("ALL")); - - List products; - - if (partRoles == null || partRoles.isEmpty()) { - products = allPlatforms - ? productRepository.findAllWithBrand() - : productRepository.findByPlatformWithBrand(platform); - } else { - List roles = partRoles.stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .toList(); - - if (roles.isEmpty()) { - products = allPlatforms - ? productRepository.findAllWithBrand() - : productRepository.findByPlatformWithBrand(platform); - } else { - products = allPlatforms - ? productRepository.findByPartRoleInWithBrand(roles) - : productRepository.findByPlatformAndPartRoleInWithBrand(platform, roles); - } - } - - if (products.isEmpty()) return List.of(); - - List productIds = products.stream().map(Product::getId).toList(); - List offers = productOfferRepository.findByProductIdIn(productIds); - - Map> offersByProductId = offers.stream() - .collect(Collectors.groupingBy(o -> o.getProduct().getId())); - - return products.stream() - .map(p -> { - ProductOffer best = pickBestOffer(offersByProductId.get(p.getId())); - return toProductDtoSummary(p, best); - }) - .toList(); + return productQueryService.getProducts(platform, partRoles); + } + + @GetMapping("/{id}/offers") + public List getOffersForProduct(@PathVariable("id") Integer productId) { + return productQueryService.getOffersForProduct(productId); } - /** - * Product details (v1 contract) - * This endpoint is allowed to be "fat" for the details page. - */ @GetMapping("/{id}") - public ResponseEntity getProduct(@PathVariable("id") String id) { - Integer productId = parsePositiveInt(id); - if (productId == null) return ResponseEntity.badRequest().build(); - - Optional productOpt = productRepository.findById(productId); - if (productOpt.isEmpty()) return ResponseEntity.notFound().build(); - - Product product = productOpt.get(); - - // Pull offers + merchant - List offers = productOfferRepository.findByProductId(productId); - ProductOffer best = pickBestOffer(offers); - - ProductDto dto = toProductDtoDetails(product, best, offers); - return ResponseEntity.ok(dto); - } - - // ========================================================================= - // ME BUILDS (TEMP HOME INSIDE ProductV1Controller) - // NOTE: Routes become /api/v1/products/me/builds (works now, we can refactor later) - // ========================================================================= - - /** - * MAIN FUNCTIONALITY: - * Create/save a build for the current logged-in user. - * - * This expects resolveUserId(auth) to return an Integer userId. - */ - @PostMapping("/me/builds") - public ResponseEntity createBuild( - Authentication auth, - @RequestBody BuildCreateRequest req - ) { - Integer userId = resolveUserId(auth); - if (userId == null) return ResponseEntity.status(401).build(); - - if (req == null || req.getTitle() == null || req.getTitle().isBlank()) { - return ResponseEntity.badRequest().build(); - } - - Build b = new Build(); - b.setUserId(userId); - b.setTitle(req.getTitle().trim()); - b.setDescription(req.getDescription()); - b.setIsPublic(req.getIsPublic() != null && req.getIsPublic()); - - b = buildRepository.save(b); - - // Save items - List items = - req.getItems() != null ? req.getItems() : List.of(); - - // Prefetch products in one pass (avoid N+1) - Set productIds = items.stream() - .map(BuildCreateRequest.BuildItemCreateRequest::getProductId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - - Map productsById = productIds.isEmpty() - ? Map.of() - : productRepository.findAllById(productIds).stream() - .collect(Collectors.toMap(Product::getId, p -> p)); - - for (BuildCreateRequest.BuildItemCreateRequest it : items) { - if (it == null) continue; - if (it.getProductId() == null) continue; - if (it.getSlot() == null || it.getSlot().isBlank()) continue; - - Product p = productsById.get(it.getProductId()); - if (p == null) continue; - - BuildItem bi = new BuildItem(); - bi.setBuild(b); - bi.setProduct(p); - bi.setSlot(it.getSlot().trim()); - bi.setPosition(it.getPosition() != null ? it.getPosition() : 0); - bi.setQuantity(it.getQuantity() != null ? it.getQuantity() : 1); - - buildItemRepository.save(bi); - } - - List savedItems = buildItemRepository.findByBuild_Id(b.getId()); - return ResponseEntity.ok(toBuildDto(b, savedItems)); - } - - /** - * MAIN FUNCTIONALITY: - * List builds for the current user (lightweight list; no items). - * - * NOTE: For performance, you should add a real repo method later like: - * findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(userId) - */ - @GetMapping("/me/builds") - public ResponseEntity> listMyBuilds(Authentication auth) { - Integer userId = resolveUserId(auth); - if (userId == null) return ResponseEntity.status(401).build(); - - List mine = buildRepository.findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(userId); - return ResponseEntity.ok(mine.stream().map(this::toBuildDtoNoItems).toList()); - } - - - @PutMapping("/me/builds/{uuid}") - public ResponseEntity replaceBuildItems( - Authentication auth, - @PathVariable("uuid") String uuidRaw, - @RequestBody BuildCreateRequest req - ) { - Integer userId = resolveUserId(auth); - if (userId == null) return ResponseEntity.status(401).build(); - - UUID uuid; - try { - uuid = UUID.fromString(uuidRaw); - } catch (Exception e) { - return ResponseEntity.badRequest().build(); - } - - Optional opt = buildRepository.findByUuid(uuid); - if (opt.isEmpty()) return ResponseEntity.notFound().build(); - - Build b = opt.get(); - if (b.getDeletedAt() != null) return ResponseEntity.notFound().build(); - - // ✅ Owner check - if (!Objects.equals(b.getUserId(), userId)) { - return ResponseEntity.status(403).build(); - } - - // Optional: allow updating build metadata too - if (req != null) { - if (req.getTitle() != null && !req.getTitle().isBlank()) { - b.setTitle(req.getTitle().trim()); - } - b.setDescription(req.getDescription()); - if (req.getIsPublic() != null) b.setIsPublic(req.getIsPublic()); - b = buildRepository.save(b); - } - - // ✅ Replace items: delete then insert - buildItemRepository.deleteByBuild_Id(b.getId()); - - List items = - (req != null && req.getItems() != null) ? req.getItems() : List.of(); - - Set productIds = items.stream() - .map(BuildCreateRequest.BuildItemCreateRequest::getProductId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - - Map productsById = productIds.isEmpty() - ? Map.of() - : productRepository.findAllById(productIds).stream() - .collect(Collectors.toMap(Product::getId, p -> p)); - - for (BuildCreateRequest.BuildItemCreateRequest it : items) { - if (it == null) continue; - if (it.getProductId() == null) continue; - if (it.getSlot() == null || it.getSlot().isBlank()) continue; - - Product p = productsById.get(it.getProductId()); - if (p == null) continue; - - BuildItem bi = new BuildItem(); - bi.setBuild(b); - bi.setProduct(p); - bi.setSlot(it.getSlot().trim()); - bi.setPosition(it.getPosition() != null ? it.getPosition() : 0); - bi.setQuantity(it.getQuantity() != null ? it.getQuantity() : 1); - - // IMPORTANT if your BuildItem doesn't have @PrePersist yet: - // bi.setCreatedAt(OffsetDateTime.now()); - - buildItemRepository.save(bi); - } - - List savedItems = buildItemRepository.findByBuild_Id(b.getId()); - return ResponseEntity.ok(toBuildDto(b, savedItems)); - } - - /** - * MAIN FUNCTIONALITY: - * Load build by UUID. - * - Owner can see it - * - Non-owner can only see if is_public = true - */ - @GetMapping("/builds/{uuid}") - public ResponseEntity getBuildByUuid( - Authentication auth, - @PathVariable("uuid") String uuidRaw - ) { - UUID uuid; - try { - uuid = UUID.fromString(uuidRaw); - } catch (Exception e) { - return ResponseEntity.badRequest().build(); - } - - Optional opt = buildRepository.findByUuid(uuid); - if (opt.isEmpty()) return ResponseEntity.notFound().build(); - - Build b = opt.get(); - if (b.getDeletedAt() != null) return ResponseEntity.notFound().build(); - - Integer userId = resolveUserId(auth); - boolean isOwner = userId != null && Objects.equals(b.getUserId(), userId); - boolean isPublic = Boolean.TRUE.equals(b.getIsPublic()); - - if (!isOwner && !isPublic) return ResponseEntity.status(403).build(); - - List items = buildItemRepository.findByBuild_Uuid(uuid); - return ResponseEntity.ok(toBuildDto(b, items)); - } - - - - // ========================================================================= - // HELPERS — Products - // ========================================================================= - - private static Integer parsePositiveInt(String raw) { - try { - int n = Integer.parseInt(raw); - return n > 0 ? n : null; - } catch (Exception e) { - return null; - } - } - - /** - * MVP: "best" means lowest effective price. - * (You can later enhance this to prefer in-stock, then price, etc.) - */ - private ProductOffer pickBestOffer(List offers) { - if (offers == null || offers.isEmpty()) return null; - - return offers.stream() - .filter(o -> o.getEffectivePrice() != null) - .min(Comparator.comparing(ProductOffer::getEffectivePrice)) - .orElse(null); - } - - /** - * Summary DTO for list/grid pages (keep small). - */ - private ProductDto toProductDtoSummary(Product p, ProductOffer bestOffer) { - ProductDto dto = new ProductDto(); - - dto.setId(String.valueOf(p.getId())); - dto.setName(p.getName()); - dto.setBrand(p.getBrand() != null ? p.getBrand().getName() : null); - dto.setPlatform(p.getPlatform()); - dto.setPartRole(p.getPartRole()); - dto.setCategoryKey(p.getRawCategoryKey()); - - dto.setPrice(bestOffer != null ? bestOffer.getEffectivePrice() : null); - dto.setBuyUrl(bestOffer != null ? bestOffer.getBuyUrl() : null); - dto.setInStock(bestOffer != null ? bestOffer.getInStock() : null); - - dto.setImageUrl(p.getMainImageUrl()); - return dto; - } - - /** - * Details DTO for product page (returns richer fields + offers table) - */ - private ProductDto toProductDtoDetails(Product p, ProductOffer bestOffer, List offers) { - ProductDto dto = new ProductDto(); - - dto.setId(String.valueOf(p.getId())); - dto.setName(p.getName()); - dto.setBrand(p.getBrand() != null ? p.getBrand().getName() : null); - dto.setPlatform(p.getPlatform()); - dto.setPartRole(p.getPartRole()); - dto.setCategoryKey(p.getRawCategoryKey()); - - dto.setPrice(bestOffer != null ? bestOffer.getEffectivePrice() : null); - dto.setBuyUrl(bestOffer != null ? bestOffer.getBuyUrl() : null); - dto.setInStock(bestOffer != null ? bestOffer.getInStock() : null); - - dto.setImageUrl(p.getMainImageUrl()); - dto.setMainImageUrl(p.getMainImageUrl()); - dto.setBattlImageUrl(p.getBattlImageUrl()); - - dto.setSlug(p.getSlug()); - dto.setMpn(p.getMpn()); - dto.setUpc(p.getUpc()); - dto.setConfiguration(p.getConfiguration() != null ? p.getConfiguration().name() : null); - dto.setPlatformLocked(p.getPlatformLocked()); - - String normalized = normalizeDescription(p.getDescription()); - dto.setDescription(normalized); - - String shortDesc = p.getShortDescription(); - if (shortDesc == null || shortDesc.isBlank()) { - shortDesc = deriveShortDescription(normalized); - } - dto.setShortDescription(shortDesc); - - List offerDtos = (offers == null ? List.of() : offers) - .stream() - .map(this::toOfferDto) - .toList(); - - dto.setOffers(offerDtos); - return dto; - } - - private ProductOfferDto toOfferDto(ProductOffer o) { - ProductOfferDto dto = new ProductOfferDto(); - - dto.setId(String.valueOf(o.getId())); - dto.setMerchantName(o.getMerchant() != null ? o.getMerchant().getName() : null); - dto.setPrice(o.getPrice()); - dto.setOriginalPrice(o.getOriginalPrice()); - dto.setInStock(Boolean.TRUE.equals(o.getInStock())); - dto.setBuyUrl(o.getBuyUrl()); - - // IMPORTANT: map lastSeenAt → lastUpdated - dto.setLastUpdated(o.getLastSeenAt()); - - return dto; - } - - // ========================================================================= - // HELPERS — Description normalization (feed -> readable bullets) - // ========================================================================= - - private static String normalizeDescription(String raw) { - if (raw == null) return null; - - String s = raw.trim(); - if (s.isEmpty()) return null; - - s = s.replace("\r\n", "\n").replace("\r", "\n"); - s = s.replace("\t", " "); - s = collapseSpaces(s); - - s = unglueHeaders(s); - s = normalizeSectionHeaders(s); - s = bulletizeContent(s); - - int MAX = 8000; - if (s.length() > MAX) { - s = s.substring(0, MAX).trim() + "…"; - } - - return s.trim(); - } - - private static String deriveShortDescription(String normalizedDescription) { - if (normalizedDescription == null || normalizedDescription.isBlank()) return null; - - String[] lines = normalizedDescription.split("\n"); - for (String line : lines) { - String t = line.trim(); - if (t.isEmpty()) continue; - if (isHeaderLine(t)) continue; - - if (t.startsWith("- ")) t = t.substring(2).trim(); - - int MAX = 220; - if (t.length() > MAX) t = t.substring(0, MAX).trim() + "…"; - return t; - } - - return null; - } - - private static boolean isHeaderLine(String line) { - String l = line.toLowerCase(Locale.ROOT).trim(); - return l.equals("features") - || l.equals("specifications") - || l.equals("specs") - || l.equals("includes") - || l.equals("notes") - || l.equals("overview"); - } - - private static String collapseSpaces(String s) { - return s.replaceAll("[ ]{2,}", " "); - } - - private static String unglueHeaders(String s) { - String[] headers = new String[]{"Features", "Specifications", "Specs", "Includes", "Overview", "Notes"}; - for (String h : headers) { - s = s.replaceAll("(?i)\\b" + h + "(?=[A-Za-z0-9])", h + "\n"); - } - return s; - } - - private static String normalizeSectionHeaders(String s) { - s = s.replaceAll("(?i)\\s*(\\bFeatures\\b)\\s*", "\n\nFeatures\n"); - s = s.replaceAll("(?i)\\s*(\\bSpecifications\\b|\\bSpecs\\b)\\s*", "\n\nSpecifications\n"); - s = s.replaceAll("(?i)\\s*(\\bIncludes\\b)\\s*", "\n\nIncludes\n"); - s = s.replaceAll("(?i)\\s*(\\bOverview\\b)\\s*", "\n\nOverview\n"); - s = s.replaceAll("(?i)\\s*(\\bNotes\\b)\\s*", "\n\nNotes\n"); - s = s.replaceAll("\\n{3,}", "\n\n"); - return s.trim(); - } - - private static String bulletizeContent(String s) { - String[] lines = s.split("\n"); - StringBuilder out = new StringBuilder(); - - for (String line : lines) { - String t = line.trim(); - if (t.isEmpty()) { - out.append("\n"); - continue; - } - - if (isHeaderLine(t)) { - out.append(t).append("\n"); - continue; - } - - if (t.startsWith("- ") || t.startsWith("• ")) { - out.append(t.startsWith("• ") ? "- " + t.substring(2).trim() : t).append("\n"); - continue; - } - - List parts = splitOnSeparators(t); - if (parts.size() == 1) { - parts = splitHeuristic(parts.get(0)); - } - - if (parts.size() > 1) { - for (String p : parts) { - String bp = p.trim(); - if (bp.isEmpty()) continue; - out.append("- ").append(bp).append("\n"); - } - } else { - out.append(t).append("\n"); - } - } - - return out.toString().replaceAll("\\n{3,}", "\n\n").trim(); - } - - private static List splitOnSeparators(String line) { - String normalized = line.replace("•", "|"); - String[] raw = normalized.split("\\s*[;|]\\s*"); - List parts = new ArrayList<>(); - for (String r : raw) { - String t = r.trim(); - if (!t.isEmpty()) parts.add(t); - } - return parts; - } - - private static List splitHeuristic(String line) { - String t = line.trim(); - if (t.length() < 180) return List.of(t); - - List parts = new ArrayList<>(); - String[] chunks = t.split("(? MAX_BULLETS) { - parts = parts.subList(0, MAX_BULLETS); - parts.add("…"); - } - - return parts; - } - - // ========================================================================= - // HELPERS — Build DTO mapping + auth user resolution - // ========================================================================= - - private BuildDto toBuildDtoNoItems(Build b) { - BuildDto dto = new BuildDto(); - dto.setId(String.valueOf(b.getId())); - dto.setUuid(b.getUuid()); - dto.setTitle(b.getTitle()); - dto.setDescription(b.getDescription()); - dto.setIsPublic(b.getIsPublic()); - dto.setCreatedAt(b.getCreatedAt()); - dto.setUpdatedAt(b.getUpdatedAt()); - dto.setItems(null); - return dto; - } - - private BuildDto toBuildDto(Build b, List items) { - BuildDto dto = toBuildDtoNoItems(b); - - List itemDtos = (items == null ? List.of() : items).stream() - .filter(Objects::nonNull) - .sorted(Comparator.comparing(BuildItem::getSlot) - .thenComparing(BuildItem::getPosition)) - .map(this::toBuildItemDto) - .toList(); - - dto.setItems(itemDtos); - return dto; - } - - private BuildItemDto toBuildItemDto(BuildItem bi) { - BuildItemDto dto = new BuildItemDto(); - dto.setId(String.valueOf(bi.getId())); - dto.setUuid(bi.getUuid()); - dto.setSlot(bi.getSlot()); - dto.setPosition(bi.getPosition()); - dto.setQuantity(bi.getQuantity()); - - Product p = bi.getProduct(); - if (p != null) { - dto.setProductId(String.valueOf(p.getId())); - dto.setProductName(p.getName()); - dto.setProductBrand(p.getBrand() != null ? p.getBrand().getName() : null); - dto.setProductImageUrl(p.getBattlImageUrl() != null ? p.getBattlImageUrl() : p.getMainImageUrl()); - } - - return dto; - } - - /** - * MAIN FUNCTIONALITY (TEMP): - * Resolve the logged-in user's id. - * - * Replace with your real principal logic. - * - * Common options: - * 1) auth.getName() returns email -> lookup userId by email via UserRepository - * 2) principal is a custom UserDetails that exposes getId() - */ -// private Integer resolveUserId(Authentication auth) { -// if (auth == null || !auth.isAuthenticated()) return null; -// -// Object principal = auth.getPrincipal(); -// if (principal == null) return null; -// -// // 1) If your principal exposes getId() (custom UserDetails) -// try { -// var m = principal.getClass().getMethod("getId"); -// Object id = m.invoke(principal); -// if (id instanceof Integer i) return i; -// if (id instanceof Long l) return Math.toIntExact(l); -// if (id != null) return Integer.parseInt(String.valueOf(id)); -// } catch (Exception ignored) { } -// -// // 2) If you store userId on "details" -// try { -// Object details = auth.getDetails(); -// if (details != null) { -// var m = details.getClass().getMethod("getId"); -// Object id = m.invoke(details); -// if (id instanceof Integer i) return i; -// if (id instanceof Long l) return Math.toIntExact(l); -// if (id != null) return Integer.parseInt(String.valueOf(id)); -// } -// } catch (Exception ignored) { } -// -// // 3) If auth.getName() is actually a numeric id (some JWT setups do this) -// try { -// String name = auth.getName(); -// if (name != null && name.matches("^\\d+$")) { -// return Integer.parseInt(name); -// } -// } catch (Exception ignored) { } -// -// // 4) If principal is a Map-like structure with "userId"/"id" (some token decoders) -// if (principal instanceof Map map) { -// Object id = map.get("userId"); -// if (id == null) id = map.get("id"); -// if (id != null) { -// try { return Integer.parseInt(String.valueOf(id)); } catch (Exception ignored) {} -// } -// } -// System.out.println("PRINCIPAL=" + auth.getPrincipal().getClass() + " :: " + auth.getPrincipal()); -// System.out.println("NAME=" + auth.getName()); -// return null; -// } - private Integer resolveUserId(Authentication auth) { - // DEV MODE ONLY - // TODO: Replace with above once auth is wired end-to-end - - return 3; // <- pick a user_id that exists in your users table + public ResponseEntity getProductById(@PathVariable("id") Integer productId) { + ProductSummaryDto dto = productQueryService.getProductById(productId); + return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build(); } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/BuildProfile.java b/src/main/java/group/goforward/battlbuilder/model/BuildProfile.java new file mode 100644 index 0000000..cfac638 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/model/BuildProfile.java @@ -0,0 +1,122 @@ +package group.goforward.battlbuilder.model; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; + +/** + * build_profiles + * 1:1 with builds (build_id is both PK and FK) + * + * Dev notes: + * - This is the "feed/meta" table for Option B (caliber, class, cover image, tags, etc.) + * - Keep it lightweight. Anything social (votes/comments/media) lives elsewhere. + */ +@Entity +@Table(name = "build_profiles") +public class BuildProfile { + + // ----------------------------------------------------- + // Primary Key = FK to builds.id + // ----------------------------------------------------- + @Id + @Column(name = "build_id", nullable = false) + private Integer buildId; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @MapsId + @JoinColumn(name = "build_id", nullable = false) + private Build build; + + // ----------------------------------------------------- + // Feed metadata fields (MVP) + // ----------------------------------------------------- + + /** + * Examples: "AR-15", "AR-10", "AR-9" + * (String for now; we can enum later once stable.) + */ + @Column(name = "platform") + private String platform; + + /** + * Examples: "5.56", "9mm", ".300 BLK" + */ + @Column(name = "caliber") + private String caliber; + + /** + * Expected values for UI: "Rifle" | "Pistol" | "NFA" + * (String for now; UI will default if missing.) + */ + @Column(name = "build_class") + private String buildClass; + + /** + * Optional hero image used by /builds cards. + */ + @Column(name = "cover_image_url") + private String coverImageUrl; + + /** + * MVP tags storage: + * - store as comma-separated string: "Duty,NV-Ready,LPVO" + * - later: switch to jsonb or join table when needed + */ + @Column(name = "tags_csv") + private String tagsCsv; + + // ----------------------------------------------------- + // Timestamps (optional but nice for auditing) + // ----------------------------------------------------- + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + public void prePersist() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + } + + @PreUpdate + public void preUpdate() { + updatedAt = OffsetDateTime.now(); + } + + // ----------------------------------------------------- + // Getters / Setters + // ----------------------------------------------------- + public Integer getBuildId() { return buildId; } + public void setBuildId(Integer buildId) { this.buildId = buildId; } + + public Build getBuild() { return build; } + public void setBuild(Build build) { this.build = build; } + + public String getPlatform() { return platform; } + public void setPlatform(String platform) { this.platform = platform; } + + public String getCaliber() { return caliber; } + public void setCaliber(String caliber) { this.caliber = caliber; } + + public String getBuildClass() { return buildClass; } + public void setBuildClass(String buildClass) { this.buildClass = buildClass; } + + public String getCoverImageUrl() { return coverImageUrl; } + public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; } + + public String getTagsCsv() { return tagsCsv; } + public void setTagsCsv(String tagsCsv) { this.tagsCsv = tagsCsv; } + + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/BuildItemRepository.java b/src/main/java/group/goforward/battlbuilder/repos/BuildItemRepository.java index 47a125f..aafe71a 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/BuildItemRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/BuildItemRepository.java @@ -3,19 +3,13 @@ package group.goforward.battlbuilder.repos; import group.goforward.battlbuilder.model.BuildItem; import org.springframework.data.jpa.repository.JpaRepository; -import jakarta.transaction.Transactional; - import java.util.List; -import java.util.UUID; public interface BuildItemRepository extends JpaRepository { - // main “load build” query - List findByBuild_Uuid(UUID buildUuid); + List findByBuild_IdIn(List buildIds); - // handy for cleanup or listing items List findByBuild_Id(Integer buildId); - @Transactional - void deleteByBuild_Id(Integer buildId); + long deleteByBuild_Id(Integer buildId); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/BuildProfileRepository.java b/src/main/java/group/goforward/battlbuilder/repos/BuildProfileRepository.java new file mode 100644 index 0000000..9a2cf1b --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/repos/BuildProfileRepository.java @@ -0,0 +1,12 @@ +package group.goforward.battlbuilder.repos; + +import group.goforward.battlbuilder.model.BuildProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; + +public interface BuildProfileRepository extends JpaRepository { + + List findByBuildIdIn(Collection buildIds); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/BuildRepository.java b/src/main/java/group/goforward/battlbuilder/repos/BuildRepository.java index c717f7f..ee84d47 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/BuildRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/BuildRepository.java @@ -1,21 +1,19 @@ package group.goforward.battlbuilder.repos; import group.goforward.battlbuilder.model.Build; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; import java.util.Optional; import java.util.UUID; public interface BuildRepository extends JpaRepository { - Optional findByUuid(UUID uuid); - // My builds list (excludes soft-deleted) - List findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(Integer userId); + Page findByIsPublicTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(Pageable pageable); + + // Temporary vault behavior until Build.user exists: + Page findByDeletedAtIsNullOrderByUpdatedAtDesc(Pageable pageable); - // Optional: if you want /me/builds/{uuid} fetch with ownership checks Optional findByUuidAndDeletedAtIsNull(UUID uuid); - - // Optional: owner-only lookups - Optional findByUuidAndUserIdAndDeletedAtIsNull(UUID uuid, Integer userId); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/ProductOfferRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ProductOfferRepository.java index b48e4b8..d90e870 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/ProductOfferRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/ProductOfferRepository.java @@ -9,16 +9,13 @@ import java.util.Collection; import java.util.List; import java.util.Optional; - public interface ProductOfferRepository extends JpaRepository { - List findByProductId(Integer productId); + List findByProduct_Id(Integer productId); - // Used by the /api/products/gunbuilder endpoint - List findByProductIdIn(Collection productIds); + List findByProduct_IdIn(Collection productIds); - // Unique offer lookup for importer upsert - Optional findByMerchantIdAndAvantlinkProductId( + Optional findByMerchant_IdAndAvantlinkProductId( Integer merchantId, String avantlinkProductId ); @@ -35,12 +32,10 @@ public interface ProductOfferRepository extends JpaRepository countByMerchantPlatformAndStatus(); @Query(""" - select po - from ProductOffer po - join fetch po.merchant - where po.product.id = :productId -""") - List findByProductIdWithMerchant( - @Param("productId") Integer productId - ); + select po + from ProductOffer po + join fetch po.merchant + where po.product.id = :productId + """) + List findByProductIdWithMerchant(@Param("productId") Integer productId); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/BuildService.java b/src/main/java/group/goforward/battlbuilder/services/BuildService.java new file mode 100644 index 0000000..1adcf3d --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/services/BuildService.java @@ -0,0 +1,22 @@ +package group.goforward.battlbuilder.services; + +import group.goforward.battlbuilder.web.dto.BuildDto; +import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; +import group.goforward.battlbuilder.web.dto.BuildSummaryDto; +import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; + +import java.util.List; +import java.util.UUID; + +public interface BuildService { + + List listPublicBuilds(int limit); + + List listMyBuilds(int limit); + + BuildDto getMyBuild(UUID uuid); + + BuildDto createMyBuild(UpdateBuildRequest req); + + BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/ProductQueryService.java b/src/main/java/group/goforward/battlbuilder/services/ProductQueryService.java new file mode 100644 index 0000000..98043d7 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/services/ProductQueryService.java @@ -0,0 +1,15 @@ +package group.goforward.battlbuilder.services; + +import group.goforward.battlbuilder.web.dto.ProductOfferDto; +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; + +import java.util.List; + +public interface ProductQueryService { + + List getProducts(String platform, List partRoles); + + List getOffersForProduct(Integer productId); + + ProductSummaryDto getProductById(Integer productId); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/BuildServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/BuildServiceImpl.java new file mode 100644 index 0000000..63bf2bc --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/services/impl/BuildServiceImpl.java @@ -0,0 +1,386 @@ +package group.goforward.battlbuilder.services.impl; + +import group.goforward.battlbuilder.model.Build; +import group.goforward.battlbuilder.model.BuildItem; +import group.goforward.battlbuilder.model.BuildProfile; +import group.goforward.battlbuilder.model.ProductOffer; +import group.goforward.battlbuilder.repos.BuildItemRepository; +import group.goforward.battlbuilder.repos.BuildProfileRepository; +import group.goforward.battlbuilder.repos.BuildRepository; +import group.goforward.battlbuilder.repos.ProductOfferRepository; +import group.goforward.battlbuilder.services.BuildService; +import group.goforward.battlbuilder.web.dto.BuildDto; +import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; +import group.goforward.battlbuilder.web.dto.BuildItemDto; +import group.goforward.battlbuilder.web.dto.BuildSummaryDto; +import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class BuildServiceImpl implements BuildService { + + private final BuildRepository buildRepository; + private final BuildProfileRepository buildProfileRepository; + private final BuildItemRepository buildItemRepository; + private final ProductOfferRepository productOfferRepository; + + public BuildServiceImpl( + BuildRepository buildRepository, + BuildProfileRepository buildProfileRepository, + BuildItemRepository buildItemRepository, + ProductOfferRepository productOfferRepository + ) { + this.buildRepository = buildRepository; + this.buildProfileRepository = buildProfileRepository; + this.buildItemRepository = buildItemRepository; + this.productOfferRepository = productOfferRepository; + } + + // --------------------------- + // Public feed (/builds) + // --------------------------- + + @Override + public List listPublicBuilds(int limit) { + int safeLimit = clamp(limit, 1, 100); + + List builds = buildRepository + .findByIsPublicTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(PageRequest.of(0, safeLimit)) + .getContent(); + + if (builds.isEmpty()) return List.of(); + + List buildIds = builds.stream().map(Build::getId).toList(); + + Map profileByBuildId = buildProfileRepository.findByBuildIdIn(buildIds) + .stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap(BuildProfile::getBuildId, p -> p)); + + List items = buildItemRepository.findByBuild_IdIn(buildIds); + + Map> itemsByBuildId = items.stream() + .filter(Objects::nonNull) + .filter(bi -> bi.getBuild() != null && bi.getBuild().getId() != null) + .collect(Collectors.groupingBy(bi -> bi.getBuild().getId())); + + Set productIds = items.stream() + .map(bi -> bi.getProduct() != null ? bi.getProduct().getId() : null) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Map bestPriceByProductId = loadBestPrices(productIds); + + return builds.stream() + .map(b -> { + BuildProfile p = profileByBuildId.get(b.getId()); + List its = itemsByBuildId.getOrDefault(b.getId(), List.of()); + + BigDecimal estDollars = computeEstimatedPriceDollars(its, bestPriceByProductId); + int estCents = dollarsToCents(estDollars); + + return toFeedCardDto(b, p, estCents); + }) + .toList(); + } + + // --------------------------- + // Vault list (/builds/me) + // --------------------------- + + @Override + public List listMyBuilds(int limit) { + int safeLimit = clamp(limit, 1, 200); + + // MVP: ownership not implemented yet -> return all non-deleted builds + List builds = buildRepository + .findByDeletedAtIsNullOrderByUpdatedAtDesc(PageRequest.of(0, safeLimit)) + .getContent(); + + if (builds.isEmpty()) return List.of(); + + return builds.stream().map(BuildSummaryDto::from).toList(); + } + + // --------------------------- + // Load one build (Vault edit / builder load) + // GET /api/v1/builds/me/{uuid} + // --------------------------- + + @Override + public BuildDto getMyBuild(UUID uuid) { + if (uuid == null) throw new IllegalArgumentException("uuid is required"); + + Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) + .orElseThrow(() -> new IllegalArgumentException("Build not found")); + + List items = buildItemRepository.findByBuild_Id(build.getId()); + return toBuildDto(build, items); + } + + // --------------------------- + // Create new build (Save As…) + // POST /api/v1/builds/me + // --------------------------- + + @Override + @Transactional + public BuildDto createMyBuild(UpdateBuildRequest req) { + if (req == null) throw new IllegalArgumentException("request body is required"); + + String title = (req.getTitle() == null || req.getTitle().isBlank()) + ? "Untitled Build" + : req.getTitle().trim(); + + Build build = new Build(); + build.setTitle(title); + build.setDescription(req.getDescription()); + build.setIsPublic(req.getIsPublic() != null ? req.getIsPublic() : Boolean.FALSE); + + if (build.getUuid() == null) build.setUuid(UUID.randomUUID()); + + Build saved = buildRepository.save(build); + + if (req.getItems() != null) { + List newItems = buildItemsFromRequest(saved, req.getItems()); + if (!newItems.isEmpty()) buildItemRepository.saveAll(newItems); + } + + List items = buildItemRepository.findByBuild_Id(saved.getId()); + return toBuildDto(saved, items); + } + + // --------------------------- + // Update build (Vault edit save) + // PUT /api/v1/builds/me/{uuid} + // --------------------------- + + @Override + @Transactional + public BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req) { + if (uuid == null) throw new IllegalArgumentException("uuid is required"); + if (req == null) throw new IllegalArgumentException("request body is required"); + + Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) + .orElseThrow(() -> new IllegalArgumentException("Build not found")); + + if (req.getTitle() != null) build.setTitle(req.getTitle().trim()); + if (req.getDescription() != null) build.setDescription(req.getDescription()); + if (req.getIsPublic() != null) build.setIsPublic(req.getIsPublic()); + + Build saved = buildRepository.save(build); + + if (req.getItems() != null) { + buildItemRepository.deleteByBuild_Id(saved.getId()); + List newItems = buildItemsFromRequest(saved, req.getItems()); + if (!newItems.isEmpty()) buildItemRepository.saveAll(newItems); + } + + List items = buildItemRepository.findByBuild_Id(saved.getId()); + return toBuildDto(saved, items); + } + + // --------------------------- + // BuildItem helper + // --------------------------- + + private List buildItemsFromRequest(Build build, List incoming) { + List out = new ArrayList<>(); + + for (UpdateBuildRequest.Item it : incoming) { + if (it == null) continue; + if (it.getProductId() == null) continue; + if (it.getSlot() == null || it.getSlot().isBlank()) continue; + + BuildItem bi = new BuildItem(); + bi.setBuild(build); + + // Product proxy by ID only + var product = new group.goforward.battlbuilder.model.Product(); + product.setId(it.getProductId()); + bi.setProduct(product); + + bi.setSlot(it.getSlot()); + bi.setPosition(it.getPosition() != null ? it.getPosition() : 0); + bi.setQuantity(it.getQuantity() != null && it.getQuantity() > 0 ? it.getQuantity() : 1); + + out.add(bi); + } + + return out; + } + + // --------------------------- + // DTO mapping (Build -> BuildDto) + // --------------------------- + + private BuildDto toBuildDto(Build build, List items) { + BuildDto dto = new BuildDto(); + dto.setId(build.getId() != null ? String.valueOf(build.getId()) : null); + dto.setUuid(build.getUuid()); + dto.setTitle(build.getTitle()); + dto.setDescription(build.getDescription()); + dto.setIsPublic(build.getIsPublic()); + dto.setCreatedAt(build.getCreatedAt()); + dto.setUpdatedAt(build.getUpdatedAt()); + + List itemDtos = (items == null ? List.of() : items).stream() + .filter(Objects::nonNull) + .filter(bi -> bi.getDeletedAt() == null) + .map(bi -> { + BuildItemDto it = new BuildItemDto(); + + it.setId(bi.getId() != null ? String.valueOf(bi.getId()) : null); + it.setUuid(bi.getUuid()); + it.setSlot(bi.getSlot()); + it.setPosition(bi.getPosition()); + it.setQuantity(bi.getQuantity()); + + // BuildItemDto.productId is String + it.setProductId( + (bi.getProduct() != null && bi.getProduct().getId() != null) + ? String.valueOf(bi.getProduct().getId()) + : null + ); + + // Optional / safe defaults for now + it.setProductName(null); + it.setProductBrand(null); + it.setProductImageUrl(null); + + return it; + }) + .toList(); + + dto.setItems(itemDtos); + return dto; + } + + // --------------------------- + // Price helpers (feed) + // --------------------------- + + private Map loadBestPrices(Set productIds) { + if (productIds == null || productIds.isEmpty()) return Map.of(); + + List offers = productOfferRepository.findByProduct_IdIn(productIds); + + Map> offersByProductId = offers.stream() + .filter(Objects::nonNull) + .filter(o -> o.getProduct() != null && o.getProduct().getId() != null) + .collect(Collectors.groupingBy(o -> o.getProduct().getId())); + + Map best = new HashMap<>(); + + for (Map.Entry> e : offersByProductId.entrySet()) { + ProductOffer winner = pickBestOffer(e.getValue()); + if (winner != null && winner.getEffectivePrice() != null) { + best.put(e.getKey(), winner.getEffectivePrice()); + } + } + + return best; + } + + private BigDecimal computeEstimatedPriceDollars( + List items, + Map bestPriceByProductId + ) { + if (items == null || items.isEmpty()) return BigDecimal.ZERO; + + BigDecimal sum = BigDecimal.ZERO; + + for (BuildItem bi : items) { + if (bi == null) continue; + if (bi.getDeletedAt() != null) continue; + if (bi.getProduct() == null || bi.getProduct().getId() == null) continue; + + Integer pid = bi.getProduct().getId(); + int qty = (bi.getQuantity() != null && bi.getQuantity() > 0) ? bi.getQuantity() : 1; + + BigDecimal each = bestPriceByProductId.get(pid); + if (each == null) continue; + + sum = sum.add(each.multiply(BigDecimal.valueOf(qty))); + } + + return sum.signum() < 0 ? BigDecimal.ZERO : sum; + } + + private static int dollarsToCents(BigDecimal dollars) { + if (dollars == null) return 0; + + BigDecimal cents = dollars + .multiply(BigDecimal.valueOf(100)) + .setScale(0, RoundingMode.HALF_UP); + + if (cents.signum() < 0) return 0; + + long asLong = cents.longValue(); + if (asLong > Integer.MAX_VALUE) return Integer.MAX_VALUE; + return (int) asLong; + } + + private ProductOffer pickBestOffer(List offers) { + if (offers == null || offers.isEmpty()) return null; + + return offers.stream() + .filter(o -> o.getEffectivePrice() != null) + .min(Comparator.comparing(ProductOffer::getEffectivePrice)) + .orElse(null); + } + + private BuildFeedCardDto toFeedCardDto(Build b, BuildProfile p, int estPriceCents) { + BuildFeedCardDto dto = new BuildFeedCardDto(); + + dto.setUuid(b.getUuid()); + dto.setTitle(b.getTitle()); + dto.setSlug(b.getUuid() != null ? b.getUuid().toString() : null); + dto.setCreator("anonymous"); + + if (p != null) { + dto.setCaliber(blankToNull(p.getCaliber())); + dto.setBuildClass(blankToNull(p.getBuildClass())); + dto.setCoverImageUrl(blankToNull(p.getCoverImageUrl())); + dto.setTags(parseTags(p.getTagsCsv())); + } else { + dto.setCaliber(null); + dto.setBuildClass(null); + dto.setCoverImageUrl(null); + dto.setTags(List.of()); + } + + dto.setPrice(estPriceCents); + dto.setVotes(0); + + return dto; + } + + private static List parseTags(String tagsCsv) { + if (tagsCsv == null || tagsCsv.isBlank()) return List.of(); + + return Arrays.stream(tagsCsv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .distinct() + .limit(12) + .toList(); + } + + private static String blankToNull(String s) { + if (s == null) return null; + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static int clamp(int n, int min, int max) { + return Math.max(min, Math.min(max, n)); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java index 1ca8df6..1f58d46 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java @@ -309,7 +309,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } ProductOffer offer = productOfferRepository - .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .findByMerchant_IdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) .orElseGet(ProductOffer::new); if (offer.getId() == null) { @@ -394,7 +394,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService if (avantlinkProductId == null || avantlinkProductId.isBlank()) return; ProductOffer offer = productOfferRepository - .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .findByMerchant_IdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) .orElse(null); if (offer == null) { diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/ProductQueryServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/ProductQueryServiceImpl.java new file mode 100644 index 0000000..d4cf2de --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/services/impl/ProductQueryServiceImpl.java @@ -0,0 +1,116 @@ +package group.goforward.battlbuilder.services.impl; + +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.model.ProductOffer; +import group.goforward.battlbuilder.repos.ProductOfferRepository; +import group.goforward.battlbuilder.repos.ProductRepository; +import group.goforward.battlbuilder.services.ProductQueryService; +import group.goforward.battlbuilder.web.dto.ProductOfferDto; +import group.goforward.battlbuilder.web.dto.ProductSummaryDto; +import group.goforward.battlbuilder.web.mapper.ProductMapper; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class ProductQueryServiceImpl implements ProductQueryService { + + private final ProductRepository productRepository; + private final ProductOfferRepository productOfferRepository; + + public ProductQueryServiceImpl( + ProductRepository productRepository, + ProductOfferRepository productOfferRepository + ) { + this.productRepository = productRepository; + this.productOfferRepository = productOfferRepository; + } + + @Override + public List getProducts(String platform, List partRoles) { + final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL"); + + List products; + if (partRoles == null || partRoles.isEmpty()) { + products = allPlatforms + ? productRepository.findAllWithBrand() + : productRepository.findByPlatformWithBrand(platform); + } else { + products = allPlatforms + ? productRepository.findByPartRoleInWithBrand(partRoles) + : productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles); + } + + if (products.isEmpty()) return List.of(); + + List productIds = products.stream().map(Product::getId).toList(); + + // ✅ canonical repo method + List allOffers = productOfferRepository.findByProduct_IdIn(productIds); + + Map> offersByProductId = allOffers.stream() + .filter(o -> o.getProduct() != null && o.getProduct().getId() != null) + .collect(Collectors.groupingBy(o -> o.getProduct().getId())); + + return products.stream() + .map(p -> { + List offersForProduct = + offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); + + ProductOffer bestOffer = pickBestOffer(offersForProduct); + + BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; + String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; + + return ProductMapper.toSummary(p, price, buyUrl); + }) + .toList(); + } + + @Override + public List getOffersForProduct(Integer productId) { + // ✅ canonical repo method + List offers = productOfferRepository.findByProduct_Id(productId); + + return offers.stream() + .map(offer -> { + ProductOfferDto dto = new ProductOfferDto(); + dto.setId(offer.getId().toString()); + dto.setMerchantName(offer.getMerchant().getName()); + dto.setPrice(offer.getEffectivePrice()); + dto.setOriginalPrice(offer.getOriginalPrice()); + dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); + dto.setBuyUrl(offer.getBuyUrl()); + dto.setLastUpdated(offer.getLastSeenAt()); + return dto; + }) + .toList(); + } + + @Override + public ProductSummaryDto getProductById(Integer productId) { + Product product = productRepository.findById(productId).orElse(null); + if (product == null) return null; + + // ✅ canonical repo method + List offers = productOfferRepository.findByProduct_Id(productId); + ProductOffer bestOffer = pickBestOffer(offers); + + BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; + String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; + + return ProductMapper.toSummary(product, price, buyUrl); + } + + private ProductOffer pickBestOffer(List offers) { + if (offers == null || offers.isEmpty()) return null; + + // MVP: lowest effective price wins. (Later: prefer in-stock, etc.) + return offers.stream() + .filter(o -> o.getEffectivePrice() != null) + .min(Comparator.comparing(ProductOffer::getEffectivePrice)) + .orElse(null); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/BuildFeedCardDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/BuildFeedCardDto.java new file mode 100644 index 0000000..c2828af --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/BuildFeedCardDto.java @@ -0,0 +1,86 @@ +package group.goforward.battlbuilder.web.dto; + +import java.util.List; +import java.util.UUID; + +/** + * Contract for /builds feed cards. + * Matches the Next.js BuildsPage expectations. + * + * Dev Notes: + * - We are using Option 2 for money: price is INTEGER CENTS (not dollars). + * Example: $19.99 -> 1999 + * - Keep the field name "price" for UI continuity for now. + * (We can rename to priceCents later with a coordinated UI change.) + * - "buildClass" is a string for now ("Rifle" | "Pistol" | "NFA"). + * Later we can move to enum if/when we want strict validation. + */ +public class BuildFeedCardDto { + + // Stable public identifier for routes + API consumers + private UUID uuid; + + // Display + private String title; + + // MVP: can just be uuid string; later can be derived from title (unique per creator) + private String slug; + + // Placeholder until user profiles exist + private String creator; + + // From BuildProfile (meta) + private String caliber; + + // "Rifle" | "Pistol" | "NFA" (string for MVP) + private String buildClass; + + /** + * Estimated build price in CENTS (Option 2). + * Example: 245000 == $2,450.00 + * + * IMPORTANT: + * - UI must divide by 100 for display. + * - Keep nullable optional; treat null as 0 in the UI. + */ + private Integer price; + + // Aggregated vote count (0 until votes table exists) + private Integer votes; + + // Tag strings (from profile meta) + private List tags; + + // Optional hero image for the card (from build_media or profile) + private String coverImageUrl; + + public UUID getUuid() { return uuid; } + public void setUuid(UUID uuid) { this.uuid = uuid; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + + public String getCreator() { return creator; } + public void setCreator(String creator) { this.creator = creator; } + + public String getCaliber() { return caliber; } + public void setCaliber(String caliber) { this.caliber = caliber; } + + public String getBuildClass() { return buildClass; } + public void setBuildClass(String buildClass) { this.buildClass = buildClass; } + + public Integer getPrice() { return price; } + public void setPrice(Integer price) { this.price = price; } + + public Integer getVotes() { return votes; } + public void setVotes(Integer votes) { this.votes = votes; } + + public List getTags() { return tags; } + public void setTags(List tags) { this.tags = tags; } + + public String getCoverImageUrl() { return coverImageUrl; } + public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/BuildSummaryDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/BuildSummaryDto.java new file mode 100644 index 0000000..bf445a2 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/BuildSummaryDto.java @@ -0,0 +1,54 @@ +package group.goforward.battlbuilder.web.dto; + +import group.goforward.battlbuilder.model.Build; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Lightweight list DTO for Vault ("My Builds"). + * Keep this stable for the UI even if entity changes. + */ +public class BuildSummaryDto { + + private Integer id; // internal DB id (optional for UI) + private UUID uuid; // stable public identifier + private String title; + private String description; + private Boolean isPublic; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public static BuildSummaryDto from(Build b) { + BuildSummaryDto dto = new BuildSummaryDto(); + dto.setId(b.getId()); + dto.setUuid(b.getUuid()); + dto.setTitle(b.getTitle()); + dto.setDescription(b.getDescription()); + dto.setIsPublic(b.getIsPublic()); + dto.setCreatedAt(b.getCreatedAt()); + dto.setUpdatedAt(b.getUpdatedAt()); + return dto; + } + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + + public UUID getUuid() { return uuid; } + public void setUuid(UUID uuid) { this.uuid = uuid; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Boolean getIsPublic() { return isPublic; } + public void setIsPublic(Boolean isPublic) { this.isPublic = isPublic; } + + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/UpdateBuildRequest.java b/src/main/java/group/goforward/battlbuilder/web/dto/UpdateBuildRequest.java new file mode 100644 index 0000000..bfc1cf1 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/UpdateBuildRequest.java @@ -0,0 +1,69 @@ +package group.goforward.battlbuilder.web.dto; + +import java.util.List; + +/** + * Request DTO for creating/updating Build + items. + * Keeps entity fields protected from client-side overposting. + * + * NOTE: + * - We reuse this for BOTH create (POST /me) and update (PUT /me/{uuid}) for MVP speed. + */ +public class UpdateBuildRequest { + + private String title; + private String description; + private Boolean isPublic; + + // Build items (builder slots) + private List items; + + // optional profile fields (if you update profile in same request later) + private String caliber; + private String buildClass; + private String coverImageUrl; + private List tags; + + public static class Item { + private Integer productId; + private String slot; + private Integer position; + private Integer quantity; + + public Integer getProductId() { return productId; } + public void setProductId(Integer productId) { this.productId = productId; } + + public String getSlot() { return slot; } + public void setSlot(String slot) { this.slot = slot; } + + public Integer getPosition() { return position; } + public void setPosition(Integer position) { this.position = position; } + + public Integer getQuantity() { return quantity; } + public void setQuantity(Integer quantity) { this.quantity = quantity; } + } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Boolean getIsPublic() { return isPublic; } + public void setIsPublic(Boolean isPublic) { this.isPublic = isPublic; } + + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + + public String getCaliber() { return caliber; } + public void setCaliber(String caliber) { this.caliber = caliber; } + + public String getBuildClass() { return buildClass; } + public void setBuildClass(String buildClass) { this.buildClass = buildClass; } + + public String getCoverImageUrl() { return coverImageUrl; } + public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; } + + public List getTags() { return tags; } + public void setTags(List tags) { this.tags = tags; } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 80e4153..75cbb60 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -46,3 +46,5 @@ minio.bucket=battlbuilds # Public base URL used to write back into products.main_image_url minio.public-base-url=https://minio.dev.gofwd.group +# --- Feature toggles --- +app.api.legacy.enabled=false \ No newline at end of file