From 6081bdbc0fa5a967d443f524ae20fb18e84f9903 Mon Sep 17 00:00:00 2001 From: Sean Date: Fri, 19 Dec 2025 05:42:20 -0500 Subject: [PATCH] added product description formatter, v1controller now includes all data needed for details page --- .../controllers/ProductV1Controller.java | 342 +++++++++++++++++- .../repos/ProductOfferRepository.java | 12 + .../battlbuilder/web/dto/ProductDto.java | 57 +++ .../battlbuilder/web/dto/ProductOfferDto.java | 1 + 4 files changed, 392 insertions(+), 20 deletions(-) diff --git a/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java b/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java index 5015d7b..3fe1536 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java @@ -5,10 +5,14 @@ import group.goforward.battlbuilder.model.ProductOffer; import group.goforward.battlbuilder.repos.ProductOfferRepository; import group.goforward.battlbuilder.repos.ProductRepository; import group.goforward.battlbuilder.web.dto.ProductDto; +import group.goforward.battlbuilder.web.dto.ProductOfferDto; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.*; import java.util.stream.Collectors; @@ -30,11 +34,7 @@ public class ProductV1Controller { /** * List products (v1 summary contract) - * - * Examples: - * - GET /api/v1/products?platform=AR-15 - * - GET /api/v1/products?platform=AR-15&partRoles=complete-upper&partRoles=upper-receiver - * - GET /api/v1/products?platform=ALL + * Keep this lightweight for grids/lists. */ @GetMapping public List listProducts( @@ -79,31 +79,254 @@ public class ProductV1Controller { return products.stream() .map(p -> { ProductOffer best = pickBestOffer(offersByProductId.get(p.getId())); - return toProductDto(p, best); + return toProductDtoSummary(p, best); }) .toList(); } /** * Product details (v1 contract) - * NOTE: accepts String to avoid MethodArgumentTypeMismatch on /undefined etc. + * 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(); - } + if (productId == null) return ResponseEntity.badRequest().build(); Optional productOpt = productRepository.findById(productId); - if (productOpt.isEmpty()) { - return ResponseEntity.notFound().build(); - } + if (productOpt.isEmpty()) return ResponseEntity.notFound().build(); + Product product = productOpt.get(); + + // Pull offers + merchant (your Hibernate log shows this join is happening) List offers = productOfferRepository.findByProductId(productId); ProductOffer best = pickBestOffer(offers); - return ResponseEntity.ok(toProductDto(productOpt.get(), best)); + ProductDto dto = toProductDtoDetails(product, best, offers); + return ResponseEntity.ok(dto); + } + + // --------------------------- + // Helpers + // --------------------------- + + + // --------------------------------------------------------------------- +// Description normalization (feed -> readable bullets) +// --------------------------------------------------------------------- + + /** + * Normalize ugly feed descriptions into readable, bullet-point friendly text. + * + * Goals: + * - Break common section headers onto their own lines (Features, Specs, Includes, etc.) + * - Convert "glued" headers like "FeaturesSub-MOA..." into "Features\n- Sub-MOA..." + * - Split run-on strings into bullets using separators (; | • \n) and heuristic sentence breaks + * - Keep it safe (no HTML parsing, no fancy dependencies) + */ + private static String normalizeDescription(String raw) { + if (raw == null) return null; + + String s = raw.trim(); + if (s.isEmpty()) return null; + + // Basic cleanup (some feeds stuff literal \u003E etc, but your example is mostly plain text) + s = s.replace("\r\n", "\n").replace("\r", "\n"); + s = s.replace("\t", " "); + s = collapseSpaces(s); + + // Fix the classic "FeaturesSub-MOA" glued header issue + s = unglueHeaders(s); + + // Add newlines around known headers to create sections + s = normalizeSectionHeaders(s); + + // If it already contains line breaks, we'll bullet-ify lines; otherwise split heuristically. + s = bulletizeContent(s); + + // Safety trim: keep details readable, not infinite + int MAX = 8000; + if (s.length() > MAX) { + s = s.substring(0, MAX).trim() + "…"; + } + + return s.trim(); + } + + /** + * Derive a short description from the normalized description. + * Keeps the page from looking empty when shortDescription is null in feeds. + */ + private static String deriveShortDescription(String normalizedDescription) { + if (normalizedDescription == null || normalizedDescription.isBlank()) return null; + + // Use first non-empty line that isn't a header like "Features" + String[] lines = normalizedDescription.split("\n"); + for (String line : lines) { + String t = line.trim(); + if (t.isEmpty()) continue; + if (isHeaderLine(t)) continue; + + // If it's a bullet line, strip leading "- " + if (t.startsWith("- ")) t = t.substring(2).trim(); + + // Keep it short + 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) { + // Collapse repeating spaces (but keep newlines) + return s.replaceAll("[ ]{2,}", " "); + } + + /** + * Insert spaces/newlines when section headers are glued to the next word. + * Example: "FeaturesSub-MOA Accuracy" -> "Features\nSub-MOA Accuracy" + */ + private static String unglueHeaders(String s) { + // Known headers that show up glued in feeds + String[] headers = new String[] { + "Features", "Specifications", "Specs", "Includes", "Overview", "Notes" + }; + + for (String h : headers) { + // Header followed immediately by a letter/number (no space) => add newline + s = s.replaceAll("(?i)\\b" + h + "(?=[A-Za-z0-9])", h + "\n"); + } + return s; + } + + /** + * Put headers on their own line and add a blank line before them (except at start). + */ + private static String normalizeSectionHeaders(String s) { + // Make sure headers start on a new line if they appear mid-string + 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"); + + // Clean extra blank lines + s = s.replaceAll("\\n{3,}", "\n\n"); + return s.trim(); + } + + /** + * Convert content lines into bullets where it makes sense. + * - Splits on common separators (; | •) into bullet lines + * - For long single-line descriptions, splits into pseudo-bullets by sentence-ish breaks + */ + 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; + } + + // Preserve headers as-is + if (isHeaderLine(t)) { + out.append(t).append("\n"); + continue; + } + + // If line already looks like bullets, keep it + if (t.startsWith("- ") || t.startsWith("• ")) { + out.append(t.startsWith("• ") ? "- " + t.substring(2).trim() : t).append("\n"); + continue; + } + + // Split on common separators first + List parts = splitOnSeparators(t); + + if (parts.size() == 1) { + // Heuristic: split long run-on text into "sentences" + parts = splitHeuristic(parts.get(0)); + } + + // Bullet the parts + if (parts.size() > 1) { + for (String p : parts) { + String bp = p.trim(); + if (bp.isEmpty()) continue; + out.append("- ").append(bp).append("\n"); + } + } else { + // Single line: keep as paragraph (no bullet) + out.append(t).append("\n"); + } + } + + // Cleanup: trim extra blank lines + String result = out.toString().replaceAll("\\n{3,}", "\n\n").trim(); + return result; + } + + private static List splitOnSeparators(String line) { + // Split on ; | • (common feed separators) + // Keep it conservative: don't split on commas (would explode calibers etc.) + 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); // short line = keep as paragraph + + // Split on ". " and " - " only when it looks like sentences, not decimals (e.g. 17.5) + // This is a heuristic, not perfect. + List parts = new ArrayList<>(); + String[] chunks = t.split("(? MAX_BULLETS) { + parts = parts.subList(0, MAX_BULLETS); + parts.add("…"); + } + + return parts; } private static Integer parsePositiveInt(String raw) { @@ -115,17 +338,23 @@ public class ProductV1Controller { } } + /** + * 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; - // MVP: lowest effective price wins return offers.stream() .filter(o -> o.getEffectivePrice() != null) .min(Comparator.comparing(ProductOffer::getEffectivePrice)) .orElse(null); } - private ProductDto toProductDto(Product p, ProductOffer bestOffer) { + /** + * 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())); @@ -135,14 +364,87 @@ public class ProductV1Controller { dto.setPartRole(p.getPartRole()); dto.setCategoryKey(p.getRawCategoryKey()); - BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; - dto.setPrice(price); - + dto.setPrice(bestOffer != null ? bestOffer.getEffectivePrice() : null); dto.setBuyUrl(bestOffer != null ? bestOffer.getBuyUrl() : null); + dto.setInStock(bestOffer != null ? bestOffer.getInStock() : null); - // v1: match what the UI expects today + // v1 list expects imageUrl 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(); + + // --- core identity --- + 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()); + + // --- best offer summary (used for headline price + CTA fallback) --- + dto.setPrice(bestOffer != null ? bestOffer.getEffectivePrice() : null); + dto.setBuyUrl(bestOffer != null ? bestOffer.getBuyUrl() : null); + dto.setInStock(bestOffer != null ? bestOffer.getInStock() : null); + + // --- images --- + dto.setImageUrl(p.getMainImageUrl()); // legacy field UI already uses + dto.setMainImageUrl(p.getMainImageUrl()); + dto.setBattlImageUrl(p.getBattlImageUrl()); + + // --- richer product fields --- + 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()); + + // --- description normalizer/formatter + String normalized = normalizeDescription(p.getDescription()); + dto.setDescription(normalized); + + String shortDesc = p.getShortDescription(); + if (shortDesc == null || shortDesc.isBlank()) { + shortDesc = deriveShortDescription(normalized); + } + dto.setShortDescription(shortDesc); + + // --- offers table --- + List offerDtos = (offers == null ? List.of() : offers) + .stream() + .map(this::toOfferDto) + .toList(); + + dto.setOffers(offerDtos); + + return dto; + } + + /** + * Offer -> ProductOfferDto mapper + * (This is what powers your offers table in the UI) + */ + 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; + } } \ 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 428a1b1..b48e4b8 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/ProductOfferRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/ProductOfferRepository.java @@ -3,11 +3,13 @@ package group.goforward.battlbuilder.repos; import group.goforward.battlbuilder.model.ProductOffer; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Collection; import java.util.List; import java.util.Optional; + public interface ProductOfferRepository extends JpaRepository { List findByProductId(Integer productId); @@ -31,4 +33,14 @@ 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 + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/ProductDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/ProductDto.java index 74838cd..f3be02b 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/ProductDto.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/ProductDto.java @@ -1,6 +1,7 @@ package group.goforward.battlbuilder.web.dto; import java.math.BigDecimal; +import java.util.List; public class ProductDto { private String id; @@ -8,10 +9,33 @@ public class ProductDto { private String brand; private String platform; private String partRole; + + // Categories private String categoryKey; + + // Best offer summary (what list + detail page need) private BigDecimal price; private String buyUrl; + private Boolean inStock; + + // Images (detail page can prefer battlImageUrl, fallback to main/imageUrl) private String imageUrl; + private String mainImageUrl; + private String battlImageUrl; + + // More “product” fields (this is what makes the details page feel legit) + private String slug; + private String mpn; + private String upc; + private String configuration; + private Boolean platformLocked; + private String shortDescription; + private String description; + + // Full offers table + private List offers; + + // ---- getters / setters ---- public String getId() { return id; } public void setId(String id) { this.id = id; } @@ -37,6 +61,39 @@ public class ProductDto { public String getBuyUrl() { return buyUrl; } public void setBuyUrl(String buyUrl) { this.buyUrl = buyUrl; } + public Boolean getInStock() { return inStock; } + public void setInStock(Boolean inStock) { this.inStock = inStock; } + public String getImageUrl() { return imageUrl; } public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + + public String getMainImageUrl() { return mainImageUrl; } + public void setMainImageUrl(String mainImageUrl) { this.mainImageUrl = mainImageUrl; } + + public String getBattlImageUrl() { return battlImageUrl; } + public void setBattlImageUrl(String battlImageUrl) { this.battlImageUrl = battlImageUrl; } + + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + + public String getMpn() { return mpn; } + public void setMpn(String mpn) { this.mpn = mpn; } + + public String getUpc() { return upc; } + public void setUpc(String upc) { this.upc = upc; } + + public String getConfiguration() { return configuration; } + public void setConfiguration(String configuration) { this.configuration = configuration; } + + public Boolean getPlatformLocked() { return platformLocked; } + public void setPlatformLocked(Boolean platformLocked) { this.platformLocked = platformLocked; } + + public String getShortDescription() { return shortDescription; } + public void setShortDescription(String shortDescription) { this.shortDescription = shortDescription; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public List getOffers() { return offers; } + public void setOffers(List offers) { this.offers = offers; } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/ProductOfferDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/ProductOfferDto.java index b2c34bd..8f7a6f3 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/ProductOfferDto.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/ProductOfferDto.java @@ -2,6 +2,7 @@ package group.goforward.battlbuilder.web.dto; import java.math.BigDecimal; import java.time.OffsetDateTime; +import java.util.List; public class ProductOfferDto { private String id;