From fb5e7537693c0a2ebdaa497c164f671899dea14f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Mon, 9 Mar 2026 19:58:53 +0100 Subject: [PATCH] feat(back-end) shop logic --- .../controller/ShopCartController.java | 85 ++++ .../dto/ShopCartAddItemRequest.java | 30 ++ .../dto/ShopCartUpdateItemRequest.java | 18 + .../repository/QuoteLineItemRepository.java | 8 + .../repository/QuoteSessionRepository.java | 3 + .../quote/QuoteSessionResponseAssembler.java | 16 + .../service/shop/ShopCartCookieService.java | 91 +++++ .../service/shop/ShopCartService.java | 362 ++++++++++++++++++ .../service/shop/ShopStorageService.java | 50 +++ .../src/main/resources/application.properties | 4 + 10 files changed, 667 insertions(+) create mode 100644 backend/src/main/java/com/printcalculator/controller/ShopCartController.java create mode 100644 backend/src/main/java/com/printcalculator/dto/ShopCartAddItemRequest.java create mode 100644 backend/src/main/java/com/printcalculator/dto/ShopCartUpdateItemRequest.java create mode 100644 backend/src/main/java/com/printcalculator/service/shop/ShopCartCookieService.java create mode 100644 backend/src/main/java/com/printcalculator/service/shop/ShopCartService.java create mode 100644 backend/src/main/java/com/printcalculator/service/shop/ShopStorageService.java diff --git a/backend/src/main/java/com/printcalculator/controller/ShopCartController.java b/backend/src/main/java/com/printcalculator/controller/ShopCartController.java new file mode 100644 index 0000000..fc58f6e --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/ShopCartController.java @@ -0,0 +1,85 @@ +package com.printcalculator.controller; + +import com.printcalculator.dto.ShopCartAddItemRequest; +import com.printcalculator.dto.ShopCartUpdateItemRequest; +import com.printcalculator.service.shop.ShopCartCookieService; +import com.printcalculator.service.shop.ShopCartService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/shop/cart") +public class ShopCartController { + private final ShopCartService shopCartService; + private final ShopCartCookieService shopCartCookieService; + + public ShopCartController(ShopCartService shopCartService, ShopCartCookieService shopCartCookieService) { + this.shopCartService = shopCartService; + this.shopCartCookieService = shopCartCookieService; + } + + @GetMapping + public ResponseEntity getCart(HttpServletRequest request, HttpServletResponse response) { + ShopCartService.CartResult result = shopCartService.loadCart(request); + applyCookie(response, result); + return ResponseEntity.ok(result.response()); + } + + @PostMapping("/items") + public ResponseEntity addItem(HttpServletRequest request, + HttpServletResponse response, + @Valid @RequestBody ShopCartAddItemRequest payload) { + ShopCartService.CartResult result = shopCartService.addItem(request, payload); + applyCookie(response, result); + return ResponseEntity.ok(result.response()); + } + + @PatchMapping("/items/{lineItemId}") + public ResponseEntity updateItem(HttpServletRequest request, + HttpServletResponse response, + @PathVariable UUID lineItemId, + @Valid @RequestBody ShopCartUpdateItemRequest payload) { + ShopCartService.CartResult result = shopCartService.updateItem(request, lineItemId, payload); + applyCookie(response, result); + return ResponseEntity.ok(result.response()); + } + + @DeleteMapping("/items/{lineItemId}") + public ResponseEntity removeItem(HttpServletRequest request, + HttpServletResponse response, + @PathVariable UUID lineItemId) { + ShopCartService.CartResult result = shopCartService.removeItem(request, lineItemId); + applyCookie(response, result); + return ResponseEntity.ok(result.response()); + } + + @DeleteMapping + public ResponseEntity clearCart(HttpServletRequest request, HttpServletResponse response) { + ShopCartService.CartResult result = shopCartService.clearCart(request); + applyCookie(response, result); + return ResponseEntity.ok(result.response()); + } + + private void applyCookie(HttpServletResponse response, ShopCartService.CartResult result) { + if (result.clearCookie()) { + response.addHeader(HttpHeaders.SET_COOKIE, shopCartCookieService.buildClearCookie().toString()); + return; + } + if (result.sessionId() != null) { + response.addHeader(HttpHeaders.SET_COOKIE, shopCartCookieService.buildSessionCookie(result.sessionId()).toString()); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopCartAddItemRequest.java b/backend/src/main/java/com/printcalculator/dto/ShopCartAddItemRequest.java new file mode 100644 index 0000000..5999f4f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopCartAddItemRequest.java @@ -0,0 +1,30 @@ +package com.printcalculator.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +public class ShopCartAddItemRequest { + @NotNull + private UUID shopProductVariantId; + + @Min(1) + private Integer quantity = 1; + + public UUID getShopProductVariantId() { + return shopProductVariantId; + } + + public void setShopProductVariantId(UUID shopProductVariantId) { + this.shopProductVariantId = shopProductVariantId; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopCartUpdateItemRequest.java b/backend/src/main/java/com/printcalculator/dto/ShopCartUpdateItemRequest.java new file mode 100644 index 0000000..5607ea9 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopCartUpdateItemRequest.java @@ -0,0 +1,18 @@ +package com.printcalculator.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public class ShopCartUpdateItemRequest { + @NotNull + @Min(1) + private Integer quantity; + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java index 7d39175..faf3780 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java @@ -4,9 +4,17 @@ import com.printcalculator.entity.QuoteLineItem; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface QuoteLineItemRepository extends JpaRepository { List findByQuoteSessionId(UUID quoteSessionId); + List findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId); + Optional findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId); + Optional findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id( + UUID quoteSessionId, + String lineItemType, + UUID shopProductVariantId + ); boolean existsByFilamentVariant_Id(Long filamentVariantId); } diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java index 3811d32..c0e64ae 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java @@ -4,10 +4,13 @@ import com.printcalculator.entity.QuoteSession; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface QuoteSessionRepository extends JpaRepository { List findByCreatedAtBefore(java.time.OffsetDateTime cutoff); List findByStatusInOrderByCreatedAtDesc(List statuses); + + Optional findByIdAndSessionType(UUID id, String sessionType); } diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java index 74ac67f..555ecc5 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java @@ -43,6 +43,22 @@ public class QuoteSessionResponseAssembler { return response; } + public Map emptyCart() { + Map response = new HashMap<>(); + response.put("session", null); + response.put("items", List.of()); + response.put("printItemsTotalChf", BigDecimal.ZERO); + response.put("cadTotalChf", BigDecimal.ZERO); + response.put("itemsTotalChf", BigDecimal.ZERO); + response.put("baseSetupCostChf", BigDecimal.ZERO); + response.put("nozzleChangeCostChf", BigDecimal.ZERO); + response.put("setupCostChf", BigDecimal.ZERO); + response.put("shippingCostChf", BigDecimal.ZERO); + response.put("globalMachineCostChf", BigDecimal.ZERO); + response.put("grandTotalChf", BigDecimal.ZERO); + return response; + } + private Map toItemDto(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) { Map dto = new HashMap<>(); dto.put("id", item.getId()); diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopCartCookieService.java b/backend/src/main/java/com/printcalculator/service/shop/ShopCartCookieService.java new file mode 100644 index 0000000..e3e317a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopCartCookieService.java @@ -0,0 +1,91 @@ +package com.printcalculator.service.shop; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +@Service +public class ShopCartCookieService { + public static final String COOKIE_NAME = "shop_cart_session"; + private static final String COOKIE_PATH = "/api/shop"; + + private final long cookieTtlDays; + private final boolean secureCookie; + private final String sameSite; + + public ShopCartCookieService( + @Value("${shop.cart.cookie.ttl-days:30}") long cookieTtlDays, + @Value("${shop.cart.cookie.secure:false}") boolean secureCookie, + @Value("${shop.cart.cookie.same-site:Lax}") String sameSite + ) { + this.cookieTtlDays = cookieTtlDays; + this.secureCookie = secureCookie; + this.sameSite = sameSite; + } + + public Optional extractSessionId(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + return Optional.empty(); + } + + for (Cookie cookie : cookies) { + if (!COOKIE_NAME.equals(cookie.getName())) { + continue; + } + try { + String value = cookie.getValue(); + if (value == null || value.isBlank()) { + return Optional.empty(); + } + return Optional.of(UUID.fromString(value.trim())); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } + return Optional.empty(); + } + + public boolean hasCartCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + return false; + } + for (Cookie cookie : cookies) { + if (COOKIE_NAME.equals(cookie.getName())) { + return true; + } + } + return false; + } + + public ResponseCookie buildSessionCookie(UUID sessionId) { + return ResponseCookie.from(COOKIE_NAME, sessionId.toString()) + .path(COOKIE_PATH) + .httpOnly(true) + .secure(secureCookie) + .sameSite(sameSite) + .maxAge(Duration.ofDays(Math.max(cookieTtlDays, 1))) + .build(); + } + + public ResponseCookie buildClearCookie() { + return ResponseCookie.from(COOKIE_NAME, "") + .path(COOKIE_PATH) + .httpOnly(true) + .secure(secureCookie) + .sameSite(sameSite) + .maxAge(Duration.ZERO) + .build(); + } + + public long getCookieTtlDays() { + return cookieTtlDays; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopCartService.java b/backend/src/main/java/com/printcalculator/service/shop/ShopCartService.java new file mode 100644 index 0000000..98c27fd --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopCartService.java @@ -0,0 +1,362 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.dto.ShopCartAddItemRequest; +import com.printcalculator.dto.ShopCartUpdateItemRequest; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductModelAsset; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.repository.ShopProductModelAssetRepository; +import com.printcalculator.repository.ShopProductVariantRepository; +import com.printcalculator.service.QuoteSessionTotalsService; +import com.printcalculator.service.quote.QuoteSessionResponseAssembler; +import com.printcalculator.service.quote.QuoteStorageService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@Service +@Transactional(readOnly = true) +public class ShopCartService { + private static final String SHOP_CART_SESSION_TYPE = "SHOP_CART"; + private static final String SHOP_LINE_ITEM_TYPE = "SHOP_PRODUCT"; + private static final String ACTIVE_STATUS = "ACTIVE"; + private static final String EXPIRED_STATUS = "EXPIRED"; + private static final String CONVERTED_STATUS = "CONVERTED"; + + private final QuoteSessionRepository quoteSessionRepository; + private final QuoteLineItemRepository quoteLineItemRepository; + private final ShopProductVariantRepository shopProductVariantRepository; + private final ShopProductModelAssetRepository shopProductModelAssetRepository; + private final QuoteSessionTotalsService quoteSessionTotalsService; + private final QuoteSessionResponseAssembler quoteSessionResponseAssembler; + private final QuoteStorageService quoteStorageService; + private final ShopStorageService shopStorageService; + private final ShopCartCookieService shopCartCookieService; + + public ShopCartService( + QuoteSessionRepository quoteSessionRepository, + QuoteLineItemRepository quoteLineItemRepository, + ShopProductVariantRepository shopProductVariantRepository, + ShopProductModelAssetRepository shopProductModelAssetRepository, + QuoteSessionTotalsService quoteSessionTotalsService, + QuoteSessionResponseAssembler quoteSessionResponseAssembler, + QuoteStorageService quoteStorageService, + ShopStorageService shopStorageService, + ShopCartCookieService shopCartCookieService + ) { + this.quoteSessionRepository = quoteSessionRepository; + this.quoteLineItemRepository = quoteLineItemRepository; + this.shopProductVariantRepository = shopProductVariantRepository; + this.shopProductModelAssetRepository = shopProductModelAssetRepository; + this.quoteSessionTotalsService = quoteSessionTotalsService; + this.quoteSessionResponseAssembler = quoteSessionResponseAssembler; + this.quoteStorageService = quoteStorageService; + this.shopStorageService = shopStorageService; + this.shopCartCookieService = shopCartCookieService; + } + + public CartResult loadCart(HttpServletRequest request) { + boolean hadCookie = shopCartCookieService.hasCartCookie(request); + Optional session = resolveValidCartSession(request); + if (session.isEmpty()) { + return CartResult.empty(quoteSessionResponseAssembler.emptyCart(), hadCookie); + } + + QuoteSession validSession = session.get(); + touchSession(validSession); + return CartResult.withSession(buildCartResponse(validSession), validSession.getId(), false); + } + + @Transactional + public CartResult addItem(HttpServletRequest request, ShopCartAddItemRequest payload) { + int quantityToAdd = normalizeQuantity(payload != null ? payload.getQuantity() : null); + ShopProductVariant variant = getPurchasableVariant(payload != null ? payload.getShopProductVariantId() : null); + QuoteSession session = resolveValidCartSession(request).orElseGet(this::createCartSession); + touchSession(session); + + QuoteLineItem lineItem = quoteLineItemRepository + .findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id( + session.getId(), + SHOP_LINE_ITEM_TYPE, + variant.getId() + ) + .orElseGet(() -> buildShopLineItem(session, variant)); + + int existingQuantity = lineItem.getQuantity() != null && lineItem.getQuantity() > 0 + ? lineItem.getQuantity() + : 0; + int newQuantity = existingQuantity + quantityToAdd; + lineItem.setQuantity(newQuantity); + refreshLineItemSnapshot(lineItem, variant); + lineItem.setUpdatedAt(OffsetDateTime.now()); + quoteLineItemRepository.save(lineItem); + + return CartResult.withSession(buildCartResponse(session), session.getId(), false); + } + + @Transactional + public CartResult updateItem(HttpServletRequest request, UUID lineItemId, ShopCartUpdateItemRequest payload) { + QuoteSession session = resolveValidCartSession(request) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart session not found")); + + QuoteLineItem item = quoteLineItemRepository.findByIdAndQuoteSession_Id(lineItemId, session.getId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart item not found")); + + if (!SHOP_LINE_ITEM_TYPE.equals(item.getLineItemType())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid cart item type"); + } + + item.setQuantity(normalizeQuantity(payload != null ? payload.getQuantity() : null)); + item.setUpdatedAt(OffsetDateTime.now()); + + if (item.getShopProductVariant() != null) { + refreshLineItemSnapshot(item, item.getShopProductVariant()); + } + + quoteLineItemRepository.save(item); + touchSession(session); + return CartResult.withSession(buildCartResponse(session), session.getId(), false); + } + + @Transactional + public CartResult removeItem(HttpServletRequest request, UUID lineItemId) { + QuoteSession session = resolveValidCartSession(request) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart session not found")); + + QuoteLineItem item = quoteLineItemRepository.findByIdAndQuoteSession_Id(lineItemId, session.getId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart item not found")); + + quoteLineItemRepository.delete(item); + touchSession(session); + return CartResult.withSession(buildCartResponse(session), session.getId(), false); + } + + @Transactional + public CartResult clearCart(HttpServletRequest request) { + boolean hadCookie = shopCartCookieService.hasCartCookie(request); + Optional session = resolveValidCartSession(request); + if (session.isPresent()) { + QuoteSession current = session.get(); + quoteSessionRepository.delete(current); + } + return CartResult.empty(quoteSessionResponseAssembler.emptyCart(), hadCookie); + } + + private Optional resolveValidCartSession(HttpServletRequest request) { + Optional sessionId = shopCartCookieService.extractSessionId(request); + if (sessionId.isEmpty()) { + return Optional.empty(); + } + + Optional session = quoteSessionRepository.findByIdAndSessionType(sessionId.get(), SHOP_CART_SESSION_TYPE); + if (session.isEmpty()) { + return Optional.empty(); + } + + QuoteSession quoteSession = session.get(); + if (isSessionUnavailable(quoteSession)) { + if (!EXPIRED_STATUS.equals(quoteSession.getStatus()) && !CONVERTED_STATUS.equals(quoteSession.getStatus())) { + quoteSession.setStatus(EXPIRED_STATUS); + quoteSessionRepository.save(quoteSession); + } + return Optional.empty(); + } + return Optional.of(quoteSession); + } + + private QuoteSession createCartSession() { + QuoteSession session = new QuoteSession(); + session.setStatus(ACTIVE_STATUS); + session.setSessionType(SHOP_CART_SESSION_TYPE); + session.setPricingVersion("v1"); + session.setMaterialCode("SHOP"); + session.setSupportsEnabled(false); + session.setCreatedAt(OffsetDateTime.now()); + session.setExpiresAt(nowPlusCookieTtl()); + session.setSetupCostChf(BigDecimal.ZERO); + return quoteSessionRepository.save(session); + } + + private Map buildCartResponse(QuoteSession session) { + List items = quoteLineItemRepository.findByQuoteSessionIdOrderByCreatedAtAsc(session.getId()); + QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items); + return quoteSessionResponseAssembler.assemble(session, items, totals); + } + + private QuoteLineItem buildShopLineItem(QuoteSession session, ShopProductVariant variant) { + ShopProduct product = variant.getProduct(); + ShopProductModelAsset modelAsset = product != null ? shopProductModelAssetRepository.findByProduct_Id(product.getId()).orElse(null) : null; + + QuoteLineItem item = new QuoteLineItem(); + item.setQuoteSession(session); + item.setStatus("READY"); + item.setLineItemType(SHOP_LINE_ITEM_TYPE); + item.setQuantity(0); + item.setCreatedAt(OffsetDateTime.now()); + item.setUpdatedAt(OffsetDateTime.now()); + item.setSupportsEnabled(false); + item.setInfillPercent(0); + item.setPricingBreakdown(new HashMap<>()); + + refreshLineItemSnapshot(item, variant); + applyModelAssetSnapshot(item, session, modelAsset); + return item; + } + + private void refreshLineItemSnapshot(QuoteLineItem item, ShopProductVariant variant) { + ShopProduct product = variant.getProduct(); + ShopCategory category = product != null ? product.getCategory() : null; + + item.setShopProduct(product); + item.setShopProductVariant(variant); + item.setShopProductSlug(product != null ? product.getSlug() : null); + item.setShopProductName(product != null ? product.getName() : null); + item.setShopVariantLabel(variant.getVariantLabel()); + item.setShopVariantColorName(variant.getColorName()); + item.setShopVariantColorHex(variant.getColorHex()); + item.setDisplayName(product != null ? product.getName() : item.getDisplayName()); + item.setColorCode(variant.getColorName()); + item.setMaterialCode(variant.getInternalMaterialCode()); + item.setQuality(null); + item.setUnitPriceChf(variant.getPriceChf() != null ? variant.getPriceChf() : BigDecimal.ZERO); + + Map breakdown = item.getPricingBreakdown() != null + ? new HashMap<>(item.getPricingBreakdown()) + : new HashMap<>(); + breakdown.put("type", SHOP_LINE_ITEM_TYPE); + breakdown.put("unitPriceChf", item.getUnitPriceChf()); + item.setPricingBreakdown(breakdown); + } + + private void applyModelAssetSnapshot(QuoteLineItem item, QuoteSession session, ShopProductModelAsset modelAsset) { + if (modelAsset == null) { + if (item.getOriginalFilename() == null || item.getOriginalFilename().isBlank()) { + item.setOriginalFilename(item.getShopProductSlug() != null ? item.getShopProductSlug() : "shop-product"); + } + item.setBoundingBoxXMm(BigDecimal.ZERO); + item.setBoundingBoxYMm(BigDecimal.ZERO); + item.setBoundingBoxZMm(BigDecimal.ZERO); + item.setStoredPath(null); + return; + } + + item.setOriginalFilename(modelAsset.getOriginalFilename()); + item.setBoundingBoxXMm(modelAsset.getBoundingBoxXMm() != null ? modelAsset.getBoundingBoxXMm() : BigDecimal.ZERO); + item.setBoundingBoxYMm(modelAsset.getBoundingBoxYMm() != null ? modelAsset.getBoundingBoxYMm() : BigDecimal.ZERO); + item.setBoundingBoxZMm(modelAsset.getBoundingBoxZMm() != null ? modelAsset.getBoundingBoxZMm() : BigDecimal.ZERO); + + String copiedStoredPath = copyModelAssetIntoSession(session, modelAsset); + item.setStoredPath(copiedStoredPath); + } + + private String copyModelAssetIntoSession(QuoteSession session, ShopProductModelAsset modelAsset) { + if (session == null || modelAsset == null || modelAsset.getProduct() == null) { + return null; + } + + Path source = shopStorageService.resolveStoredProductPath( + modelAsset.getStoredRelativePath(), + modelAsset.getProduct().getId() + ); + if (source == null || !Files.exists(source)) { + return null; + } + + try { + Path sessionDir = quoteStorageService.sessionStorageDir(session.getId()); + String extension = quoteStorageService.getSafeExtension(modelAsset.getOriginalFilename(), "stl"); + Path destination = quoteStorageService.resolveSessionPath( + sessionDir, + UUID.randomUUID() + "." + extension + ); + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + return quoteStorageService.toStoredPath(destination); + } catch (IOException e) { + return null; + } + } + + private ShopProductVariant getPurchasableVariant(UUID variantId) { + if (variantId == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "shopProductVariantId is required"); + } + + ShopProductVariant variant = shopProductVariantRepository.findById(variantId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Variant not found")); + + ShopProduct product = variant.getProduct(); + ShopCategory category = product != null ? product.getCategory() : null; + if (product == null + || category == null + || !Boolean.TRUE.equals(variant.getIsActive()) + || !Boolean.TRUE.equals(product.getIsActive()) + || !Boolean.TRUE.equals(category.getIsActive())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Variant not available"); + } + + return variant; + } + + private void touchSession(QuoteSession session) { + session.setStatus(ACTIVE_STATUS); + session.setExpiresAt(nowPlusCookieTtl()); + quoteSessionRepository.save(session); + } + + private OffsetDateTime nowPlusCookieTtl() { + return OffsetDateTime.now().plusDays(Math.max(shopCartCookieService.getCookieTtlDays(), 1)); + } + + private boolean isSessionUnavailable(QuoteSession session) { + if (session == null) { + return true; + } + if (!SHOP_CART_SESSION_TYPE.equalsIgnoreCase(session.getSessionType())) { + return true; + } + if (!ACTIVE_STATUS.equalsIgnoreCase(session.getStatus())) { + return true; + } + if (CONVERTED_STATUS.equalsIgnoreCase(session.getStatus())) { + return true; + } + OffsetDateTime expiresAt = session.getExpiresAt(); + return expiresAt != null && expiresAt.isBefore(OffsetDateTime.now()); + } + + private int normalizeQuantity(Integer quantity) { + if (quantity == null || quantity < 1) { + return 1; + } + return quantity; + } + + public record CartResult(Map response, UUID sessionId, boolean clearCookie) { + public static CartResult withSession(Map response, UUID sessionId, boolean clearCookie) { + return new CartResult(response, sessionId, clearCookie); + } + + public static CartResult empty(Map response, boolean clearCookie) { + return new CartResult(response, null, clearCookie); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopStorageService.java b/backend/src/main/java/com/printcalculator/service/shop/ShopStorageService.java new file mode 100644 index 0000000..fbf2ef8 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopStorageService.java @@ -0,0 +1,50 @@ +package com.printcalculator.service.shop; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +@Service +public class ShopStorageService { + private final Path storageRoot; + + public ShopStorageService(@Value("${shop.storage.root:storage_shop}") String storageRoot) { + this.storageRoot = Paths.get(storageRoot).toAbsolutePath().normalize(); + } + + public Path productModelStorageDir(UUID productId) throws IOException { + Path dir = storageRoot.resolve(Path.of("products", productId.toString(), "3d-models")).normalize(); + if (!dir.startsWith(storageRoot)) { + throw new IOException("Invalid shop product storage path"); + } + Files.createDirectories(dir); + return dir; + } + + public Path resolveStoredProductPath(String storedRelativePath, UUID expectedProductId) { + if (storedRelativePath == null || storedRelativePath.isBlank()) { + return null; + } + try { + Path raw = Path.of(storedRelativePath).normalize(); + Path resolved = raw.isAbsolute() ? raw : storageRoot.resolve(raw).normalize(); + Path expectedPrefix = storageRoot.resolve(Path.of("products", expectedProductId.toString())).normalize(); + if (!resolved.startsWith(expectedPrefix)) { + return null; + } + return resolved; + } catch (InvalidPathException e) { + return null; + } + } + + public String toStoredPath(Path absolutePath) { + return storageRoot.relativize(absolutePath.toAbsolutePath().normalize()).toString(); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 4edba9a..b42b99e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -31,6 +31,10 @@ media.storage.root=${MEDIA_STORAGE_ROOT:storage_media} media.public.base-url=${MEDIA_PUBLIC_BASE_URL:http://localhost:8080/media} media.ffmpeg.path=${MEDIA_FFMPEG_PATH:ffmpeg} media.upload.max-file-size-bytes=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:26214400} +shop.storage.root=${SHOP_STORAGE_ROOT:storage_shop} +shop.cart.cookie.ttl-days=${SHOP_CART_COOKIE_TTL_DAYS:30} +shop.cart.cookie.secure=${SHOP_CART_COOKIE_SECURE:false} +shop.cart.cookie.same-site=${SHOP_CART_COOKIE_SAME_SITE:Lax} # TWINT Configuration payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}