From cd0c13203fb6c5ec502583cd564cb2025f867b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Mon, 9 Mar 2026 20:32:13 +0100 Subject: [PATCH] feat(back-end): category and shop implementation --- .../controller/PublicShopController.java | 77 ++ .../admin/AdminMediaController.java | 7 + .../admin/AdminShopCategoryController.java | 64 ++ .../admin/AdminShopProductController.java | 99 +++ .../dto/AdminShopCategoryDto.java | 215 ++++++ .../dto/AdminShopCategoryRefDto.java | 33 + .../dto/AdminShopProductDto.java | 261 +++++++ .../dto/AdminShopProductVariantDto.java | 116 +++ .../dto/AdminUpsertShopCategoryRequest.java | 105 +++ .../dto/AdminUpsertShopProductRequest.java | 133 ++++ .../AdminUpsertShopProductVariantRequest.java | 97 +++ .../dto/ShopCategoryDetailDto.java | 23 + .../dto/ShopCategoryRefDto.java | 10 + .../dto/ShopCategoryTreeDto.java | 22 + .../dto/ShopProductCatalogResponseDto.java | 11 + .../dto/ShopProductDetailDto.java | 30 + .../dto/ShopProductModelDto.java | 14 + .../dto/ShopProductSummaryDto.java | 20 + .../dto/ShopProductVariantOptionDto.java | 15 + .../repository/MediaUsageRepository.java | 13 + .../repository/OrderItemRepository.java | 2 + .../repository/QuoteLineItemRepository.java | 2 + .../repository/ShopCategoryRepository.java | 8 + .../ShopProductModelAssetRepository.java | 6 + .../repository/ShopProductRepository.java | 10 + .../ShopProductVariantRepository.java | 9 + .../admin/AdminMediaControllerService.java | 12 + .../AdminShopCategoryControllerService.java | 334 +++++++++ .../AdminShopProductControllerService.java | 686 ++++++++++++++++++ .../media/PublicMediaQueryService.java | 48 +- .../shop/PublicShopCatalogService.java | 504 +++++++++++++ 31 files changed, 2973 insertions(+), 13 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/controller/PublicShopController.java create mode 100644 backend/src/main/java/com/printcalculator/controller/admin/AdminShopCategoryController.java create mode 100644 backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminShopCategoryRefDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java create mode 100644 backend/src/main/java/com/printcalculator/dto/ShopCategoryDetailDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/ShopCategoryRefDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/ShopCategoryTreeDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/ShopProductCatalogResponseDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/ShopProductModelDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java create mode 100644 backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java create mode 100644 backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java create mode 100644 backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java diff --git a/backend/src/main/java/com/printcalculator/controller/PublicShopController.java b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java new file mode 100644 index 0000000..e4680f0 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java @@ -0,0 +1,77 @@ +package com.printcalculator.controller; + +import com.printcalculator.dto.ShopCategoryDetailDto; +import com.printcalculator.dto.ShopCategoryTreeDto; +import com.printcalculator.dto.ShopProductCatalogResponseDto; +import com.printcalculator.dto.ShopProductDetailDto; +import com.printcalculator.service.shop.PublicShopCatalogService; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.util.List; + +@RestController +@RequestMapping("/api/shop") +@Transactional(readOnly = true) +public class PublicShopController { + private final PublicShopCatalogService publicShopCatalogService; + + public PublicShopController(PublicShopCatalogService publicShopCatalogService) { + this.publicShopCatalogService = publicShopCatalogService; + } + + @GetMapping("/categories") + public ResponseEntity> getCategories(@RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicShopCatalogService.getCategories(lang)); + } + + @GetMapping("/categories/{slug}") + public ResponseEntity getCategory(@PathVariable String slug, + @RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicShopCatalogService.getCategory(slug, lang)); + } + + @GetMapping("/products") + public ResponseEntity getProducts( + @RequestParam(required = false) String categorySlug, + @RequestParam(required = false) Boolean featured, + @RequestParam(required = false) String lang + ) { + return ResponseEntity.ok(publicShopCatalogService.getProductCatalog(categorySlug, featured, lang)); + } + + @GetMapping("/products/{slug}") + public ResponseEntity getProduct(@PathVariable String slug, + @RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang)); + } + + @GetMapping("/products/{slug}/model") + public ResponseEntity getProductModel(@PathVariable String slug) throws IOException { + PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug); + Resource resource = new UrlResource(model.path().toUri()); + MediaType contentType = MediaType.APPLICATION_OCTET_STREAM; + if (model.mimeType() != null && !model.mimeType().isBlank()) { + try { + contentType = MediaType.parseMediaType(model.mimeType()); + } catch (IllegalArgumentException ignored) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + } + + return ResponseEntity.ok() + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + model.filename() + "\"") + .body(resource); + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java index df5d06f..d487145 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java @@ -53,6 +53,13 @@ public class AdminMediaController { return ResponseEntity.ok(adminMediaControllerService.getAsset(mediaAssetId)); } + @GetMapping("/usages") + public ResponseEntity> getUsages(@RequestParam String usageType, + @RequestParam String usageKey, + @RequestParam(required = false) UUID ownerId) { + return ResponseEntity.ok(adminMediaControllerService.getUsages(usageType, usageKey, ownerId)); + } + @PatchMapping("/assets/{mediaAssetId}") @Transactional public ResponseEntity updateAsset(@PathVariable UUID mediaAssetId, diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminShopCategoryController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopCategoryController.java new file mode 100644 index 0000000..21a9b37 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopCategoryController.java @@ -0,0 +1,64 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminShopCategoryDto; +import com.printcalculator.dto.AdminUpsertShopCategoryRequest; +import com.printcalculator.service.admin.AdminShopCategoryControllerService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/admin/shop/categories") +@Transactional(readOnly = true) +public class AdminShopCategoryController { + private final AdminShopCategoryControllerService adminShopCategoryControllerService; + + public AdminShopCategoryController(AdminShopCategoryControllerService adminShopCategoryControllerService) { + this.adminShopCategoryControllerService = adminShopCategoryControllerService; + } + + @GetMapping + public ResponseEntity> getCategories() { + return ResponseEntity.ok(adminShopCategoryControllerService.getCategories()); + } + + @GetMapping("/tree") + public ResponseEntity> getCategoryTree() { + return ResponseEntity.ok(adminShopCategoryControllerService.getCategoryTree()); + } + + @GetMapping("/{categoryId}") + public ResponseEntity getCategory(@PathVariable UUID categoryId) { + return ResponseEntity.ok(adminShopCategoryControllerService.getCategory(categoryId)); + } + + @PostMapping + @Transactional + public ResponseEntity createCategory(@RequestBody AdminUpsertShopCategoryRequest payload) { + return ResponseEntity.ok(adminShopCategoryControllerService.createCategory(payload)); + } + + @PutMapping("/{categoryId}") + @Transactional + public ResponseEntity updateCategory(@PathVariable UUID categoryId, + @RequestBody AdminUpsertShopCategoryRequest payload) { + return ResponseEntity.ok(adminShopCategoryControllerService.updateCategory(categoryId, payload)); + } + + @DeleteMapping("/{categoryId}") + @Transactional + public ResponseEntity deleteCategory(@PathVariable UUID categoryId) { + adminShopCategoryControllerService.deleteCategory(categoryId); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java new file mode 100644 index 0000000..dc31270 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java @@ -0,0 +1,99 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminShopProductDto; +import com.printcalculator.dto.AdminUpsertShopProductRequest; +import com.printcalculator.service.admin.AdminShopProductControllerService; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/admin/shop/products") +@Transactional(readOnly = true) +public class AdminShopProductController { + private final AdminShopProductControllerService adminShopProductControllerService; + + public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService) { + this.adminShopProductControllerService = adminShopProductControllerService; + } + + @GetMapping + public ResponseEntity> getProducts() { + return ResponseEntity.ok(adminShopProductControllerService.getProducts()); + } + + @GetMapping("/{productId}") + public ResponseEntity getProduct(@PathVariable UUID productId) { + return ResponseEntity.ok(adminShopProductControllerService.getProduct(productId)); + } + + @PostMapping + @Transactional + public ResponseEntity createProduct(@RequestBody AdminUpsertShopProductRequest payload) { + return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload)); + } + + @PutMapping("/{productId}") + @Transactional + public ResponseEntity updateProduct(@PathVariable UUID productId, + @RequestBody AdminUpsertShopProductRequest payload) { + return ResponseEntity.ok(adminShopProductControllerService.updateProduct(productId, payload)); + } + + @DeleteMapping("/{productId}") + @Transactional + public ResponseEntity deleteProduct(@PathVariable UUID productId) { + adminShopProductControllerService.deleteProduct(productId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{productId}/model") + @Transactional + public ResponseEntity uploadProductModel(@PathVariable UUID productId, + @RequestParam("file") MultipartFile file) throws IOException { + return ResponseEntity.ok(adminShopProductControllerService.uploadProductModel(productId, file)); + } + + @DeleteMapping("/{productId}/model") + @Transactional + public ResponseEntity deleteProductModel(@PathVariable UUID productId) { + adminShopProductControllerService.deleteProductModel(productId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{productId}/model") + public ResponseEntity getProductModel(@PathVariable UUID productId) throws IOException { + AdminShopProductControllerService.ProductModelDownload model = adminShopProductControllerService.getProductModel(productId); + Resource resource = new UrlResource(model.path().toUri()); + MediaType contentType = MediaType.APPLICATION_OCTET_STREAM; + if (model.mimeType() != null && !model.mimeType().isBlank()) { + try { + contentType = MediaType.parseMediaType(model.mimeType()); + } catch (IllegalArgumentException ignored) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + } + + return ResponseEntity.ok() + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + model.filename() + "\"") + .body(resource); + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java new file mode 100644 index 0000000..3e43c0d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java @@ -0,0 +1,215 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public class AdminShopCategoryDto { + private UUID id; + private UUID parentCategoryId; + private String parentCategoryName; + private String slug; + private String name; + private String description; + private String seoTitle; + private String seoDescription; + private String ogTitle; + private String ogDescription; + private Boolean indexable; + private Boolean isActive; + private Integer sortOrder; + private Integer depth; + private Integer childCount; + private Integer directProductCount; + private Integer descendantProductCount; + private String mediaUsageType; + private String mediaUsageKey; + private List breadcrumbs; + private List children; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public UUID getParentCategoryId() { + return parentCategoryId; + } + + public void setParentCategoryId(UUID parentCategoryId) { + this.parentCategoryId = parentCategoryId; + } + + public String getParentCategoryName() { + return parentCategoryName; + } + + public void setParentCategoryName(String parentCategoryName) { + this.parentCategoryName = parentCategoryName; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getOgTitle() { + return ogTitle; + } + + public void setOgTitle(String ogTitle) { + this.ogTitle = ogTitle; + } + + public String getOgDescription() { + return ogDescription; + } + + public void setOgDescription(String ogDescription) { + this.ogDescription = ogDescription; + } + + public Boolean getIndexable() { + return indexable; + } + + public void setIndexable(Boolean indexable) { + this.indexable = indexable; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Integer getDepth() { + return depth; + } + + public void setDepth(Integer depth) { + this.depth = depth; + } + + public Integer getChildCount() { + return childCount; + } + + public void setChildCount(Integer childCount) { + this.childCount = childCount; + } + + public Integer getDirectProductCount() { + return directProductCount; + } + + public void setDirectProductCount(Integer directProductCount) { + this.directProductCount = directProductCount; + } + + public Integer getDescendantProductCount() { + return descendantProductCount; + } + + public void setDescendantProductCount(Integer descendantProductCount) { + this.descendantProductCount = descendantProductCount; + } + + public String getMediaUsageType() { + return mediaUsageType; + } + + public void setMediaUsageType(String mediaUsageType) { + this.mediaUsageType = mediaUsageType; + } + + public String getMediaUsageKey() { + return mediaUsageKey; + } + + public void setMediaUsageKey(String mediaUsageKey) { + this.mediaUsageKey = mediaUsageKey; + } + + public List getBreadcrumbs() { + return breadcrumbs; + } + + public void setBreadcrumbs(List breadcrumbs) { + this.breadcrumbs = breadcrumbs; + } + + public List getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } + + 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; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryRefDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryRefDto.java new file mode 100644 index 0000000..624591a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryRefDto.java @@ -0,0 +1,33 @@ +package com.printcalculator.dto; + +import java.util.UUID; + +public class AdminShopCategoryRefDto { + private UUID id; + private String slug; + private String name; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java new file mode 100644 index 0000000..a40301d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java @@ -0,0 +1,261 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public class AdminShopProductDto { + private UUID id; + private UUID categoryId; + private String categoryName; + private String categorySlug; + private String slug; + private String name; + private String excerpt; + private String description; + private String seoTitle; + private String seoDescription; + private String ogTitle; + private String ogDescription; + private Boolean indexable; + private Boolean isFeatured; + private Boolean isActive; + private Integer sortOrder; + private Integer variantCount; + private Integer activeVariantCount; + private BigDecimal priceFromChf; + private BigDecimal priceToChf; + private String mediaUsageType; + private String mediaUsageKey; + private List mediaUsages; + private List images; + private ShopProductModelDto model3d; + private List variants; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public UUID getCategoryId() { + return categoryId; + } + + public void setCategoryId(UUID categoryId) { + this.categoryId = categoryId; + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + public String getCategorySlug() { + return categorySlug; + } + + public void setCategorySlug(String categorySlug) { + this.categorySlug = categorySlug; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getExcerpt() { + return excerpt; + } + + public void setExcerpt(String excerpt) { + this.excerpt = excerpt; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getOgTitle() { + return ogTitle; + } + + public void setOgTitle(String ogTitle) { + this.ogTitle = ogTitle; + } + + public String getOgDescription() { + return ogDescription; + } + + public void setOgDescription(String ogDescription) { + this.ogDescription = ogDescription; + } + + public Boolean getIndexable() { + return indexable; + } + + public void setIndexable(Boolean indexable) { + this.indexable = indexable; + } + + public Boolean getIsFeatured() { + return isFeatured; + } + + public void setIsFeatured(Boolean featured) { + isFeatured = featured; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Integer getVariantCount() { + return variantCount; + } + + public void setVariantCount(Integer variantCount) { + this.variantCount = variantCount; + } + + public Integer getActiveVariantCount() { + return activeVariantCount; + } + + public void setActiveVariantCount(Integer activeVariantCount) { + this.activeVariantCount = activeVariantCount; + } + + public BigDecimal getPriceFromChf() { + return priceFromChf; + } + + public void setPriceFromChf(BigDecimal priceFromChf) { + this.priceFromChf = priceFromChf; + } + + public BigDecimal getPriceToChf() { + return priceToChf; + } + + public void setPriceToChf(BigDecimal priceToChf) { + this.priceToChf = priceToChf; + } + + public String getMediaUsageType() { + return mediaUsageType; + } + + public void setMediaUsageType(String mediaUsageType) { + this.mediaUsageType = mediaUsageType; + } + + public String getMediaUsageKey() { + return mediaUsageKey; + } + + public void setMediaUsageKey(String mediaUsageKey) { + this.mediaUsageKey = mediaUsageKey; + } + + public List getMediaUsages() { + return mediaUsages; + } + + public void setMediaUsages(List mediaUsages) { + this.mediaUsages = mediaUsages; + } + + public List getImages() { + return images; + } + + public void setImages(List images) { + this.images = images; + } + + public ShopProductModelDto getModel3d() { + return model3d; + } + + public void setModel3d(ShopProductModelDto model3d) { + this.model3d = model3d; + } + + public List getVariants() { + return variants; + } + + public void setVariants(List variants) { + this.variants = variants; + } + + 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; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java new file mode 100644 index 0000000..e03c629 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java @@ -0,0 +1,116 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminShopProductVariantDto { + private UUID id; + private String sku; + private String variantLabel; + private String colorName; + private String colorHex; + private String internalMaterialCode; + private BigDecimal priceChf; + private Boolean isDefault; + private Boolean isActive; + private Integer sortOrder; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getSku() { + return sku; + } + + public void setSku(String sku) { + this.sku = sku; + } + + public String getVariantLabel() { + return variantLabel; + } + + public void setVariantLabel(String variantLabel) { + this.variantLabel = variantLabel; + } + + public String getColorName() { + return colorName; + } + + public void setColorName(String colorName) { + this.colorName = colorName; + } + + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getInternalMaterialCode() { + return internalMaterialCode; + } + + public void setInternalMaterialCode(String internalMaterialCode) { + this.internalMaterialCode = internalMaterialCode; + } + + public BigDecimal getPriceChf() { + return priceChf; + } + + public void setPriceChf(BigDecimal priceChf) { + this.priceChf = priceChf; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public void setIsDefault(Boolean aDefault) { + isDefault = aDefault; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + 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; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java new file mode 100644 index 0000000..28096f2 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java @@ -0,0 +1,105 @@ +package com.printcalculator.dto; + +import java.util.UUID; + +public class AdminUpsertShopCategoryRequest { + private UUID parentCategoryId; + private String slug; + private String name; + private String description; + private String seoTitle; + private String seoDescription; + private String ogTitle; + private String ogDescription; + private Boolean indexable; + private Boolean isActive; + private Integer sortOrder; + + public UUID getParentCategoryId() { + return parentCategoryId; + } + + public void setParentCategoryId(UUID parentCategoryId) { + this.parentCategoryId = parentCategoryId; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getOgTitle() { + return ogTitle; + } + + public void setOgTitle(String ogTitle) { + this.ogTitle = ogTitle; + } + + public String getOgDescription() { + return ogDescription; + } + + public void setOgDescription(String ogDescription) { + this.ogDescription = ogDescription; + } + + public Boolean getIndexable() { + return indexable; + } + + public void setIndexable(Boolean indexable) { + this.indexable = indexable; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java new file mode 100644 index 0000000..f4c4ac4 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java @@ -0,0 +1,133 @@ +package com.printcalculator.dto; + +import java.util.List; +import java.util.UUID; + +public class AdminUpsertShopProductRequest { + private UUID categoryId; + private String slug; + private String name; + private String excerpt; + private String description; + private String seoTitle; + private String seoDescription; + private String ogTitle; + private String ogDescription; + private Boolean indexable; + private Boolean isFeatured; + private Boolean isActive; + private Integer sortOrder; + private List variants; + + public UUID getCategoryId() { + return categoryId; + } + + public void setCategoryId(UUID categoryId) { + this.categoryId = categoryId; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getExcerpt() { + return excerpt; + } + + public void setExcerpt(String excerpt) { + this.excerpt = excerpt; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getOgTitle() { + return ogTitle; + } + + public void setOgTitle(String ogTitle) { + this.ogTitle = ogTitle; + } + + public String getOgDescription() { + return ogDescription; + } + + public void setOgDescription(String ogDescription) { + this.ogDescription = ogDescription; + } + + public Boolean getIndexable() { + return indexable; + } + + public void setIndexable(Boolean indexable) { + this.indexable = indexable; + } + + public Boolean getIsFeatured() { + return isFeatured; + } + + public void setIsFeatured(Boolean featured) { + isFeatured = featured; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public List getVariants() { + return variants; + } + + public void setVariants(List variants) { + this.variants = variants; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java new file mode 100644 index 0000000..14ef9af --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java @@ -0,0 +1,97 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public class AdminUpsertShopProductVariantRequest { + private UUID id; + private String sku; + private String variantLabel; + private String colorName; + private String colorHex; + private String internalMaterialCode; + private BigDecimal priceChf; + private Boolean isDefault; + private Boolean isActive; + private Integer sortOrder; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getSku() { + return sku; + } + + public void setSku(String sku) { + this.sku = sku; + } + + public String getVariantLabel() { + return variantLabel; + } + + public void setVariantLabel(String variantLabel) { + this.variantLabel = variantLabel; + } + + public String getColorName() { + return colorName; + } + + public void setColorName(String colorName) { + this.colorName = colorName; + } + + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getInternalMaterialCode() { + return internalMaterialCode; + } + + public void setInternalMaterialCode(String internalMaterialCode) { + this.internalMaterialCode = internalMaterialCode; + } + + public BigDecimal getPriceChf() { + return priceChf; + } + + public void setPriceChf(BigDecimal priceChf) { + this.priceChf = priceChf; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public void setIsDefault(Boolean aDefault) { + isDefault = aDefault; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopCategoryDetailDto.java b/backend/src/main/java/com/printcalculator/dto/ShopCategoryDetailDto.java new file mode 100644 index 0000000..117ef38 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopCategoryDetailDto.java @@ -0,0 +1,23 @@ +package com.printcalculator.dto; + +import java.util.List; +import java.util.UUID; + +public record ShopCategoryDetailDto( + UUID id, + String slug, + String name, + String description, + String seoTitle, + String seoDescription, + String ogTitle, + String ogDescription, + Boolean indexable, + Integer sortOrder, + Integer productCount, + List breadcrumbs, + PublicMediaUsageDto primaryImage, + List images, + List children +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopCategoryRefDto.java b/backend/src/main/java/com/printcalculator/dto/ShopCategoryRefDto.java new file mode 100644 index 0000000..785198d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopCategoryRefDto.java @@ -0,0 +1,10 @@ +package com.printcalculator.dto; + +import java.util.UUID; + +public record ShopCategoryRefDto( + UUID id, + String slug, + String name +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopCategoryTreeDto.java b/backend/src/main/java/com/printcalculator/dto/ShopCategoryTreeDto.java new file mode 100644 index 0000000..94ffecc --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopCategoryTreeDto.java @@ -0,0 +1,22 @@ +package com.printcalculator.dto; + +import java.util.List; +import java.util.UUID; + +public record ShopCategoryTreeDto( + UUID id, + UUID parentCategoryId, + String slug, + String name, + String description, + String seoTitle, + String seoDescription, + String ogTitle, + String ogDescription, + Boolean indexable, + Integer sortOrder, + Integer productCount, + PublicMediaUsageDto primaryImage, + List children +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductCatalogResponseDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductCatalogResponseDto.java new file mode 100644 index 0000000..2de21e5 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductCatalogResponseDto.java @@ -0,0 +1,11 @@ +package com.printcalculator.dto; + +import java.util.List; + +public record ShopProductCatalogResponseDto( + String categorySlug, + Boolean featuredOnly, + ShopCategoryDetailDto category, + List products +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java new file mode 100644 index 0000000..265cb8b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java @@ -0,0 +1,30 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +public record ShopProductDetailDto( + UUID id, + String slug, + String name, + String excerpt, + String description, + String seoTitle, + String seoDescription, + String ogTitle, + String ogDescription, + Boolean indexable, + Boolean isFeatured, + Integer sortOrder, + ShopCategoryRefDto category, + List breadcrumbs, + BigDecimal priceFromChf, + BigDecimal priceToChf, + ShopProductVariantOptionDto defaultVariant, + List variants, + PublicMediaUsageDto primaryImage, + List images, + ShopProductModelDto model3d +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductModelDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductModelDto.java new file mode 100644 index 0000000..e590987 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductModelDto.java @@ -0,0 +1,14 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; + +public record ShopProductModelDto( + String url, + String originalFilename, + String mimeType, + Long fileSizeBytes, + BigDecimal boundingBoxXMm, + BigDecimal boundingBoxYMm, + BigDecimal boundingBoxZMm +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java new file mode 100644 index 0000000..d563a07 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java @@ -0,0 +1,20 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record ShopProductSummaryDto( + UUID id, + String slug, + String name, + String excerpt, + Boolean isFeatured, + Integer sortOrder, + ShopCategoryRefDto category, + BigDecimal priceFromChf, + BigDecimal priceToChf, + ShopProductVariantOptionDto defaultVariant, + PublicMediaUsageDto primaryImage, + ShopProductModelDto model3d +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java new file mode 100644 index 0000000..318a87c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java @@ -0,0 +1,15 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record ShopProductVariantOptionDto( + UUID id, + String sku, + String variantLabel, + String colorName, + String colorHex, + BigDecimal priceChf, + Boolean isDefault +) { +} diff --git a/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java index 08bea80..c0beaba 100644 --- a/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java @@ -17,6 +17,19 @@ public interface MediaUsageRepository extends JpaRepository { List findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(String usageType, String usageKey); + List findByUsageTypeAndUsageKeyOrderBySortOrderAscCreatedAtAsc(String usageType, + String usageKey); + + @Query(""" + select usage from MediaUsage usage + where usage.usageType = :usageType + and usage.usageKey in :usageKeys + and usage.isActive = true + order by usage.usageKey asc, usage.sortOrder asc, usage.createdAt asc + """) + List findActiveByUsageTypeAndUsageKeys(@Param("usageType") String usageType, + @Param("usageKeys") Collection usageKeys); + @Query(""" select usage from MediaUsage usage where usage.usageType = :usageType diff --git a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java index 3503bb2..4005d5f 100644 --- a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java @@ -9,4 +9,6 @@ import java.util.UUID; public interface OrderItemRepository extends JpaRepository { List findByOrder_Id(UUID orderId); boolean existsByFilamentVariant_Id(Long filamentVariantId); + boolean existsByShopProduct_Id(UUID shopProductId); + boolean existsByShopProductVariant_Id(UUID shopProductVariantId); } diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java index faf3780..5b51980 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java @@ -17,4 +17,6 @@ public interface QuoteLineItemRepository extends JpaRepository { Optional findBySlug(String slug); + Optional findBySlugIgnoreCase(String slug); + + Optional findBySlugAndIsActiveTrue(String slug); + boolean existsBySlugIgnoreCase(String slug); + boolean existsByParentCategory_Id(UUID parentCategoryId); + List findAllByOrderBySortOrderAscNameAsc(); + + List findAllByIsActiveTrueOrderBySortOrderAscNameAsc(); } diff --git a/backend/src/main/java/com/printcalculator/repository/ShopProductModelAssetRepository.java b/backend/src/main/java/com/printcalculator/repository/ShopProductModelAssetRepository.java index c278be6..f381943 100644 --- a/backend/src/main/java/com/printcalculator/repository/ShopProductModelAssetRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/ShopProductModelAssetRepository.java @@ -3,9 +3,15 @@ package com.printcalculator.repository; import com.printcalculator.entity.ShopProductModelAsset; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; +import java.util.List; import java.util.Optional; import java.util.UUID; public interface ShopProductModelAssetRepository extends JpaRepository { Optional findByProduct_Id(UUID productId); + + List findByProduct_IdIn(Collection productIds); + + void deleteByProduct_Id(UUID productId); } diff --git a/backend/src/main/java/com/printcalculator/repository/ShopProductRepository.java b/backend/src/main/java/com/printcalculator/repository/ShopProductRepository.java index 832d433..6b180c5 100644 --- a/backend/src/main/java/com/printcalculator/repository/ShopProductRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/ShopProductRepository.java @@ -10,9 +10,19 @@ import java.util.UUID; public interface ShopProductRepository extends JpaRepository { Optional findBySlug(String slug); + Optional findBySlugIgnoreCase(String slug); + + Optional findBySlugAndIsActiveTrue(String slug); + boolean existsBySlugIgnoreCase(String slug); List findAllByOrderBySortOrderAscNameAsc(); + List findAllByOrderByIsFeaturedDescSortOrderAscNameAsc(); + List findByCategory_IdOrderBySortOrderAscNameAsc(UUID categoryId); + + List findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc(); + + boolean existsByCategory_Id(UUID categoryId); } diff --git a/backend/src/main/java/com/printcalculator/repository/ShopProductVariantRepository.java b/backend/src/main/java/com/printcalculator/repository/ShopProductVariantRepository.java index 6ef842f..4e285cb 100644 --- a/backend/src/main/java/com/printcalculator/repository/ShopProductVariantRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/ShopProductVariantRepository.java @@ -3,6 +3,7 @@ package com.printcalculator.repository; import com.printcalculator.entity.ShopProductVariant; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -10,7 +11,15 @@ import java.util.UUID; public interface ShopProductVariantRepository extends JpaRepository { List findByProduct_IdOrderBySortOrderAscColorNameAsc(UUID productId); + List findByProduct_IdInOrderBySortOrderAscColorNameAsc(Collection productIds); + + List findByProduct_IdAndIsActiveTrueOrderBySortOrderAscColorNameAsc(UUID productId); + + List findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(Collection productIds); + Optional findFirstByProduct_IdAndIsDefaultTrue(UUID productId); boolean existsBySkuIgnoreCase(String sku); + + boolean existsBySkuIgnoreCaseAndIdNot(String sku, UUID variantId); } diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java index 0e30a60..86766b6 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java @@ -330,6 +330,18 @@ public class AdminMediaControllerService { mediaUsageRepository.delete(getUsageOrThrow(mediaUsageId)); } + public List getUsages(String usageType, String usageKey, UUID ownerId) { + String normalizedUsageType = requireUsageType(usageType); + String normalizedUsageKey = requireUsageKey(usageKey); + return mediaUsageRepository.findByUsageScope(normalizedUsageType, normalizedUsageKey, ownerId) + .stream() + .sorted(Comparator + .comparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))) + .map(this::toUsageDto) + .toList(); + } + private List generateDerivedVariants(MediaAsset asset, Path sourceFile, Path tempDirectory) throws IOException { Path generatedDirectory = Files.createDirectories(tempDirectory.resolve("generated")); String storageFolder = extractStorageFolder(asset.getStorageKey()); diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java new file mode 100644 index 0000000..e327ac6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java @@ -0,0 +1,334 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminShopCategoryDto; +import com.printcalculator.dto.AdminShopCategoryRefDto; +import com.printcalculator.dto.AdminUpsertShopCategoryRequest; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.repository.ShopCategoryRepository; +import com.printcalculator.repository.ShopProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.text.Normalizer; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Service +@Transactional(readOnly = true) +public class AdminShopCategoryControllerService { + private static final String SHOP_CATEGORY_MEDIA_USAGE_TYPE = "SHOP_CATEGORY"; + private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}+"); + private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-z0-9]+"); + private static final Pattern EDGE_DASH_PATTERN = Pattern.compile("(^-+|-+$)"); + + private final ShopCategoryRepository shopCategoryRepository; + private final ShopProductRepository shopProductRepository; + + public AdminShopCategoryControllerService(ShopCategoryRepository shopCategoryRepository, + ShopProductRepository shopProductRepository) { + this.shopCategoryRepository = shopCategoryRepository; + this.shopProductRepository = shopProductRepository; + } + + public List getCategories() { + CategoryContext context = buildContext(); + List result = new ArrayList<>(); + appendFlatCategories(null, 0, context, result); + return result; + } + + public List getCategoryTree() { + return buildCategoryTree(null, 0, buildContext()); + } + + public AdminShopCategoryDto getCategory(UUID categoryId) { + CategoryContext context = buildContext(); + ShopCategory category = context.categoriesById().get(categoryId); + if (category == null) { + throw new ResponseStatusException(NOT_FOUND, "Shop category not found"); + } + return toDto(category, resolveDepth(category), context, true); + } + + @Transactional + public AdminShopCategoryDto createCategory(AdminUpsertShopCategoryRequest payload) { + ensurePayload(payload); + String normalizedName = normalizeRequiredName(payload.getName()); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); + ensureSlugAvailable(normalizedSlug, null); + + ShopCategory category = new ShopCategory(); + category.setCreatedAt(OffsetDateTime.now()); + applyPayload(category, payload, normalizedName, normalizedSlug, null); + + ShopCategory saved = shopCategoryRepository.save(category); + return getCategory(saved.getId()); + } + + @Transactional + public AdminShopCategoryDto updateCategory(UUID categoryId, AdminUpsertShopCategoryRequest payload) { + ensurePayload(payload); + + ShopCategory category = shopCategoryRepository.findById(categoryId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found")); + + String normalizedName = normalizeRequiredName(payload.getName()); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); + ensureSlugAvailable(normalizedSlug, category.getId()); + + applyPayload(category, payload, normalizedName, normalizedSlug, category.getId()); + ShopCategory saved = shopCategoryRepository.save(category); + return getCategory(saved.getId()); + } + + @Transactional + public void deleteCategory(UUID categoryId) { + ShopCategory category = shopCategoryRepository.findById(categoryId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found")); + + if (shopCategoryRepository.existsByParentCategory_Id(categoryId)) { + throw new ResponseStatusException(CONFLICT, "Category has child categories and cannot be deleted"); + } + if (shopProductRepository.existsByCategory_Id(categoryId)) { + throw new ResponseStatusException(CONFLICT, "Category has products and cannot be deleted"); + } + + shopCategoryRepository.delete(category); + } + + private void applyPayload(ShopCategory category, + AdminUpsertShopCategoryRequest payload, + String normalizedName, + String normalizedSlug, + UUID currentCategoryId) { + ShopCategory parentCategory = resolveParentCategory(payload.getParentCategoryId(), currentCategoryId); + + category.setParentCategory(parentCategory); + category.setSlug(normalizedSlug); + category.setName(normalizedName); + category.setDescription(normalizeOptional(payload.getDescription())); + category.setSeoTitle(normalizeOptional(payload.getSeoTitle())); + category.setSeoDescription(normalizeOptional(payload.getSeoDescription())); + category.setOgTitle(normalizeOptional(payload.getOgTitle())); + category.setOgDescription(normalizeOptional(payload.getOgDescription())); + category.setIndexable(payload.getIndexable() == null || payload.getIndexable()); + category.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + category.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0); + category.setUpdatedAt(OffsetDateTime.now()); + } + + private ShopCategory resolveParentCategory(UUID parentCategoryId, UUID currentCategoryId) { + if (parentCategoryId == null) { + return null; + } + if (currentCategoryId != null && currentCategoryId.equals(parentCategoryId)) { + throw new ResponseStatusException(BAD_REQUEST, "Category cannot be its own parent"); + } + + ShopCategory parentCategory = shopCategoryRepository.findById(parentCategoryId) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Parent category not found")); + + if (currentCategoryId != null) { + ShopCategory ancestor = parentCategory; + while (ancestor != null) { + if (currentCategoryId.equals(ancestor.getId())) { + throw new ResponseStatusException(BAD_REQUEST, "Category hierarchy would create a cycle"); + } + ancestor = ancestor.getParentCategory(); + } + } + + return parentCategory; + } + + private void ensurePayload(AdminUpsertShopCategoryRequest payload) { + if (payload == null) { + throw new ResponseStatusException(BAD_REQUEST, "Payload is required"); + } + } + + private String normalizeRequiredName(String name) { + String normalized = normalizeOptional(name); + if (normalized == null) { + throw new ResponseStatusException(BAD_REQUEST, "Category name is required"); + } + return normalized; + } + + private String normalizeAndValidateSlug(String slug, String fallbackName) { + String source = normalizeOptional(slug); + if (source == null) { + source = fallbackName; + } + + String normalized = Normalizer.normalize(source, Normalizer.Form.NFD); + normalized = DIACRITICS_PATTERN.matcher(normalized).replaceAll(""); + normalized = normalized.toLowerCase(Locale.ROOT); + normalized = NON_ALPHANUMERIC_PATTERN.matcher(normalized).replaceAll("-"); + normalized = EDGE_DASH_PATTERN.matcher(normalized).replaceAll(""); + + if (normalized.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Slug is invalid"); + } + return normalized; + } + + private void ensureSlugAvailable(String slug, UUID currentCategoryId) { + shopCategoryRepository.findBySlugIgnoreCase(slug).ifPresent(existing -> { + if (currentCategoryId == null || !existing.getId().equals(currentCategoryId)) { + throw new ResponseStatusException(BAD_REQUEST, "Category slug already exists"); + } + }); + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + + private CategoryContext buildContext() { + List categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc(); + List products = shopProductRepository.findAll(); + + Map categoriesById = categories.stream() + .collect(Collectors.toMap(ShopCategory::getId, category -> category, (left, right) -> left, LinkedHashMap::new)); + Map> childrenByParentId = new LinkedHashMap<>(); + for (ShopCategory category : categories) { + UUID parentId = category.getParentCategory() != null ? category.getParentCategory().getId() : null; + childrenByParentId.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(category); + } + Comparator comparator = Comparator + .comparing(ShopCategory::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ShopCategory::getName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)); + childrenByParentId.values().forEach(children -> children.sort(comparator)); + + Map directProductCounts = new LinkedHashMap<>(); + for (ShopProduct product : products) { + if (product.getCategory() == null || product.getCategory().getId() == null) { + continue; + } + directProductCounts.merge(product.getCategory().getId(), 1, Integer::sum); + } + + Map descendantProductCounts = new LinkedHashMap<>(); + for (ShopCategory category : categories) { + resolveDescendantProductCount(category.getId(), childrenByParentId, directProductCounts, descendantProductCounts); + } + + return new CategoryContext(categoriesById, childrenByParentId, directProductCounts, descendantProductCounts); + } + + private int resolveDescendantProductCount(UUID categoryId, + Map> childrenByParentId, + Map directProductCounts, + Map descendantProductCounts) { + Integer cached = descendantProductCounts.get(categoryId); + if (cached != null) { + return cached; + } + + int total = directProductCounts.getOrDefault(categoryId, 0); + for (ShopCategory child : childrenByParentId.getOrDefault(categoryId, List.of())) { + total += resolveDescendantProductCount(child.getId(), childrenByParentId, directProductCounts, descendantProductCounts); + } + descendantProductCounts.put(categoryId, total); + return total; + } + + private void appendFlatCategories(UUID parentId, + int depth, + CategoryContext context, + List result) { + for (ShopCategory category : context.childrenByParentId().getOrDefault(parentId, List.of())) { + result.add(toDto(category, depth, context, false)); + appendFlatCategories(category.getId(), depth + 1, context, result); + } + } + + private List buildCategoryTree(UUID parentId, int depth, CategoryContext context) { + return context.childrenByParentId().getOrDefault(parentId, List.of()).stream() + .map(category -> toDto(category, depth, context, true)) + .toList(); + } + + private AdminShopCategoryDto toDto(ShopCategory category, + int depth, + CategoryContext context, + boolean includeChildren) { + AdminShopCategoryDto dto = new AdminShopCategoryDto(); + dto.setId(category.getId()); + dto.setParentCategoryId(category.getParentCategory() != null ? category.getParentCategory().getId() : null); + dto.setParentCategoryName(category.getParentCategory() != null ? category.getParentCategory().getName() : null); + dto.setSlug(category.getSlug()); + dto.setName(category.getName()); + dto.setDescription(category.getDescription()); + dto.setSeoTitle(category.getSeoTitle()); + dto.setSeoDescription(category.getSeoDescription()); + dto.setOgTitle(category.getOgTitle()); + dto.setOgDescription(category.getOgDescription()); + dto.setIndexable(category.getIndexable()); + dto.setIsActive(category.getIsActive()); + dto.setSortOrder(category.getSortOrder()); + dto.setDepth(depth); + dto.setChildCount(context.childrenByParentId().getOrDefault(category.getId(), List.of()).size()); + dto.setDirectProductCount(context.directProductCounts().getOrDefault(category.getId(), 0)); + dto.setDescendantProductCount(context.descendantProductCounts().getOrDefault(category.getId(), 0)); + dto.setMediaUsageType(SHOP_CATEGORY_MEDIA_USAGE_TYPE); + dto.setMediaUsageKey(category.getId().toString()); + dto.setBreadcrumbs(buildBreadcrumbs(category)); + dto.setChildren(includeChildren ? buildCategoryTree(category.getId(), depth + 1, context) : List.of()); + dto.setCreatedAt(category.getCreatedAt()); + dto.setUpdatedAt(category.getUpdatedAt()); + return dto; + } + + private List buildBreadcrumbs(ShopCategory category) { + List breadcrumbs = new ArrayList<>(); + ShopCategory current = category; + while (current != null) { + AdminShopCategoryRefDto ref = new AdminShopCategoryRefDto(); + ref.setId(current.getId()); + ref.setSlug(current.getSlug()); + ref.setName(current.getName()); + breadcrumbs.add(ref); + current = current.getParentCategory(); + } + java.util.Collections.reverse(breadcrumbs); + return breadcrumbs; + } + + private int resolveDepth(ShopCategory category) { + int depth = 0; + ShopCategory current = category != null ? category.getParentCategory() : null; + while (current != null) { + depth++; + current = current.getParentCategory(); + } + return depth; + } + + private record CategoryContext( + Map categoriesById, + Map> childrenByParentId, + Map directProductCounts, + Map descendantProductCounts + ) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java new file mode 100644 index 0000000..1a2e2bf --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java @@ -0,0 +1,686 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminMediaUsageDto; +import com.printcalculator.dto.AdminShopProductDto; +import com.printcalculator.dto.AdminShopProductVariantDto; +import com.printcalculator.dto.AdminUpsertShopProductRequest; +import com.printcalculator.dto.AdminUpsertShopProductVariantRequest; +import com.printcalculator.dto.PublicMediaUsageDto; +import com.printcalculator.dto.ShopProductModelDto; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductModelAsset; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.model.ModelDimensions; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.ShopCategoryRepository; +import com.printcalculator.repository.ShopProductModelAssetRepository; +import com.printcalculator.repository.ShopProductRepository; +import com.printcalculator.repository.ShopProductVariantRepository; +import com.printcalculator.service.SlicerService; +import com.printcalculator.service.media.PublicMediaQueryService; +import com.printcalculator.service.shop.ShopStorageService; +import com.printcalculator.service.storage.ClamAVService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.Normalizer; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class AdminShopProductControllerService { + private static final String SHOP_PRODUCT_MEDIA_USAGE_TYPE = "SHOP_PRODUCT"; + private static final Set SUPPORTED_MODEL_EXTENSIONS = Set.of("stl", "3mf"); + private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$"); + private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}+"); + private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-z0-9]+"); + private static final Pattern EDGE_DASH_PATTERN = Pattern.compile("(^-+|-+$)"); + + private final ShopProductRepository shopProductRepository; + private final ShopCategoryRepository shopCategoryRepository; + private final ShopProductVariantRepository shopProductVariantRepository; + private final ShopProductModelAssetRepository shopProductModelAssetRepository; + private final QuoteLineItemRepository quoteLineItemRepository; + private final OrderItemRepository orderItemRepository; + private final PublicMediaQueryService publicMediaQueryService; + private final AdminMediaControllerService adminMediaControllerService; + private final ShopStorageService shopStorageService; + private final SlicerService slicerService; + private final ClamAVService clamAVService; + + public AdminShopProductControllerService(ShopProductRepository shopProductRepository, + ShopCategoryRepository shopCategoryRepository, + ShopProductVariantRepository shopProductVariantRepository, + ShopProductModelAssetRepository shopProductModelAssetRepository, + QuoteLineItemRepository quoteLineItemRepository, + OrderItemRepository orderItemRepository, + PublicMediaQueryService publicMediaQueryService, + AdminMediaControllerService adminMediaControllerService, + ShopStorageService shopStorageService, + SlicerService slicerService, + ClamAVService clamAVService) { + this.shopProductRepository = shopProductRepository; + this.shopCategoryRepository = shopCategoryRepository; + this.shopProductVariantRepository = shopProductVariantRepository; + this.shopProductModelAssetRepository = shopProductModelAssetRepository; + this.quoteLineItemRepository = quoteLineItemRepository; + this.orderItemRepository = orderItemRepository; + this.publicMediaQueryService = publicMediaQueryService; + this.adminMediaControllerService = adminMediaControllerService; + this.shopStorageService = shopStorageService; + this.slicerService = slicerService; + this.clamAVService = clamAVService; + } + + public List getProducts() { + return toProductDtos(shopProductRepository.findAllByOrderByIsFeaturedDescSortOrderAscNameAsc()); + } + + public AdminShopProductDto getProduct(UUID productId) { + ShopProduct product = shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + return toProductDtos(List.of(product)).get(0); + } + + @Transactional + public AdminShopProductDto createProduct(AdminUpsertShopProductRequest payload) { + ensurePayload(payload); + String normalizedName = normalizeRequired(payload.getName(), "Product name is required"); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); + ensureSlugAvailable(normalizedSlug, null); + + ShopProduct product = new ShopProduct(); + product.setCreatedAt(OffsetDateTime.now()); + applyProductPayload(product, payload, normalizedName, normalizedSlug, resolveCategory(payload.getCategoryId())); + ShopProduct saved = shopProductRepository.save(product); + syncVariants(saved, payload.getVariants()); + return getProduct(saved.getId()); + } + + @Transactional + public AdminShopProductDto updateProduct(UUID productId, AdminUpsertShopProductRequest payload) { + ensurePayload(payload); + ShopProduct product = shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + + String normalizedName = normalizeRequired(payload.getName(), "Product name is required"); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); + ensureSlugAvailable(normalizedSlug, productId); + + applyProductPayload(product, payload, normalizedName, normalizedSlug, resolveCategory(payload.getCategoryId())); + ShopProduct saved = shopProductRepository.save(product); + syncVariants(saved, payload.getVariants()); + return getProduct(saved.getId()); + } + + @Transactional + public void deleteProduct(UUID productId) { + ShopProduct product = shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + + if (quoteLineItemRepository.existsByShopProduct_Id(productId) + || orderItemRepository.existsByShopProduct_Id(productId)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Product is already used in carts or orders and cannot be deleted"); + } + + List variants = shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(productId); + for (ShopProductVariant variant : variants) { + if (quoteLineItemRepository.existsByShopProductVariant_Id(variant.getId()) + || orderItemRepository.existsByShopProductVariant_Id(variant.getId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "One or more variants are already used in carts or orders and cannot be deleted"); + } + } + + shopProductModelAssetRepository.findByProduct_Id(productId).ifPresent(asset -> deleteExistingModelFile(asset, productId)); + shopProductRepository.delete(product); + } + + @Transactional + public AdminShopProductDto uploadProductModel(UUID productId, MultipartFile file) throws IOException { + ShopProduct product = shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + validateModelUpload(file); + + Path tempDirectory = Files.createTempDirectory("shop-product-model-"); + Path destination = null; + try { + String cleanedFilename = sanitizeOriginalFilename(file.getOriginalFilename()); + String extension = resolveExtension(cleanedFilename); + Path uploadPath = tempDirectory.resolve("upload." + extension); + file.transferTo(uploadPath); + + try (InputStream inputStream = Files.newInputStream(uploadPath)) { + clamAVService.scan(inputStream); + } + + Path storageDir = shopStorageService.productModelStorageDir(productId); + destination = storageDir.resolve(UUID.randomUUID() + ".stl"); + if ("3mf".equals(extension)) { + slicerService.convert3mfToPersistentStl(uploadPath.toFile(), destination); + } else { + Files.copy(uploadPath, destination, StandardCopyOption.REPLACE_EXISTING); + } + + ModelDimensions dimensions = slicerService.inspectModelDimensions(destination.toFile()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unable to extract model dimensions")); + + ShopProductModelAsset asset = shopProductModelAssetRepository.findByProduct_Id(productId) + .orElseGet(ShopProductModelAsset::new); + String previousStoredRelativePath = asset.getStoredRelativePath(); + + asset.setProduct(product); + asset.setOriginalFilename(buildDownloadFilename(cleanedFilename)); + asset.setStoredFilename(destination.getFileName().toString()); + asset.setStoredRelativePath(shopStorageService.toStoredPath(destination)); + asset.setMimeType("model/stl"); + asset.setFileSizeBytes(Files.size(destination)); + asset.setSha256Hex(computeSha256(destination)); + asset.setBoundingBoxXMm(BigDecimal.valueOf(dimensions.xMm())); + asset.setBoundingBoxYMm(BigDecimal.valueOf(dimensions.yMm())); + asset.setBoundingBoxZMm(BigDecimal.valueOf(dimensions.zMm())); + if (asset.getCreatedAt() == null) { + asset.setCreatedAt(OffsetDateTime.now()); + } + asset.setUpdatedAt(OffsetDateTime.now()); + shopProductModelAssetRepository.save(asset); + deleteStoredRelativePath(previousStoredRelativePath, productId, asset.getStoredRelativePath()); + + return getProduct(productId); + } catch (IOException | RuntimeException e) { + deletePathQuietly(destination); + throw e; + } finally { + deleteRecursively(tempDirectory); + } + } + + @Transactional + public void deleteProductModel(UUID productId) { + shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + ShopProductModelAsset asset = shopProductModelAssetRepository.findByProduct_Id(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product model not found")); + + deleteExistingModelFile(asset, productId); + shopProductModelAssetRepository.delete(asset); + } + + public ProductModelDownload getProductModel(UUID productId) { + ShopProduct product = shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + ShopProductModelAsset asset = shopProductModelAssetRepository.findByProduct_Id(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product model not found")); + + Path path = shopStorageService.resolveStoredProductPath(asset.getStoredRelativePath(), product.getId()); + if (path == null || !Files.exists(path)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product model not found"); + } + + return new ProductModelDownload(path, asset.getOriginalFilename(), asset.getMimeType()); + } + + private void syncVariants(ShopProduct product, List variantPayloads) { + List normalizedPayloads = normalizeVariantPayloads(variantPayloads); + List existingVariants = shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(product.getId()); + Map existingById = existingVariants.stream() + .collect(Collectors.toMap(ShopProductVariant::getId, variant -> variant, (left, right) -> left, LinkedHashMap::new)); + + Set retainedIds = new LinkedHashSet<>(); + List variantsToSave = new ArrayList<>(); + + for (AdminUpsertShopProductVariantRequest payload : normalizedPayloads) { + ShopProductVariant variant; + if (payload.getId() != null) { + variant = existingById.get(payload.getId()); + if (variant == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant does not belong to the product"); + } + retainedIds.add(variant.getId()); + } else { + variant = new ShopProductVariant(); + variant.setCreatedAt(OffsetDateTime.now()); + } + + applyVariantPayload(variant, product, payload); + variantsToSave.add(variant); + } + + List variantsToDelete = existingVariants.stream() + .filter(variant -> !retainedIds.contains(variant.getId())) + .toList(); + for (ShopProductVariant variant : variantsToDelete) { + if (quoteLineItemRepository.existsByShopProductVariant_Id(variant.getId()) + || orderItemRepository.existsByShopProductVariant_Id(variant.getId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Variant is already used in carts or orders and cannot be removed"); + } + } + + if (!variantsToDelete.isEmpty()) { + shopProductVariantRepository.deleteAll(variantsToDelete); + } + shopProductVariantRepository.saveAll(variantsToSave); + } + + private void applyProductPayload(ShopProduct product, + AdminUpsertShopProductRequest payload, + String normalizedName, + String normalizedSlug, + ShopCategory category) { + product.setCategory(category); + product.setSlug(normalizedSlug); + product.setName(normalizedName); + product.setExcerpt(normalizeOptional(payload.getExcerpt())); + product.setDescription(normalizeOptional(payload.getDescription())); + product.setSeoTitle(normalizeOptional(payload.getSeoTitle())); + product.setSeoDescription(normalizeOptional(payload.getSeoDescription())); + product.setOgTitle(normalizeOptional(payload.getOgTitle())); + product.setOgDescription(normalizeOptional(payload.getOgDescription())); + product.setIndexable(payload.getIndexable() == null || payload.getIndexable()); + product.setIsFeatured(Boolean.TRUE.equals(payload.getIsFeatured())); + product.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + product.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0); + product.setUpdatedAt(OffsetDateTime.now()); + } + + private void applyVariantPayload(ShopProductVariant variant, + ShopProduct product, + AdminUpsertShopProductVariantRequest payload) { + String normalizedColorName = normalizeRequired(payload.getColorName(), "Variant colorName is required"); + String normalizedVariantLabel = normalizeOptional(payload.getVariantLabel()); + String normalizedSku = normalizeOptional(payload.getSku()); + String normalizedMaterialCode = normalizeRequired( + payload.getInternalMaterialCode(), + "Variant internalMaterialCode is required" + ).toUpperCase(Locale.ROOT); + + BigDecimal price = payload.getPriceChf(); + if (price == null || price.compareTo(BigDecimal.ZERO) < 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant priceChf must be >= 0"); + } + if (price.scale() > 2) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant priceChf must have at most 2 decimal places"); + } + + if (normalizedSku != null) { + if (variant.getId() == null) { + if (shopProductVariantRepository.existsBySkuIgnoreCase(normalizedSku)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant SKU already exists"); + } + } else if (shopProductVariantRepository.existsBySkuIgnoreCaseAndIdNot(normalizedSku, variant.getId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant SKU already exists"); + } + } + + variant.setProduct(product); + variant.setSku(normalizedSku); + variant.setVariantLabel(normalizedVariantLabel != null ? normalizedVariantLabel : normalizedColorName); + variant.setColorName(normalizedColorName); + variant.setColorHex(normalizeColorHex(payload.getColorHex())); + variant.setInternalMaterialCode(normalizedMaterialCode); + variant.setPriceChf(price); + variant.setIsDefault(Boolean.TRUE.equals(payload.getIsDefault())); + variant.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + variant.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0); + variant.setUpdatedAt(OffsetDateTime.now()); + } + + private List normalizeVariantPayloads(List payloads) { + if (payloads == null || payloads.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one variant is required"); + } + + List normalized = new ArrayList<>(payloads); + Set colorKeys = new LinkedHashSet<>(); + int defaultCount = 0; + for (AdminUpsertShopProductVariantRequest payload : normalized) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant payload is required"); + } + String colorName = normalizeRequired(payload.getColorName(), "Variant colorName is required"); + String colorKey = colorName.toLowerCase(Locale.ROOT); + if (!colorKeys.add(colorKey)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate variant colorName: " + colorName); + } + if (Boolean.TRUE.equals(payload.getIsDefault())) { + defaultCount++; + } + } + + if (defaultCount > 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Only one variant can be default"); + } + if (defaultCount == 0) { + AdminUpsertShopProductVariantRequest fallbackDefault = normalized.stream() + .filter(payload -> payload.getIsActive() == null || payload.getIsActive()) + .findFirst() + .orElse(normalized.get(0)); + fallbackDefault.setIsDefault(true); + } + return normalized; + } + + private List toProductDtos(List products) { + if (products == null || products.isEmpty()) { + return List.of(); + } + + List productIds = products.stream().map(ShopProduct::getId).toList(); + Map> variantsByProductId = shopProductVariantRepository + .findByProduct_IdInOrderBySortOrderAscColorNameAsc(productIds) + .stream() + .collect(Collectors.groupingBy( + variant -> variant.getProduct().getId(), + LinkedHashMap::new, + Collectors.toList() + )); + Map modelAssetsByProductId = shopProductModelAssetRepository.findByProduct_IdIn(productIds) + .stream() + .collect(Collectors.toMap(asset -> asset.getProduct().getId(), asset -> asset, (left, right) -> left, LinkedHashMap::new)); + Map> publicImagesByUsageKey = publicMediaQueryService.getUsageMediaMap( + SHOP_PRODUCT_MEDIA_USAGE_TYPE, + products.stream().map(this::mediaUsageKey).toList(), + null + ); + + return products.stream() + .map(product -> { + String usageKey = mediaUsageKey(product); + return toProductDto( + product, + variantsByProductId.getOrDefault(product.getId(), List.of()), + modelAssetsByProductId.get(product.getId()), + publicImagesByUsageKey.getOrDefault(usageKey, List.of()), + adminMediaControllerService.getUsages(SHOP_PRODUCT_MEDIA_USAGE_TYPE, usageKey, null) + ); + }) + .toList(); + } + + private AdminShopProductDto toProductDto(ShopProduct product, + List variants, + ShopProductModelAsset modelAsset, + List images, + List mediaUsages) { + AdminShopProductDto dto = new AdminShopProductDto(); + dto.setId(product.getId()); + dto.setCategoryId(product.getCategory() != null ? product.getCategory().getId() : null); + dto.setCategoryName(product.getCategory() != null ? product.getCategory().getName() : null); + dto.setCategorySlug(product.getCategory() != null ? product.getCategory().getSlug() : null); + dto.setSlug(product.getSlug()); + dto.setName(product.getName()); + dto.setExcerpt(product.getExcerpt()); + dto.setDescription(product.getDescription()); + dto.setSeoTitle(product.getSeoTitle()); + dto.setSeoDescription(product.getSeoDescription()); + dto.setOgTitle(product.getOgTitle()); + dto.setOgDescription(product.getOgDescription()); + dto.setIndexable(product.getIndexable()); + dto.setIsFeatured(product.getIsFeatured()); + dto.setIsActive(product.getIsActive()); + dto.setSortOrder(product.getSortOrder()); + dto.setVariantCount(variants.size()); + dto.setActiveVariantCount((int) variants.stream().filter(variant -> Boolean.TRUE.equals(variant.getIsActive())).count()); + dto.setPriceFromChf(resolvePriceFrom(variants)); + dto.setPriceToChf(resolvePriceTo(variants)); + dto.setMediaUsageType(SHOP_PRODUCT_MEDIA_USAGE_TYPE); + dto.setMediaUsageKey(mediaUsageKey(product)); + dto.setMediaUsages(mediaUsages); + dto.setImages(images); + dto.setModel3d(toModelDto(product, modelAsset)); + dto.setVariants(variants.stream().map(this::toVariantDto).toList()); + dto.setCreatedAt(product.getCreatedAt()); + dto.setUpdatedAt(product.getUpdatedAt()); + return dto; + } + + private AdminShopProductVariantDto toVariantDto(ShopProductVariant variant) { + AdminShopProductVariantDto dto = new AdminShopProductVariantDto(); + dto.setId(variant.getId()); + dto.setSku(variant.getSku()); + dto.setVariantLabel(variant.getVariantLabel()); + dto.setColorName(variant.getColorName()); + dto.setColorHex(variant.getColorHex()); + dto.setInternalMaterialCode(variant.getInternalMaterialCode()); + dto.setPriceChf(variant.getPriceChf()); + dto.setIsDefault(variant.getIsDefault()); + dto.setIsActive(variant.getIsActive()); + dto.setSortOrder(variant.getSortOrder()); + dto.setCreatedAt(variant.getCreatedAt()); + dto.setUpdatedAt(variant.getUpdatedAt()); + return dto; + } + + private ShopProductModelDto toModelDto(ShopProduct product, ShopProductModelAsset asset) { + if (asset == null) { + return null; + } + return new ShopProductModelDto( + "/api/admin/shop/products/" + product.getId() + "/model", + asset.getOriginalFilename(), + asset.getMimeType(), + asset.getFileSizeBytes(), + asset.getBoundingBoxXMm(), + asset.getBoundingBoxYMm(), + asset.getBoundingBoxZMm() + ); + } + + private BigDecimal resolvePriceFrom(List variants) { + return variants.stream() + .map(ShopProductVariant::getPriceChf) + .filter(Objects::nonNull) + .min(BigDecimal::compareTo) + .orElse(BigDecimal.ZERO); + } + + private BigDecimal resolvePriceTo(List variants) { + return variants.stream() + .map(ShopProductVariant::getPriceChf) + .filter(Objects::nonNull) + .max(BigDecimal::compareTo) + .orElse(BigDecimal.ZERO); + } + + private ShopCategory resolveCategory(UUID categoryId) { + if (categoryId == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "categoryId is required"); + } + return shopCategoryRepository.findById(categoryId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Category not found")); + } + + private void ensurePayload(AdminUpsertShopProductRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required"); + } + } + + private void ensureSlugAvailable(String slug, UUID currentProductId) { + shopProductRepository.findBySlugIgnoreCase(slug).ifPresent(existing -> { + if (currentProductId == null || !existing.getId().equals(currentProductId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Product slug already exists"); + } + }); + } + + private String normalizeRequired(String value, String message) { + String normalized = normalizeOptional(value); + if (normalized == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + return normalized; + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + + private String normalizeAndValidateSlug(String slug, String fallbackName) { + String source = normalizeOptional(slug); + if (source == null) { + source = fallbackName; + } + + String normalized = Normalizer.normalize(source, Normalizer.Form.NFD); + normalized = DIACRITICS_PATTERN.matcher(normalized).replaceAll(""); + normalized = normalized.toLowerCase(Locale.ROOT); + normalized = NON_ALPHANUMERIC_PATTERN.matcher(normalized).replaceAll("-"); + normalized = EDGE_DASH_PATTERN.matcher(normalized).replaceAll(""); + if (normalized.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Slug is invalid"); + } + return normalized; + } + + private String normalizeColorHex(String value) { + String normalized = normalizeOptional(value); + if (normalized == null) { + return null; + } + if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant colorHex must be in format #RRGGBB"); + } + return normalized.toUpperCase(Locale.ROOT); + } + + private void validateModelUpload(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file is required"); + } + String extension = resolveExtension(sanitizeOriginalFilename(file.getOriginalFilename())); + if (!SUPPORTED_MODEL_EXTENSIONS.contains(extension)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported 3D model type. Allowed: stl, 3mf"); + } + } + + private String sanitizeOriginalFilename(String originalFilename) { + String cleaned = StringUtils.cleanPath(originalFilename == null ? "" : originalFilename); + int separatorIndex = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\')); + String basename = separatorIndex >= 0 ? cleaned.substring(separatorIndex + 1) : cleaned; + basename = basename.replace("\r", "_").replace("\n", "_"); + return basename.isBlank() ? "model.stl" : basename; + } + + private String resolveExtension(String filename) { + int dotIndex = filename.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex == filename.length() - 1) { + return ""; + } + return filename.substring(dotIndex + 1).toLowerCase(Locale.ROOT); + } + + private String buildDownloadFilename(String originalFilename) { + int dotIndex = originalFilename.lastIndexOf('.'); + String base = dotIndex > 0 ? originalFilename.substring(0, dotIndex) : originalFilename; + return base + ".stl"; + } + + private String mediaUsageKey(ShopProduct product) { + return product.getId().toString(); + } + + private void deleteExistingModelFile(ShopProductModelAsset asset, UUID productId) { + if (asset == null || asset.getStoredRelativePath() == null || asset.getStoredRelativePath().isBlank()) { + return; + } + Path existingPath = shopStorageService.resolveStoredProductPath(asset.getStoredRelativePath(), productId); + if (existingPath == null) { + return; + } + try { + Files.deleteIfExists(existingPath); + } catch (IOException ignored) { + } + } + + private void deleteStoredRelativePath(String storedRelativePath, UUID productId, String excludeStoredRelativePath) { + if (storedRelativePath == null || storedRelativePath.isBlank()) { + return; + } + if (Objects.equals(storedRelativePath, excludeStoredRelativePath)) { + return; + } + Path existingPath = shopStorageService.resolveStoredProductPath(storedRelativePath, productId); + deletePathQuietly(existingPath); + } + + private String computeSha256(Path file) throws IOException { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-256 digest unavailable", e); + } + + try (InputStream inputStream = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = inputStream.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read); + } + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + private void deletePathQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + } + } + + private void deleteRecursively(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + try (var walk = Files.walk(path)) { + walk.sorted(Comparator.reverseOrder()).forEach(current -> { + try { + Files.deleteIfExists(current); + } catch (IOException ignored) { + } + }); + } catch (IOException ignored) { + } + } + + public record ProductModelDownload(Path path, String filename, String mimeType) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java b/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java index bbf0fe7..f7e65b3 100644 --- a/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java +++ b/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java @@ -14,6 +14,7 @@ import org.springframework.web.server.ResponseStatusException; import java.time.OffsetDateTime; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -48,22 +49,40 @@ public class PublicMediaQueryService { public List getUsageMedia(String usageType, String usageKey, String language) { String normalizedUsageType = normalizeUsageType(usageType); String normalizedUsageKey = normalizeUsageKey(usageKey); + return getUsageMediaMap(normalizedUsageType, List.of(normalizedUsageKey), language) + .getOrDefault(normalizedUsageKey, List.of()); + } + + public Map> getUsageMediaMap(String usageType, + List usageKeys, + String language) { + String normalizedUsageType = normalizeUsageType(usageType); String normalizedLanguage = normalizeLanguage(language); + List normalizedUsageKeys = (usageKeys == null + ? List.of() + : usageKeys) + .stream() + .filter(Objects::nonNull) + .map(this::normalizeUsageKey) + .distinct() + .toList(); + + if (normalizedUsageKeys.isEmpty()) { + return Map.of(); + } List usages = mediaUsageRepository - .findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc( - normalizedUsageType, - normalizedUsageKey - ) + .findActiveByUsageTypeAndUsageKeys(normalizedUsageType, normalizedUsageKeys) .stream() .filter(this::isPublicReadyUsage) .sorted(Comparator - .comparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .comparing(MediaUsage::getUsageKey, Comparator.nullsLast(String::compareTo)) + .thenComparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo)) .thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))) .toList(); if (usages.isEmpty()) { - return List.of(); + return Map.of(); } List assetIds = usages.stream() @@ -79,13 +98,16 @@ public class PublicMediaQueryService { .filter(variant -> !Objects.equals("ORIGINAL", variant.getFormat())) .collect(Collectors.groupingBy(variant -> variant.getMediaAsset().getId())); - return usages.stream() - .map(usage -> toDto( - usage, - variantsByAssetId.getOrDefault(usage.getMediaAsset().getId(), List.of()), - normalizedLanguage - )) - .toList(); + Map> result = new LinkedHashMap<>(); + for (MediaUsage usage : usages) { + result.computeIfAbsent(usage.getUsageKey(), ignored -> new java.util.ArrayList<>()) + .add(toDto( + usage, + variantsByAssetId.getOrDefault(usage.getMediaAsset().getId(), List.of()), + normalizedLanguage + )); + } + return result; } private boolean isPublicReadyUsage(MediaUsage usage) { diff --git a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java new file mode 100644 index 0000000..99a7b53 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -0,0 +1,504 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.dto.PublicMediaUsageDto; +import com.printcalculator.dto.ShopCategoryDetailDto; +import com.printcalculator.dto.ShopCategoryRefDto; +import com.printcalculator.dto.ShopCategoryTreeDto; +import com.printcalculator.dto.ShopProductCatalogResponseDto; +import com.printcalculator.dto.ShopProductDetailDto; +import com.printcalculator.dto.ShopProductModelDto; +import com.printcalculator.dto.ShopProductSummaryDto; +import com.printcalculator.dto.ShopProductVariantOptionDto; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductModelAsset; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.repository.ShopCategoryRepository; +import com.printcalculator.repository.ShopProductModelAssetRepository; +import com.printcalculator.repository.ShopProductRepository; +import com.printcalculator.repository.ShopProductVariantRepository; +import com.printcalculator.service.media.PublicMediaQueryService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class PublicShopCatalogService { + private static final String SHOP_CATEGORY_MEDIA_USAGE_TYPE = "SHOP_CATEGORY"; + private static final String SHOP_PRODUCT_MEDIA_USAGE_TYPE = "SHOP_PRODUCT"; + + private final ShopCategoryRepository shopCategoryRepository; + private final ShopProductRepository shopProductRepository; + private final ShopProductVariantRepository shopProductVariantRepository; + private final ShopProductModelAssetRepository shopProductModelAssetRepository; + private final PublicMediaQueryService publicMediaQueryService; + private final ShopStorageService shopStorageService; + + public PublicShopCatalogService(ShopCategoryRepository shopCategoryRepository, + ShopProductRepository shopProductRepository, + ShopProductVariantRepository shopProductVariantRepository, + ShopProductModelAssetRepository shopProductModelAssetRepository, + PublicMediaQueryService publicMediaQueryService, + ShopStorageService shopStorageService) { + this.shopCategoryRepository = shopCategoryRepository; + this.shopProductRepository = shopProductRepository; + this.shopProductVariantRepository = shopProductVariantRepository; + this.shopProductModelAssetRepository = shopProductModelAssetRepository; + this.publicMediaQueryService = publicMediaQueryService; + this.shopStorageService = shopStorageService; + } + + public List getCategories(String language) { + CategoryContext categoryContext = loadCategoryContext(language); + return buildCategoryTree(null, categoryContext); + } + + public ShopCategoryDetailDto getCategory(String slug, String language) { + ShopCategory category = shopCategoryRepository.findBySlugAndIsActiveTrue(slug) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found")); + + CategoryContext categoryContext = loadCategoryContext(language); + if (!categoryContext.categoriesById().containsKey(category.getId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found"); + } + + return buildCategoryDetail(category, categoryContext); + } + + public ShopProductCatalogResponseDto getProductCatalog(String categorySlug, Boolean featuredOnly, String language) { + CategoryContext categoryContext = loadCategoryContext(language); + PublicProductContext productContext = loadPublicProductContext(categoryContext, language); + + ShopCategory selectedCategory = null; + if (categorySlug != null && !categorySlug.isBlank()) { + selectedCategory = categoryContext.categoriesBySlug().get(categorySlug.trim()); + if (selectedCategory == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found"); + } + } + + Collection allowedCategoryIds = selectedCategory == null + ? categoryContext.categoriesById().keySet() + : resolveDescendantCategoryIds(selectedCategory.getId(), categoryContext.childrenByParentId()); + + List products = productContext.entries().stream() + .filter(entry -> allowedCategoryIds.contains(entry.product().getCategory().getId())) + .filter(entry -> !Boolean.TRUE.equals(featuredOnly) || Boolean.TRUE.equals(entry.product().getIsFeatured())) + .map(entry -> toProductSummaryDto(entry, productContext.productMediaBySlug())) + .toList(); + + ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null + ? buildCategoryDetail(selectedCategory, categoryContext) + : null; + + return new ShopProductCatalogResponseDto( + selectedCategory != null ? selectedCategory.getSlug() : null, + Boolean.TRUE.equals(featuredOnly), + selectedCategoryDetail, + products + ); + } + + public ShopProductDetailDto getProduct(String slug, String language) { + CategoryContext categoryContext = loadCategoryContext(language); + PublicProductContext productContext = loadPublicProductContext(categoryContext, language); + + ProductEntry entry = productContext.entriesBySlug().get(slug); + if (entry == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); + } + + ShopCategory category = entry.product().getCategory(); + if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); + } + + return toProductDetailDto(entry, productContext.productMediaBySlug()); + } + + public ProductModelDownload getProductModelDownload(String slug) { + CategoryContext categoryContext = loadCategoryContext(null); + PublicProductContext productContext = loadPublicProductContext(categoryContext, null); + ProductEntry entry = productContext.entriesBySlug().get(slug); + if (entry == null || entry.modelAsset() == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product model not found"); + } + + Path path = shopStorageService.resolveStoredProductPath( + entry.modelAsset().getStoredRelativePath(), + entry.product().getId() + ); + if (path == null || !Files.exists(path)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product model not found"); + } + + return new ProductModelDownload( + path, + entry.modelAsset().getOriginalFilename(), + entry.modelAsset().getMimeType() + ); + } + + private CategoryContext loadCategoryContext(String language) { + List categories = shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc(); + + Map categoriesById = categories.stream() + .collect(Collectors.toMap(ShopCategory::getId, category -> category, (left, right) -> left, LinkedHashMap::new)); + Map categoriesBySlug = categories.stream() + .collect(Collectors.toMap(ShopCategory::getSlug, category -> category, (left, right) -> left, LinkedHashMap::new)); + Map> childrenByParentId = buildChildrenByParentId(categories); + + List publicProducts = loadPublicProducts(categoriesById.keySet()); + Map descendantProductCounts = resolveDescendantProductCounts(categories, childrenByParentId, publicProducts); + Map> categoryMediaBySlug = publicMediaQueryService.getUsageMediaMap( + SHOP_CATEGORY_MEDIA_USAGE_TYPE, + categories.stream().map(this::categoryMediaUsageKey).toList(), + language + ); + + return new CategoryContext( + categoriesById, + categoriesBySlug, + childrenByParentId, + descendantProductCounts, + categoryMediaBySlug + ); + } + + private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) { + List entries = loadPublicProducts(categoryContext.categoriesById().keySet()); + Map> productMediaBySlug = publicMediaQueryService.getUsageMediaMap( + SHOP_PRODUCT_MEDIA_USAGE_TYPE, + entries.stream().map(entry -> productMediaUsageKey(entry.product())).toList(), + language + ); + + Map entriesBySlug = entries.stream() + .collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new)); + + return new PublicProductContext(entries, entriesBySlug, productMediaBySlug); + } + + private List loadPublicProducts(Collection activeCategoryIds) { + List products = shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc(); + if (products.isEmpty()) { + return List.of(); + } + + List productIds = products.stream().map(ShopProduct::getId).toList(); + Map> variantsByProductId = shopProductVariantRepository + .findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(productIds) + .stream() + .collect(Collectors.groupingBy( + variant -> variant.getProduct().getId(), + LinkedHashMap::new, + Collectors.toList() + )); + Map modelAssetByProductId = shopProductModelAssetRepository.findByProduct_IdIn(productIds) + .stream() + .collect(Collectors.toMap(asset -> asset.getProduct().getId(), asset -> asset, (left, right) -> left, LinkedHashMap::new)); + + return products.stream() + .filter(product -> product.getCategory() != null) + .filter(product -> activeCategoryIds.contains(product.getCategory().getId())) + .map(product -> { + List activeVariants = variantsByProductId.getOrDefault(product.getId(), List.of()); + if (activeVariants.isEmpty()) { + return null; + } + ShopProductVariant defaultVariant = pickDefaultVariant(activeVariants); + return new ProductEntry( + product, + activeVariants, + defaultVariant, + modelAssetByProductId.get(product.getId()) + ); + }) + .filter(Objects::nonNull) + .toList(); + } + + private Map> buildChildrenByParentId(List categories) { + Map> childrenByParentId = new LinkedHashMap<>(); + for (ShopCategory category : categories) { + UUID parentId = category.getParentCategory() != null ? category.getParentCategory().getId() : null; + childrenByParentId.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(category); + } + Comparator comparator = Comparator + .comparing(ShopCategory::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ShopCategory::getName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)); + childrenByParentId.values().forEach(children -> children.sort(comparator)); + return childrenByParentId; + } + + private Map resolveDescendantProductCounts(List categories, + Map> childrenByParentId, + List publicProducts) { + Map directProductCounts = new LinkedHashMap<>(); + for (ProductEntry entry : publicProducts) { + UUID categoryId = entry.product().getCategory().getId(); + directProductCounts.merge(categoryId, 1, Integer::sum); + } + + Map descendantCounts = new LinkedHashMap<>(); + for (ShopCategory category : categories) { + resolveCategoryProductCount(category.getId(), childrenByParentId, directProductCounts, descendantCounts); + } + return descendantCounts; + } + + private int resolveCategoryProductCount(UUID categoryId, + Map> childrenByParentId, + Map directProductCounts, + Map descendantCounts) { + Integer cached = descendantCounts.get(categoryId); + if (cached != null) { + return cached; + } + + int total = directProductCounts.getOrDefault(categoryId, 0); + for (ShopCategory child : childrenByParentId.getOrDefault(categoryId, List.of())) { + total += resolveCategoryProductCount(child.getId(), childrenByParentId, directProductCounts, descendantCounts); + } + descendantCounts.put(categoryId, total); + return total; + } + + private List buildCategoryTree(UUID parentId, CategoryContext categoryContext) { + return categoryContext.childrenByParentId().getOrDefault(parentId, List.of()).stream() + .map(category -> new ShopCategoryTreeDto( + category.getId(), + category.getParentCategory() != null ? category.getParentCategory().getId() : null, + category.getSlug(), + category.getName(), + category.getDescription(), + category.getSeoTitle(), + category.getSeoDescription(), + category.getOgTitle(), + category.getOgDescription(), + category.getIndexable(), + category.getSortOrder(), + categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0), + selectPrimaryMedia(categoryContext.categoryMediaBySlug().get(categoryMediaUsageKey(category))), + buildCategoryTree(category.getId(), categoryContext) + )) + .toList(); + } + + private ShopCategoryDetailDto buildCategoryDetail(ShopCategory category, CategoryContext categoryContext) { + List images = categoryContext.categoryMediaBySlug().getOrDefault(categoryMediaUsageKey(category), List.of()); + return new ShopCategoryDetailDto( + category.getId(), + category.getSlug(), + category.getName(), + category.getDescription(), + category.getSeoTitle(), + category.getSeoDescription(), + category.getOgTitle(), + category.getOgDescription(), + category.getIndexable(), + category.getSortOrder(), + categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0), + buildCategoryBreadcrumbs(category), + selectPrimaryMedia(images), + images, + buildCategoryTree(category.getId(), categoryContext) + ); + } + + private List buildCategoryBreadcrumbs(ShopCategory category) { + List breadcrumbs = new ArrayList<>(); + ShopCategory current = category; + while (current != null) { + breadcrumbs.add(new ShopCategoryRefDto(current.getId(), current.getSlug(), current.getName())); + current = current.getParentCategory(); + } + java.util.Collections.reverse(breadcrumbs); + return breadcrumbs; + } + + private List resolveDescendantCategoryIds(UUID rootId, Map> childrenByParentId) { + List ids = new ArrayList<>(); + collectDescendantCategoryIds(rootId, childrenByParentId, ids); + return ids; + } + + private void collectDescendantCategoryIds(UUID categoryId, + Map> childrenByParentId, + List accumulator) { + accumulator.add(categoryId); + for (ShopCategory child : childrenByParentId.getOrDefault(categoryId, List.of())) { + collectDescendantCategoryIds(child.getId(), childrenByParentId, accumulator); + } + } + + private ShopProductSummaryDto toProductSummaryDto(ProductEntry entry, + Map> productMediaBySlug) { + List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); + return new ShopProductSummaryDto( + entry.product().getId(), + entry.product().getSlug(), + entry.product().getName(), + entry.product().getExcerpt(), + entry.product().getIsFeatured(), + entry.product().getSortOrder(), + new ShopCategoryRefDto( + entry.product().getCategory().getId(), + entry.product().getCategory().getSlug(), + entry.product().getCategory().getName() + ), + resolvePriceFrom(entry.variants()), + resolvePriceTo(entry.variants()), + toVariantDto(entry.defaultVariant(), entry.defaultVariant()), + selectPrimaryMedia(images), + toProductModelDto(entry) + ); + } + + private ShopProductDetailDto toProductDetailDto(ProductEntry entry, + Map> productMediaBySlug) { + List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); + return new ShopProductDetailDto( + entry.product().getId(), + entry.product().getSlug(), + entry.product().getName(), + entry.product().getExcerpt(), + entry.product().getDescription(), + entry.product().getSeoTitle(), + entry.product().getSeoDescription(), + entry.product().getOgTitle(), + entry.product().getOgDescription(), + entry.product().getIndexable(), + entry.product().getIsFeatured(), + entry.product().getSortOrder(), + new ShopCategoryRefDto( + entry.product().getCategory().getId(), + entry.product().getCategory().getSlug(), + entry.product().getCategory().getName() + ), + buildCategoryBreadcrumbs(entry.product().getCategory()), + resolvePriceFrom(entry.variants()), + resolvePriceTo(entry.variants()), + toVariantDto(entry.defaultVariant(), entry.defaultVariant()), + entry.variants().stream() + .map(variant -> toVariantDto(variant, entry.defaultVariant())) + .toList(), + selectPrimaryMedia(images), + images, + toProductModelDto(entry) + ); + } + + private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant, ShopProductVariant defaultVariant) { + if (variant == null) { + return null; + } + return new ShopProductVariantOptionDto( + variant.getId(), + variant.getSku(), + variant.getVariantLabel(), + variant.getColorName(), + variant.getColorHex(), + variant.getPriceChf(), + defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId()) + ); + } + + private ShopProductModelDto toProductModelDto(ProductEntry entry) { + if (entry.modelAsset() == null) { + return null; + } + return new ShopProductModelDto( + "/api/shop/products/" + entry.product().getSlug() + "/model", + entry.modelAsset().getOriginalFilename(), + entry.modelAsset().getMimeType(), + entry.modelAsset().getFileSizeBytes(), + entry.modelAsset().getBoundingBoxXMm(), + entry.modelAsset().getBoundingBoxYMm(), + entry.modelAsset().getBoundingBoxZMm() + ); + } + + private ShopProductVariant pickDefaultVariant(List variants) { + return variants.stream() + .filter(variant -> Boolean.TRUE.equals(variant.getIsDefault())) + .findFirst() + .orElseGet(() -> variants.isEmpty() ? null : variants.get(0)); + } + + private BigDecimal resolvePriceFrom(List variants) { + return variants.stream() + .map(ShopProductVariant::getPriceChf) + .filter(Objects::nonNull) + .min(BigDecimal::compareTo) + .orElse(BigDecimal.ZERO); + } + + private BigDecimal resolvePriceTo(List variants) { + return variants.stream() + .map(ShopProductVariant::getPriceChf) + .filter(Objects::nonNull) + .max(BigDecimal::compareTo) + .orElse(BigDecimal.ZERO); + } + + private PublicMediaUsageDto selectPrimaryMedia(List images) { + if (images == null || images.isEmpty()) { + return null; + } + return images.stream() + .filter(image -> Boolean.TRUE.equals(image.getIsPrimary())) + .findFirst() + .orElse(images.get(0)); + } + + private String categoryMediaUsageKey(ShopCategory category) { + return category.getId().toString(); + } + + private String productMediaUsageKey(ShopProduct product) { + return product.getId().toString(); + } + + public record ProductModelDownload(Path path, String filename, String mimeType) { + } + + private record CategoryContext( + Map categoriesById, + Map categoriesBySlug, + Map> childrenByParentId, + Map descendantProductCounts, + Map> categoryMediaBySlug + ) { + } + + private record PublicProductContext( + List entries, + Map entriesBySlug, + Map> productMediaBySlug + ) { + } + + private record ProductEntry( + ShopProduct product, + List variants, + ShopProductVariant defaultVariant, + ShopProductModelAsset modelAsset + ) { + } +}