From e15486b2ccd075f7ee31c9ae4977286fc3700a5e Mon Sep 17 00:00:00 2001 From: Sean Date: Fri, 19 Dec 2025 08:05:38 -0500 Subject: [PATCH] support for user builds and user accounts --- .../controllers/ProductV1Controller.java | 687 ++++++++++++------ .../goforward/battlbuilder/model/Build.java | 146 ++-- .../battlbuilder/model/BuildItem.java | 12 + .../repos/BuildItemRepository.java | 15 +- .../battlbuilder/repos/BuildRepository.java | 11 + .../web/dto/BuildCreateRequest.java | 45 ++ .../battlbuilder/web/dto/BuildDto.java | 43 ++ .../battlbuilder/web/dto/BuildItemDto.java | 44 ++ 8 files changed, 711 insertions(+), 292 deletions(-) create mode 100644 src/main/java/group/goforward/battlbuilder/web/dto/BuildCreateRequest.java create mode 100644 src/main/java/group/goforward/battlbuilder/web/dto/BuildDto.java create mode 100644 src/main/java/group/goforward/battlbuilder/web/dto/BuildItemDto.java diff --git a/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java b/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java index 3fe1536..cff9406 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java @@ -1,18 +1,22 @@ 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.web.dto.ProductOfferDto; - import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; 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; @@ -24,14 +28,26 @@ public class ProductV1Controller { private final ProductRepository productRepository; private final ProductOfferRepository productOfferRepository; + // ✅ Builds + private final BuildRepository buildRepository; + private final BuildItemRepository buildItemRepository; + public ProductV1Controller( ProductRepository productRepository, - ProductOfferRepository productOfferRepository + ProductOfferRepository productOfferRepository, + BuildRepository buildRepository, + BuildItemRepository buildItemRepository ) { this.productRepository = productRepository; this.productOfferRepository = productOfferRepository; + this.buildRepository = buildRepository; + this.buildItemRepository = buildItemRepository; } + // ========================================================================= + // PRODUCTS + // ========================================================================= + /** * List products (v1 summary contract) * Keep this lightweight for grids/lists. @@ -98,7 +114,7 @@ public class ProductV1Controller { Product product = productOpt.get(); - // Pull offers + merchant (your Hibernate log shows this join is happening) + // Pull offers + merchant List offers = productOfferRepository.findByProductId(productId); ProductOffer best = pickBestOffer(offers); @@ -106,228 +122,208 @@ public class ProductV1Controller { return ResponseEntity.ok(dto); } - // --------------------------- - // Helpers - // --------------------------- - - - // --------------------------------------------------------------------- -// Description normalization (feed -> readable bullets) -// --------------------------------------------------------------------- + // ========================================================================= + // ME BUILDS (TEMP HOME INSIDE ProductV1Controller) + // NOTE: Routes become /api/v1/products/me/builds (works now, we can refactor later) + // ========================================================================= /** - * Normalize ugly feed descriptions into readable, bullet-point friendly text. + * MAIN FUNCTIONALITY: + * Create/save a build for the current logged-in user. * - * 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) + * This expects resolveUserId(auth) to return an Integer userId. */ - private static String normalizeDescription(String raw) { - if (raw == null) return null; + @PostMapping("/me/builds") + public ResponseEntity createBuild( + Authentication auth, + @RequestBody BuildCreateRequest req + ) { + Integer userId = resolveUserId(auth); + if (userId == null) return ResponseEntity.status(401).build(); - 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() + "…"; + if (req == null || req.getTitle() == null || req.getTitle().isBlank()) { + return ResponseEntity.badRequest().build(); } - return s.trim(); + 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)); } /** - * Derive a short description from the normalized description. - * Keeps the page from looking empty when shortDescription is null in feeds. + * 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) */ - private static String deriveShortDescription(String normalizedDescription) { - if (normalizedDescription == null || normalizedDescription.isBlank()) return null; + @GetMapping("/me/builds") + public ResponseEntity> listMyBuilds(Authentication auth) { + Integer userId = resolveUserId(auth); + if (userId == null) return ResponseEntity.status(401).build(); - // 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; + List mine = buildRepository.findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(userId); + return ResponseEntity.ok(mine.stream().map(this::toBuildDtoNoItems).toList()); + } - // 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; + @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(); } - return null; - } + Optional opt = buildRepository.findByUuid(uuid); + if (opt.isEmpty()) return ResponseEntity.notFound().build(); - 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"); - } + Build b = opt.get(); + if (b.getDeletedAt() != null) return ResponseEntity.notFound().build(); - private static String collapseSpaces(String s) { - // Collapse repeating spaces (but keep newlines) - return s.replaceAll("[ ]{2,}", " "); + // ✅ 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)); } /** - * Insert spaces/newlines when section headers are glued to the next word. - * Example: "FeaturesSub-MOA Accuracy" -> "Features\nSub-MOA Accuracy" + * MAIN FUNCTIONALITY: + * Load build by UUID. + * - Owner can see it + * - Non-owner can only see if is_public = true */ - 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"); - } + @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(); } - // Cleanup: trim extra blank lines - String result = out.toString().replaceAll("\\n{3,}", "\n\n").trim(); - return result; + 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)); } - 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; - } + // ========================================================================= + // HELPERS — Products + // ========================================================================= private static Integer parsePositiveInt(String raw) { try { @@ -368,9 +364,7 @@ public class ProductV1Controller { dto.setBuyUrl(bestOffer != null ? bestOffer.getBuyUrl() : null); dto.setInStock(bestOffer != null ? bestOffer.getInStock() : null); - // v1 list expects imageUrl dto.setImageUrl(p.getMainImageUrl()); - return dto; } @@ -380,7 +374,6 @@ public class ProductV1Controller { 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); @@ -388,24 +381,20 @@ public class ProductV1Controller { 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.setImageUrl(p.getMainImageUrl()); 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); @@ -415,28 +404,20 @@ public class ProductV1Controller { } 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.setMerchantName(o.getMerchant() != null ? o.getMerchant().getName() : null); dto.setPrice(o.getPrice()); dto.setOriginalPrice(o.getOriginalPrice()); dto.setInStock(Boolean.TRUE.equals(o.getInStock())); @@ -447,4 +428,276 @@ public class ProductV1Controller { 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 + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/Build.java b/src/main/java/group/goforward/battlbuilder/model/Build.java index bf2fa77..e857cf8 100644 --- a/src/main/java/group/goforward/battlbuilder/model/Build.java +++ b/src/main/java/group/goforward/battlbuilder/model/Build.java @@ -1,107 +1,109 @@ package group.goforward.battlbuilder.model; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; + import org.hibernate.annotations.ColumnDefault; -import java.time.Instant; +import java.time.OffsetDateTime; import java.util.UUID; @Entity @Table(name = "builds") public class Build { + + // ----------------------------------------------------- + // Primary key (Postgres GENERATED ALWAYS AS IDENTITY) + // ----------------------------------------------------- @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) private Integer id; - @Column(name = "account_id", nullable = false) - private Integer accountId; + // ----------------------------------------------------- + // UUID (DB default gen_random_uuid()) + // ----------------------------------------------------- + @ColumnDefault("gen_random_uuid()") + @Column(name = "uuid", nullable = false) + private UUID uuid; - @Column(name = "name", nullable = false) - private String name; + // ----------------------------------------------------- + // Ownership (nullable; ON DELETE SET NULL) + // NOTE: Keeping this as Integer for now to avoid forcing a User entity relationship + // ----------------------------------------------------- + @Column(name = "user_id") + private Integer userId; - @Column(name = "description", length = Integer.MAX_VALUE) + // ----------------------------------------------------- + // Main fields + // ----------------------------------------------------- + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "description") private String description; + @ColumnDefault("false") + @Column(name = "is_public", nullable = false) + private Boolean isPublic = false; + + // ----------------------------------------------------- + // Timestamps + // ----------------------------------------------------- + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + @ColumnDefault("now()") @Column(name = "updated_at", nullable = false) - private Instant updatedAt; - - @ColumnDefault("now()") - @Column(name = "created_at", nullable = false) - private Instant createdAt; + private OffsetDateTime updatedAt; @Column(name = "deleted_at") - private Instant deletedAt; + private OffsetDateTime deletedAt; - @ColumnDefault("gen_random_uuid()") - @Column(name = "uuid") - private UUID uuid; - - public Integer getId() { - return id; + // ----------------------------------------------------- + // Hibernate lifecycle + // ----------------------------------------------------- + @PrePersist + public void prePersist() { + if (uuid == null) uuid = UUID.randomUUID(); + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + if (isPublic == null) isPublic = false; } - public void setId(Integer id) { - this.id = id; + @PreUpdate + public void preUpdate() { + updatedAt = OffsetDateTime.now(); } - public Integer getAccountId() { - return accountId; - } + // ----------------------------------------------------- + // Getters / Setters + // ----------------------------------------------------- + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } - public void setAccountId(Integer accountId) { - this.accountId = accountId; - } + public UUID getUuid() { return uuid; } + public void setUuid(UUID uuid) { this.uuid = uuid; } - public String getName() { - return name; - } + public Integer getUserId() { return userId; } + public void setUserId(Integer userId) { this.userId = userId; } - public void setName(String name) { - this.name = name; - } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } - public String getDescription() { - return description; - } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } - public void setDescription(String description) { - this.description = description; - } + public Boolean getIsPublic() { return isPublic; } + public void setIsPublic(Boolean isPublic) { this.isPublic = isPublic; } - public Instant getUpdatedAt() { - return updatedAt; - } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } - public void setUpdatedAt(Instant updatedAt) { - this.updatedAt = updatedAt; - } - - public Instant getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(Instant createdAt) { - this.createdAt = createdAt; - } - - public Instant getDeletedAt() { - return deletedAt; - } - - public void setDeletedAt(Instant deletedAt) { - this.deletedAt = deletedAt; - } - - public UUID getUuid() { - return uuid; - } - - public void setUuid(UUID uuid) { - this.uuid = uuid; - } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } + public OffsetDateTime getDeletedAt() { return deletedAt; } + public void setDeletedAt(OffsetDateTime deletedAt) { this.deletedAt = deletedAt; } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/BuildItem.java b/src/main/java/group/goforward/battlbuilder/model/BuildItem.java index 0294bc2..225f28d 100644 --- a/src/main/java/group/goforward/battlbuilder/model/BuildItem.java +++ b/src/main/java/group/goforward/battlbuilder/model/BuildItem.java @@ -60,6 +60,18 @@ public class BuildItem { @Column(name = "deleted_at") private OffsetDateTime deletedAt; + @PrePersist + protected void onCreate() { + if (this.uuid == null) this.uuid = java.util.UUID.randomUUID(); + if (this.createdAt == null) this.createdAt = OffsetDateTime.now(); + if (this.updatedAt == null) this.updatedAt = this.createdAt; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + public Integer getId() { return id; } diff --git a/src/main/java/group/goforward/battlbuilder/repos/BuildItemRepository.java b/src/main/java/group/goforward/battlbuilder/repos/BuildItemRepository.java index 38b4a58..47a125f 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/BuildItemRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/BuildItemRepository.java @@ -2,11 +2,20 @@ 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.Optional; import java.util.UUID; public interface BuildItemRepository extends JpaRepository { - List findByBuildId(Integer buildId); - Optional findByUuid(UUID uuid); + + // main “load build” query + List findByBuild_Uuid(UUID buildUuid); + + // handy for cleanup or listing items + List findByBuild_Id(Integer buildId); + + @Transactional + void deleteByBuild_Id(Integer buildId); } \ 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 622b1d3..c717f7f 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/BuildRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/BuildRepository.java @@ -2,9 +2,20 @@ package group.goforward.battlbuilder.repos; import group.goforward.battlbuilder.model.Build; 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); + + // 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/web/dto/BuildCreateRequest.java b/src/main/java/group/goforward/battlbuilder/web/dto/BuildCreateRequest.java new file mode 100644 index 0000000..6c26788 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/BuildCreateRequest.java @@ -0,0 +1,45 @@ +package group.goforward.battlbuilder.web.dto; + +import java.util.List; + +public class BuildCreateRequest { + + // main build info + private String title; + private String description; + private Boolean isPublic; + + // items = your builder slots; for now, slot can be the CategoryId string + private List items; + + 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 static class BuildItemCreateRequest { + private String slot; + private Integer productId; + private Integer position; + private Integer quantity; + + public String getSlot() { return slot; } + public void setSlot(String slot) { this.slot = slot; } + + public Integer getProductId() { return productId; } + public void setProductId(Integer productId) { this.productId = productId; } + + 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; } + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/BuildDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/BuildDto.java new file mode 100644 index 0000000..e80e81c --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/BuildDto.java @@ -0,0 +1,43 @@ +package group.goforward.battlbuilder.web.dto; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public class BuildDto { + private String id; + private UUID uuid; + + private String title; + private String description; + private Boolean isPublic; + + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + private List items; + + public String getId() { return id; } + public void setId(String 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; } + + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/BuildItemDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/BuildItemDto.java new file mode 100644 index 0000000..59716f6 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/BuildItemDto.java @@ -0,0 +1,44 @@ +package group.goforward.battlbuilder.web.dto; + +import java.util.UUID; + +public class BuildItemDto { + private String id; + private UUID uuid; + + private String slot; + private Integer position; + private Integer quantity; + + private String productId; + private String productName; + private String productBrand; + private String productImageUrl; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public UUID getUuid() { return uuid; } + public void setUuid(UUID uuid) { this.uuid = uuid; } + + 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 getProductId() { return productId; } + public void setProductId(String productId) { this.productId = productId; } + + public String getProductName() { return productName; } + public void setProductName(String productName) { this.productName = productName; } + + public String getProductBrand() { return productBrand; } + public void setProductBrand(String productBrand) { this.productBrand = productBrand; } + + public String getProductImageUrl() { return productImageUrl; } + public void setProductImageUrl(String productImageUrl) { this.productImageUrl = productImageUrl; } +} \ No newline at end of file