diff --git a/.gitignore b/.gitignore index ab81c7a..bfef9e1 100644 --- a/.gitignore +++ b/.gitignore @@ -44,8 +44,14 @@ build/ ./storage_orders ./storage_quotes +./storage_requests +./storage_media +./storage_shop storage_orders storage_quotes +storage_requests +storage_media +storage_shop # Qodana local reports/artifacts backend/.qodana/ diff --git a/README.md b/README.md index f7a89c4..06988c5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di s ## Stack Tecnologico -- **Backend**: Java 21, Spring Boot 3.4, PostgreSQL, Flyway. +- **Backend**: Java 21, Spring Boot 3.4, PostgreSQL. - **Frontend**: Angular 19, Angular Material, Three.js. - **Slicer**: OrcaSlicer (invocato via CLI). @@ -21,14 +21,20 @@ Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di s * **Node.js 22** e **npm** installati. * **PostgreSQL** attivo. * **OrcaSlicer** installato sul sistema. +* **FFmpeg** installato sul sistema o presente nell'immagine Docker del backend. ## Avvio Rapido ### 1. Database -Crea un database PostgreSQL chiamato `printcalc`. Le tabelle verranno create automaticamente al primo avvio tramite Flyway. +Crea un database PostgreSQL chiamato `printcalc`. Lo schema viene gestito dal progetto tramite configurazione JPA/SQL del repository. ### 2. Backend -Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. +Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. Per il media service pubblico puoi configurare anche: + +- `MEDIA_STORAGE_ROOT` per la root `storage_media` usata dal backend (`original/`, `public/`, `private/`) +- `SHOP_STORAGE_ROOT` per la root `storage_shop` usata dal backend per i modelli dei prodotti shop +- `MEDIA_FFMPEG_PATH` per il binario `ffmpeg` (nel deploy Docker default: `/usr/local/bin/ffmpeg-media`) +- `MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES` per il limite per asset immagine ```bash cd backend @@ -57,11 +63,51 @@ I prezzi non sono più gestiti tramite variabili d'ambiente fisse ma tramite tab * `/backend`: API Spring Boot. * `/frontend`: Applicazione Angular. * `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer. +* `/storage_media`: Originali e varianti media pubbliche/private su filesystem. +* `/storage_shop`: Modelli e file prodotti dello shop. + +## Media pubblici + +Il backend salva sempre l'originale in `storage_media/original/` e precomputa le varianti pubbliche in `storage_media/public/`. La cartella `storage_media/private/` è predisposta per asset non pubblici. + +Nel deploy Docker i volumi attesi sono `/mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media` e `/mnt/cache/appdata/print-calculator/${ENV}/storage_shop:/app/storage_shop`. + +Nginx non deve passare dal backend per i file pubblici. Configurazione attesa: + +```nginx +location /media/ { + alias /mnt/cache/appdata/print-calculator/${ENV}/storage_media/public/; +} +``` + +Usage key iniziali previste per frontend: + +- `HOME_SECTION / shop-gallery` +- `HOME_SECTION / founders-gallery` +- `HOME_SECTION / capability-prototyping` +- `HOME_SECTION / capability-custom-parts` +- `HOME_SECTION / capability-small-series` +- `HOME_SECTION / capability-cad` +- `ABOUT_MEMBER / joe` +- `ABOUT_MEMBER / matteo` +- riservati per estensioni future: `SHOP_PRODUCT`, `SHOP_CATEGORY`, `SHOP_GALLERY` + +Operativamente: + +- carica i file dal media admin endpoint del backend +- associa ogni asset con `POST /api/admin/media/usages` +- per `ABOUT_MEMBER` imposta `isPrimary=true` sulla foto principale del membro +- home e about leggono da `GET /api/public/media/usages?usageType=...&usageKey=...` +- il frontend usa `` e preferisce AVIF/WEBP con fallback JPEG, senza usare l'originale +- nel back-office frontend la gestione operativa della home passa dalla pagina `admin/home-media` ## Troubleshooting ### Percorso OrcaSlicer Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno). +### FFmpeg e media pubblici +Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e AVIF (encoder + muxer AVIF). Nel container backend il default è `/usr/local/bin/ffmpeg-media`: usa `/usr/bin/ffmpeg` se già compatibile, altrimenti installa un fallback statico con supporto AVIF. Se gli URL media restituiti dalle API admin non sono raggiungibili, controlla che `APP_FRONTEND_BASE_URL` punti al dominio corretto, che `location /media/` sia esposto da Nginx e che il volume `storage_media` sia montato correttamente. + ### Database connection Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente. diff --git a/backend/Dockerfile b/backend/Dockerfile index 6067fdf..13a016d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,17 +12,49 @@ RUN ./gradlew bootJar -x test --no-daemon FROM eclipse-temurin:21-jre-jammy ARG ORCA_VERSION=2.3.1 ARG ORCA_DOWNLOAD_URL +ARG FFMPEG_STATIC_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -# Install system dependencies for OrcaSlicer (same as before) -RUN apt-get update && apt-get install -y \ +# Install system dependencies for OrcaSlicer and media processing. +# Prefer system ffmpeg; if AVIF support is incomplete, install a static ffmpeg fallback. +RUN set -eux; \ + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ffmpeg \ wget \ + xz-utils \ + ca-certificates \ assimp-utils \ libgl1 \ libglib2.0-0 \ libgtk-3-0 \ libdbus-1-3 \ - libwebkit2gtk-4.0-37 \ - && rm -rf /var/lib/apt/lists/* + libwebkit2gtk-4.0-37; \ + check_ffmpeg_support() { \ + ffmpeg_bin="$1"; \ + "$ffmpeg_bin" -hide_banner -encoders > /tmp/ffmpeg-encoders.txt 2>&1 || return 1; \ + "$ffmpeg_bin" -hide_banner -muxers > /tmp/ffmpeg-muxers.txt 2>&1 || return 1; \ + grep -Eq '[[:space:]]mjpeg[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \ + grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \ + grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \ + grep -Eq '[[:space:]]avif([[:space:]]|,|$)' /tmp/ffmpeg-muxers.txt || return 1; \ + return 0; \ + }; \ + if check_ffmpeg_support /usr/bin/ffmpeg; then \ + ln -sf /usr/bin/ffmpeg /usr/local/bin/ffmpeg-media; \ + else \ + echo "System ffmpeg lacks AVIF support, installing static fallback from ${FFMPEG_STATIC_URL}"; \ + wget -q "${FFMPEG_STATIC_URL}" -O /tmp/ffmpeg-static.tar.xz; \ + tar -xJf /tmp/ffmpeg-static.tar.xz -C /tmp; \ + FFMPEG_STATIC_BIN="$(find /tmp -maxdepth 2 -type f -name ffmpeg | head -n 1)"; \ + test -n "${FFMPEG_STATIC_BIN}"; \ + install -m 0755 "${FFMPEG_STATIC_BIN}" /usr/local/bin/ffmpeg-media; \ + check_ffmpeg_support /usr/local/bin/ffmpeg-media; \ + fi; \ + rm -f /tmp/ffmpeg-muxers.txt; \ + rm -f /tmp/ffmpeg-encoders.txt; \ + rm -f /tmp/ffmpeg-static.tar.xz; \ + rm -rf /tmp/ffmpeg-*-amd64-static; \ + rm -rf /var/lib/apt/lists/* # Install OrcaSlicer WORKDIR /opt @@ -62,6 +94,8 @@ ENV PATH="/opt/orcaslicer/usr/bin:${PATH}" # Set Slicer Path env variable for Java app ENV SLICER_PATH="/opt/orcaslicer/AppRun" ENV ASSIMP_PATH="assimp" +# Use ffmpeg selected at image build time (system or static fallback) for media generation. +ENV MEDIA_FFMPEG_PATH="/usr/local/bin/ffmpeg-media" WORKDIR /app # Copy JAR from build stage diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index cd79e27..89a076a 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,10 +1,61 @@ #!/bin/sh +set -e + +# In container default to the ffmpeg selected during image build. +if [ -z "${MEDIA_FFMPEG_PATH:-}" ]; then + MEDIA_FFMPEG_PATH="/usr/local/bin/ffmpeg-media" +fi +export MEDIA_FFMPEG_PATH + +validate_ffmpeg_support() { + ffmpeg_bin="$1" + if ! command -v "$ffmpeg_bin" >/dev/null 2>&1; then + echo "ERROR: FFmpeg executable not found: ${ffmpeg_bin}" >&2 + exit 11 + fi + + encoders="$(mktemp)" + muxers="$(mktemp)" + trap 'rm -f "$encoders" "$muxers"' EXIT + + "$ffmpeg_bin" -hide_banner -encoders > "$encoders" 2>&1 || { + echo "ERROR: Unable to inspect FFmpeg encoders from ${ffmpeg_bin}" >&2 + cat "$encoders" >&2 + exit 12 + } + "$ffmpeg_bin" -hide_banner -muxers > "$muxers" 2>&1 || { + echo "ERROR: Unable to inspect FFmpeg muxers from ${ffmpeg_bin}" >&2 + cat "$muxers" >&2 + exit 13 + } + + grep -Eq '[[:space:]]mjpeg[[:space:]]' "$encoders" || { + echo "ERROR: FFmpeg '${ffmpeg_bin}' missing JPEG encoder (mjpeg)." >&2 + exit 14 + } + grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' "$encoders" || { + echo "ERROR: FFmpeg '${ffmpeg_bin}' missing WebP encoder." >&2 + exit 15 + } + grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' "$encoders" || { + echo "ERROR: FFmpeg '${ffmpeg_bin}' missing AVIF-capable encoder." >&2 + exit 16 + } + grep -Eq '[[:space:]]avif([[:space:]]|,|$)' "$muxers" || { + echo "ERROR: FFmpeg '${ffmpeg_bin}' missing AVIF muxer." >&2 + exit 17 + } +} + +validate_ffmpeg_support "$MEDIA_FFMPEG_PATH" + echo "----------------------------------------------------------------" echo "Starting Backend Application" echo "DB_URL: $DB_URL" echo "DB_USERNAME: $DB_USERNAME" echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL" echo "SLICER_PATH: $SLICER_PATH" +echo "MEDIA_FFMPEG_PATH: $MEDIA_FFMPEG_PATH" echo "----------------------------------------------------------------" # Determine which environment variables to use for database connection diff --git a/backend/src/main/java/com/printcalculator/controller/PublicMediaController.java b/backend/src/main/java/com/printcalculator/controller/PublicMediaController.java new file mode 100644 index 0000000..ec3523b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/PublicMediaController.java @@ -0,0 +1,31 @@ +package com.printcalculator.controller; + +import com.printcalculator.dto.PublicMediaUsageDto; +import com.printcalculator.service.media.PublicMediaQueryService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/public/media") +@Transactional(readOnly = true) +public class PublicMediaController { + + private final PublicMediaQueryService publicMediaQueryService; + + public PublicMediaController(PublicMediaQueryService publicMediaQueryService) { + this.publicMediaQueryService = publicMediaQueryService; + } + + @GetMapping("/usages") + public ResponseEntity> getUsageMedia(@RequestParam String usageType, + @RequestParam String usageKey, + @RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicMediaQueryService.getUsageMedia(usageType, usageKey, lang)); + } +} 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/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 5f13e46..cde3605 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -62,6 +62,7 @@ public class QuoteSessionController { public ResponseEntity createSession() { QuoteSession session = new QuoteSession(); session.setStatus("ACTIVE"); + session.setSessionType("PRINT_QUOTE"); session.setPricingVersion("v1"); session.setMaterialCode("PLA"); session.setSupportsEnabled(false); 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/controller/admin/AdminMediaController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java new file mode 100644 index 0000000..d487145 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java @@ -0,0 +1,89 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminCreateMediaUsageRequest; +import com.printcalculator.dto.AdminMediaAssetDto; +import com.printcalculator.dto.AdminMediaUsageDto; +import com.printcalculator.dto.AdminUpdateMediaAssetRequest; +import com.printcalculator.dto.AdminUpdateMediaUsageRequest; +import com.printcalculator.service.admin.AdminMediaControllerService; +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.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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/admin/media") +@Transactional(readOnly = true) +public class AdminMediaController { + + private final AdminMediaControllerService adminMediaControllerService; + + public AdminMediaController(AdminMediaControllerService adminMediaControllerService) { + this.adminMediaControllerService = adminMediaControllerService; + } + + @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity uploadAsset(@RequestParam("file") MultipartFile file, + @RequestParam(value = "title", required = false) String title, + @RequestParam(value = "altText", required = false) String altText, + @RequestParam(value = "visibility", required = false) String visibility) { + return ResponseEntity.ok(adminMediaControllerService.uploadAsset(file, title, altText, visibility)); + } + + @GetMapping("/assets") + public ResponseEntity> listAssets() { + return ResponseEntity.ok(adminMediaControllerService.listAssets()); + } + + @GetMapping("/assets/{mediaAssetId}") + public ResponseEntity getAsset(@PathVariable UUID mediaAssetId) { + 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, + @RequestBody AdminUpdateMediaAssetRequest payload) { + return ResponseEntity.ok(adminMediaControllerService.updateAsset(mediaAssetId, payload)); + } + + @PostMapping("/usages") + @Transactional + public ResponseEntity createUsage(@RequestBody AdminCreateMediaUsageRequest payload) { + return ResponseEntity.ok(adminMediaControllerService.createUsage(payload)); + } + + @PatchMapping("/usages/{mediaUsageId}") + @Transactional + public ResponseEntity updateUsage(@PathVariable UUID mediaUsageId, + @RequestBody AdminUpdateMediaUsageRequest payload) { + return ResponseEntity.ok(adminMediaControllerService.updateUsage(mediaUsageId, payload)); + } + + @DeleteMapping("/usages/{mediaUsageId}") + @Transactional + public ResponseEntity deleteUsage(@PathVariable UUID mediaUsageId) { + adminMediaControllerService.deleteUsage(mediaUsageId); + return ResponseEntity.noContent().build(); + } +} 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/AdminCreateMediaUsageRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java new file mode 100644 index 0000000..9580b29 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java @@ -0,0 +1,79 @@ +package com.printcalculator.dto; + +import java.util.UUID; +import java.util.Map; + +public class AdminCreateMediaUsageRequest { + private String usageType; + private String usageKey; + private UUID ownerId; + private UUID mediaAssetId; + private Integer sortOrder; + private Boolean isPrimary; + private Boolean isActive; + private Map translations; + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public UUID getMediaAssetId() { + return mediaAssetId; + } + + public void setMediaAssetId(UUID mediaAssetId) { + this.mediaAssetId = mediaAssetId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Map getTranslations() { + return translations; + } + + public void setTranslations(Map translations) { + this.translations = translations; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminMediaAssetDto.java b/backend/src/main/java/com/printcalculator/dto/AdminMediaAssetDto.java new file mode 100644 index 0000000..d934610 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminMediaAssetDto.java @@ -0,0 +1,153 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class AdminMediaAssetDto { + private UUID id; + private String originalFilename; + private String storageKey; + private String mimeType; + private Long fileSizeBytes; + private String sha256Hex; + private Integer widthPx; + private Integer heightPx; + private String status; + private String visibility; + private String title; + private String altText; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + private List variants = new ArrayList<>(); + private List usages = new ArrayList<>(); + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getVisibility() { + return visibility; + } + + public void setVisibility(String visibility) { + this.visibility = visibility; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public List getVariants() { + return variants; + } + + public void setVariants(List variants) { + this.variants = variants; + } + + public List getUsages() { + return usages; + } + + public void setUsages(List usages) { + this.usages = usages; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java b/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java new file mode 100644 index 0000000..7d16330 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java @@ -0,0 +1,98 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; + +public class AdminMediaUsageDto { + private UUID id; + private String usageType; + private String usageKey; + private UUID ownerId; + private UUID mediaAssetId; + private Integer sortOrder; + private Boolean isPrimary; + private Boolean isActive; + private Map translations; + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public UUID getMediaAssetId() { + return mediaAssetId; + } + + public void setMediaAssetId(UUID mediaAssetId) { + this.mediaAssetId = mediaAssetId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Map getTranslations() { + return translations; + } + + public void setTranslations(Map translations) { + this.translations = translations; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminMediaVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminMediaVariantDto.java new file mode 100644 index 0000000..6a200bf --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminMediaVariantDto.java @@ -0,0 +1,106 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminMediaVariantDto { + private UUID id; + private String variantName; + private String format; + private String storageKey; + private String mimeType; + private Integer widthPx; + private Integer heightPx; + private Long fileSizeBytes; + private Boolean isGenerated; + private String publicUrl; + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getVariantName() { + return variantName; + } + + public void setVariantName(String variantName) { + this.variantName = variantName; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public Boolean getIsGenerated() { + return isGenerated; + } + + public void setIsGenerated(Boolean generated) { + isGenerated = generated; + } + + public String getPublicUrl() { + return publicUrl; + } + + public void setPublicUrl(String publicUrl) { + this.publicUrl = publicUrl; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java b/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java index 1b362eb..7d29ea5 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java @@ -7,6 +7,7 @@ import java.util.UUID; public class AdminQuoteSessionDto { private UUID id; private String status; + private String sessionType; private String materialCode; private OffsetDateTime createdAt; private OffsetDateTime expiresAt; @@ -32,6 +33,14 @@ public class AdminQuoteSessionDto { this.status = status; } + public String getSessionType() { + return sessionType; + } + + public void setSessionType(String sessionType) { + this.sessionType = sessionType; + } + public String getMaterialCode() { return materialCode; } 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..67d7278 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java @@ -0,0 +1,441 @@ +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 nameIt; + private String nameEn; + private String nameDe; + private String nameFr; + private String excerpt; + private String excerptIt; + private String excerptEn; + private String excerptDe; + private String excerptFr; + private String description; + private String descriptionIt; + private String descriptionEn; + private String descriptionDe; + private String descriptionFr; + private String seoTitle; + private String seoTitleIt; + private String seoTitleEn; + private String seoTitleDe; + private String seoTitleFr; + private String seoDescription; + private String seoDescriptionIt; + private String seoDescriptionEn; + private String seoDescriptionDe; + private String seoDescriptionFr; + 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 getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + + public String getExcerpt() { + return excerpt; + } + + public void setExcerpt(String excerpt) { + this.excerpt = excerpt; + } + + public String getExcerptIt() { + return excerptIt; + } + + public void setExcerptIt(String excerptIt) { + this.excerptIt = excerptIt; + } + + public String getExcerptEn() { + return excerptEn; + } + + public void setExcerptEn(String excerptEn) { + this.excerptEn = excerptEn; + } + + public String getExcerptDe() { + return excerptDe; + } + + public void setExcerptDe(String excerptDe) { + this.excerptDe = excerptDe; + } + + public String getExcerptFr() { + return excerptFr; + } + + public void setExcerptFr(String excerptFr) { + this.excerptFr = excerptFr; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoTitleIt() { + return seoTitleIt; + } + + public void setSeoTitleIt(String seoTitleIt) { + this.seoTitleIt = seoTitleIt; + } + + public String getSeoTitleEn() { + return seoTitleEn; + } + + public void setSeoTitleEn(String seoTitleEn) { + this.seoTitleEn = seoTitleEn; + } + + public String getSeoTitleDe() { + return seoTitleDe; + } + + public void setSeoTitleDe(String seoTitleDe) { + this.seoTitleDe = seoTitleDe; + } + + public String getSeoTitleFr() { + return seoTitleFr; + } + + public void setSeoTitleFr(String seoTitleFr) { + this.seoTitleFr = seoTitleFr; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getSeoDescriptionIt() { + return seoDescriptionIt; + } + + public void setSeoDescriptionIt(String seoDescriptionIt) { + this.seoDescriptionIt = seoDescriptionIt; + } + + public String getSeoDescriptionEn() { + return seoDescriptionEn; + } + + public void setSeoDescriptionEn(String seoDescriptionEn) { + this.seoDescriptionEn = seoDescriptionEn; + } + + public String getSeoDescriptionDe() { + return seoDescriptionDe; + } + + public void setSeoDescriptionDe(String seoDescriptionDe) { + this.seoDescriptionDe = seoDescriptionDe; + } + + public String getSeoDescriptionFr() { + return seoDescriptionFr; + } + + public void setSeoDescriptionFr(String seoDescriptionFr) { + this.seoDescriptionFr = seoDescriptionFr; + } + + 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/AdminUpdateMediaAssetRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaAssetRequest.java new file mode 100644 index 0000000..2d3d8ff --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaAssetRequest.java @@ -0,0 +1,40 @@ +package com.printcalculator.dto; + +public class AdminUpdateMediaAssetRequest { + private String title; + private String altText; + private String visibility; + private String status; + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + + public String getVisibility() { + return visibility; + } + + public void setVisibility(String visibility) { + this.visibility = visibility; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java new file mode 100644 index 0000000..aa4c8bc --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java @@ -0,0 +1,79 @@ +package com.printcalculator.dto; + +import java.util.UUID; +import java.util.Map; + +public class AdminUpdateMediaUsageRequest { + private String usageType; + private String usageKey; + private UUID ownerId; + private UUID mediaAssetId; + private Integer sortOrder; + private Boolean isPrimary; + private Boolean isActive; + private Map translations; + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public UUID getMediaAssetId() { + return mediaAssetId; + } + + public void setMediaAssetId(UUID mediaAssetId) { + this.mediaAssetId = mediaAssetId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Map getTranslations() { + return translations; + } + + public void setTranslations(Map translations) { + this.translations = translations; + } +} 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..8bebb25 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java @@ -0,0 +1,313 @@ +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 nameIt; + private String nameEn; + private String nameDe; + private String nameFr; + private String excerpt; + private String excerptIt; + private String excerptEn; + private String excerptDe; + private String excerptFr; + private String description; + private String descriptionIt; + private String descriptionEn; + private String descriptionDe; + private String descriptionFr; + private String seoTitle; + private String seoTitleIt; + private String seoTitleEn; + private String seoTitleDe; + private String seoTitleFr; + private String seoDescription; + private String seoDescriptionIt; + private String seoDescriptionEn; + private String seoDescriptionDe; + private String seoDescriptionFr; + 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 getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + + public String getExcerpt() { + return excerpt; + } + + public void setExcerpt(String excerpt) { + this.excerpt = excerpt; + } + + public String getExcerptIt() { + return excerptIt; + } + + public void setExcerptIt(String excerptIt) { + this.excerptIt = excerptIt; + } + + public String getExcerptEn() { + return excerptEn; + } + + public void setExcerptEn(String excerptEn) { + this.excerptEn = excerptEn; + } + + public String getExcerptDe() { + return excerptDe; + } + + public void setExcerptDe(String excerptDe) { + this.excerptDe = excerptDe; + } + + public String getExcerptFr() { + return excerptFr; + } + + public void setExcerptFr(String excerptFr) { + this.excerptFr = excerptFr; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoTitleIt() { + return seoTitleIt; + } + + public void setSeoTitleIt(String seoTitleIt) { + this.seoTitleIt = seoTitleIt; + } + + public String getSeoTitleEn() { + return seoTitleEn; + } + + public void setSeoTitleEn(String seoTitleEn) { + this.seoTitleEn = seoTitleEn; + } + + public String getSeoTitleDe() { + return seoTitleDe; + } + + public void setSeoTitleDe(String seoTitleDe) { + this.seoTitleDe = seoTitleDe; + } + + public String getSeoTitleFr() { + return seoTitleFr; + } + + public void setSeoTitleFr(String seoTitleFr) { + this.seoTitleFr = seoTitleFr; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getSeoDescriptionIt() { + return seoDescriptionIt; + } + + public void setSeoDescriptionIt(String seoDescriptionIt) { + this.seoDescriptionIt = seoDescriptionIt; + } + + public String getSeoDescriptionEn() { + return seoDescriptionEn; + } + + public void setSeoDescriptionEn(String seoDescriptionEn) { + this.seoDescriptionEn = seoDescriptionEn; + } + + public String getSeoDescriptionDe() { + return seoDescriptionDe; + } + + public void setSeoDescriptionDe(String seoDescriptionDe) { + this.seoDescriptionDe = seoDescriptionDe; + } + + public String getSeoDescriptionFr() { + return seoDescriptionFr; + } + + public void setSeoDescriptionFr(String seoDescriptionFr) { + this.seoDescriptionFr = seoDescriptionFr; + } + + 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/MediaTextTranslationDto.java b/backend/src/main/java/com/printcalculator/dto/MediaTextTranslationDto.java new file mode 100644 index 0000000..3771af3 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/MediaTextTranslationDto.java @@ -0,0 +1,22 @@ +package com.printcalculator.dto; + +public class MediaTextTranslationDto { + private String title; + private String altText; + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/OrderDto.java b/backend/src/main/java/com/printcalculator/dto/OrderDto.java index 9653d99..4534d37 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderDto.java @@ -8,6 +8,7 @@ import java.util.UUID; public class OrderDto { private UUID id; private String orderNumber; + private String sourceType; private String status; private String paymentStatus; private String paymentMethod; @@ -45,6 +46,9 @@ public class OrderDto { public String getOrderNumber() { return orderNumber; } public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; } + public String getSourceType() { return sourceType; } + public void setSourceType(String sourceType) { this.sourceType = sourceType; } + public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } diff --git a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java index d6f5f68..efbcc87 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java @@ -5,10 +5,19 @@ import java.util.UUID; public class OrderItemDto { private UUID id; + private String itemType; private String originalFilename; + private String displayName; private String materialCode; private String colorCode; private Long filamentVariantId; + private UUID shopProductId; + private UUID shopProductVariantId; + private String shopProductSlug; + private String shopProductName; + private String shopVariantLabel; + private String shopVariantColorName; + private String shopVariantColorHex; private String filamentVariantDisplayName; private String filamentColorName; private String filamentColorHex; @@ -28,9 +37,15 @@ public class OrderItemDto { public UUID getId() { return id; } public void setId(UUID id) { this.id = id; } + public String getItemType() { return itemType; } + public void setItemType(String itemType) { this.itemType = itemType; } + public String getOriginalFilename() { return originalFilename; } public void setOriginalFilename(String originalFilename) { this.originalFilename = originalFilename; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getMaterialCode() { return materialCode; } public void setMaterialCode(String materialCode) { this.materialCode = materialCode; } @@ -40,6 +55,27 @@ public class OrderItemDto { public Long getFilamentVariantId() { return filamentVariantId; } public void setFilamentVariantId(Long filamentVariantId) { this.filamentVariantId = filamentVariantId; } + public UUID getShopProductId() { return shopProductId; } + public void setShopProductId(UUID shopProductId) { this.shopProductId = shopProductId; } + + public UUID getShopProductVariantId() { return shopProductVariantId; } + public void setShopProductVariantId(UUID shopProductVariantId) { this.shopProductVariantId = shopProductVariantId; } + + public String getShopProductSlug() { return shopProductSlug; } + public void setShopProductSlug(String shopProductSlug) { this.shopProductSlug = shopProductSlug; } + + public String getShopProductName() { return shopProductName; } + public void setShopProductName(String shopProductName) { this.shopProductName = shopProductName; } + + public String getShopVariantLabel() { return shopVariantLabel; } + public void setShopVariantLabel(String shopVariantLabel) { this.shopVariantLabel = shopVariantLabel; } + + public String getShopVariantColorName() { return shopVariantColorName; } + public void setShopVariantColorName(String shopVariantColorName) { this.shopVariantColorName = shopVariantColorName; } + + public String getShopVariantColorHex() { return shopVariantColorHex; } + public void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; } + public String getFilamentVariantDisplayName() { return filamentVariantDisplayName; } public void setFilamentVariantDisplayName(String filamentVariantDisplayName) { this.filamentVariantDisplayName = filamentVariantDisplayName; } diff --git a/backend/src/main/java/com/printcalculator/dto/PublicMediaUsageDto.java b/backend/src/main/java/com/printcalculator/dto/PublicMediaUsageDto.java new file mode 100644 index 0000000..6672c84 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/PublicMediaUsageDto.java @@ -0,0 +1,96 @@ +package com.printcalculator.dto; + +import java.util.UUID; + +public class PublicMediaUsageDto { + private UUID mediaAssetId; + private String title; + private String altText; + private String usageType; + private String usageKey; + private Integer sortOrder; + private Boolean isPrimary; + private PublicMediaVariantDto thumb; + private PublicMediaVariantDto card; + private PublicMediaVariantDto hero; + + public UUID getMediaAssetId() { + return mediaAssetId; + } + + public void setMediaAssetId(UUID mediaAssetId) { + this.mediaAssetId = mediaAssetId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public PublicMediaVariantDto getThumb() { + return thumb; + } + + public void setThumb(PublicMediaVariantDto thumb) { + this.thumb = thumb; + } + + public PublicMediaVariantDto getCard() { + return card; + } + + public void setCard(PublicMediaVariantDto card) { + this.card = card; + } + + public PublicMediaVariantDto getHero() { + return hero; + } + + public void setHero(PublicMediaVariantDto hero) { + this.hero = hero; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/PublicMediaVariantDto.java b/backend/src/main/java/com/printcalculator/dto/PublicMediaVariantDto.java new file mode 100644 index 0000000..173bd75 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/PublicMediaVariantDto.java @@ -0,0 +1,31 @@ +package com.printcalculator.dto; + +public class PublicMediaVariantDto { + private String avifUrl; + private String webpUrl; + private String jpegUrl; + + public String getAvifUrl() { + return avifUrl; + } + + public void setAvifUrl(String avifUrl) { + this.avifUrl = avifUrl; + } + + public String getWebpUrl() { + return webpUrl; + } + + public void setWebpUrl(String webpUrl) { + this.webpUrl = webpUrl; + } + + public String getJpegUrl() { + return jpegUrl; + } + + public void setJpegUrl(String jpegUrl) { + this.jpegUrl = jpegUrl; + } +} 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/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/entity/MediaAsset.java b/backend/src/main/java/com/printcalculator/entity/MediaAsset.java new file mode 100644 index 0000000..9c26d00 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/MediaAsset.java @@ -0,0 +1,177 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "media_asset", indexes = { + @Index(name = "ix_media_asset_status_visibility_created_at", columnList = "status, visibility, created_at") +}) +public class MediaAsset { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "media_asset_id", nullable = false) + private UUID id; + + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) + private String originalFilename; + + @Column(name = "storage_key", nullable = false, length = Integer.MAX_VALUE, unique = true) + private String storageKey; + + @Column(name = "mime_type", nullable = false, length = Integer.MAX_VALUE) + private String mimeType; + + @Column(name = "file_size_bytes", nullable = false) + private Long fileSizeBytes; + + @Column(name = "sha256_hex", nullable = false, length = Integer.MAX_VALUE) + private String sha256Hex; + + @Column(name = "width_px") + private Integer widthPx; + + @Column(name = "height_px") + private Integer heightPx; + + @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) + private String status; + + @Column(name = "visibility", nullable = false, length = Integer.MAX_VALUE) + private String visibility; + + @Column(name = "title", length = Integer.MAX_VALUE) + private String title; + + @Column(name = "alt_text", length = Integer.MAX_VALUE) + private String altText; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getVisibility() { + return visibility; + } + + public void setVisibility(String visibility) { + this.visibility = visibility; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + + 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/entity/MediaUsage.java b/backend/src/main/java/com/printcalculator/entity/MediaUsage.java new file mode 100644 index 0000000..4231a7b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/MediaUsage.java @@ -0,0 +1,273 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "media_usage", indexes = { + @Index(name = "ix_media_usage_scope", columnList = "usage_type, usage_key, is_active, sort_order") +}) +public class MediaUsage { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "media_usage_id", nullable = false) + private UUID id; + + @Column(name = "usage_type", nullable = false, length = Integer.MAX_VALUE) + private String usageType; + + @Column(name = "usage_key", nullable = false, length = Integer.MAX_VALUE) + private String usageKey; + + @Column(name = "owner_id") + private UUID ownerId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "media_asset_id", nullable = false) + private MediaAsset mediaAsset; + + @ColumnDefault("0") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @ColumnDefault("false") + @Column(name = "is_primary", nullable = false) + private Boolean isPrimary; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @Column(name = "title_it", length = Integer.MAX_VALUE) + private String titleIt; + + @Column(name = "title_en", length = Integer.MAX_VALUE) + private String titleEn; + + @Column(name = "title_de", length = Integer.MAX_VALUE) + private String titleDe; + + @Column(name = "title_fr", length = Integer.MAX_VALUE) + private String titleFr; + + @Column(name = "alt_text_it", length = Integer.MAX_VALUE) + private String altTextIt; + + @Column(name = "alt_text_en", length = Integer.MAX_VALUE) + private String altTextEn; + + @Column(name = "alt_text_de", length = Integer.MAX_VALUE) + private String altTextDe; + + @Column(name = "alt_text_fr", length = Integer.MAX_VALUE) + private String altTextFr; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public MediaAsset getMediaAsset() { + return mediaAsset; + } + + public void setMediaAsset(MediaAsset mediaAsset) { + this.mediaAsset = mediaAsset; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public String getTitleIt() { + return titleIt; + } + + public void setTitleIt(String titleIt) { + this.titleIt = titleIt; + } + + public String getTitleEn() { + return titleEn; + } + + public void setTitleEn(String titleEn) { + this.titleEn = titleEn; + } + + public String getTitleDe() { + return titleDe; + } + + public void setTitleDe(String titleDe) { + this.titleDe = titleDe; + } + + public String getTitleFr() { + return titleFr; + } + + public void setTitleFr(String titleFr) { + this.titleFr = titleFr; + } + + public String getAltTextIt() { + return altTextIt; + } + + public void setAltTextIt(String altTextIt) { + this.altTextIt = altTextIt; + } + + public String getAltTextEn() { + return altTextEn; + } + + public void setAltTextEn(String altTextEn) { + this.altTextEn = altTextEn; + } + + public String getAltTextDe() { + return altTextDe; + } + + public void setAltTextDe(String altTextDe) { + this.altTextDe = altTextDe; + } + + public String getAltTextFr() { + return altTextFr; + } + + public void setAltTextFr(String altTextFr) { + this.altTextFr = altTextFr; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public String getTitleForLanguage(String language) { + if (language == null) { + return null; + } + return switch (language.trim().toLowerCase()) { + case "it" -> titleIt; + case "en" -> titleEn; + case "de" -> titleDe; + case "fr" -> titleFr; + default -> null; + }; + } + + public void setTitleForLanguage(String language, String value) { + if (language == null) { + return; + } + switch (language.trim().toLowerCase()) { + case "it" -> titleIt = value; + case "en" -> titleEn = value; + case "de" -> titleDe = value; + case "fr" -> titleFr = value; + default -> { + } + } + } + + public String getAltTextForLanguage(String language) { + if (language == null) { + return null; + } + return switch (language.trim().toLowerCase()) { + case "it" -> altTextIt; + case "en" -> altTextEn; + case "de" -> altTextDe; + case "fr" -> altTextFr; + default -> null; + }; + } + + public void setAltTextForLanguage(String language, String value) { + if (language == null) { + return; + } + switch (language.trim().toLowerCase()) { + case "it" -> altTextIt = value; + case "en" -> altTextEn = value; + case "de" -> altTextDe = value; + case "fr" -> altTextFr = value; + default -> { + } + } + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/MediaVariant.java b/backend/src/main/java/com/printcalculator/entity/MediaVariant.java new file mode 100644 index 0000000..e5a757d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/MediaVariant.java @@ -0,0 +1,154 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "media_variant", indexes = { + @Index(name = "ix_media_variant_asset", columnList = "media_asset_id") +}, uniqueConstraints = { + @UniqueConstraint(name = "uq_media_variant_asset_name_format", columnNames = {"media_asset_id", "variant_name", "format"}) +}) +public class MediaVariant { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "media_variant_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "media_asset_id", nullable = false) + private MediaAsset mediaAsset; + + @Column(name = "variant_name", nullable = false, length = Integer.MAX_VALUE) + private String variantName; + + @Column(name = "format", nullable = false, length = Integer.MAX_VALUE) + private String format; + + @Column(name = "storage_key", nullable = false, length = Integer.MAX_VALUE, unique = true) + private String storageKey; + + @Column(name = "mime_type", nullable = false, length = Integer.MAX_VALUE) + private String mimeType; + + @Column(name = "width_px", nullable = false) + private Integer widthPx; + + @Column(name = "height_px", nullable = false) + private Integer heightPx; + + @Column(name = "file_size_bytes", nullable = false) + private Long fileSizeBytes; + + @ColumnDefault("true") + @Column(name = "is_generated", nullable = false) + private Boolean isGenerated; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public MediaAsset getMediaAsset() { + return mediaAsset; + } + + public void setMediaAsset(MediaAsset mediaAsset) { + this.mediaAsset = mediaAsset; + } + + public String getVariantName() { + return variantName; + } + + public void setVariantName(String variantName) { + this.variantName = variantName; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public Boolean getIsGenerated() { + return isGenerated; + } + + public void setIsGenerated(Boolean generated) { + isGenerated = generated; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/Order.java b/backend/src/main/java/com/printcalculator/entity/Order.java index 1b01df1..71ec184 100644 --- a/backend/src/main/java/com/printcalculator/entity/Order.java +++ b/backend/src/main/java/com/printcalculator/entity/Order.java @@ -20,6 +20,10 @@ public class Order { @JoinColumn(name = "source_quote_session_id") private QuoteSession sourceQuoteSession; + @ColumnDefault("'CALCULATOR'") + @Column(name = "source_type", nullable = false, length = Integer.MAX_VALUE) + private String sourceType; + @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) private String status; @@ -151,6 +155,34 @@ public class Order { @Column(name = "paid_at") private OffsetDateTime paidAt; + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (shippingSameAsBilling == null) { + shippingSameAsBilling = true; + } + if (sourceType == null || sourceType.isBlank()) { + sourceType = "CALCULATOR"; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + if (shippingSameAsBilling == null) { + shippingSameAsBilling = true; + } + if (sourceType == null || sourceType.isBlank()) { + sourceType = "CALCULATOR"; + } + } + public UUID getId() { return id; } @@ -177,6 +209,14 @@ public class Order { this.sourceQuoteSession = sourceQuoteSession; } + public String getSourceType() { + return sourceType; + } + + public void setSourceType(String sourceType) { + this.sourceType = sourceType; + } + public String getStatus() { return status; } diff --git a/backend/src/main/java/com/printcalculator/entity/OrderItem.java b/backend/src/main/java/com/printcalculator/entity/OrderItem.java index b77573d..e9035c5 100644 --- a/backend/src/main/java/com/printcalculator/entity/OrderItem.java +++ b/backend/src/main/java/com/printcalculator/entity/OrderItem.java @@ -23,9 +23,16 @@ public class OrderItem { @JoinColumn(name = "order_id", nullable = false) private Order order; + @ColumnDefault("'PRINT_FILE'") + @Column(name = "item_type", nullable = false, length = Integer.MAX_VALUE) + private String itemType; + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) private String originalFilename; + @Column(name = "display_name", length = Integer.MAX_VALUE) + private String displayName; + @Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE) private String storedRelativePath; @@ -66,6 +73,29 @@ public class OrderItem { @JoinColumn(name = "filament_variant_id") private FilamentVariant filamentVariant; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_product_id") + private ShopProduct shopProduct; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_product_variant_id") + private ShopProductVariant shopProductVariant; + + @Column(name = "shop_product_slug", length = Integer.MAX_VALUE) + private String shopProductSlug; + + @Column(name = "shop_product_name", length = Integer.MAX_VALUE) + private String shopProductName; + + @Column(name = "shop_variant_label", length = Integer.MAX_VALUE) + private String shopVariantLabel; + + @Column(name = "shop_variant_color_name", length = Integer.MAX_VALUE) + private String shopVariantColorName; + + @Column(name = "shop_variant_color_hex", length = Integer.MAX_VALUE) + private String shopVariantColorHex; + @Column(name = "color_code", length = Integer.MAX_VALUE) private String colorCode; @@ -106,6 +136,14 @@ public class OrderItem { if (quantity == null) { quantity = 1; } + if (itemType == null || itemType.isBlank()) { + itemType = "PRINT_FILE"; + } + if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) { + displayName = originalFilename; + } else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) { + displayName = shopProductName; + } } public UUID getId() { @@ -124,6 +162,14 @@ public class OrderItem { this.order = order; } + public String getItemType() { + return itemType; + } + + public void setItemType(String itemType) { + this.itemType = itemType; + } + public String getOriginalFilename() { return originalFilename; } @@ -132,6 +178,14 @@ public class OrderItem { this.originalFilename = originalFilename; } + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + public String getStoredRelativePath() { return storedRelativePath; } @@ -236,6 +290,62 @@ public class OrderItem { this.filamentVariant = filamentVariant; } + public ShopProduct getShopProduct() { + return shopProduct; + } + + public void setShopProduct(ShopProduct shopProduct) { + this.shopProduct = shopProduct; + } + + public ShopProductVariant getShopProductVariant() { + return shopProductVariant; + } + + public void setShopProductVariant(ShopProductVariant shopProductVariant) { + this.shopProductVariant = shopProductVariant; + } + + public String getShopProductSlug() { + return shopProductSlug; + } + + public void setShopProductSlug(String shopProductSlug) { + this.shopProductSlug = shopProductSlug; + } + + public String getShopProductName() { + return shopProductName; + } + + public void setShopProductName(String shopProductName) { + this.shopProductName = shopProductName; + } + + public String getShopVariantLabel() { + return shopVariantLabel; + } + + public void setShopVariantLabel(String shopVariantLabel) { + this.shopVariantLabel = shopVariantLabel; + } + + public String getShopVariantColorName() { + return shopVariantColorName; + } + + public void setShopVariantColorName(String shopVariantColorName) { + this.shopVariantColorName = shopVariantColorName; + } + + public String getShopVariantColorHex() { + return shopVariantColorHex; + } + + public void setShopVariantColorHex(String shopVariantColorHex) { + this.shopVariantColorHex = shopVariantColorHex; + } + public String getColorCode() { return colorCode; } diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java index 4103f8c..f1e3042 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java @@ -30,9 +30,16 @@ public class QuoteLineItem { @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) private String status; + @ColumnDefault("'PRINT_FILE'") + @Column(name = "line_item_type", nullable = false, length = Integer.MAX_VALUE) + private String lineItemType; + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) private String originalFilename; + @Column(name = "display_name", length = Integer.MAX_VALUE) + private String displayName; + @ColumnDefault("1") @Column(name = "quantity", nullable = false) private Integer quantity; @@ -45,6 +52,31 @@ public class QuoteLineItem { @com.fasterxml.jackson.annotation.JsonIgnore private FilamentVariant filamentVariant; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_product_id") + @com.fasterxml.jackson.annotation.JsonIgnore + private ShopProduct shopProduct; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_product_variant_id") + @com.fasterxml.jackson.annotation.JsonIgnore + private ShopProductVariant shopProductVariant; + + @Column(name = "shop_product_slug", length = Integer.MAX_VALUE) + private String shopProductSlug; + + @Column(name = "shop_product_name", length = Integer.MAX_VALUE) + private String shopProductName; + + @Column(name = "shop_variant_label", length = Integer.MAX_VALUE) + private String shopVariantLabel; + + @Column(name = "shop_variant_color_name", length = Integer.MAX_VALUE) + private String shopVariantColorName; + + @Column(name = "shop_variant_color_hex", length = Integer.MAX_VALUE) + private String shopVariantColorHex; + @Column(name = "material_code", length = Integer.MAX_VALUE) private String materialCode; @@ -102,6 +134,41 @@ public class QuoteLineItem { @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (quantity == null) { + quantity = 1; + } + if (lineItemType == null || lineItemType.isBlank()) { + lineItemType = "PRINT_FILE"; + } + if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) { + displayName = originalFilename; + } else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) { + displayName = shopProductName; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + if (lineItemType == null || lineItemType.isBlank()) { + lineItemType = "PRINT_FILE"; + } + if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) { + displayName = originalFilename; + } else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) { + displayName = shopProductName; + } + } + public UUID getId() { return id; } @@ -126,6 +193,14 @@ public class QuoteLineItem { this.status = status; } + public String getLineItemType() { + return lineItemType; + } + + public void setLineItemType(String lineItemType) { + this.lineItemType = lineItemType; + } + public String getOriginalFilename() { return originalFilename; } @@ -134,6 +209,14 @@ public class QuoteLineItem { this.originalFilename = originalFilename; } + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + public Integer getQuantity() { return quantity; } @@ -158,6 +241,62 @@ public class QuoteLineItem { this.filamentVariant = filamentVariant; } + public ShopProduct getShopProduct() { + return shopProduct; + } + + public void setShopProduct(ShopProduct shopProduct) { + this.shopProduct = shopProduct; + } + + public ShopProductVariant getShopProductVariant() { + return shopProductVariant; + } + + public void setShopProductVariant(ShopProductVariant shopProductVariant) { + this.shopProductVariant = shopProductVariant; + } + + public String getShopProductSlug() { + return shopProductSlug; + } + + public void setShopProductSlug(String shopProductSlug) { + this.shopProductSlug = shopProductSlug; + } + + public String getShopProductName() { + return shopProductName; + } + + public void setShopProductName(String shopProductName) { + this.shopProductName = shopProductName; + } + + public String getShopVariantLabel() { + return shopVariantLabel; + } + + public void setShopVariantLabel(String shopVariantLabel) { + this.shopVariantLabel = shopVariantLabel; + } + + public String getShopVariantColorName() { + return shopVariantColorName; + } + + public void setShopVariantColorName(String shopVariantColorName) { + this.shopVariantColorName = shopVariantColorName; + } + + public String getShopVariantColorHex() { + return shopVariantColorHex; + } + + public void setShopVariantColorHex(String shopVariantColorHex) { + this.shopVariantColorHex = shopVariantColorHex; + } + public String getMaterialCode() { return materialCode; } diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteSession.java b/backend/src/main/java/com/printcalculator/entity/QuoteSession.java index e9746ef..6b0faad 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteSession.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteSession.java @@ -22,6 +22,10 @@ public class QuoteSession { @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) private String status; + @ColumnDefault("'PRINT_QUOTE'") + @Column(name = "session_type", nullable = false, length = Integer.MAX_VALUE) + private String sessionType; + @Column(name = "pricing_version", nullable = false, length = Integer.MAX_VALUE) private String pricingVersion; @@ -70,6 +74,19 @@ public class QuoteSession { @Column(name = "cad_hourly_rate_chf", precision = 10, scale = 2) private BigDecimal cadHourlyRateChf; + @PrePersist + private void onCreate() { + if (sessionType == null || sessionType.isBlank()) { + sessionType = "PRINT_QUOTE"; + } + if (supportsEnabled == null) { + supportsEnabled = false; + } + if (createdAt == null) { + createdAt = OffsetDateTime.now(); + } + } + public UUID getId() { return id; } @@ -86,6 +103,14 @@ public class QuoteSession { this.status = status; } + public String getSessionType() { + return sessionType; + } + + public void setSessionType(String sessionType) { + this.sessionType = sessionType; + } + public String getPricingVersion() { return pricingVersion; } diff --git a/backend/src/main/java/com/printcalculator/entity/ShopCategory.java b/backend/src/main/java/com/printcalculator/entity/ShopCategory.java new file mode 100644 index 0000000..a018a97 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/ShopCategory.java @@ -0,0 +1,221 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "shop_category", indexes = { + @Index(name = "ix_shop_category_parent_sort", columnList = "parent_category_id, sort_order"), + @Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order") +}) +public class ShopCategory { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "shop_category_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_category_id") + private ShopCategory parentCategory; + + @Column(name = "slug", nullable = false, unique = true, length = Integer.MAX_VALUE) + private String slug; + + @Column(name = "name", nullable = false, length = Integer.MAX_VALUE) + private String name; + + @Column(name = "description", length = Integer.MAX_VALUE) + private String description; + + @Column(name = "seo_title", length = Integer.MAX_VALUE) + private String seoTitle; + + @Column(name = "seo_description", length = Integer.MAX_VALUE) + private String seoDescription; + + @Column(name = "og_title", length = Integer.MAX_VALUE) + private String ogTitle; + + @Column(name = "og_description", length = Integer.MAX_VALUE) + private String ogDescription; + + @ColumnDefault("true") + @Column(name = "indexable", nullable = false) + private Boolean indexable; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @ColumnDefault("0") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (indexable == null) { + indexable = true; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + if (indexable == null) { + indexable = true; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public ShopCategory getParentCategory() { + return parentCategory; + } + + public void setParentCategory(ShopCategory parentCategory) { + this.parentCategory = parentCategory; + } + + 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 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/entity/ShopProduct.java b/backend/src/main/java/com/printcalculator/entity/ShopProduct.java new file mode 100644 index 0000000..2c6bda8 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/ShopProduct.java @@ -0,0 +1,593 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "shop_product", indexes = { + @Index(name = "ix_shop_product_category_active_sort", columnList = "shop_category_id, is_active, sort_order"), + @Index(name = "ix_shop_product_featured_sort", columnList = "is_featured, is_active, sort_order") +}) +public class ShopProduct { + public static final List SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr"); + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "shop_product_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "shop_category_id", nullable = false) + private ShopCategory category; + + @Column(name = "slug", nullable = false, unique = true, length = Integer.MAX_VALUE) + private String slug; + + @Column(name = "name", nullable = false, length = Integer.MAX_VALUE) + private String name; + + @Column(name = "name_it", length = Integer.MAX_VALUE) + private String nameIt; + + @Column(name = "name_en", length = Integer.MAX_VALUE) + private String nameEn; + + @Column(name = "name_de", length = Integer.MAX_VALUE) + private String nameDe; + + @Column(name = "name_fr", length = Integer.MAX_VALUE) + private String nameFr; + + @Column(name = "excerpt", length = Integer.MAX_VALUE) + private String excerpt; + + @Column(name = "excerpt_it", length = Integer.MAX_VALUE) + private String excerptIt; + + @Column(name = "excerpt_en", length = Integer.MAX_VALUE) + private String excerptEn; + + @Column(name = "excerpt_de", length = Integer.MAX_VALUE) + private String excerptDe; + + @Column(name = "excerpt_fr", length = Integer.MAX_VALUE) + private String excerptFr; + + @Column(name = "description", length = Integer.MAX_VALUE) + private String description; + + @Column(name = "description_it", length = Integer.MAX_VALUE) + private String descriptionIt; + + @Column(name = "description_en", length = Integer.MAX_VALUE) + private String descriptionEn; + + @Column(name = "description_de", length = Integer.MAX_VALUE) + private String descriptionDe; + + @Column(name = "description_fr", length = Integer.MAX_VALUE) + private String descriptionFr; + + @Column(name = "seo_title", length = Integer.MAX_VALUE) + private String seoTitle; + + @Column(name = "seo_title_it", length = Integer.MAX_VALUE) + private String seoTitleIt; + + @Column(name = "seo_title_en", length = Integer.MAX_VALUE) + private String seoTitleEn; + + @Column(name = "seo_title_de", length = Integer.MAX_VALUE) + private String seoTitleDe; + + @Column(name = "seo_title_fr", length = Integer.MAX_VALUE) + private String seoTitleFr; + + @Column(name = "seo_description", length = Integer.MAX_VALUE) + private String seoDescription; + + @Column(name = "seo_description_it", length = Integer.MAX_VALUE) + private String seoDescriptionIt; + + @Column(name = "seo_description_en", length = Integer.MAX_VALUE) + private String seoDescriptionEn; + + @Column(name = "seo_description_de", length = Integer.MAX_VALUE) + private String seoDescriptionDe; + + @Column(name = "seo_description_fr", length = Integer.MAX_VALUE) + private String seoDescriptionFr; + + @Column(name = "og_title", length = Integer.MAX_VALUE) + private String ogTitle; + + @Column(name = "og_description", length = Integer.MAX_VALUE) + private String ogDescription; + + @ColumnDefault("true") + @Column(name = "indexable", nullable = false) + private Boolean indexable; + + @ColumnDefault("false") + @Column(name = "is_featured", nullable = false) + private Boolean isFeatured; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @ColumnDefault("0") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (indexable == null) { + indexable = true; + } + if (isFeatured == null) { + isFeatured = false; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + if (indexable == null) { + indexable = true; + } + if (isFeatured == null) { + isFeatured = false; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public ShopCategory getCategory() { + return category; + } + + public void setCategory(ShopCategory category) { + this.category = category; + } + + 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 getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + + public String getExcerpt() { + return excerpt; + } + + public void setExcerpt(String excerpt) { + this.excerpt = excerpt; + } + + public String getExcerptIt() { + return excerptIt; + } + + public void setExcerptIt(String excerptIt) { + this.excerptIt = excerptIt; + } + + public String getExcerptEn() { + return excerptEn; + } + + public void setExcerptEn(String excerptEn) { + this.excerptEn = excerptEn; + } + + public String getExcerptDe() { + return excerptDe; + } + + public void setExcerptDe(String excerptDe) { + this.excerptDe = excerptDe; + } + + public String getExcerptFr() { + return excerptFr; + } + + public void setExcerptFr(String excerptFr) { + this.excerptFr = excerptFr; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + + 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 getSeoTitleIt() { + return seoTitleIt; + } + + public void setSeoTitleIt(String seoTitleIt) { + this.seoTitleIt = seoTitleIt; + } + + public String getSeoTitleEn() { + return seoTitleEn; + } + + public void setSeoTitleEn(String seoTitleEn) { + this.seoTitleEn = seoTitleEn; + } + + public String getSeoTitleDe() { + return seoTitleDe; + } + + public void setSeoTitleDe(String seoTitleDe) { + this.seoTitleDe = seoTitleDe; + } + + public String getSeoTitleFr() { + return seoTitleFr; + } + + public void setSeoTitleFr(String seoTitleFr) { + this.seoTitleFr = seoTitleFr; + } + + public String getSeoDescriptionIt() { + return seoDescriptionIt; + } + + public void setSeoDescriptionIt(String seoDescriptionIt) { + this.seoDescriptionIt = seoDescriptionIt; + } + + public String getSeoDescriptionEn() { + return seoDescriptionEn; + } + + public void setSeoDescriptionEn(String seoDescriptionEn) { + this.seoDescriptionEn = seoDescriptionEn; + } + + public String getSeoDescriptionDe() { + return seoDescriptionDe; + } + + public void setSeoDescriptionDe(String seoDescriptionDe) { + this.seoDescriptionDe = seoDescriptionDe; + } + + public String getSeoDescriptionFr() { + return seoDescriptionFr; + } + + public void setSeoDescriptionFr(String seoDescriptionFr) { + this.seoDescriptionFr = seoDescriptionFr; + } + + 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 OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public String getNameForLanguage(String language) { + return resolveLocalizedValue(language, name, nameIt, nameEn, nameDe, nameFr); + } + + public void setNameForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> nameIt = value; + case "en" -> nameEn = value; + case "de" -> nameDe = value; + case "fr" -> nameFr = value; + default -> { + } + } + } + + public String getExcerptForLanguage(String language) { + return resolveLocalizedValue(language, excerpt, excerptIt, excerptEn, excerptDe, excerptFr); + } + + public void setExcerptForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> excerptIt = value; + case "en" -> excerptEn = value; + case "de" -> excerptDe = value; + case "fr" -> excerptFr = value; + default -> { + } + } + } + + public String getDescriptionForLanguage(String language) { + return resolveLocalizedValue(language, description, descriptionIt, descriptionEn, descriptionDe, descriptionFr); + } + + public void setDescriptionForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> descriptionIt = value; + case "en" -> descriptionEn = value; + case "de" -> descriptionDe = value; + case "fr" -> descriptionFr = value; + default -> { + } + } + } + + public String getSeoTitleForLanguage(String language) { + return resolveLocalizedValue(language, seoTitle, seoTitleIt, seoTitleEn, seoTitleDe, seoTitleFr); + } + + public void setSeoTitleForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> seoTitleIt = value; + case "en" -> seoTitleEn = value; + case "de" -> seoTitleDe = value; + case "fr" -> seoTitleFr = value; + default -> { + } + } + } + + public String getSeoDescriptionForLanguage(String language) { + return resolveLocalizedValue(language, seoDescription, seoDescriptionIt, seoDescriptionEn, seoDescriptionDe, seoDescriptionFr); + } + + public void setSeoDescriptionForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> seoDescriptionIt = value; + case "en" -> seoDescriptionEn = value; + case "de" -> seoDescriptionDe = value; + case "fr" -> seoDescriptionFr = value; + default -> { + } + } + } + + private String resolveLocalizedValue(String language, + String fallback, + String valueIt, + String valueEn, + String valueDe, + String valueFr) { + String normalizedLanguage = normalizeLanguage(language); + String preferred = switch (normalizedLanguage) { + case "it" -> valueIt; + case "en" -> valueEn; + case "de" -> valueDe; + case "fr" -> valueFr; + default -> null; + }; + String resolved = firstNonBlank(preferred, fallback); + if (resolved != null) { + return resolved; + } + return firstNonBlank(valueIt, valueEn, valueDe, valueFr); + } + + private String normalizeLanguage(String language) { + if (language == null) { + return ""; + } + String normalized = language.trim().toLowerCase(); + int separatorIndex = normalized.indexOf('-'); + if (separatorIndex > 0) { + normalized = normalized.substring(0, separatorIndex); + } + return normalized; + } + + private String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/ShopProductModelAsset.java b/backend/src/main/java/com/printcalculator/entity/ShopProductModelAsset.java new file mode 100644 index 0000000..9287281 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/ShopProductModelAsset.java @@ -0,0 +1,189 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "shop_product_model_asset", indexes = { + @Index(name = "ix_shop_product_model_asset_product", columnList = "shop_product_id") +}) +public class ShopProductModelAsset { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "shop_product_model_asset_id", nullable = false) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "shop_product_id", nullable = false, unique = true) + private ShopProduct product; + + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) + private String originalFilename; + + @Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE) + private String storedRelativePath; + + @Column(name = "stored_filename", nullable = false, length = Integer.MAX_VALUE) + private String storedFilename; + + @Column(name = "file_size_bytes") + private Long fileSizeBytes; + + @Column(name = "mime_type", length = Integer.MAX_VALUE) + private String mimeType; + + @Column(name = "sha256_hex", length = Integer.MAX_VALUE) + private String sha256Hex; + + @Column(name = "bounding_box_x_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxXMm; + + @Column(name = "bounding_box_y_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxYMm; + + @Column(name = "bounding_box_z_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxZMm; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public ShopProduct getProduct() { + return product; + } + + public void setProduct(ShopProduct product) { + this.product = product; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getStoredRelativePath() { + return storedRelativePath; + } + + public void setStoredRelativePath(String storedRelativePath) { + this.storedRelativePath = storedRelativePath; + } + + public String getStoredFilename() { + return storedFilename; + } + + public void setStoredFilename(String storedFilename) { + this.storedFilename = storedFilename; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } + + public BigDecimal getBoundingBoxXMm() { + return boundingBoxXMm; + } + + public void setBoundingBoxXMm(BigDecimal boundingBoxXMm) { + this.boundingBoxXMm = boundingBoxXMm; + } + + public BigDecimal getBoundingBoxYMm() { + return boundingBoxYMm; + } + + public void setBoundingBoxYMm(BigDecimal boundingBoxYMm) { + this.boundingBoxYMm = boundingBoxYMm; + } + + public BigDecimal getBoundingBoxZMm() { + return boundingBoxZMm; + } + + public void setBoundingBoxZMm(BigDecimal boundingBoxZMm) { + this.boundingBoxZMm = boundingBoxZMm; + } + + 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/entity/ShopProductVariant.java b/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java new file mode 100644 index 0000000..d1d6d03 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java @@ -0,0 +1,218 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "shop_product_variant", indexes = { + @Index(name = "ix_shop_product_variant_product_active_sort", columnList = "shop_product_id, is_active, sort_order"), + @Index(name = "ix_shop_product_variant_sku", columnList = "sku") +}) +public class ShopProductVariant { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "shop_product_variant_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "shop_product_id", nullable = false) + private ShopProduct product; + + @Column(name = "sku", unique = true, length = Integer.MAX_VALUE) + private String sku; + + @Column(name = "variant_label", nullable = false, length = Integer.MAX_VALUE) + private String variantLabel; + + @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) + private String colorName; + + @Column(name = "color_hex", length = Integer.MAX_VALUE) + private String colorHex; + + @Column(name = "internal_material_code", nullable = false, length = Integer.MAX_VALUE) + private String internalMaterialCode; + + @ColumnDefault("0.00") + @Column(name = "price_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal priceChf; + + @ColumnDefault("false") + @Column(name = "is_default", nullable = false) + private Boolean isDefault; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @ColumnDefault("0") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (priceChf == null) { + priceChf = BigDecimal.ZERO; + } + if (isDefault == null) { + isDefault = false; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + if (priceChf == null) { + priceChf = BigDecimal.ZERO; + } + if (isDefault == null) { + isDefault = false; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public ShopProduct getProduct() { + return product; + } + + public void setProduct(ShopProduct product) { + this.product = product; + } + + 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/repository/MediaAssetRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaAssetRepository.java new file mode 100644 index 0000000..e41b95a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/MediaAssetRepository.java @@ -0,0 +1,11 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.MediaAsset; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface MediaAssetRepository extends JpaRepository { + List findAllByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java new file mode 100644 index 0000000..c0beaba --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java @@ -0,0 +1,43 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.MediaUsage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +public interface MediaUsageRepository extends JpaRepository { + List findByMediaAsset_IdOrderBySortOrderAscCreatedAtAsc(UUID mediaAssetId); + + List findByMediaAsset_IdIn(Collection mediaAssetIds); + + 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 + and usage.usageKey = :usageKey + and ((:ownerId is null and usage.ownerId is null) or usage.ownerId = :ownerId) + order by usage.sortOrder asc, usage.createdAt asc + """) + List findByUsageScope(@Param("usageType") String usageType, + @Param("usageKey") String usageKey, + @Param("ownerId") UUID ownerId); +} diff --git a/backend/src/main/java/com/printcalculator/repository/MediaVariantRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaVariantRepository.java new file mode 100644 index 0000000..013346d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/MediaVariantRepository.java @@ -0,0 +1,14 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.MediaVariant; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +public interface MediaVariantRepository extends JpaRepository { + List findByMediaAsset_IdOrderByCreatedAtAsc(UUID mediaAssetId); + + List findByMediaAsset_IdIn(Collection mediaAssetIds); +} 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 7d39175..5b51980 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java @@ -4,9 +4,19 @@ 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); + boolean existsByShopProduct_Id(UUID shopProductId); + boolean existsByShopProductVariant_Id(UUID shopProductVariantId); } 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/repository/ShopCategoryRepository.java b/backend/src/main/java/com/printcalculator/repository/ShopCategoryRepository.java new file mode 100644 index 0000000..26e1887 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/ShopCategoryRepository.java @@ -0,0 +1,24 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.ShopCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ShopCategoryRepository 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 new file mode 100644 index 0000000..f381943 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/ShopProductModelAssetRepository.java @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..6b180c5 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/ShopProductRepository.java @@ -0,0 +1,28 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.ShopProduct; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +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 new file mode 100644 index 0000000..4e285cb --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/ShopProductVariantRepository.java @@ -0,0 +1,25 @@ +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; + +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/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index ebef7d8..9179f7d 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -104,6 +104,7 @@ public class OrderService { Order order = new Order(); order.setSourceQuoteSession(session); + order.setSourceType(resolveOrderSourceType(session)); order.setCustomer(customer); order.setCustomerEmail(request.getCustomer().getEmail()); order.setCustomerPhone(request.getCustomer().getPhone()); @@ -172,12 +173,27 @@ public class OrderService { for (QuoteLineItem qItem : quoteItems) { OrderItem oItem = new OrderItem(); oItem.setOrder(order); + oItem.setItemType(qItem.getLineItemType() != null ? qItem.getLineItemType() : "PRINT_FILE"); oItem.setOriginalFilename(qItem.getOriginalFilename()); + oItem.setDisplayName( + qItem.getDisplayName() != null && !qItem.getDisplayName().isBlank() + ? qItem.getDisplayName() + : qItem.getOriginalFilename() + ); int quantity = qItem.getQuantity() != null && qItem.getQuantity() > 0 ? qItem.getQuantity() : 1; oItem.setQuantity(quantity); oItem.setColorCode(qItem.getColorCode()); oItem.setFilamentVariant(qItem.getFilamentVariant()); - if (qItem.getFilamentVariant() != null + oItem.setShopProduct(qItem.getShopProduct()); + oItem.setShopProductVariant(qItem.getShopProductVariant()); + oItem.setShopProductSlug(qItem.getShopProductSlug()); + oItem.setShopProductName(qItem.getShopProductName()); + oItem.setShopVariantLabel(qItem.getShopVariantLabel()); + oItem.setShopVariantColorName(qItem.getShopVariantColorName()); + oItem.setShopVariantColorHex(qItem.getShopVariantColorHex()); + if (qItem.getMaterialCode() != null && !qItem.getMaterialCode().isBlank()) { + oItem.setMaterialCode(qItem.getMaterialCode()); + } else if (qItem.getFilamentVariant() != null && qItem.getFilamentVariant().getFilamentMaterialType() != null && qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) { oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode()); @@ -319,6 +335,13 @@ public class OrderService { } } + private String resolveOrderSourceType(QuoteSession session) { + if (session != null && "SHOP_CART".equalsIgnoreCase(session.getSessionType())) { + return "SHOP"; + } + return "CALCULATOR"; + } + private String getDisplayOrderNumber(Order order) { String orderNumber = order.getOrderNumber(); if (orderNumber != null && !orderNumber.isBlank()) { diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java new file mode 100644 index 0000000..0e0624a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java @@ -0,0 +1,870 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminCreateMediaUsageRequest; +import com.printcalculator.dto.AdminMediaAssetDto; +import com.printcalculator.dto.AdminMediaUsageDto; +import com.printcalculator.dto.AdminMediaVariantDto; +import com.printcalculator.dto.MediaTextTranslationDto; +import com.printcalculator.dto.AdminUpdateMediaAssetRequest; +import com.printcalculator.dto.AdminUpdateMediaUsageRequest; +import com.printcalculator.entity.MediaAsset; +import com.printcalculator.entity.MediaUsage; +import com.printcalculator.entity.MediaVariant; +import com.printcalculator.repository.MediaAssetRepository; +import com.printcalculator.repository.MediaUsageRepository; +import com.printcalculator.repository.MediaVariantRepository; +import com.printcalculator.service.media.MediaFfmpegService; +import com.printcalculator.service.media.MediaImageInspector; +import com.printcalculator.service.media.MediaStorageService; +import com.printcalculator.service.storage.ClamAVService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +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.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +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.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class AdminMediaControllerService { + + private static final Logger logger = LoggerFactory.getLogger(AdminMediaControllerService.class); + + private static final String STATUS_UPLOADED = "UPLOADED"; + private static final String STATUS_PROCESSING = "PROCESSING"; + private static final String STATUS_READY = "READY"; + private static final String STATUS_FAILED = "FAILED"; + private static final String STATUS_ARCHIVED = "ARCHIVED"; + + private static final String VISIBILITY_PUBLIC = "PUBLIC"; + private static final String VISIBILITY_PRIVATE = "PRIVATE"; + + private static final String FORMAT_ORIGINAL = "ORIGINAL"; + private static final String FORMAT_JPEG = "JPEG"; + private static final String FORMAT_WEBP = "WEBP"; + private static final String FORMAT_AVIF = "AVIF"; + + private static final Set ALLOWED_STATUSES = Set.of( + STATUS_UPLOADED, STATUS_PROCESSING, STATUS_READY, STATUS_FAILED, STATUS_ARCHIVED + ); + private static final Set ALLOWED_VISIBILITIES = Set.of(VISIBILITY_PUBLIC, VISIBILITY_PRIVATE); + private static final Set ALLOWED_UPLOAD_MIME_TYPES = Set.of( + "image/jpeg", "image/png", "image/webp" + ); + private static final List SUPPORTED_MEDIA_LANGUAGES = List.of("it", "en", "de", "fr"); + private static final Map GENERATED_FORMAT_MIME_TYPES = Map.of( + FORMAT_JPEG, "image/jpeg", + FORMAT_WEBP, "image/webp", + FORMAT_AVIF, "image/avif" + ); + private static final Map GENERATED_FORMAT_EXTENSIONS = Map.of( + FORMAT_JPEG, "jpg", + FORMAT_WEBP, "webp", + FORMAT_AVIF, "avif" + ); + private static final List PRESETS = List.of( + new PresetDefinition("thumb", 320), + new PresetDefinition("card", 640), + new PresetDefinition("hero", 1280) + ); + private static final DateTimeFormatter STORAGE_FOLDER_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM"); + + private final MediaAssetRepository mediaAssetRepository; + private final MediaVariantRepository mediaVariantRepository; + private final MediaUsageRepository mediaUsageRepository; + private final MediaStorageService mediaStorageService; + private final MediaImageInspector mediaImageInspector; + private final MediaFfmpegService mediaFfmpegService; + private final ClamAVService clamAVService; + private final long maxUploadFileSizeBytes; + + public AdminMediaControllerService(MediaAssetRepository mediaAssetRepository, + MediaVariantRepository mediaVariantRepository, + MediaUsageRepository mediaUsageRepository, + MediaStorageService mediaStorageService, + MediaImageInspector mediaImageInspector, + MediaFfmpegService mediaFfmpegService, + ClamAVService clamAVService, + @Value("${media.upload.max-file-size-bytes:26214400}") long maxUploadFileSizeBytes) { + this.mediaAssetRepository = mediaAssetRepository; + this.mediaVariantRepository = mediaVariantRepository; + this.mediaUsageRepository = mediaUsageRepository; + this.mediaStorageService = mediaStorageService; + this.mediaImageInspector = mediaImageInspector; + this.mediaFfmpegService = mediaFfmpegService; + this.clamAVService = clamAVService; + this.maxUploadFileSizeBytes = maxUploadFileSizeBytes; + } + + @Transactional(noRollbackFor = ResponseStatusException.class) + public AdminMediaAssetDto uploadAsset(MultipartFile file, + String title, + String altText, + String visibility) { + validateUpload(file); + + Path tempDirectory = null; + MediaAsset asset = null; + + try { + String normalizedVisibility = normalizeVisibility(visibility, true); + tempDirectory = Files.createTempDirectory("media-asset-"); + Path uploadFile = tempDirectory.resolve("upload.bin"); + file.transferTo(uploadFile); + + try (InputStream inputStream = Files.newInputStream(uploadFile)) { + clamAVService.scan(inputStream); + } + + MediaImageInspector.ImageMetadata metadata = mediaImageInspector.inspect(uploadFile); + if (!ALLOWED_UPLOAD_MIME_TYPES.contains(metadata.mimeType())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unsupported image type. Allowed: jpg, jpeg, png, webp." + ); + } + + String storageFolder = buildStorageFolder(); + String originalStorageKey = storageFolder + "/original." + metadata.fileExtension(); + String normalizedFilename = sanitizeOriginalFilename(file.getOriginalFilename(), metadata.fileExtension()); + String normalizedTitle = normalizeText(title); + String normalizedAltText = normalizeText(altText); + long originalFileSize = Files.size(uploadFile); + String sha256Hex = computeSha256(uploadFile); + + mediaStorageService.storeOriginal(uploadFile, originalStorageKey); + + OffsetDateTime now = OffsetDateTime.now(); + asset = new MediaAsset(); + asset.setOriginalFilename(normalizedFilename); + asset.setStorageKey(originalStorageKey); + asset.setMimeType(metadata.mimeType()); + asset.setFileSizeBytes(originalFileSize); + asset.setSha256Hex(sha256Hex); + asset.setWidthPx(metadata.widthPx()); + asset.setHeightPx(metadata.heightPx()); + asset.setStatus(STATUS_UPLOADED); + asset.setVisibility(normalizedVisibility); + asset.setTitle(normalizedTitle); + asset.setAltText(normalizedAltText); + asset.setCreatedAt(now); + asset.setUpdatedAt(now); + asset = mediaAssetRepository.save(asset); + + MediaVariant originalVariant = new MediaVariant(); + originalVariant.setMediaAsset(asset); + originalVariant.setVariantName("original"); + originalVariant.setFormat(FORMAT_ORIGINAL); + originalVariant.setStorageKey(originalStorageKey); + originalVariant.setMimeType(metadata.mimeType()); + originalVariant.setWidthPx(metadata.widthPx()); + originalVariant.setHeightPx(metadata.heightPx()); + originalVariant.setFileSizeBytes(originalFileSize); + originalVariant.setIsGenerated(false); + originalVariant.setCreatedAt(now); + mediaVariantRepository.save(originalVariant); + + asset.setStatus(STATUS_PROCESSING); + asset.setUpdatedAt(OffsetDateTime.now()); + asset = mediaAssetRepository.save(asset); + + List generatedVariants = generateDerivedVariants(asset, uploadFile, tempDirectory); + mediaVariantRepository.saveAll(generatedVariants); + + asset.setStatus(STATUS_READY); + asset.setUpdatedAt(OffsetDateTime.now()); + mediaAssetRepository.save(asset); + + return getAsset(asset.getId()); + } catch (ResponseStatusException e) { + markFailed(asset, e.getReason(), e); + throw e; + } catch (IOException e) { + markFailed(asset, "Media processing failed.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Media processing failed."); + } finally { + deleteRecursively(tempDirectory); + } + } + + public List listAssets() { + return toAssetDtos(mediaAssetRepository.findAllByOrderByCreatedAtDesc()); + } + + public AdminMediaAssetDto getAsset(UUID mediaAssetId) { + MediaAsset asset = getAssetOrThrow(mediaAssetId); + return toAssetDtos(List.of(asset)).getFirst(); + } + + @Transactional(noRollbackFor = ResponseStatusException.class) + public AdminMediaAssetDto updateAsset(UUID mediaAssetId, AdminUpdateMediaAssetRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required."); + } + + MediaAsset asset = getAssetOrThrow(mediaAssetId); + String requestedVisibility = normalizeVisibility(payload.getVisibility(), false); + String requestedStatus = normalizeStatus(payload.getStatus(), false); + + if (requestedVisibility != null && !requestedVisibility.equals(asset.getVisibility())) { + moveGeneratedVariants(asset, requestedVisibility); + asset.setVisibility(requestedVisibility); + } + if (requestedStatus != null) { + asset.setStatus(requestedStatus); + } + if (payload.getTitle() != null) { + asset.setTitle(normalizeText(payload.getTitle())); + } + if (payload.getAltText() != null) { + asset.setAltText(normalizeText(payload.getAltText())); + } + + asset.setUpdatedAt(OffsetDateTime.now()); + mediaAssetRepository.save(asset); + return getAsset(asset.getId()); + } + + @Transactional + public AdminMediaUsageDto createUsage(AdminCreateMediaUsageRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required."); + } + + MediaAsset asset = getAssetOrThrow(payload.getMediaAssetId()); + String usageType = requireUsageType(payload.getUsageType()); + String usageKey = requireUsageKey(payload.getUsageKey()); + boolean isPrimary = Boolean.TRUE.equals(payload.getIsPrimary()); + Map translations = requireTranslations(payload.getTranslations()); + + if (isPrimary) { + unsetPrimaryForScope(usageType, usageKey, payload.getOwnerId(), null); + } + + MediaUsage usage = new MediaUsage(); + usage.setUsageType(usageType); + usage.setUsageKey(usageKey); + usage.setOwnerId(payload.getOwnerId()); + usage.setMediaAsset(asset); + usage.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0); + usage.setIsPrimary(isPrimary); + usage.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + usage.setCreatedAt(OffsetDateTime.now()); + applyTranslations(usage, translations); + + MediaUsage saved = mediaUsageRepository.save(usage); + return toUsageDto(saved); + } + + @Transactional + public AdminMediaUsageDto updateUsage(UUID mediaUsageId, AdminUpdateMediaUsageRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required."); + } + + MediaUsage usage = getUsageOrThrow(mediaUsageId); + + if (payload.getUsageType() != null) { + usage.setUsageType(requireUsageType(payload.getUsageType())); + } + if (payload.getUsageKey() != null) { + usage.setUsageKey(requireUsageKey(payload.getUsageKey())); + } + if (payload.getOwnerId() != null) { + usage.setOwnerId(payload.getOwnerId()); + } + if (payload.getMediaAssetId() != null) { + usage.setMediaAsset(getAssetOrThrow(payload.getMediaAssetId())); + } + if (payload.getSortOrder() != null) { + usage.setSortOrder(payload.getSortOrder()); + } + if (payload.getIsActive() != null) { + usage.setIsActive(payload.getIsActive()); + } + if (payload.getIsPrimary() != null) { + usage.setIsPrimary(payload.getIsPrimary()); + } + if (payload.getTranslations() != null) { + applyTranslations(usage, requireTranslations(payload.getTranslations())); + } + + if (Boolean.TRUE.equals(usage.getIsPrimary())) { + unsetPrimaryForScope(usage.getUsageType(), usage.getUsageKey(), usage.getOwnerId(), usage.getId()); + } + + MediaUsage saved = mediaUsageRepository.save(usage); + return toUsageDto(saved); + } + + @Transactional + public void deleteUsage(UUID mediaUsageId) { + 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()); + + List pendingVariants = new ArrayList<>(); + Set skippedFormats = new LinkedHashSet<>(); + for (PresetDefinition preset : PRESETS) { + VariantDimensions dimensions = computeVariantDimensions( + asset.getWidthPx(), + asset.getHeightPx(), + preset.maxDimension() + ); + + for (String format : List.of(FORMAT_JPEG, FORMAT_WEBP, FORMAT_AVIF)) { + if (!mediaFfmpegService.canEncode(format)) { + skippedFormats.add(format); + continue; + } + String extension = GENERATED_FORMAT_EXTENSIONS.get(format); + Path outputFile = generatedDirectory.resolve(preset.name() + "." + extension); + try { + mediaFfmpegService.generateVariant( + sourceFile, + outputFile, + dimensions.widthPx(), + dimensions.heightPx(), + format + ); + } catch (IOException e) { + if (FORMAT_AVIF.equals(format)) { + skippedFormats.add(format); + logger.warn( + "Skipping AVIF variant generation for asset {} preset '{}' because FFmpeg AVIF generation failed: {}", + asset.getId(), + preset.name(), + e.getMessage() + ); + continue; + } + throw e; + } + + MediaVariant variant = new MediaVariant(); + variant.setMediaAsset(asset); + variant.setVariantName(preset.name()); + variant.setFormat(format); + variant.setStorageKey(storageFolder + "/" + preset.name() + "." + extension); + variant.setMimeType(GENERATED_FORMAT_MIME_TYPES.get(format)); + variant.setWidthPx(dimensions.widthPx()); + variant.setHeightPx(dimensions.heightPx()); + variant.setFileSizeBytes(Files.size(outputFile)); + variant.setIsGenerated(true); + variant.setCreatedAt(OffsetDateTime.now()); + + pendingVariants.add(new PendingGeneratedVariant(variant, outputFile)); + } + } + + if (!skippedFormats.isEmpty()) { + logger.warn( + "Skipping media formats for asset {} because FFmpeg support is unavailable: {}", + asset.getId(), + String.join(", ", skippedFormats) + ); + } + + List storedKeys = new ArrayList<>(); + try { + for (PendingGeneratedVariant pendingVariant : pendingVariants) { + storeGeneratedVariant(asset.getVisibility(), pendingVariant); + storedKeys.add(pendingVariant.variant().getStorageKey()); + } + } catch (IOException e) { + cleanupStoredGeneratedVariants(asset.getVisibility(), storedKeys); + throw e; + } + + return pendingVariants.stream() + .map(PendingGeneratedVariant::variant) + .toList(); + } + + private void storeGeneratedVariant(String visibility, PendingGeneratedVariant pendingVariant) throws IOException { + if (VISIBILITY_PUBLIC.equals(visibility)) { + mediaStorageService.storePublic(pendingVariant.file(), pendingVariant.variant().getStorageKey()); + return; + } + mediaStorageService.storePrivate(pendingVariant.file(), pendingVariant.variant().getStorageKey()); + } + + private void cleanupStoredGeneratedVariants(String visibility, Collection storageKeys) { + for (String storageKey : storageKeys) { + try { + mediaStorageService.deleteGenerated(visibility, storageKey); + } catch (IOException cleanupException) { + logger.warn("Failed to clean up media variant {}", storageKey, cleanupException); + } + } + } + + private void moveGeneratedVariants(MediaAsset asset, String requestedVisibility) { + List variants = mediaVariantRepository.findByMediaAsset_IdOrderByCreatedAtAsc(asset.getId()); + List movedStorageKeys = new ArrayList<>(); + try { + for (MediaVariant variant : variants) { + if (FORMAT_ORIGINAL.equals(variant.getFormat())) { + continue; + } + mediaStorageService.moveGenerated(variant.getStorageKey(), asset.getVisibility(), requestedVisibility); + movedStorageKeys.add(variant.getStorageKey()); + } + } catch (IOException e) { + reverseMovedVariants(asset.getVisibility(), requestedVisibility, movedStorageKeys); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to move media files."); + } + } + + private void reverseMovedVariants(String originalVisibility, String requestedVisibility, List movedStorageKeys) { + List reversedOrder = new ArrayList<>(movedStorageKeys); + java.util.Collections.reverse(reversedOrder); + for (String storageKey : reversedOrder) { + try { + mediaStorageService.moveGenerated(storageKey, requestedVisibility, originalVisibility); + } catch (IOException reverseException) { + logger.error("Failed to restore media variant {}", storageKey, reverseException); + } + } + } + + private void unsetPrimaryForScope(String usageType, String usageKey, UUID ownerId, UUID excludeUsageId) { + List existingUsages = mediaUsageRepository.findByUsageScope(usageType, usageKey, ownerId); + List usagesToUpdate = existingUsages.stream() + .filter(existing -> excludeUsageId == null || !existing.getId().equals(excludeUsageId)) + .filter(existing -> Boolean.TRUE.equals(existing.getIsPrimary())) + .peek(existing -> existing.setIsPrimary(false)) + .toList(); + + if (!usagesToUpdate.isEmpty()) { + mediaUsageRepository.saveAll(usagesToUpdate); + } + } + + private List toAssetDtos(List assets) { + if (assets == null || assets.isEmpty()) { + return List.of(); + } + + List assetIds = assets.stream() + .map(MediaAsset::getId) + .filter(Objects::nonNull) + .toList(); + + Map> variantsByAssetId = mediaVariantRepository.findByMediaAsset_IdIn(assetIds) + .stream() + .sorted(this::compareVariants) + .collect(Collectors.groupingBy(variant -> variant.getMediaAsset().getId(), LinkedHashMap::new, Collectors.toList())); + + Map> usagesByAssetId = mediaUsageRepository.findByMediaAsset_IdIn(assetIds) + .stream() + .sorted(Comparator + .comparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))) + .collect(Collectors.groupingBy(usage -> usage.getMediaAsset().getId(), LinkedHashMap::new, Collectors.toList())); + + return assets.stream() + .map(asset -> toAssetDto( + asset, + variantsByAssetId.getOrDefault(asset.getId(), List.of()), + usagesByAssetId.getOrDefault(asset.getId(), List.of()) + )) + .toList(); + } + + private AdminMediaAssetDto toAssetDto(MediaAsset asset, List variants, List usages) { + AdminMediaAssetDto dto = new AdminMediaAssetDto(); + dto.setId(asset.getId()); + dto.setOriginalFilename(asset.getOriginalFilename()); + dto.setStorageKey(asset.getStorageKey()); + dto.setMimeType(asset.getMimeType()); + dto.setFileSizeBytes(asset.getFileSizeBytes()); + dto.setSha256Hex(asset.getSha256Hex()); + dto.setWidthPx(asset.getWidthPx()); + dto.setHeightPx(asset.getHeightPx()); + dto.setStatus(asset.getStatus()); + dto.setVisibility(asset.getVisibility()); + dto.setTitle(asset.getTitle()); + dto.setAltText(asset.getAltText()); + dto.setCreatedAt(asset.getCreatedAt()); + dto.setUpdatedAt(asset.getUpdatedAt()); + dto.setVariants(variants.stream().map(variant -> toVariantDto(asset, variant)).toList()); + dto.setUsages(usages.stream().map(this::toUsageDto).toList()); + return dto; + } + + private AdminMediaVariantDto toVariantDto(MediaAsset asset, MediaVariant variant) { + AdminMediaVariantDto dto = new AdminMediaVariantDto(); + dto.setId(variant.getId()); + dto.setVariantName(variant.getVariantName()); + dto.setFormat(variant.getFormat()); + dto.setStorageKey(variant.getStorageKey()); + dto.setMimeType(variant.getMimeType()); + dto.setWidthPx(variant.getWidthPx()); + dto.setHeightPx(variant.getHeightPx()); + dto.setFileSizeBytes(variant.getFileSizeBytes()); + dto.setIsGenerated(variant.getIsGenerated()); + dto.setCreatedAt(variant.getCreatedAt()); + if (VISIBILITY_PUBLIC.equals(asset.getVisibility()) && !FORMAT_ORIGINAL.equals(variant.getFormat())) { + dto.setPublicUrl(mediaStorageService.buildPublicUrl(variant.getStorageKey())); + } + return dto; + } + + private AdminMediaUsageDto toUsageDto(MediaUsage usage) { + AdminMediaUsageDto dto = new AdminMediaUsageDto(); + dto.setId(usage.getId()); + dto.setUsageType(usage.getUsageType()); + dto.setUsageKey(usage.getUsageKey()); + dto.setOwnerId(usage.getOwnerId()); + dto.setMediaAssetId(usage.getMediaAsset().getId()); + dto.setSortOrder(usage.getSortOrder()); + dto.setIsPrimary(usage.getIsPrimary()); + dto.setIsActive(usage.getIsActive()); + dto.setTranslations(extractTranslations(usage)); + dto.setCreatedAt(usage.getCreatedAt()); + return dto; + } + + private int compareVariants(MediaVariant left, MediaVariant right) { + return Comparator + .comparingInt((MediaVariant variant) -> variantNameOrder(variant.getVariantName())) + .thenComparingInt(variant -> formatOrder(variant.getFormat())) + .thenComparing(MediaVariant::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)) + .compare(left, right); + } + + private int variantNameOrder(String variantName) { + if ("original".equalsIgnoreCase(variantName)) { + return 0; + } + if ("thumb".equalsIgnoreCase(variantName)) { + return 10; + } + if ("card".equalsIgnoreCase(variantName)) { + return 20; + } + if ("hero".equalsIgnoreCase(variantName)) { + return 30; + } + return 100; + } + + private int formatOrder(String format) { + return switch (format) { + case FORMAT_ORIGINAL -> 0; + case FORMAT_JPEG -> 10; + case FORMAT_WEBP -> 20; + case FORMAT_AVIF -> 30; + default -> 100; + }; + } + + private MediaAsset getAssetOrThrow(UUID mediaAssetId) { + if (mediaAssetId == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Media asset id is required."); + } + return mediaAssetRepository.findById(mediaAssetId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Media asset not found.")); + } + + private MediaUsage getUsageOrThrow(UUID mediaUsageId) { + return mediaUsageRepository.findById(mediaUsageId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Media usage not found.")); + } + + private void validateUpload(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Image file is required."); + } + if (file.getSize() < 0 || file.getSize() > maxUploadFileSizeBytes) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Image file exceeds the maximum allowed size."); + } + } + + private String requireUsageType(String usageType) { + if (usageType == null || usageType.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageType is required."); + } + return usageType.trim().toUpperCase(Locale.ROOT); + } + + private String requireUsageKey(String usageKey) { + if (usageKey == null || usageKey.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageKey is required."); + } + return usageKey.trim(); + } + + private String normalizeVisibility(String visibility, boolean defaultPublic) { + if (visibility == null) { + return defaultPublic ? VISIBILITY_PUBLIC : null; + } + String normalized = visibility.trim().toUpperCase(Locale.ROOT); + if (normalized.isBlank()) { + return defaultPublic ? VISIBILITY_PUBLIC : null; + } + if (!ALLOWED_VISIBILITIES.contains(normalized)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid visibility. Allowed: " + String.join(", ", new LinkedHashSet<>(ALLOWED_VISIBILITIES)) + ); + } + return normalized; + } + + private String normalizeStatus(String status, boolean required) { + if (status == null) { + if (required) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Status is required."); + } + return null; + } + String normalized = status.trim().toUpperCase(Locale.ROOT); + if (normalized.isBlank()) { + if (required) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Status is required."); + } + return null; + } + if (!ALLOWED_STATUSES.contains(normalized)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid status. Allowed: " + String.join(", ", new LinkedHashSet<>(ALLOWED_STATUSES)) + ); + } + return normalized; + } + + private Map requireTranslations(Map translations) { + if (translations == null || translations.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "translations are required."); + } + + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : translations.entrySet()) { + String language = normalizeTranslationLanguage(entry.getKey()); + if (normalized.containsKey(language)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate translation language: " + language + "."); + } + normalized.put(language, entry.getValue()); + } + + if (!normalized.keySet().equals(new LinkedHashSet<>(SUPPORTED_MEDIA_LANGUAGES))) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "translations must include exactly: " + String.join(", ", SUPPORTED_MEDIA_LANGUAGES) + "." + ); + } + + LinkedHashMap result = new LinkedHashMap<>(); + for (String language : SUPPORTED_MEDIA_LANGUAGES) { + MediaTextTranslationDto translation = normalized.get(language); + if (translation == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing translation for language " + language + "."); + } + + String title = normalizeRequiredTranslationValue(translation.getTitle(), language, "title"); + String altText = normalizeRequiredTranslationValue(translation.getAltText(), language, "altText"); + + MediaTextTranslationDto dto = new MediaTextTranslationDto(); + dto.setTitle(title); + dto.setAltText(altText); + result.put(language, dto); + } + return result; + } + + private String normalizeTranslationLanguage(String language) { + if (language == null || language.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Translation language is required."); + } + String normalized = language.trim().toLowerCase(Locale.ROOT); + if (!SUPPORTED_MEDIA_LANGUAGES.contains(normalized)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unsupported translation language: " + normalized + "." + ); + } + return normalized; + } + + private String normalizeRequiredTranslationValue(String value, String language, String fieldName) { + String normalized = normalizeText(value); + if (normalized == null || normalized.isBlank()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Translation " + fieldName + " is required for language " + language + "." + ); + } + return normalized; + } + + private void applyTranslations(MediaUsage usage, Map translations) { + for (String language : SUPPORTED_MEDIA_LANGUAGES) { + MediaTextTranslationDto translation = translations.get(language); + usage.setTitleForLanguage(language, translation.getTitle()); + usage.setAltTextForLanguage(language, translation.getAltText()); + } + } + + private Map extractTranslations(MediaUsage usage) { + LinkedHashMap translations = new LinkedHashMap<>(); + String fallbackTitle = usage.getMediaAsset() != null ? usage.getMediaAsset().getTitle() : null; + String fallbackAltText = usage.getMediaAsset() != null ? usage.getMediaAsset().getAltText() : null; + + for (String language : SUPPORTED_MEDIA_LANGUAGES) { + MediaTextTranslationDto dto = new MediaTextTranslationDto(); + dto.setTitle(firstNonBlank(usage.getTitleForLanguage(language), fallbackTitle)); + dto.setAltText(firstNonBlank(usage.getAltTextForLanguage(language), fallbackAltText)); + translations.put(language, dto); + } + return translations; + } + + private String firstNonBlank(String preferred, String fallback) { + return StringUtils.hasText(preferred) ? preferred : normalizeText(fallback); + } + + private String normalizeText(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + + private String sanitizeOriginalFilename(String originalFilename, String extension) { + 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", "_"); + if (basename.isBlank()) { + return "upload." + extension; + } + return basename; + } + + private String buildStorageFolder() { + return STORAGE_FOLDER_FORMATTER.format(LocalDate.now()) + "/" + UUID.randomUUID(); + } + + private String extractStorageFolder(String originalStorageKey) { + Path path = Paths.get(originalStorageKey).normalize(); + Path parent = path.getParent(); + if (parent == null) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Invalid media storage key."); + } + return parent.toString().replace('\\', '/'); + } + + private VariantDimensions computeVariantDimensions(Integer widthPx, Integer heightPx, int maxDimension) { + if (widthPx == null || heightPx == null || widthPx <= 0 || heightPx <= 0) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Invalid image dimensions."); + } + double scale = Math.min(1.0d, (double) maxDimension / Math.max(widthPx, heightPx)); + int targetWidth = Math.max(1, (int) Math.round(widthPx * scale)); + int targetHeight = Math.max(1, (int) Math.round(heightPx * scale)); + return new VariantDimensions(targetWidth, targetHeight); + } + + private String computeSha256(Path file) throws IOException { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available.", e); + } + + try (InputStream inputStream = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = inputStream.read(buffer)) >= 0) { + digest.update(buffer, 0, read); + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + private void markFailed(MediaAsset asset, String message, Exception exception) { + if (asset == null || asset.getId() == null) { + logger.warn("Media upload failed before asset persistence: {}", message, exception); + return; + } + asset.setStatus(STATUS_FAILED); + asset.setUpdatedAt(OffsetDateTime.now()); + mediaAssetRepository.save(asset); + logger.warn("Media asset {} marked as FAILED: {}", asset.getId(), message, exception); + } + + private void deleteRecursively(Path directory) { + if (directory == null || !Files.exists(directory)) { + return; + } + try (var walk = Files.walk(directory)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException e) { + logger.warn("Failed to clean temporary media directory {}", directory, e); + } catch (UncheckedIOException e) { + logger.warn("Failed to clean temporary media directory {}", directory, e); + } + } + + private record PresetDefinition(String name, int maxDimension) { + } + + private record VariantDimensions(int widthPx, int heightPx) { + } + + private record PendingGeneratedVariant(MediaVariant variant, Path file) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java index 1291c1a..2aef314 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java @@ -301,6 +301,7 @@ public class AdminOperationsControllerService { } else { session = new QuoteSession(); session.setStatus("CAD_ACTIVE"); + session.setSessionType("PRINT_QUOTE"); session.setPricingVersion("v1"); session.setMaterialCode("PLA"); session.setNozzleDiameterMm(BigDecimal.valueOf(0.4)); @@ -398,6 +399,7 @@ public class AdminOperationsControllerService { AdminQuoteSessionDto dto = new AdminQuoteSessionDto(); dto.setId(session.getId()); dto.setStatus(session.getStatus()); + dto.setSessionType(session.getSessionType() != null ? session.getSessionType() : "PRINT_QUOTE"); dto.setMaterialCode(session.getMaterialCode()); dto.setCreatedAt(session.getCreatedAt()); dto.setExpiresAt(session.getExpiresAt()); 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..7a3d262 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java @@ -0,0 +1,864 @@ +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.beans.factory.annotation.Value; +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; + private final long maxModelFileSizeBytes; + + public AdminShopProductControllerService(ShopProductRepository shopProductRepository, + ShopCategoryRepository shopCategoryRepository, + ShopProductVariantRepository shopProductVariantRepository, + ShopProductModelAssetRepository shopProductModelAssetRepository, + QuoteLineItemRepository quoteLineItemRepository, + OrderItemRepository orderItemRepository, + PublicMediaQueryService publicMediaQueryService, + AdminMediaControllerService adminMediaControllerService, + ShopStorageService shopStorageService, + SlicerService slicerService, + ClamAVService clamAVService, + @Value("${shop.model.max-file-size-bytes:104857600}") long maxModelFileSizeBytes) { + 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; + this.maxModelFileSizeBytes = maxModelFileSizeBytes; + } + + 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); + LocalizedProductContent localizedContent = normalizeLocalizedProductContent(payload); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName()); + ensureSlugAvailable(normalizedSlug, null); + + ShopProduct product = new ShopProduct(); + product.setCreatedAt(OffsetDateTime.now()); + applyProductPayload(product, payload, localizedContent, 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")); + + LocalizedProductContent localizedContent = normalizeLocalizedProductContent(payload); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName()); + ensureSlugAvailable(normalizedSlug, productId); + + applyProductPayload(product, payload, localizedContent, 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); + shopProductModelAssetRepository.delete(asset); + }); + if (!variants.isEmpty()) { + shopProductVariantRepository.deleteAll(variants); + } + 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, + LocalizedProductContent localizedContent, + String normalizedSlug, + ShopCategory category) { + product.setCategory(category); + product.setSlug(normalizedSlug); + product.setName(localizedContent.defaultName()); + product.setNameIt(localizedContent.names().get("it")); + product.setNameEn(localizedContent.names().get("en")); + product.setNameDe(localizedContent.names().get("de")); + product.setNameFr(localizedContent.names().get("fr")); + product.setExcerpt(localizedContent.defaultExcerpt()); + product.setExcerptIt(localizedContent.excerpts().get("it")); + product.setExcerptEn(localizedContent.excerpts().get("en")); + product.setExcerptDe(localizedContent.excerpts().get("de")); + product.setExcerptFr(localizedContent.excerpts().get("fr")); + product.setDescription(localizedContent.defaultDescription()); + product.setDescriptionIt(localizedContent.descriptions().get("it")); + product.setDescriptionEn(localizedContent.descriptions().get("en")); + product.setDescriptionDe(localizedContent.descriptions().get("de")); + product.setDescriptionFr(localizedContent.descriptions().get("fr")); + product.setSeoTitle(localizedContent.defaultSeoTitle()); + product.setSeoTitleIt(localizedContent.seoTitles().get("it")); + product.setSeoTitleEn(localizedContent.seoTitles().get("en")); + product.setSeoTitleDe(localizedContent.seoTitles().get("de")); + product.setSeoTitleFr(localizedContent.seoTitles().get("fr")); + product.setSeoDescription(localizedContent.defaultSeoDescription()); + product.setSeoDescriptionIt(localizedContent.seoDescriptions().get("it")); + product.setSeoDescriptionEn(localizedContent.seoDescriptions().get("en")); + product.setSeoDescriptionDe(localizedContent.seoDescriptions().get("de")); + product.setSeoDescriptionFr(localizedContent.seoDescriptions().get("fr")); + product.setOgTitle(localizedContent.defaultSeoTitle()); + product.setOgDescription(localizedContent.defaultSeoDescription()); + 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 variantKeys = 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 materialCode = normalizeRequired( + payload.getInternalMaterialCode(), + "Variant internalMaterialCode is required" + ).toUpperCase(Locale.ROOT); + String variantKey = materialCode + "|" + colorName.toLowerCase(Locale.ROOT); + if (!variantKeys.add(variantKey)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Duplicate variant combination: " + materialCode + " / " + 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.setNameIt(product.getNameIt()); + dto.setNameEn(product.getNameEn()); + dto.setNameDe(product.getNameDe()); + dto.setNameFr(product.getNameFr()); + dto.setExcerpt(product.getExcerpt()); + dto.setExcerptIt(product.getExcerptIt()); + dto.setExcerptEn(product.getExcerptEn()); + dto.setExcerptDe(product.getExcerptDe()); + dto.setExcerptFr(product.getExcerptFr()); + dto.setDescription(product.getDescription()); + dto.setDescriptionIt(product.getDescriptionIt()); + dto.setDescriptionEn(product.getDescriptionEn()); + dto.setDescriptionDe(product.getDescriptionDe()); + dto.setDescriptionFr(product.getDescriptionFr()); + dto.setSeoTitle(product.getSeoTitle()); + dto.setSeoTitleIt(product.getSeoTitleIt()); + dto.setSeoTitleEn(product.getSeoTitleEn()); + dto.setSeoTitleDe(product.getSeoTitleDe()); + dto.setSeoTitleFr(product.getSeoTitleFr()); + dto.setSeoDescription(product.getSeoDescription()); + dto.setSeoDescriptionIt(product.getSeoDescriptionIt()); + dto.setSeoDescriptionEn(product.getSeoDescriptionEn()); + dto.setSeoDescriptionDe(product.getSeoDescriptionDe()); + dto.setSeoDescriptionFr(product.getSeoDescriptionFr()); + 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 LocalizedProductContent normalizeLocalizedProductContent(AdminUpsertShopProductRequest payload) { + String legacyName = normalizeOptional(payload.getName()); + String fallbackName = firstNonBlank( + legacyName, + normalizeOptional(payload.getNameIt()), + normalizeOptional(payload.getNameEn()), + normalizeOptional(payload.getNameDe()), + normalizeOptional(payload.getNameFr()) + ); + if (fallbackName == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Product name is required"); + } + + Map names = new LinkedHashMap<>(); + names.put("it", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameIt()), fallbackName), "Italian product name is required")); + names.put("en", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameEn()), fallbackName), "English product name is required")); + names.put("de", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameDe()), fallbackName), "German product name is required")); + names.put("fr", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameFr()), fallbackName), "French product name is required")); + + String fallbackExcerpt = firstNonBlank( + normalizeOptional(payload.getExcerpt()), + normalizeOptional(payload.getExcerptIt()), + normalizeOptional(payload.getExcerptEn()), + normalizeOptional(payload.getExcerptDe()), + normalizeOptional(payload.getExcerptFr()) + ); + Map excerpts = new LinkedHashMap<>(); + excerpts.put("it", firstNonBlank(normalizeOptional(payload.getExcerptIt()), fallbackExcerpt)); + excerpts.put("en", firstNonBlank(normalizeOptional(payload.getExcerptEn()), fallbackExcerpt)); + excerpts.put("de", firstNonBlank(normalizeOptional(payload.getExcerptDe()), fallbackExcerpt)); + excerpts.put("fr", firstNonBlank(normalizeOptional(payload.getExcerptFr()), fallbackExcerpt)); + + String fallbackDescription = firstNonBlank( + normalizeOptional(payload.getDescription()), + normalizeOptional(payload.getDescriptionIt()), + normalizeOptional(payload.getDescriptionEn()), + normalizeOptional(payload.getDescriptionDe()), + normalizeOptional(payload.getDescriptionFr()) + ); + Map descriptions = new LinkedHashMap<>(); + descriptions.put("it", firstNonBlank(normalizeOptional(payload.getDescriptionIt()), fallbackDescription)); + descriptions.put("en", firstNonBlank(normalizeOptional(payload.getDescriptionEn()), fallbackDescription)); + descriptions.put("de", firstNonBlank(normalizeOptional(payload.getDescriptionDe()), fallbackDescription)); + descriptions.put("fr", firstNonBlank(normalizeOptional(payload.getDescriptionFr()), fallbackDescription)); + + String fallbackSeoTitle = firstNonBlank( + normalizeOptional(payload.getSeoTitle()), + normalizeOptional(payload.getSeoTitleIt()), + normalizeOptional(payload.getSeoTitleEn()), + normalizeOptional(payload.getSeoTitleDe()), + normalizeOptional(payload.getSeoTitleFr()) + ); + Map seoTitles = new LinkedHashMap<>(); + seoTitles.put("it", firstNonBlank(normalizeOptional(payload.getSeoTitleIt()), fallbackSeoTitle)); + seoTitles.put("en", firstNonBlank(normalizeOptional(payload.getSeoTitleEn()), fallbackSeoTitle)); + seoTitles.put("de", firstNonBlank(normalizeOptional(payload.getSeoTitleDe()), fallbackSeoTitle)); + seoTitles.put("fr", firstNonBlank(normalizeOptional(payload.getSeoTitleFr()), fallbackSeoTitle)); + + String fallbackSeoDescription = firstNonBlank( + normalizeOptional(payload.getSeoDescription()), + normalizeOptional(payload.getSeoDescriptionIt()), + normalizeOptional(payload.getSeoDescriptionEn()), + normalizeOptional(payload.getSeoDescriptionDe()), + normalizeOptional(payload.getSeoDescriptionFr()) + ); + Map seoDescriptions = new LinkedHashMap<>(); + seoDescriptions.put("it", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionIt()), fallbackSeoDescription), "Italian")); + seoDescriptions.put("en", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionEn()), fallbackSeoDescription), "English")); + seoDescriptions.put("de", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionDe()), fallbackSeoDescription), "German")); + seoDescriptions.put("fr", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionFr()), fallbackSeoDescription), "French")); + + return new LocalizedProductContent( + names.get("it"), + firstNonBlank(excerpts.get("it"), fallbackExcerpt), + firstNonBlank(descriptions.get("it"), fallbackDescription), + firstNonBlank(seoTitles.get("it"), fallbackSeoTitle), + firstNonBlank(seoDescriptions.get("it"), fallbackSeoDescription), + names, + excerpts, + descriptions, + seoTitles, + seoDescriptions + ); + } + + 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 firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + 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 String validateSeoDescriptionLength(String value, String languageLabel) { + if (value != null && value.length() > 160) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, languageLabel + " SEO description must be at most 160 characters"); + } + return value; + } + + private void validateModelUpload(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file is required"); + } + if (maxModelFileSizeBytes > 0 && file.getSize() > maxModelFileSizeBytes) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file exceeds size limit"); + } + 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) { + } + + private record LocalizedProductContent( + String defaultName, + String defaultExcerpt, + String defaultDescription, + String defaultSeoTitle, + String defaultSeoDescription, + Map names, + Map excerpts, + Map descriptions, + Map seoTitles, + Map seoDescriptions + ) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java new file mode 100644 index 0000000..f197534 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java @@ -0,0 +1,347 @@ +package com.printcalculator.service.media; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class MediaFfmpegService { + + private static final Logger logger = LoggerFactory.getLogger(MediaFfmpegService.class); + + private static final Map> ENCODER_CANDIDATES = Map.of( + "JPEG", List.of("mjpeg"), + "WEBP", List.of("libwebp", "webp"), + "AVIF", List.of("libaom-av1", "librav1e", "libsvtav1") + ); + private static final Map> REQUIRED_MUXERS = Map.of( + "AVIF", List.of("avif") + ); + + private final String ffmpegExecutable; + private final Set availableEncoders; + private final Set availableMuxers; + + public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) { + this.ffmpegExecutable = resolveExecutable(ffmpegPath); + this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders()); + this.availableMuxers = Collections.unmodifiableSet(loadAvailableMuxers()); + } + + public void generateVariant(Path source, Path target, int widthPx, int heightPx, String format) throws IOException { + if (widthPx <= 0 || heightPx <= 0) { + throw new IllegalArgumentException("Variant dimensions must be positive."); + } + + Path sourcePath = sanitizeMediaPath(source, "source", true); + Path targetPath = sanitizeMediaPath(target, "target", false); + Files.createDirectories(targetPath.getParent()); + + String normalizedFormat = normalizeFormat(format); + String encoder = resolveEncoder(normalizedFormat); + if (encoder == null) { + throw new IOException("FFmpeg encoder not available for media format " + normalizedFormat + "."); + } + if (!hasRequiredMuxer(normalizedFormat)) { + throw new IOException("FFmpeg muxer not available for media format " + normalizedFormat + "."); + } + + List command = new ArrayList<>(); + command.add(ffmpegExecutable); + command.add("-y"); + command.add("-hide_banner"); + command.add("-loglevel"); + command.add("error"); + command.add("-i"); + command.add(sourcePath.toString()); + command.add("-vf"); + command.add("scale=" + widthPx + ":" + heightPx + ":flags=lanczos,setsar=1"); + command.add("-frames:v"); + command.add("1"); + command.add("-an"); + + switch (normalizedFormat) { + case "JPEG" -> { + command.add("-c:v"); + command.add(encoder); + command.add("-q:v"); + command.add("2"); + } + case "WEBP" -> { + command.add("-c:v"); + command.add(encoder); + command.add("-quality"); + command.add("82"); + } + case "AVIF" -> { + command.add("-c:v"); + command.add(encoder); + command.add("-crf"); + command.add("30"); + command.add("-b:v"); + command.add("0"); + command.add("-f"); + command.add("avif"); + } + default -> throw new IllegalArgumentException("Unsupported media format: " + normalizedFormat); + } + + command.add(targetPath.toString()); + + Process process = startValidatedProcess(command); + String output; + try (InputStream processStream = process.getInputStream()) { + output = new String(processStream.readAllBytes(), StandardCharsets.UTF_8); + } + + int exitCode; + try { + exitCode = process.waitFor(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("FFmpeg execution interrupted.", e); + } + + if (exitCode != 0 || !Files.exists(targetPath) || Files.size(targetPath) == 0) { + throw new IOException("FFmpeg failed to generate media variant. " + truncate(output)); + } + } + + public boolean canEncode(String format) { + String normalizedFormat = normalizeFormat(format); + return resolveEncoder(normalizedFormat) != null && hasRequiredMuxer(normalizedFormat); + } + + private String resolveEncoder(String format) { + String normalizedFormat = normalizeFormat(format); + if (normalizedFormat == null) { + return null; + } + List candidates = ENCODER_CANDIDATES.get(normalizedFormat); + if (candidates == null) { + return null; + } + return candidates.stream() + .filter(availableEncoders::contains) + .findFirst() + .orElse(null); + } + + private boolean hasRequiredMuxer(String format) { + List requiredMuxers = REQUIRED_MUXERS.get(format); + if (requiredMuxers == null || requiredMuxers.isEmpty()) { + return true; + } + return requiredMuxers.stream().anyMatch(availableMuxers::contains); + } + + private String normalizeFormat(String format) { + if (format == null) { + return null; + } + return format.trim().toUpperCase(Locale.ROOT); + } + + private Set loadAvailableEncoders() { + List command = List.of(ffmpegExecutable, "-hide_banner", "-encoders"); + try { + Process process = startValidatedProcess(command); + String output; + try (InputStream processStream = process.getInputStream()) { + output = new String(processStream.readAllBytes(), StandardCharsets.UTF_8); + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + logger.warn("Unable to inspect FFmpeg encoders. Falling back to empty encoder list."); + return Set.of(); + } + return parseAvailableEncoders(output); + } catch (Exception e) { + logger.warn( + "Unable to inspect FFmpeg encoders for executable '{}'. Falling back to empty encoder list. {}", + ffmpegExecutable, + e.getMessage() + ); + return Set.of(); + } + } + + private Set loadAvailableMuxers() { + List command = List.of(ffmpegExecutable, "-hide_banner", "-muxers"); + try { + Process process = startValidatedProcess(command); + String output; + try (InputStream processStream = process.getInputStream()) { + output = new String(processStream.readAllBytes(), StandardCharsets.UTF_8); + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + logger.warn("Unable to inspect FFmpeg muxers. Falling back to empty muxer list."); + return Set.of(); + } + return parseAvailableMuxers(output); + } catch (Exception e) { + logger.warn( + "Unable to inspect FFmpeg muxers for executable '{}'. Falling back to empty muxer list. {}", + ffmpegExecutable, + e.getMessage() + ); + return Set.of(); + } + } + + private Process startValidatedProcess(List command) throws IOException { + // nosemgrep: java.lang.security.audit.command-injection-process-builder.command-injection-process-builder + return new ProcessBuilder(List.copyOf(command)) + .redirectErrorStream(true) + .start(); + } + + static String sanitizeExecutable(String configuredExecutable) { + if (configuredExecutable == null) { + throw new IllegalArgumentException("media.ffmpeg.path must not be null."); + } + + String candidate = configuredExecutable.trim(); + if (candidate.isEmpty()) { + throw new IllegalArgumentException("media.ffmpeg.path must point to an FFmpeg executable."); + } + if (candidate.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("media.ffmpeg.path contains control characters."); + } + + try { + Path executablePath = Path.of(candidate); + Path filename = executablePath.getFileName(); + String executableName = filename == null ? candidate : filename.toString(); + if (executableName.isBlank() || executableName.startsWith("-")) { + throw new IllegalArgumentException("media.ffmpeg.path must be an executable path, not an option."); + } + + return executablePath.normalize().toString(); + } catch (InvalidPathException e) { + throw new IllegalArgumentException("media.ffmpeg.path is not a valid executable path.", e); + } + } + + static String resolveExecutable(String configuredExecutable) { + String candidate = sanitizeExecutable(configuredExecutable); + + try { + Path configuredPath = Path.of(candidate); + if (!configuredPath.isAbsolute()) { + return candidate; + } + if (Files.isExecutable(configuredPath)) { + return configuredPath.toString(); + } + + Path filename = configuredPath.getFileName(); + String fallbackExecutable = filename == null ? null : filename.toString(); + if (fallbackExecutable != null && !fallbackExecutable.isBlank()) { + logger.warn( + "Configured FFmpeg executable '{}' not found or not executable. Falling back to '{}' from PATH.", + configuredPath, + fallbackExecutable + ); + return fallbackExecutable; + } + return candidate; + } catch (InvalidPathException e) { + throw new IllegalArgumentException("media.ffmpeg.path is not a valid executable path.", e); + } + } + + private Path sanitizeMediaPath(Path path, String label, boolean requireExistingFile) throws IOException { + if (path == null) { + throw new IllegalArgumentException("Media " + label + " path is required."); + } + + Path normalized = path.toAbsolutePath().normalize(); + Path filename = normalized.getFileName(); + if (filename == null || filename.toString().isBlank()) { + throw new IOException("Media " + label + " path must include a file name."); + } + if (filename.toString().startsWith("-")) { + throw new IOException("Media " + label + " file name must not start with '-'."); + } + + if (requireExistingFile) { + if (!Files.isRegularFile(normalized) || !Files.isReadable(normalized)) { + throw new IOException("Media " + label + " file is not readable."); + } + } else if (normalized.getParent() == null) { + throw new IOException("Media " + label + " path must include a parent directory."); + } + + return normalized; + } + + private Set parseAvailableEncoders(String output) { + if (output == null || output.isBlank()) { + return Set.of(); + } + + Set encoders = new LinkedHashSet<>(); + for (String line : output.split("\\R")) { + String trimmed = line.trim(); + if (trimmed.isBlank() || trimmed.startsWith("--") || trimmed.startsWith("Encoders:")) { + continue; + } + if (trimmed.length() < 7) { + continue; + } + String[] parts = trimmed.split("\\s+", 3); + if (parts.length < 2) { + continue; + } + encoders.add(parts[1]); + } + return encoders; + } + + private Set parseAvailableMuxers(String output) { + if (output == null || output.isBlank()) { + return Set.of(); + } + + Set muxers = new LinkedHashSet<>(); + for (String line : output.split("\\R")) { + String trimmed = line.trim(); + if (trimmed.isBlank() || trimmed.startsWith("--") || trimmed.startsWith("Muxers:")) { + continue; + } + String[] parts = trimmed.split("\\s+", 3); + if (parts.length < 2) { + continue; + } + muxers.add(parts[1]); + } + return muxers; + } + + private String truncate(String output) { + if (output == null || output.isBlank()) { + return ""; + } + String normalized = output.trim().replace('\n', ' '); + return normalized.length() <= 300 ? normalized : normalized.substring(0, 300); + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaImageInspector.java b/backend/src/main/java/com/printcalculator/service/media/MediaImageInspector.java new file mode 100644 index 0000000..55449ed --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/MediaImageInspector.java @@ -0,0 +1,122 @@ +package com.printcalculator.service.media; + +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +@Service +public class MediaImageInspector { + + private static final byte[] PNG_SIGNATURE = new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + }; + + public ImageMetadata inspect(Path file) throws IOException { + try (InputStream inputStream = Files.newInputStream(file)) { + byte[] header = inputStream.readNBytes(64); + if (isJpeg(header)) { + return readWithImageIo(file, "image/jpeg", "jpg"); + } + if (isPng(header)) { + return readWithImageIo(file, "image/png", "png"); + } + if (isWebp(header)) { + Dimensions dimensions = readWebpDimensions(header); + return new ImageMetadata("image/webp", "webp", dimensions.width(), dimensions.height()); + } + } + + throw new IllegalArgumentException("Unsupported image type. Allowed: jpg, jpeg, png, webp."); + } + + private ImageMetadata readWithImageIo(Path file, String mimeType, String extension) throws IOException { + BufferedImage image = ImageIO.read(file.toFile()); + if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) { + throw new IllegalArgumentException("Uploaded image is invalid or unreadable."); + } + return new ImageMetadata(mimeType, extension, image.getWidth(), image.getHeight()); + } + + private boolean isJpeg(byte[] header) { + return header.length >= 3 + && (header[0] & 0xFF) == 0xFF + && (header[1] & 0xFF) == 0xD8 + && (header[2] & 0xFF) == 0xFF; + } + + private boolean isPng(byte[] header) { + if (header.length < PNG_SIGNATURE.length) { + return false; + } + for (int i = 0; i < PNG_SIGNATURE.length; i++) { + if (header[i] != PNG_SIGNATURE[i]) { + return false; + } + } + return true; + } + + private boolean isWebp(byte[] header) { + return header.length >= 16 + && "RIFF".equals(ascii(header, 0, 4)) + && "WEBP".equals(ascii(header, 8, 4)); + } + + private Dimensions readWebpDimensions(byte[] header) { + if (header.length < 30) { + throw new IllegalArgumentException("Uploaded WebP image is invalid."); + } + + String chunkType = ascii(header, 12, 4); + return switch (chunkType) { + case "VP8X" -> new Dimensions( + littleEndian24(header, 24) + 1, + littleEndian24(header, 27) + 1 + ); + case "VP8 " -> new Dimensions( + littleEndian16(header, 26) & 0x3FFF, + littleEndian16(header, 28) & 0x3FFF + ); + case "VP8L" -> { + int packed = littleEndian32(header, 21); + int width = (packed & 0x3FFF) + 1; + int height = ((packed >> 14) & 0x3FFF) + 1; + yield new Dimensions(width, height); + } + default -> throw new IllegalArgumentException("Uploaded WebP image is invalid."); + }; + } + + private String ascii(byte[] header, int offset, int length) { + return new String(header, offset, length, StandardCharsets.US_ASCII); + } + + private int littleEndian16(byte[] header, int offset) { + return (header[offset] & 0xFF) | ((header[offset + 1] & 0xFF) << 8); + } + + private int littleEndian24(byte[] header, int offset) { + return (header[offset] & 0xFF) + | ((header[offset + 1] & 0xFF) << 8) + | ((header[offset + 2] & 0xFF) << 16); + } + + private int littleEndian32(byte[] header, int offset) { + return (header[offset] & 0xFF) + | ((header[offset + 1] & 0xFF) << 8) + | ((header[offset + 2] & 0xFF) << 16) + | ((header[offset + 3] & 0xFF) << 24); + } + + private record Dimensions(int width, int height) { + } + + public record ImageMetadata(String mimeType, String fileExtension, int widthPx, int heightPx) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java b/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java new file mode 100644 index 0000000..f1ee7e1 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java @@ -0,0 +1,145 @@ +package com.printcalculator.service.media; + +import com.printcalculator.exception.StorageException; +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.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Locale; + +@Service +public class MediaStorageService { + + private final Path normalizedRootLocation; + private final Path originalRootLocation; + private final Path publicRootLocation; + private final Path privateRootLocation; + private final String frontendBaseUrl; + + public MediaStorageService(@Value("${media.storage.root:storage_media}") String storageRoot, + @Value("${app.frontend.base-url:${APP_FRONTEND_BASE_URL:http://localhost:8081}}") String frontendBaseUrl) { + this.normalizedRootLocation = Paths.get(storageRoot).toAbsolutePath().normalize(); + this.originalRootLocation = normalizedRootLocation.resolve("original").normalize(); + this.publicRootLocation = normalizedRootLocation.resolve("public").normalize(); + this.privateRootLocation = normalizedRootLocation.resolve("private").normalize(); + this.frontendBaseUrl = frontendBaseUrl; + init(); + } + + public void init() { + try { + Files.createDirectories(originalRootLocation); + Files.createDirectories(publicRootLocation); + Files.createDirectories(privateRootLocation); + } catch (IOException e) { + throw new StorageException("Could not initialize media storage.", e); + } + } + + public void storeOriginal(Path source, String storageKey) throws IOException { + copy(source, resolveOriginal(storageKey)); + } + + public void storePublic(Path source, String storageKey) throws IOException { + copy(source, resolvePublic(storageKey)); + } + + public void storePrivate(Path source, String storageKey) throws IOException { + copy(source, resolvePrivate(storageKey)); + } + + public void deleteGenerated(String visibility, String storageKey) throws IOException { + Files.deleteIfExists(resolve(resolveVariantRoot(normalizeVisibility(visibility)), storageKey)); + } + + public void moveGenerated(String storageKey, String fromVisibility, String toVisibility) throws IOException { + String normalizedFrom = normalizeVisibility(fromVisibility); + String normalizedTo = normalizeVisibility(toVisibility); + if (normalizedFrom.equals(normalizedTo)) { + return; + } + + Path source = resolve(resolveVariantRoot(normalizedFrom), storageKey); + Path target = resolve(resolveVariantRoot(normalizedTo), storageKey); + Files.createDirectories(target.getParent()); + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + + public String buildPublicUrl(String storageKey) { + if (storageKey == null || storageKey.isBlank()) { + return null; + } + String mediaBaseUrl = buildMediaBaseUrl(); + String normalizedKey = storageKey.startsWith("/") ? storageKey.substring(1) : storageKey; + if (mediaBaseUrl.endsWith("/")) { + return mediaBaseUrl + normalizedKey; + } + return mediaBaseUrl + "/" + normalizedKey; + } + + private void copy(Path source, Path destination) throws IOException { + Files.createDirectories(destination.getParent()); + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + + private Path resolveOriginal(String storageKey) { + return resolve(originalRootLocation, storageKey); + } + + private Path resolvePublic(String storageKey) { + return resolve(publicRootLocation, storageKey); + } + + private Path resolvePrivate(String storageKey) { + return resolve(privateRootLocation, storageKey); + } + + private Path resolveVariantRoot(String visibility) { + return switch (visibility) { + case "PUBLIC" -> publicRootLocation; + case "PRIVATE" -> privateRootLocation; + default -> throw new StorageException("Unsupported media visibility: " + visibility); + }; + } + + private Path resolve(Path baseRoot, String storageKey) { + if (storageKey == null || storageKey.isBlank()) { + throw new StorageException("Storage key is required."); + } + Path relativePath = Paths.get(storageKey).normalize(); + if (relativePath.isAbsolute()) { + throw new StorageException("Absolute paths are not allowed."); + } + + Path resolved = baseRoot.resolve(relativePath).normalize(); + if (!resolved.startsWith(baseRoot)) { + throw new StorageException("Cannot access files outside media storage root."); + } + return resolved; + } + + private String normalizeVisibility(String visibility) { + if (visibility == null || visibility.isBlank()) { + throw new StorageException("Visibility is required."); + } + return visibility.trim().toUpperCase(Locale.ROOT); + } + + private String buildMediaBaseUrl() { + String normalized = frontendBaseUrl != null ? frontendBaseUrl.trim() : ""; + if (normalized.contains("localhost")){ + return "http://localhost:8081"; + } + if (normalized.isBlank()) { + normalized = "http://localhost:8081"; + } + if (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized + "/media"; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java b/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java new file mode 100644 index 0000000..f7e65b3 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java @@ -0,0 +1,192 @@ +package com.printcalculator.service.media; + +import com.printcalculator.dto.PublicMediaUsageDto; +import com.printcalculator.dto.PublicMediaVariantDto; +import com.printcalculator.entity.MediaAsset; +import com.printcalculator.entity.MediaUsage; +import com.printcalculator.entity.MediaVariant; +import com.printcalculator.repository.MediaUsageRepository; +import com.printcalculator.repository.MediaVariantRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +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; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class PublicMediaQueryService { + + private static final String STATUS_READY = "READY"; + private static final String VISIBILITY_PUBLIC = "PUBLIC"; + private static final String FORMAT_JPEG = "JPEG"; + private static final String FORMAT_WEBP = "WEBP"; + private static final String FORMAT_AVIF = "AVIF"; + private static final List SUPPORTED_MEDIA_LANGUAGES = List.of("it", "en", "de", "fr"); + + private final MediaUsageRepository mediaUsageRepository; + private final MediaVariantRepository mediaVariantRepository; + private final MediaStorageService mediaStorageService; + + public PublicMediaQueryService(MediaUsageRepository mediaUsageRepository, + MediaVariantRepository mediaVariantRepository, + MediaStorageService mediaStorageService) { + this.mediaUsageRepository = mediaUsageRepository; + this.mediaVariantRepository = mediaVariantRepository; + this.mediaStorageService = mediaStorageService; + } + + 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 + .findActiveByUsageTypeAndUsageKeys(normalizedUsageType, normalizedUsageKeys) + .stream() + .filter(this::isPublicReadyUsage) + .sorted(Comparator + .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 Map.of(); + } + + List assetIds = usages.stream() + .map(MediaUsage::getMediaAsset) + .filter(Objects::nonNull) + .map(MediaAsset::getId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + Map> variantsByAssetId = mediaVariantRepository.findByMediaAsset_IdIn(assetIds) + .stream() + .filter(variant -> !Objects.equals("ORIGINAL", variant.getFormat())) + .collect(Collectors.groupingBy(variant -> variant.getMediaAsset().getId())); + + 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) { + MediaAsset asset = usage.getMediaAsset(); + return asset != null + && STATUS_READY.equals(asset.getStatus()) + && VISIBILITY_PUBLIC.equals(asset.getVisibility()); + } + + private PublicMediaUsageDto toDto(MediaUsage usage, List variants, String language) { + Map> variantsByPresetAndFormat = variants.stream() + .collect(Collectors.groupingBy( + MediaVariant::getVariantName, + Collectors.toMap(MediaVariant::getFormat, Function.identity(), (left, right) -> right) + )); + + PublicMediaUsageDto dto = new PublicMediaUsageDto(); + dto.setMediaAssetId(usage.getMediaAsset().getId()); + dto.setTitle(resolveLocalizedValue(usage.getTitleForLanguage(language), usage.getMediaAsset().getTitle())); + dto.setAltText(resolveLocalizedValue(usage.getAltTextForLanguage(language), usage.getMediaAsset().getAltText())); + dto.setUsageType(usage.getUsageType()); + dto.setUsageKey(usage.getUsageKey()); + dto.setSortOrder(usage.getSortOrder()); + dto.setIsPrimary(usage.getIsPrimary()); + dto.setThumb(buildPresetDto(variantsByPresetAndFormat.get("thumb"))); + dto.setCard(buildPresetDto(variantsByPresetAndFormat.get("card"))); + dto.setHero(buildPresetDto(variantsByPresetAndFormat.get("hero"))); + return dto; + } + + private PublicMediaVariantDto buildPresetDto(Map variantsByFormat) { + PublicMediaVariantDto dto = new PublicMediaVariantDto(); + if (variantsByFormat == null || variantsByFormat.isEmpty()) { + return dto; + } + + dto.setAvifUrl(buildVariantUrl(variantsByFormat.get(FORMAT_AVIF))); + dto.setWebpUrl(buildVariantUrl(variantsByFormat.get(FORMAT_WEBP))); + dto.setJpegUrl(buildVariantUrl(variantsByFormat.get(FORMAT_JPEG))); + return dto; + } + + private String buildVariantUrl(MediaVariant variant) { + if (variant == null || variant.getStorageKey() == null || variant.getStorageKey().isBlank()) { + return null; + } + return mediaStorageService.buildPublicUrl(variant.getStorageKey()); + } + + private String normalizeUsageType(String usageType) { + if (usageType == null || usageType.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageType is required."); + } + return usageType.trim().toUpperCase(Locale.ROOT); + } + + private String normalizeUsageKey(String usageKey) { + if (usageKey == null || usageKey.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageKey is required."); + } + return usageKey.trim(); + } + + private String normalizeLanguage(String language) { + if (language == null || language.isBlank()) { + return "it"; + } + + String normalized = language.trim().toLowerCase(Locale.ROOT); + return SUPPORTED_MEDIA_LANGUAGES.contains(normalized) ? normalized : "it"; + } + + private String resolveLocalizedValue(String preferred, String fallback) { + if (preferred != null && !preferred.isBlank()) { + return preferred; + } + if (fallback != null && !fallback.isBlank()) { + return fallback.trim(); + } + return null; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java index dfda322..164ac74 100644 --- a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java @@ -197,6 +197,7 @@ public class AdminOrderControllerService { OrderDto dto = new OrderDto(); dto.setId(order.getId()); dto.setOrderNumber(getDisplayOrderNumber(order)); + dto.setSourceType(order.getSourceType() != null ? order.getSourceType() : "CALCULATOR"); dto.setStatus(order.getStatus()); paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> { @@ -260,9 +261,26 @@ public class AdminOrderControllerService { List itemDtos = items.stream().map(item -> { OrderItemDto itemDto = new OrderItemDto(); itemDto.setId(item.getId()); + itemDto.setItemType(item.getItemType() != null ? item.getItemType() : "PRINT_FILE"); itemDto.setOriginalFilename(item.getOriginalFilename()); + itemDto.setDisplayName( + item.getDisplayName() != null && !item.getDisplayName().isBlank() + ? item.getDisplayName() + : item.getOriginalFilename() + ); itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setColorCode(item.getColorCode()); + if (item.getShopProduct() != null) { + itemDto.setShopProductId(item.getShopProduct().getId()); + } + if (item.getShopProductVariant() != null) { + itemDto.setShopProductVariantId(item.getShopProductVariant().getId()); + } + itemDto.setShopProductSlug(item.getShopProductSlug()); + itemDto.setShopProductName(item.getShopProductName()); + itemDto.setShopVariantLabel(item.getShopVariantLabel()); + itemDto.setShopVariantColorName(item.getShopVariantColorName()); + itemDto.setShopVariantColorHex(item.getShopVariantColorHex()); if (item.getFilamentVariant() != null) { itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); diff --git a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java index 4baca4b..9b1ae40 100644 --- a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java @@ -255,6 +255,7 @@ public class OrderControllerService { OrderDto dto = new OrderDto(); dto.setId(order.getId()); dto.setOrderNumber(getDisplayOrderNumber(order)); + dto.setSourceType(order.getSourceType() != null ? order.getSourceType() : "CALCULATOR"); dto.setStatus(order.getStatus()); paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> { @@ -314,9 +315,26 @@ public class OrderControllerService { List itemDtos = items.stream().map(item -> { OrderItemDto itemDto = new OrderItemDto(); itemDto.setId(item.getId()); + itemDto.setItemType(item.getItemType() != null ? item.getItemType() : "PRINT_FILE"); itemDto.setOriginalFilename(item.getOriginalFilename()); + itemDto.setDisplayName( + item.getDisplayName() != null && !item.getDisplayName().isBlank() + ? item.getDisplayName() + : item.getOriginalFilename() + ); itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setColorCode(item.getColorCode()); + if (item.getShopProduct() != null) { + itemDto.setShopProductId(item.getShopProduct().getId()); + } + if (item.getShopProductVariant() != null) { + itemDto.setShopProductVariantId(item.getShopProductVariant().getId()); + } + itemDto.setShopProductSlug(item.getShopProductSlug()); + itemDto.setShopProductName(item.getShopProductName()); + itemDto.setShopVariantLabel(item.getShopVariantLabel()); + itemDto.setShopVariantColorName(item.getShopVariantColorName()); + itemDto.setShopVariantColorHex(item.getShopVariantColorHex()); if (item.getFilamentVariant() != null) { itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); diff --git a/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java b/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java index 96ec578..0709c9c 100644 --- a/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java +++ b/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java @@ -88,14 +88,9 @@ public class InvoicePdfRenderingService { vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode()); } - List> invoiceLineItems = items.stream().map(i -> { - Map line = new HashMap<>(); - line.put("description", "Stampa 3D: " + i.getOriginalFilename()); - line.put("quantity", i.getQuantity()); - line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf())); - line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf())); - return line; - }).collect(Collectors.toList()); + List> invoiceLineItems = items.stream() + .map(this::toInvoiceLineItem) + .collect(Collectors.toList()); if (order.getCadTotalChf() != null && order.getCadTotalChf().compareTo(BigDecimal.ZERO) > 0) { BigDecimal cadHours = order.getCadHours() != null ? order.getCadHours() : BigDecimal.ZERO; @@ -157,4 +152,45 @@ public class InvoicePdfRenderingService { private String formatCadHours(BigDecimal hours) { return hours.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString(); } + + private Map toInvoiceLineItem(OrderItem item) { + Map line = new HashMap<>(); + line.put("description", buildLineDescription(item)); + line.put("quantity", item.getQuantity()); + line.put("unitPriceFormatted", String.format("CHF %.2f", item.getUnitPriceChf())); + line.put("lineTotalFormatted", String.format("CHF %.2f", item.getLineTotalChf())); + return line; + } + + private String buildLineDescription(OrderItem item) { + if (item == null) { + return "Articolo"; + } + + if ("SHOP_PRODUCT".equalsIgnoreCase(item.getItemType())) { + String productName = firstNonBlank( + item.getDisplayName(), + item.getShopProductName(), + item.getOriginalFilename(), + "Prodotto shop" + ); + String variantLabel = firstNonBlank(item.getShopVariantLabel(), item.getShopVariantColorName(), null); + return variantLabel != null ? productName + " - " + variantLabel : productName; + } + + String fileName = firstNonBlank(item.getDisplayName(), item.getOriginalFilename(), "File 3D"); + return "Stampa 3D: " + fileName; + } + + private String firstNonBlank(String... values) { + if (values == null || values.length == 0) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } } diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java index cae1ec5..50496b3 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java @@ -237,7 +237,9 @@ public class QuoteSessionItemService { Path convertedPersistentPath) { QuoteLineItem item = new QuoteLineItem(); item.setQuoteSession(session); + item.setLineItemType("PRINT_FILE"); item.setOriginalFilename(originalFilename); + item.setDisplayName(originalFilename); item.setStoredPath(quoteStorageService.toStoredPath(persistentPath)); item.setQuantity(normalizeQuantity(settings.getQuantity())); item.setColorCode(selectedVariant.getColorName()); 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 3652586..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,15 +43,45 @@ 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()); + dto.put("lineItemType", item.getLineItemType() != null ? item.getLineItemType() : "PRINT_FILE"); dto.put("originalFilename", item.getOriginalFilename()); + dto.put( + "displayName", + item.getDisplayName() != null && !item.getDisplayName().isBlank() + ? item.getDisplayName() + : item.getOriginalFilename() + ); dto.put("quantity", item.getQuantity()); dto.put("printTimeSeconds", item.getPrintTimeSeconds()); dto.put("materialGrams", item.getMaterialGrams()); dto.put("colorCode", item.getColorCode()); dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null); + dto.put("shopProductId", item.getShopProduct() != null ? item.getShopProduct().getId() : null); + dto.put("shopProductVariantId", item.getShopProductVariant() != null ? item.getShopProductVariant().getId() : null); + dto.put("shopProductSlug", item.getShopProductSlug()); + dto.put("shopProductName", item.getShopProductName()); + dto.put("shopVariantLabel", item.getShopVariantLabel()); + dto.put("shopVariantColorName", item.getShopVariantColorName()); + dto.put("shopVariantColorHex", item.getShopVariantColorHex()); dto.put("materialCode", item.getMaterialCode()); dto.put("quality", item.getQuality()); dto.put("nozzleDiameterMm", item.getNozzleDiameterMm()); 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..9323706 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -0,0 +1,508 @@ +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(), language)) + .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(), language); + } + + 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, + String language) { + List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); + return new ShopProductSummaryDto( + entry.product().getId(), + entry.product().getSlug(), + entry.product().getNameForLanguage(language), + entry.product().getExcerptForLanguage(language), + 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, + String language) { + List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); + String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language); + String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language); + return new ShopProductDetailDto( + entry.product().getId(), + entry.product().getSlug(), + entry.product().getNameForLanguage(language), + entry.product().getExcerptForLanguage(language), + entry.product().getDescriptionForLanguage(language), + localizedSeoTitle, + localizedSeoDescription, + localizedSeoTitle, + localizedSeoDescription, + 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 + ) { + } +} 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-local.properties b/backend/src/main/resources/application-local.properties index 04cf953..6acebb6 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -3,6 +3,10 @@ app.mail.admin.enabled=false app.mail.contact-request.admin.enabled=false # Admin back-office local test credentials -admin.password=local-admin-password +admin.password=ciaociao admin.session.secret=local-session-secret-for-dev-only-000000000000000000000000 admin.session.ttl-minutes=480 + +# Local media storage served by a local static server on port 8081. +media.storage.root=/Users/joe/IdeaProjects/print-calculator/storage_media +media.ffmpeg.path=ffmpeg diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 0915c7e..9985c08 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -26,6 +26,16 @@ clamav.host=${CLAMAV_HOST:clamav} clamav.port=${CLAMAV_PORT:3310} clamav.enabled=${CLAMAV_ENABLED:false} +# Media configuration +media.storage.root=${MEDIA_STORAGE_ROOT:storage_media} +media.ffmpeg.path=${MEDIA_FFMPEG_PATH:ffmpeg} +media.upload.max-file-size-bytes=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:26214400} +shop.model.max-file-size-bytes=${SHOP_MODEL_MAX_FILE_SIZE_BYTES:104857600} +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.} diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java new file mode 100644 index 0000000..799526d --- /dev/null +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java @@ -0,0 +1,138 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.config.SecurityConfig; +import com.printcalculator.service.order.AdminOrderControllerService; +import com.printcalculator.security.AdminLoginThrottleService; +import com.printcalculator.security.AdminSessionAuthenticationFilter; +import com.printcalculator.security.AdminSessionService; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class}) +@Import({ + SecurityConfig.class, + AdminSessionAuthenticationFilter.class, + AdminSessionService.class, + AdminLoginThrottleService.class, + AdminOrderControllerSecurityTest.TransactionTestConfig.class +}) +@TestPropertySource(properties = { + "admin.password=test-admin-password", + "admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "admin.session.ttl-minutes=60" +}) +class AdminOrderControllerSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AdminOrderControllerService adminOrderControllerService; + + @Test + void confirmationDocument_withoutAdminCookie_shouldReturn401() throws Exception { + UUID orderId = UUID.randomUUID(); + + mockMvc.perform(get("/api/admin/orders/{orderId}/documents/confirmation", orderId)) + .andExpect(status().isUnauthorized()); + } + + @Test + void confirmationDocument_withAdminCookie_shouldReturnPdf() throws Exception { + UUID orderId = UUID.randomUUID(); + when(adminOrderControllerService.downloadOrderConfirmation(orderId)) + .thenReturn(ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .body("confirmation".getBytes())); + + mockMvc.perform(get("/api/admin/orders/{orderId}/documents/confirmation", orderId) + .cookie(loginAndExtractCookie())) + .andExpect(status().isOk()) + .andExpect(content().bytes("confirmation".getBytes())); + } + + @Test + void invoiceDocument_withAdminCookie_shouldReturnPdf() throws Exception { + UUID orderId = UUID.randomUUID(); + when(adminOrderControllerService.downloadOrderInvoice(orderId)) + .thenReturn(ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .body("invoice".getBytes())); + + mockMvc.perform(get("/api/admin/orders/{orderId}/documents/invoice", orderId) + .cookie(loginAndExtractCookie())) + .andExpect(status().isOk()) + .andExpect(content().bytes("invoice".getBytes())); + } + + private Cookie loginAndExtractCookie() throws Exception { + MvcResult login = mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.44"); + return req; + }) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"test-admin-password\"}")) + .andExpect(status().isOk()) + .andReturn(); + + String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(setCookie); + String[] parts = setCookie.split(";", 2); + String[] keyValue = parts[0].split("=", 2); + return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : ""); + } + + @TestConfiguration + static class TransactionTestConfig { + @Bean + PlatformTransactionManager transactionManager() { + return new AbstractPlatformTransactionManager() { + @Override + protected Object doGetTransaction() { + return new Object(); + } + + @Override + protected void doBegin(Object transaction, TransactionDefinition definition) { + // No-op transaction manager for WebMvc security tests. + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + // No-op transaction manager for WebMvc security tests. + } + + @Override + protected void doRollback(DefaultTransactionStatus status) { + // No-op transaction manager for WebMvc security tests. + } + }; + } + } +} diff --git a/backend/src/test/java/com/printcalculator/entity/ShopProductTest.java b/backend/src/test/java/com/printcalculator/entity/ShopProductTest.java new file mode 100644 index 0000000..2a07ec2 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/entity/ShopProductTest.java @@ -0,0 +1,48 @@ +package com.printcalculator.entity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ShopProductTest { + + @Test + void localizedAccessorsShouldReturnLanguageSpecificValues() { + ShopProduct product = new ShopProduct(); + product.setName("Desk Cable Clip"); + product.setNameIt("Fermacavo da scrivania"); + product.setNameEn("Desk Cable Clip"); + product.setNameDe("Schreibtisch-Kabelclip"); + product.setNameFr("Clip de cable de bureau"); + product.setExcerpt("Legacy excerpt"); + product.setExcerptIt("Clip compatta per i cavi sulla scrivania."); + product.setExcerptEn("Compact clip to keep desk cables in place."); + product.setExcerptDe("Kompakter Clip fur ordentliche Kabel auf dem Schreibtisch."); + product.setExcerptFr("Clip compact pour garder les cables du bureau en ordre."); + product.setDescription("Legacy description"); + product.setDescriptionIt("Supporto con base stabile e passaggio cavi frontale."); + product.setDescriptionEn("Stable desk clip with front cable routing."); + product.setDescriptionDe("Stabiler Tischclip mit frontaler Kabelfuhrung."); + product.setDescriptionFr("Clip de bureau stable avec passage frontal des cables."); + + assertEquals("Fermacavo da scrivania", product.getNameForLanguage("it")); + assertEquals("Desk Cable Clip", product.getNameForLanguage("en")); + assertEquals("Schreibtisch-Kabelclip", product.getNameForLanguage("de")); + assertEquals("Clip de cable de bureau", product.getNameForLanguage("fr")); + assertEquals("Compact clip to keep desk cables in place.", product.getExcerptForLanguage("en")); + assertEquals("Clip compact pour garder les cables du bureau en ordre.", product.getExcerptForLanguage("fr")); + assertEquals("Stabiler Tischclip mit frontaler Kabelfuhrung.", product.getDescriptionForLanguage("de")); + } + + @Test + void localizedAccessorsShouldFallbackToLegacyValues() { + ShopProduct product = new ShopProduct(); + product.setName("Desk Cable Clip"); + product.setExcerpt("Compact desk cable clip."); + product.setDescription("Stable clip with front cable channel."); + + assertEquals("Desk Cable Clip", product.getNameForLanguage("it")); + assertEquals("Compact desk cable clip.", product.getExcerptForLanguage("de")); + assertEquals("Stable clip with front cable channel.", product.getDescriptionForLanguage("fr-CH")); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java new file mode 100644 index 0000000..aa90829 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java @@ -0,0 +1,248 @@ +package com.printcalculator.service; + +import com.printcalculator.dto.AddressDto; +import com.printcalculator.dto.CreateOrderRequest; +import com.printcalculator.dto.CustomerDto; +import com.printcalculator.entity.Customer; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.event.OrderCreatedEvent; +import com.printcalculator.repository.CustomerRepository; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock + private OrderRepository orderRepo; + @Mock + private OrderItemRepository orderItemRepo; + @Mock + private QuoteSessionRepository quoteSessionRepo; + @Mock + private QuoteLineItemRepository quoteLineItemRepo; + @Mock + private CustomerRepository customerRepo; + @Mock + private StorageService storageService; + @Mock + private InvoicePdfRenderingService invoiceService; + @Mock + private QrBillService qrBillService; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private PaymentService paymentService; + @Mock + private QuoteSessionTotalsService quoteSessionTotalsService; + + @InjectMocks + private OrderService service; + + @Test + void createOrderFromQuote_withShopCart_shouldPreserveShopSnapshotAndMaterialCode() throws Exception { + UUID sessionId = UUID.randomUUID(); + UUID orderId = UUID.randomUUID(); + UUID orderItemId = UUID.randomUUID(); + + QuoteSession session = new QuoteSession(); + session.setId(sessionId); + session.setStatus("ACTIVE"); + session.setSessionType("SHOP_CART"); + session.setMaterialCode("SHOP"); + session.setPricingVersion("v1"); + session.setSetupCostChf(BigDecimal.ZERO); + session.setExpiresAt(OffsetDateTime.now().plusDays(30)); + + ShopCategory category = new ShopCategory(); + category.setId(UUID.randomUUID()); + category.setSlug("cable-management"); + category.setName("Cable Management"); + + ShopProduct product = new ShopProduct(); + product.setId(UUID.randomUUID()); + product.setCategory(category); + product.setSlug("desk-cable-clip"); + product.setName("Desk Cable Clip"); + + ShopProductVariant variant = new ShopProductVariant(); + variant.setId(UUID.randomUUID()); + variant.setProduct(product); + variant.setVariantLabel("Coral Red"); + variant.setColorName("Coral Red"); + variant.setColorHex("#ff6b6b"); + variant.setInternalMaterialCode("PLA-MATTE"); + variant.setPriceChf(new BigDecimal("14.90")); + + Path sourceDir = Path.of("storage_quotes").toAbsolutePath().normalize().resolve(sessionId.toString()); + Files.createDirectories(sourceDir); + Path sourceFile = sourceDir.resolve("shop-product.stl"); + Files.writeString(sourceFile, "solid product\nendsolid product\n", StandardCharsets.UTF_8); + + QuoteLineItem qItem = new QuoteLineItem(); + qItem.setId(UUID.randomUUID()); + qItem.setQuoteSession(session); + qItem.setStatus("READY"); + qItem.setLineItemType("SHOP_PRODUCT"); + qItem.setOriginalFilename("shop-product.stl"); + qItem.setDisplayName("Desk Cable Clip"); + qItem.setQuantity(2); + qItem.setColorCode("Coral Red"); + qItem.setMaterialCode("PLA-MATTE"); + qItem.setShopProduct(product); + qItem.setShopProductVariant(variant); + qItem.setShopProductSlug(product.getSlug()); + qItem.setShopProductName(product.getName()); + qItem.setShopVariantLabel("Coral Red"); + qItem.setShopVariantColorName("Coral Red"); + qItem.setShopVariantColorHex("#ff6b6b"); + qItem.setBoundingBoxXMm(new BigDecimal("60.000")); + qItem.setBoundingBoxYMm(new BigDecimal("40.000")); + qItem.setBoundingBoxZMm(new BigDecimal("20.000")); + qItem.setUnitPriceChf(new BigDecimal("14.90")); + qItem.setStoredPath(sourceFile.toString()); + + Customer customer = new Customer(); + customer.setId(UUID.randomUUID()); + customer.setEmail("buyer@example.com"); + + when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session)); + when(customerRepo.findByEmail("buyer@example.com")).thenReturn(Optional.empty()); + when(customerRepo.save(any(Customer.class))).thenAnswer(invocation -> { + Customer saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(customer.getId()); + } + return saved; + }); + when(quoteLineItemRepo.findByQuoteSessionId(sessionId)).thenReturn(List.of(qItem)); + when(quoteSessionTotalsService.compute(eq(session), eq(List.of(qItem)))).thenReturn( + new QuoteSessionTotalsService.QuoteSessionTotals( + new BigDecimal("29.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("29.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("2.00"), + new BigDecimal("31.80"), + BigDecimal.ZERO + ) + ); + when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> { + Order saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(orderId); + } + return saved; + }); + when(orderItemRepo.save(any(OrderItem.class))).thenAnswer(invocation -> { + OrderItem saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(orderItemId); + } + return saved; + }); + when(qrBillService.generateQrBillSvg(any(Order.class))).thenReturn("".getBytes(StandardCharsets.UTF_8)); + when(invoiceService.generateDocumentPdf(any(Order.class), any(List.class), eq(true), eq(qrBillService), isNull())) + .thenReturn("pdf".getBytes(StandardCharsets.UTF_8)); + when(paymentService.getOrCreatePaymentForOrder(any(Order.class), eq("OTHER"))).thenReturn(new Payment()); + + Order order = service.createOrderFromQuote(sessionId, buildRequest()); + + assertEquals(orderId, order.getId()); + assertEquals("SHOP", order.getSourceType()); + assertEquals("CONVERTED", session.getStatus()); + assertEquals(orderId, session.getConvertedOrderId()); + assertAmountEquals("29.80", order.getSubtotalChf()); + assertAmountEquals("31.80", order.getTotalChf()); + + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(OrderItem.class); + verify(orderItemRepo, times(2)).save(itemCaptor.capture()); + OrderItem savedItem = itemCaptor.getAllValues().getLast(); + assertEquals("SHOP_PRODUCT", savedItem.getItemType()); + assertEquals("Desk Cable Clip", savedItem.getDisplayName()); + assertEquals("PLA-MATTE", savedItem.getMaterialCode()); + assertEquals("desk-cable-clip", savedItem.getShopProductSlug()); + assertEquals("Desk Cable Clip", savedItem.getShopProductName()); + assertEquals("Coral Red", savedItem.getShopVariantLabel()); + assertEquals("Coral Red", savedItem.getShopVariantColorName()); + assertEquals("#ff6b6b", savedItem.getShopVariantColorHex()); + assertAmountEquals("14.90", savedItem.getUnitPriceChf()); + assertAmountEquals("29.80", savedItem.getLineTotalChf()); + + verify(storageService).store(eq(sourceFile), eq(Path.of( + "orders", orderId.toString(), "3d-files", orderItemId.toString(), savedItem.getStoredFilename() + ))); + verify(paymentService).getOrCreatePaymentForOrder(order, "OTHER"); + verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class)); + } + + private CreateOrderRequest buildRequest() { + CustomerDto customer = new CustomerDto(); + customer.setEmail("buyer@example.com"); + customer.setPhone("+41790000000"); + customer.setCustomerType("PRIVATE"); + + AddressDto billing = new AddressDto(); + billing.setFirstName("Joe"); + billing.setLastName("Buyer"); + billing.setAddressLine1("Via Test 1"); + billing.setZip("6900"); + billing.setCity("Lugano"); + billing.setCountryCode("CH"); + + CreateOrderRequest request = new CreateOrderRequest(); + request.setCustomer(customer); + request.setBillingAddress(billing); + request.setShippingSameAsBilling(true); + request.setLanguage("it"); + request.setAcceptTerms(true); + request.setAcceptPrivacy(true); + return request; + } + + private void assertAmountEquals(String expected, BigDecimal actual) { + assertTrue(new BigDecimal(expected).compareTo(actual) == 0, + "Expected " + expected + " but got " + actual); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java new file mode 100644 index 0000000..55b03d0 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java @@ -0,0 +1,496 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminCreateMediaUsageRequest; +import com.printcalculator.dto.AdminMediaAssetDto; +import com.printcalculator.dto.AdminMediaUsageDto; +import com.printcalculator.dto.AdminUpdateMediaAssetRequest; +import com.printcalculator.dto.MediaTextTranslationDto; +import com.printcalculator.entity.MediaAsset; +import com.printcalculator.entity.MediaUsage; +import com.printcalculator.entity.MediaVariant; +import com.printcalculator.repository.MediaAssetRepository; +import com.printcalculator.repository.MediaUsageRepository; +import com.printcalculator.repository.MediaVariantRepository; +import com.printcalculator.service.media.MediaFfmpegService; +import com.printcalculator.service.media.MediaImageInspector; +import com.printcalculator.service.media.MediaStorageService; +import com.printcalculator.service.storage.ClamAVService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +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.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AdminMediaControllerServiceTest { + + @Mock + private MediaAssetRepository mediaAssetRepository; + @Mock + private MediaVariantRepository mediaVariantRepository; + @Mock + private MediaUsageRepository mediaUsageRepository; + @Mock + private MediaImageInspector mediaImageInspector; + @Mock + private MediaFfmpegService mediaFfmpegService; + @Mock + private ClamAVService clamAVService; + + @TempDir + Path tempDir; + + private AdminMediaControllerService service; + private Path storageRoot; + + private final Map assets = new LinkedHashMap<>(); + private final Map variants = new LinkedHashMap<>(); + private final Map usages = new LinkedHashMap<>(); + + @BeforeEach + void setUp() throws Exception { + storageRoot = tempDir.resolve("storage_media"); + MediaStorageService mediaStorageService = new MediaStorageService( + storageRoot.toString(), + "https://cdn.example" + ); + + service = new AdminMediaControllerService( + mediaAssetRepository, + mediaVariantRepository, + mediaUsageRepository, + mediaStorageService, + mediaImageInspector, + mediaFfmpegService, + clamAVService, + 1024 * 1024 + ); + + when(clamAVService.scan(any())).thenReturn(true); + when(mediaFfmpegService.canEncode(anyString())).thenReturn(true); + + when(mediaAssetRepository.save(any(MediaAsset.class))).thenAnswer(invocation -> { + MediaAsset asset = invocation.getArgument(0); + if (asset.getId() == null) { + asset.setId(UUID.randomUUID()); + } + assets.put(asset.getId(), asset); + return asset; + }); + when(mediaAssetRepository.findById(any(UUID.class))).thenAnswer(invocation -> + Optional.ofNullable(assets.get(invocation.getArgument(0))) + ); + when(mediaAssetRepository.findAllByOrderByCreatedAtDesc()).thenAnswer(invocation -> assets.values().stream() + .sorted(Comparator.comparing(MediaAsset::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)).reversed()) + .toList()); + + when(mediaVariantRepository.save(any(MediaVariant.class))).thenAnswer(invocation -> persistVariant(invocation.getArgument(0))); + when(mediaVariantRepository.saveAll(any())).thenAnswer(invocation -> { + Iterable iterable = invocation.getArgument(0); + List saved = new ArrayList<>(); + for (MediaVariant variant : iterable) { + saved.add(persistVariant(variant)); + } + return saved; + }); + when(mediaVariantRepository.findByMediaAsset_IdIn(anyCollection())).thenAnswer(invocation -> + variantsForAssets(invocation.getArgument(0)) + ); + when(mediaVariantRepository.findByMediaAsset_IdOrderByCreatedAtAsc(any(UUID.class))).thenAnswer(invocation -> + variants.values().stream() + .filter(variant -> variant.getMediaAsset().getId().equals(invocation.getArgument(0))) + .sorted(Comparator.comparing(MediaVariant::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))) + .toList() + ); + + when(mediaUsageRepository.save(any(MediaUsage.class))).thenAnswer(invocation -> persistUsage(invocation.getArgument(0))); + when(mediaUsageRepository.saveAll(any())).thenAnswer(invocation -> { + Iterable iterable = invocation.getArgument(0); + List saved = new ArrayList<>(); + for (MediaUsage usage : iterable) { + saved.add(persistUsage(usage)); + } + return saved; + }); + when(mediaUsageRepository.findByMediaAsset_IdIn(anyCollection())).thenAnswer(invocation -> + usagesForAssets(invocation.getArgument(0)) + ); + when(mediaUsageRepository.findByUsageScope(anyString(), anyString(), nullable(UUID.class))).thenAnswer(invocation -> + usages.values().stream() + .filter(usage -> usage.getUsageType().equals(invocation.getArgument(0))) + .filter(usage -> usage.getUsageKey().equals(invocation.getArgument(1))) + .filter(usage -> { + UUID ownerId = invocation.getArgument(2); + return ownerId == null ? usage.getOwnerId() == null : ownerId.equals(usage.getOwnerId()); + }) + .sorted(Comparator.comparing(MediaUsage::getSortOrder).thenComparing(MediaUsage::getCreatedAt)) + .toList() + ); + when(mediaUsageRepository.findById(any(UUID.class))).thenAnswer(invocation -> + Optional.ofNullable(usages.get(invocation.getArgument(0))) + ); + + doAnswer(invocation -> { + Path outputFile = invocation.getArgument(1); + String format = invocation.getArgument(4); + Files.createDirectories(outputFile.getParent()); + Files.writeString(outputFile, "generated-" + format, StandardCharsets.UTF_8); + return null; + }).when(mediaFfmpegService).generateVariant(any(Path.class), any(Path.class), anyInt(), anyInt(), anyString()); + } + + @Test + void uploadAsset_withValidImage_shouldPersistMetadataAndExposePublicUrls() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/png", "png", 1600, 900) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "landing-hero.png", + "image/png", + "png-image-content".getBytes(StandardCharsets.UTF_8) + ); + + AdminMediaAssetDto dto = service.uploadAsset(file, " Landing hero ", " Main headline ", null); + + assertEquals("READY", dto.getStatus()); + assertEquals("PUBLIC", dto.getVisibility()); + assertEquals("landing-hero.png", dto.getOriginalFilename()); + assertEquals("Landing hero", dto.getTitle()); + assertEquals("Main headline", dto.getAltText()); + assertEquals("image/png", dto.getMimeType()); + assertEquals(1600, dto.getWidthPx()); + assertEquals(900, dto.getHeightPx()); + assertEquals(file.getSize(), dto.getFileSizeBytes()); + assertEquals(64, dto.getSha256Hex().length()); + assertEquals(10, dto.getVariants().size()); + + long publicVariants = dto.getVariants().stream() + .filter(variant -> !"ORIGINAL".equals(variant.getFormat())) + .count(); + assertEquals(9, publicVariants); + assertTrue(dto.getVariants().stream() + .filter(variant -> "WEBP".equals(variant.getFormat()) && "hero".equals(variant.getVariantName())) + .allMatch(variant -> variant.getPublicUrl().startsWith("https://cdn.example/media/"))); + assertTrue(dto.getVariants().stream() + .filter(variant -> "ORIGINAL".equals(variant.getFormat())) + .allMatch(variant -> variant.getPublicUrl() == null)); + + MediaVariant heroWebp = variants.values().stream() + .filter(variant -> "hero".equals(variant.getVariantName())) + .filter(variant -> "WEBP".equals(variant.getFormat())) + .findFirst() + .orElseThrow(); + assertTrue(Files.exists(storageRoot.resolve("public").resolve(heroWebp.getStorageKey()))); + + MediaVariant originalVariant = variants.values().stream() + .filter(variant -> "ORIGINAL".equals(variant.getFormat())) + .findFirst() + .orElseThrow(); + assertTrue(Files.exists(storageRoot.resolve("original").resolve(originalVariant.getStorageKey()))); + } + + @Test + void uploadAsset_withUnsupportedImageType_shouldReturnBadRequest() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/svg+xml", "svg", 400, 400) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.svg", + "image/svg+xml", + "".getBytes(StandardCharsets.UTF_8) + ); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.uploadAsset(file, null, null, null) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(assets.isEmpty()); + assertTrue(variants.isEmpty()); + } + + @Test + void uploadAsset_withLimitedEncoders_shouldKeepAssetReadyAndExposeOnlySupportedVariants() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/jpeg", "jpg", 1200, 800) + ); + when(mediaFfmpegService.canEncode("JPEG")).thenReturn(true); + when(mediaFfmpegService.canEncode("WEBP")).thenReturn(false); + when(mediaFfmpegService.canEncode("AVIF")).thenReturn(false); + + MockMultipartFile file = new MockMultipartFile( + "file", + "capability.jpg", + "image/jpeg", + "jpeg-image-content".getBytes(StandardCharsets.UTF_8) + ); + + AdminMediaAssetDto dto = service.uploadAsset(file, "Capability", null, "PUBLIC"); + + assertEquals("READY", dto.getStatus()); + assertEquals(4, dto.getVariants().size()); + assertEquals(3, dto.getVariants().stream() + .filter(variant -> "JPEG".equals(variant.getFormat())) + .count()); + assertTrue(dto.getVariants().stream() + .noneMatch(variant -> "WEBP".equals(variant.getFormat()) || "AVIF".equals(variant.getFormat()))); + } + + @Test + void uploadAsset_whenAvifGenerationFails_shouldKeepAssetReadyAndStoreOtherVariants() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/png", "png", 1600, 900) + ); + doThrow(new java.io.IOException("FFmpeg failed to generate media variant. Unrecognized option 'still-picture'.")) + .when(mediaFfmpegService) + .generateVariant(any(Path.class), any(Path.class), anyInt(), anyInt(), org.mockito.ArgumentMatchers.eq("AVIF")); + + MockMultipartFile file = new MockMultipartFile( + "file", + "landing-hero.png", + "image/png", + "png-image-content".getBytes(StandardCharsets.UTF_8) + ); + + AdminMediaAssetDto dto = service.uploadAsset(file, " Landing hero ", " Main headline ", null); + + assertEquals("READY", dto.getStatus()); + assertEquals(7, dto.getVariants().size()); + assertTrue(dto.getVariants().stream().noneMatch(variant -> "AVIF".equals(variant.getFormat()))); + assertEquals(3, dto.getVariants().stream().filter(variant -> "JPEG".equals(variant.getFormat())).count()); + assertEquals(3, dto.getVariants().stream().filter(variant -> "WEBP".equals(variant.getFormat())).count()); + } + + @Test + void uploadAsset_withOversizedFile_shouldFailValidationBeforePersistence() { + service = new AdminMediaControllerService( + mediaAssetRepository, + mediaVariantRepository, + mediaUsageRepository, + new MediaStorageService(storageRoot.toString(), "https://cdn.example/media"), + mediaImageInspector, + mediaFfmpegService, + clamAVService, + 4 + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "big.png", + "image/png", + "12345".getBytes(StandardCharsets.UTF_8) + ); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.uploadAsset(file, null, null, null) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verifyNoInteractions(mediaAssetRepository, mediaVariantRepository, mediaUsageRepository, mediaImageInspector, mediaFfmpegService); + } + + @Test + void createUsage_withPrimaryFlag_shouldUnsetExistingPrimaryAndMapUsageOnAsset() { + MediaAsset asset = persistAsset(seedAsset("PUBLIC")); + + MediaUsage existingPrimary = new MediaUsage(); + existingPrimary.setId(UUID.randomUUID()); + existingPrimary.setUsageType("HOME"); + existingPrimary.setUsageKey("landing"); + existingPrimary.setOwnerId(null); + existingPrimary.setMediaAsset(asset); + existingPrimary.setSortOrder(0); + existingPrimary.setIsPrimary(true); + existingPrimary.setIsActive(true); + existingPrimary.setCreatedAt(OffsetDateTime.now().minusDays(1)); + persistUsage(existingPrimary); + + AdminCreateMediaUsageRequest payload = new AdminCreateMediaUsageRequest(); + payload.setUsageType("home"); + payload.setUsageKey("landing"); + payload.setMediaAssetId(asset.getId()); + payload.setSortOrder(5); + payload.setIsPrimary(true); + payload.setTranslations(buildTranslations("Landing hero", "Hero home alt")); + + AdminMediaUsageDto created = service.createUsage(payload); + + assertEquals("HOME", created.getUsageType()); + assertEquals("landing", created.getUsageKey()); + assertEquals(asset.getId(), created.getMediaAssetId()); + assertEquals(5, created.getSortOrder()); + assertTrue(created.getIsPrimary()); + assertEquals("Landing hero IT", created.getTranslations().get("it").getTitle()); + assertEquals("Hero home alt EN", created.getTranslations().get("en").getAltText()); + assertFalse(usages.get(existingPrimary.getId()).getIsPrimary()); + + AdminMediaAssetDto assetDto = service.getAsset(asset.getId()); + assertEquals(2, assetDto.getUsages().size()); + assertTrue(assetDto.getUsages().stream().anyMatch(usage -> usage.getId().equals(created.getId()) && usage.getIsPrimary())); + } + + @Test + void createUsage_withoutAllTranslations_shouldFailValidation() { + MediaAsset asset = persistAsset(seedAsset("PUBLIC")); + + AdminCreateMediaUsageRequest payload = new AdminCreateMediaUsageRequest(); + payload.setUsageType("home"); + payload.setUsageKey("landing"); + payload.setMediaAssetId(asset.getId()); + payload.setTranslations(new LinkedHashMap<>(Map.of( + "it", translation("Titolo IT", "Alt IT"), + "en", translation("Title EN", "Alt EN") + ))); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.createUsage(payload) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains("translations must include exactly")); + } + + @Test + void updateAsset_withPrivateVisibility_shouldMoveGeneratedFilesAndHidePublicUrls() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/jpeg", "jpg", 1200, 800) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "gallery.jpg", + "image/jpeg", + "jpeg-image-content".getBytes(StandardCharsets.UTF_8) + ); + + AdminMediaAssetDto uploaded = service.uploadAsset(file, null, null, "PUBLIC"); + MediaVariant thumbJpeg = variants.values().stream() + .filter(variant -> "thumb".equals(variant.getVariantName())) + .filter(variant -> "JPEG".equals(variant.getFormat())) + .findFirst() + .orElseThrow(); + + Path publicFile = storageRoot.resolve("public").resolve(thumbJpeg.getStorageKey()); + assertTrue(Files.exists(publicFile)); + + AdminUpdateMediaAssetRequest payload = new AdminUpdateMediaAssetRequest(); + payload.setVisibility("PRIVATE"); + + AdminMediaAssetDto updated = service.updateAsset(uploaded.getId(), payload); + + assertEquals("PRIVATE", updated.getVisibility()); + assertFalse(Files.exists(publicFile)); + assertTrue(Files.exists(storageRoot.resolve("private").resolve(thumbJpeg.getStorageKey()))); + assertTrue(updated.getVariants().stream() + .filter(variant -> !"ORIGINAL".equals(variant.getFormat())) + .allMatch(variant -> variant.getPublicUrl() == null)); + } + + private MediaAsset seedAsset(String visibility) { + MediaAsset asset = new MediaAsset(); + asset.setId(UUID.randomUUID()); + asset.setOriginalFilename("asset.png"); + asset.setStorageKey("2026/03/" + UUID.randomUUID() + "/original.png"); + asset.setMimeType("image/png"); + asset.setFileSizeBytes(123L); + asset.setSha256Hex("a".repeat(64)); + asset.setWidthPx(1200); + asset.setHeightPx(800); + asset.setStatus("READY"); + asset.setVisibility(visibility); + asset.setCreatedAt(OffsetDateTime.now()); + asset.setUpdatedAt(OffsetDateTime.now()); + return asset; + } + + private MediaAsset persistAsset(MediaAsset asset) { + assets.put(asset.getId(), asset); + return asset; + } + + private MediaVariant persistVariant(MediaVariant variant) { + if (variant.getId() == null) { + variant.setId(UUID.randomUUID()); + } + variants.put(variant.getId(), variant); + return variant; + } + + private MediaUsage persistUsage(MediaUsage usage) { + if (usage.getId() == null) { + usage.setId(UUID.randomUUID()); + } + usages.put(usage.getId(), usage); + return usage; + } + + private Map buildTranslations(String titleBase, String altBase) { + LinkedHashMap translations = new LinkedHashMap<>(); + translations.put("it", translation(titleBase + " IT", altBase + " IT")); + translations.put("en", translation(titleBase + " EN", altBase + " EN")); + translations.put("de", translation(titleBase + " DE", altBase + " DE")); + translations.put("fr", translation(titleBase + " FR", altBase + " FR")); + return translations; + } + + private MediaTextTranslationDto translation(String title, String altText) { + MediaTextTranslationDto dto = new MediaTextTranslationDto(); + dto.setTitle(title); + dto.setAltText(altText); + return dto; + } + + private List variantsForAssets(Collection assetIds) { + return variants.values().stream() + .filter(variant -> assetIds.contains(variant.getMediaAsset().getId())) + .toList(); + } + + private List usagesForAssets(Collection assetIds) { + return usages.values().stream() + .filter(usage -> assetIds.contains(usage.getMediaAsset().getId())) + .toList(); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminShopProductControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminShopProductControllerServiceTest.java new file mode 100644 index 0000000..a4fe132 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminShopProductControllerServiceTest.java @@ -0,0 +1,137 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductModelAsset; +import com.printcalculator.entity.ShopProductVariant; +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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminShopProductControllerServiceTest { + + @Mock + private ShopProductRepository shopProductRepository; + @Mock + private ShopCategoryRepository shopCategoryRepository; + @Mock + private ShopProductVariantRepository shopProductVariantRepository; + @Mock + private ShopProductModelAssetRepository shopProductModelAssetRepository; + @Mock + private QuoteLineItemRepository quoteLineItemRepository; + @Mock + private OrderItemRepository orderItemRepository; + @Mock + private PublicMediaQueryService publicMediaQueryService; + @Mock + private AdminMediaControllerService adminMediaControllerService; + @Mock + private ShopStorageService shopStorageService; + @Mock + private SlicerService slicerService; + @Mock + private ClamAVService clamAVService; + + private AdminShopProductControllerService service; + + @BeforeEach + void setUp() { + service = new AdminShopProductControllerService( + shopProductRepository, + shopCategoryRepository, + shopProductVariantRepository, + shopProductModelAssetRepository, + quoteLineItemRepository, + orderItemRepository, + publicMediaQueryService, + adminMediaControllerService, + shopStorageService, + slicerService, + clamAVService, + 104857600L + ); + } + + @Test + void deleteProduct_shouldDeleteManagedDependenciesBeforeDeletingProduct() { + UUID productId = UUID.randomUUID(); + UUID variantId = UUID.randomUUID(); + + ShopProduct product = new ShopProduct(); + product.setId(productId); + + ShopProductVariant variant = new ShopProductVariant(); + variant.setId(variantId); + variant.setProduct(product); + + ShopProductModelAsset asset = new ShopProductModelAsset(); + asset.setId(UUID.randomUUID()); + asset.setProduct(product); + asset.setStoredRelativePath("products/" + productId + "/model.stl"); + + when(shopProductRepository.findById(productId)).thenReturn(Optional.of(product)); + when(quoteLineItemRepository.existsByShopProduct_Id(productId)).thenReturn(false); + when(orderItemRepository.existsByShopProduct_Id(productId)).thenReturn(false); + when(shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(productId)).thenReturn(List.of(variant)); + when(quoteLineItemRepository.existsByShopProductVariant_Id(variantId)).thenReturn(false); + when(orderItemRepository.existsByShopProductVariant_Id(variantId)).thenReturn(false); + when(shopProductModelAssetRepository.findByProduct_Id(productId)).thenReturn(Optional.of(asset)); + when(shopStorageService.resolveStoredProductPath(asset.getStoredRelativePath(), productId)) + .thenReturn(Path.of("/tmp/shop-model.stl")); + + service.deleteProduct(productId); + + InOrder inOrder = inOrder(shopProductModelAssetRepository, shopProductVariantRepository, shopProductRepository); + inOrder.verify(shopProductModelAssetRepository).delete(asset); + inOrder.verify(shopProductVariantRepository).deleteAll(List.of(variant)); + inOrder.verify(shopProductRepository).delete(product); + + verify(shopStorageService).resolveStoredProductPath(asset.getStoredRelativePath(), productId); + } + + @Test + void deleteProduct_shouldSkipDependencyDeletesWhenNothingIsAttached() { + UUID productId = UUID.randomUUID(); + + ShopProduct product = new ShopProduct(); + product.setId(productId); + + when(shopProductRepository.findById(productId)).thenReturn(Optional.of(product)); + when(quoteLineItemRepository.existsByShopProduct_Id(productId)).thenReturn(false); + when(orderItemRepository.existsByShopProduct_Id(productId)).thenReturn(false); + when(shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(productId)).thenReturn(List.of()); + when(shopProductModelAssetRepository.findByProduct_Id(productId)).thenReturn(Optional.empty()); + + service.deleteProduct(productId); + + verify(shopProductRepository).delete(product); + verify(shopProductVariantRepository, never()).deleteAll(any()); + verify(shopProductModelAssetRepository, never()).delete(any()); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java b/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java new file mode 100644 index 0000000..4cf683a --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/media/MediaFfmpegServiceTest.java @@ -0,0 +1,165 @@ +package com.printcalculator.service.media; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MediaFfmpegServiceTest { + + @TempDir + Path tempDir; + + @Test + void sanitizeExecutable_rejectsControlCharacters() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> MediaFfmpegService.sanitizeExecutable("ffmpeg\n--help") + ); + + assertEquals("media.ffmpeg.path contains control characters.", ex.getMessage()); + } + + @Test + void resolveExecutable_shouldFallbackToPathWhenAbsoluteLocationIsMissing() { + String resolved = MediaFfmpegService.resolveExecutable("/opt/homebrew/bin/ffmpeg"); + + assertEquals("ffmpeg", resolved); + } + + @Test + void generateVariant_rejectsSourceNamesStartingWithDash() throws Exception { + MediaFfmpegService service = new MediaFfmpegService("missing-ffmpeg-binary"); + Path source = tempDir.resolve("-input.png"); + Path target = tempDir.resolve("output.jpg"); + Files.writeString(source, "image"); + + IOException ex = assertThrows( + IOException.class, + () -> service.generateVariant(source, target, 120, 80, "JPEG") + ); + + assertEquals("Media source file name must not start with '-'.", ex.getMessage()); + } + + @Test + void generateVariant_rejectsTargetNamesStartingWithDash() throws Exception { + MediaFfmpegService service = new MediaFfmpegService("missing-ffmpeg-binary"); + Path source = tempDir.resolve("input.png"); + Path target = tempDir.resolve("-output.jpg"); + Files.writeString(source, "image"); + + IOException ex = assertThrows( + IOException.class, + () -> service.generateVariant(source, target, 120, 80, "JPEG") + ); + + assertEquals("Media target file name must not start with '-'.", ex.getMessage()); + } + + @Test + void generateVariant_avifShouldNotUseStillPictureFlag() throws Exception { + Path fakeFfmpeg = tempDir.resolve("fake-ffmpeg.sh"); + Files.writeString( + fakeFfmpeg, + """ + #!/bin/sh + if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then + cat <<'EOF' + V..... mjpeg + V..... libwebp + V..... libaom-av1 + EOF + exit 0 + fi + if [ "$1" = "-hide_banner" ] && [ "$2" = "-muxers" ]; then + cat <<'EOF' + E avif + EOF + exit 0 + fi + + for arg in "$@"; do + if [ "$arg" = "-still-picture" ]; then + echo "Unrecognized option 'still-picture'. Error splitting the argument list: Option not found" + exit 1 + fi + done + + last_arg="" + for arg in "$@"; do + last_arg="$arg" + done + + mkdir -p "$(dirname "$last_arg")" + printf 'ok' > "$last_arg" + exit 0 + """ + ); + Files.setPosixFilePermissions( + fakeFfmpeg, + Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE + ) + ); + + MediaFfmpegService service = new MediaFfmpegService(fakeFfmpeg.toString()); + Path source = tempDir.resolve("input.png"); + Path target = tempDir.resolve("output.avif"); + Files.writeString(source, "image"); + + service.generateVariant(source, target, 120, 80, "AVIF"); + + assertTrue(Files.exists(target)); + assertEquals("ok", Files.readString(target)); + } + + @Test + void canEncode_avifShouldRequireMuxerAvailability() throws Exception { + Path fakeFfmpeg = tempDir.resolve("fake-ffmpeg-no-avif-muxer.sh"); + Files.writeString( + fakeFfmpeg, + """ + #!/bin/sh + if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then + cat <<'EOF' + V..... mjpeg + V..... libaom-av1 + EOF + exit 0 + fi + if [ "$1" = "-hide_banner" ] && [ "$2" = "-muxers" ]; then + cat <<'EOF' + E mp4 + EOF + exit 0 + fi + exit 0 + """ + ); + Files.setPosixFilePermissions( + fakeFfmpeg, + Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE + ) + ); + + MediaFfmpegService service = new MediaFfmpegService(fakeFfmpeg.toString()); + + assertTrue(service.canEncode("JPEG")); + assertFalse(service.canEncode("AVIF")); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java b/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java new file mode 100644 index 0000000..06fae7a --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java @@ -0,0 +1,162 @@ +package com.printcalculator.service.media; + +import com.printcalculator.dto.PublicMediaUsageDto; +import com.printcalculator.dto.MediaTextTranslationDto; +import com.printcalculator.entity.MediaAsset; +import com.printcalculator.entity.MediaUsage; +import com.printcalculator.entity.MediaVariant; +import com.printcalculator.repository.MediaUsageRepository; +import com.printcalculator.repository.MediaVariantRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PublicMediaQueryServiceTest { + + @Mock + private MediaUsageRepository mediaUsageRepository; + @Mock + private MediaVariantRepository mediaVariantRepository; + + @TempDir + Path tempDir; + + private PublicMediaQueryService service; + + @BeforeEach + void setUp() { + MediaStorageService mediaStorageService = new MediaStorageService( + tempDir.resolve("storage_media").toString(), + "https://cdn.example" + ); + service = new PublicMediaQueryService(mediaUsageRepository, mediaVariantRepository, mediaStorageService); + } + + @Test + void getUsageMedia_shouldReturnOnlyActiveReadyPublicUsagesOrderedBySortOrder() { + MediaAsset readyPublicAsset = buildAsset("READY", "PUBLIC", "Shop hero fallback", "Shop alt fallback"); + MediaAsset draftAsset = buildAsset("PROCESSING", "PUBLIC", "Draft", "Draft alt"); + MediaAsset privateAsset = buildAsset("READY", "PRIVATE", "Private", "Private alt"); + + MediaUsage usageSecond = buildUsage(readyPublicAsset, "HOME_SECTION", "shop-gallery", 2, false, true); + MediaUsage usageFirst = buildUsage(readyPublicAsset, "HOME_SECTION", "shop-gallery", 1, true, true); + applyTranslation(usageSecond, "it", "Shop hero IT", "Shop alt IT"); + applyTranslation(usageSecond, "en", "Shop hero EN", "Shop alt EN"); + applyTranslation(usageSecond, "de", "Shop hero DE", "Shop alt DE"); + applyTranslation(usageSecond, "fr", "Shop hero FR", "Shop alt FR"); + applyTranslation(usageFirst, "it", "Shop hero IT", "Shop alt IT"); + applyTranslation(usageFirst, "en", "Shop hero EN", "Shop alt EN"); + applyTranslation(usageFirst, "de", "Shop hero DE", "Shop alt DE"); + applyTranslation(usageFirst, "fr", "Shop hero FR", "Shop alt FR"); + MediaUsage usageDraft = buildUsage(draftAsset, "HOME_SECTION", "shop-gallery", 0, false, true); + MediaUsage usagePrivate = buildUsage(privateAsset, "HOME_SECTION", "shop-gallery", 3, false, true); + + when(mediaUsageRepository.findActiveByUsageTypeAndUsageKeys( + "HOME_SECTION", List.of("shop-gallery") + )).thenReturn(List.of(usageSecond, usageFirst, usageDraft, usagePrivate)); + when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(readyPublicAsset.getId()))) + .thenReturn(List.of( + buildVariant(readyPublicAsset, "thumb", "JPEG", "asset/thumb.jpg"), + buildVariant(readyPublicAsset, "thumb", "WEBP", "asset/thumb.webp"), + buildVariant(readyPublicAsset, "hero", "AVIF", "asset/hero.avif"), + buildVariant(readyPublicAsset, "hero", "JPEG", "asset/hero.jpg") + )); + + List result = service.getUsageMedia("home_section", "shop-gallery", "en"); + + assertEquals(2, result.size()); + assertEquals(1, result.get(0).getSortOrder()); + assertEquals(Boolean.TRUE, result.get(0).getIsPrimary()); + assertEquals("Shop hero EN", result.get(0).getTitle()); + assertEquals("Shop alt EN", result.get(0).getAltText()); + assertEquals("https://cdn.example/media/asset/thumb.jpg", result.get(0).getThumb().getJpegUrl()); + assertEquals("https://cdn.example/media/asset/thumb.webp", result.get(0).getThumb().getWebpUrl()); + assertEquals("https://cdn.example/media/asset/hero.avif", result.get(0).getHero().getAvifUrl()); + } + + @Test + void getUsageMedia_shouldReturnNullForMissingFormatsOrPresetsAndFallbackToAssetMetadata() { + MediaAsset asset = buildAsset("READY", "PUBLIC", "Joe portrait", "Joe portrait fallback"); + MediaUsage usage = buildUsage(asset, "ABOUT_MEMBER", "joe", 0, true, true); + + when(mediaUsageRepository.findActiveByUsageTypeAndUsageKeys( + "ABOUT_MEMBER", List.of("joe") + )).thenReturn(List.of(usage)); + when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(asset.getId()))) + .thenReturn(List.of(buildVariant(asset, "card", "JPEG", "joe/card.jpg"))); + + List result = service.getUsageMedia("ABOUT_MEMBER", "joe", "fr"); + + assertEquals(1, result.size()); + assertEquals("Joe portrait", result.get(0).getTitle()); + assertEquals("Joe portrait fallback", result.get(0).getAltText()); + assertNull(result.get(0).getThumb().getJpegUrl()); + assertNull(result.get(0).getCard().getAvifUrl()); + assertEquals("https://cdn.example/media/joe/card.jpg", result.get(0).getCard().getJpegUrl()); + assertNull(result.get(0).getHero().getWebpUrl()); + assertTrue(result.get(0).getIsPrimary()); + } + + private MediaAsset buildAsset(String status, String visibility, String title, String altText) { + MediaAsset asset = new MediaAsset(); + asset.setId(UUID.randomUUID()); + asset.setStatus(status); + asset.setVisibility(visibility); + asset.setTitle(title); + asset.setAltText(altText); + asset.setCreatedAt(OffsetDateTime.now()); + asset.setUpdatedAt(OffsetDateTime.now()); + return asset; + } + + private MediaUsage buildUsage(MediaAsset asset, + String usageType, + String usageKey, + int sortOrder, + boolean isPrimary, + boolean isActive) { + MediaUsage usage = new MediaUsage(); + usage.setId(UUID.randomUUID()); + usage.setMediaAsset(asset); + usage.setUsageType(usageType); + usage.setUsageKey(usageKey); + usage.setSortOrder(sortOrder); + usage.setIsPrimary(isPrimary); + usage.setIsActive(isActive); + usage.setCreatedAt(OffsetDateTime.now().plusSeconds(sortOrder)); + return usage; + } + + private MediaVariant buildVariant(MediaAsset asset, String variantName, String format, String storageKey) { + MediaVariant variant = new MediaVariant(); + variant.setId(UUID.randomUUID()); + variant.setMediaAsset(asset); + variant.setVariantName(variantName); + variant.setFormat(format); + variant.setStorageKey(storageKey); + variant.setCreatedAt(OffsetDateTime.now()); + return variant; + } + + private void applyTranslation(MediaUsage usage, String language, String title, String altText) { + MediaTextTranslationDto translation = new MediaTextTranslationDto(); + translation.setTitle(title); + translation.setAltText(altText); + usage.setTitleForLanguage(language, translation.getTitle()); + usage.setAltTextForLanguage(language, translation.getAltText()); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java b/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java new file mode 100644 index 0000000..086c319 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java @@ -0,0 +1,85 @@ +package com.printcalculator.service.payment; + +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import org.junit.jupiter.api.Test; +import org.thymeleaf.TemplateEngine; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class InvoicePdfRenderingServiceTest { + + @Test + void generateDocumentPdf_shouldDescribeShopItemsWithProductAndVariant() { + CapturingInvoicePdfRenderingService service = new CapturingInvoicePdfRenderingService(); + QrBillService qrBillService = mock(QrBillService.class); + when(qrBillService.generateQrBillSvg(org.mockito.ArgumentMatchers.any(Order.class))) + .thenReturn("".getBytes(StandardCharsets.UTF_8)); + + Order order = new Order(); + order.setId(UUID.randomUUID()); + order.setCreatedAt(OffsetDateTime.parse("2026-03-10T10:15:30+01:00")); + order.setBillingCustomerType("PRIVATE"); + order.setBillingFirstName("Joe"); + order.setBillingLastName("Buyer"); + order.setBillingAddressLine1("Via Test 1"); + order.setBillingZip("6900"); + order.setBillingCity("Lugano"); + order.setBillingCountryCode("CH"); + order.setSetupCostChf(BigDecimal.ZERO); + order.setShippingCostChf(new BigDecimal("2.00")); + order.setSubtotalChf(new BigDecimal("36.80")); + order.setTotalChf(new BigDecimal("38.80")); + order.setCadTotalChf(BigDecimal.ZERO); + + OrderItem shopItem = new OrderItem(); + shopItem.setItemType("SHOP_PRODUCT"); + shopItem.setDisplayName("Desk Cable Clip"); + shopItem.setOriginalFilename("desk-cable-clip.stl"); + shopItem.setShopProductName("Desk Cable Clip"); + shopItem.setShopVariantLabel("Coral Red"); + shopItem.setQuantity(2); + shopItem.setUnitPriceChf(new BigDecimal("14.90")); + shopItem.setLineTotalChf(new BigDecimal("29.80")); + + OrderItem printItem = new OrderItem(); + printItem.setItemType("PRINT_FILE"); + printItem.setDisplayName("gear-cover.stl"); + printItem.setOriginalFilename("gear-cover.stl"); + printItem.setQuantity(1); + printItem.setUnitPriceChf(new BigDecimal("7.00")); + printItem.setLineTotalChf(new BigDecimal("7.00")); + + byte[] pdf = service.generateDocumentPdf(order, List.of(shopItem, printItem), true, qrBillService, null); + + assertNotNull(pdf); + @SuppressWarnings("unchecked") + List> invoiceLineItems = (List>) service.capturedVariables.get("invoiceLineItems"); + assertEquals("Desk Cable Clip - Coral Red", invoiceLineItems.getFirst().get("description")); + assertEquals("Stampa 3D: gear-cover.stl", invoiceLineItems.get(1).get("description")); + } + + private static class CapturingInvoicePdfRenderingService extends InvoicePdfRenderingService { + private Map capturedVariables; + + private CapturingInvoicePdfRenderingService() { + super(mock(TemplateEngine.class)); + } + + @Override + public byte[] generateInvoicePdfBytesFromTemplate(Map invoiceTemplateVariables, String qrBillSvg) { + this.capturedVariables = invoiceTemplateVariables; + return new byte[]{1, 2, 3}; + } + } +} diff --git a/backend/src/test/java/com/printcalculator/service/shop/ShopCartServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/ShopCartServiceTest.java new file mode 100644 index 0000000..41e3154 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/shop/ShopCartServiceTest.java @@ -0,0 +1,220 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.dto.ShopCartAddItemRequest; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ShopCartServiceTest { + + @Mock + private QuoteSessionRepository quoteSessionRepository; + @Mock + private QuoteLineItemRepository quoteLineItemRepository; + @Mock + private ShopProductVariantRepository shopProductVariantRepository; + @Mock + private ShopProductModelAssetRepository shopProductModelAssetRepository; + @Mock + private QuoteSessionTotalsService quoteSessionTotalsService; + @Mock + private QuoteSessionResponseAssembler quoteSessionResponseAssembler; + @Mock + private ShopStorageService shopStorageService; + @Mock + private ShopCartCookieService shopCartCookieService; + + private ShopCartService service; + + @BeforeEach + void setUp() { + service = new ShopCartService( + quoteSessionRepository, + quoteLineItemRepository, + shopProductVariantRepository, + shopProductModelAssetRepository, + quoteSessionTotalsService, + quoteSessionResponseAssembler, + new QuoteStorageService(), + shopStorageService, + shopCartCookieService + ); + } + + @Test + void addItem_shouldCreateServerCartAndPersistVariantPricingSnapshot() { + UUID sessionId = UUID.randomUUID(); + UUID lineItemId = UUID.randomUUID(); + UUID variantId = UUID.randomUUID(); + List savedItems = new ArrayList<>(); + + ShopProductVariant variant = buildVariant(variantId); + + when(shopCartCookieService.extractSessionId(any())).thenReturn(Optional.empty()); + when(shopCartCookieService.getCookieTtlDays()).thenReturn(30L); + when(shopProductVariantRepository.findById(variantId)).thenReturn(Optional.of(variant)); + when(shopProductModelAssetRepository.findByProduct_Id(variant.getProduct().getId())).thenReturn(Optional.empty()); + when(quoteSessionRepository.save(any(QuoteSession.class))).thenAnswer(invocation -> { + QuoteSession session = invocation.getArgument(0); + if (session.getId() == null) { + session.setId(sessionId); + } + return session; + }); + when(quoteLineItemRepository.findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id( + eq(sessionId), + eq("SHOP_PRODUCT"), + eq(variantId) + )).thenReturn(Optional.empty()); + when(quoteLineItemRepository.save(any(QuoteLineItem.class))).thenAnswer(invocation -> { + QuoteLineItem item = invocation.getArgument(0); + if (item.getId() == null) { + item.setId(lineItemId); + } + savedItems.clear(); + savedItems.add(item); + return item; + }); + when(quoteLineItemRepository.findByQuoteSessionIdOrderByCreatedAtAsc(sessionId)).thenAnswer(invocation -> List.copyOf(savedItems)); + when(quoteSessionTotalsService.compute(any(), any())).thenReturn( + new QuoteSessionTotalsService.QuoteSessionTotals( + new BigDecimal("22.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("22.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("2.00"), + new BigDecimal("24.80"), + BigDecimal.ZERO + ) + ); + when(quoteSessionResponseAssembler.assemble(any(), any(), any())).thenAnswer(invocation -> { + QuoteSession session = invocation.getArgument(0); + Map response = new HashMap<>(); + response.put("session", session); + response.put("items", List.of()); + response.put("grandTotalChf", new BigDecimal("24.80")); + return response; + }); + + ShopCartAddItemRequest payload = new ShopCartAddItemRequest(); + payload.setShopProductVariantId(variantId); + payload.setQuantity(2); + + ShopCartService.CartResult result = service.addItem(new MockHttpServletRequest(), payload); + + assertEquals(sessionId, result.sessionId()); + assertFalse(result.clearCookie()); + assertEquals(new BigDecimal("24.80"), result.response().get("grandTotalChf")); + + QuoteLineItem savedItem = savedItems.getFirst(); + assertEquals("SHOP_PRODUCT", savedItem.getLineItemType()); + assertEquals("Desk Cable Clip", savedItem.getDisplayName()); + assertEquals("desk-cable-clip", savedItem.getOriginalFilename()); + assertEquals(2, savedItem.getQuantity()); + assertEquals("PLA", savedItem.getMaterialCode()); + assertEquals("Coral Red", savedItem.getColorCode()); + assertEquals("Desk Cable Clip", savedItem.getShopProductName()); + assertEquals("Coral Red", savedItem.getShopVariantLabel()); + assertEquals("Coral Red", savedItem.getShopVariantColorName()); + assertAmountEquals("11.40", savedItem.getUnitPriceChf()); + assertNull(savedItem.getStoredPath()); + } + + @Test + void loadCart_withExpiredCookieSession_shouldExpireSessionAndAskCookieClear() { + UUID sessionId = UUID.randomUUID(); + QuoteSession session = new QuoteSession(); + session.setId(sessionId); + session.setSessionType("SHOP_CART"); + session.setStatus("ACTIVE"); + session.setExpiresAt(OffsetDateTime.now().minusHours(1)); + + Map emptyResponse = new HashMap<>(); + emptyResponse.put("session", null); + emptyResponse.put("items", List.of()); + + when(shopCartCookieService.hasCartCookie(any())).thenReturn(true); + when(shopCartCookieService.extractSessionId(any())).thenReturn(Optional.of(sessionId)); + when(quoteSessionRepository.findByIdAndSessionType(sessionId, "SHOP_CART")).thenReturn(Optional.of(session)); + when(quoteSessionRepository.save(any(QuoteSession.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(quoteSessionResponseAssembler.emptyCart()).thenReturn(emptyResponse); + + ShopCartService.CartResult result = service.loadCart(new MockHttpServletRequest()); + + assertTrue(result.clearCookie()); + assertNull(result.sessionId()); + assertEquals(emptyResponse, result.response()); + assertEquals("EXPIRED", session.getStatus()); + verify(quoteSessionRepository).save(session); + } + + private ShopProductVariant buildVariant(UUID variantId) { + ShopCategory category = new ShopCategory(); + category.setId(UUID.randomUUID()); + category.setSlug("cable-management"); + category.setName("Cable Management"); + category.setIsActive(true); + + ShopProduct product = new ShopProduct(); + product.setId(UUID.randomUUID()); + product.setCategory(category); + product.setSlug("desk-cable-clip"); + product.setName("Desk Cable Clip"); + product.setIsActive(true); + + ShopProductVariant variant = new ShopProductVariant(); + variant.setId(variantId); + variant.setProduct(product); + variant.setSku("DEMO-CLIP-CORAL"); + variant.setVariantLabel("Coral Red"); + variant.setColorName("Coral Red"); + variant.setColorHex("#ff6b6b"); + variant.setInternalMaterialCode("PLA"); + variant.setPriceChf(new BigDecimal("11.40")); + variant.setIsActive(true); + variant.setIsDefault(false); + return variant; + } + + private void assertAmountEquals(String expected, BigDecimal actual) { + assertTrue(new BigDecimal(expected).compareTo(actual) == 0, + "Expected " + expected + " but got " + actual); + } +} diff --git a/db.sql b/db.sql index d7fd322..c3975e6 100644 --- a/db.sql +++ b/db.sql @@ -919,6 +919,425 @@ CREATE TABLE IF NOT EXISTS custom_quote_request_attachments CREATE INDEX IF NOT EXISTS ix_custom_quote_attachments_request ON custom_quote_request_attachments (request_id); +CREATE TABLE IF NOT EXISTS media_asset +( + media_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + original_filename text NOT NULL, + storage_key text NOT NULL UNIQUE, + mime_type text NOT NULL, + file_size_bytes bigint NOT NULL CHECK (file_size_bytes >= 0), + sha256_hex text NOT NULL, + width_px integer, + height_px integer, + status text NOT NULL CHECK (status IN ('UPLOADED', 'PROCESSING', 'READY', 'FAILED', 'ARCHIVED')), + visibility text NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')), + title text, + alt_text text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_media_asset_status_visibility_created_at + ON media_asset (status, visibility, created_at DESC); + +CREATE TABLE IF NOT EXISTS media_variant +( + media_variant_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + media_asset_id uuid NOT NULL REFERENCES media_asset (media_asset_id) ON DELETE CASCADE, + variant_name text NOT NULL, + format text NOT NULL CHECK (format IN ('ORIGINAL', 'JPEG', 'WEBP', 'AVIF')), + storage_key text NOT NULL UNIQUE, + mime_type text NOT NULL, + width_px integer NOT NULL, + height_px integer NOT NULL, + file_size_bytes bigint NOT NULL CHECK (file_size_bytes >= 0), + is_generated boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_media_variant_asset_name_format UNIQUE (media_asset_id, variant_name, format) +); + +CREATE INDEX IF NOT EXISTS ix_media_variant_asset + ON media_variant (media_asset_id); + +CREATE TABLE IF NOT EXISTS media_usage +( + media_usage_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + usage_type text NOT NULL, + usage_key text NOT NULL, + owner_id uuid, + media_asset_id uuid NOT NULL REFERENCES media_asset (media_asset_id) ON DELETE CASCADE, + sort_order integer NOT NULL DEFAULT 0, + is_primary boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + title_it text, + title_en text, + title_de text, + title_fr text, + alt_text_it text, + alt_text_en text, + alt_text_de text, + alt_text_fr text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_media_usage_scope + ON media_usage (usage_type, usage_key, is_active, sort_order); + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS title_it text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS title_en text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS title_de text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS title_fr text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS alt_text_it text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS alt_text_en text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS alt_text_de text; + +ALTER TABLE media_usage + ADD COLUMN IF NOT EXISTS alt_text_fr text; + +CREATE TABLE IF NOT EXISTS shop_category +( + shop_category_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + parent_category_id uuid REFERENCES shop_category (shop_category_id) ON DELETE SET NULL, + slug text NOT NULL UNIQUE, + name text NOT NULL, + description text, + seo_title text, + seo_description text, + og_title text, + og_description text, + indexable boolean NOT NULL DEFAULT true, + is_active boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT chk_shop_category_not_self_parent CHECK ( + parent_category_id IS NULL OR parent_category_id <> shop_category_id + ) +); + +CREATE INDEX IF NOT EXISTS ix_shop_category_parent_sort + ON shop_category (parent_category_id, sort_order, created_at DESC); + +CREATE INDEX IF NOT EXISTS ix_shop_category_active_sort + ON shop_category (is_active, sort_order, created_at DESC); + +CREATE TABLE IF NOT EXISTS shop_product +( + shop_product_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + shop_category_id uuid NOT NULL REFERENCES shop_category (shop_category_id), + slug text NOT NULL UNIQUE, + name text NOT NULL, + name_it text, + name_en text, + name_de text, + name_fr text, + excerpt text, + excerpt_it text, + excerpt_en text, + excerpt_de text, + excerpt_fr text, + description text, + description_it text, + description_en text, + description_de text, + description_fr text, + seo_title text, + seo_title_it text, + seo_title_en text, + seo_title_de text, + seo_title_fr text, + seo_description text, + seo_description_it text, + seo_description_en text, + seo_description_de text, + seo_description_fr text, + og_title text, + og_description text, + indexable boolean NOT NULL DEFAULT true, + is_featured boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS seo_title_it text, + ADD COLUMN IF NOT EXISTS seo_title_en text, + ADD COLUMN IF NOT EXISTS seo_title_de text, + ADD COLUMN IF NOT EXISTS seo_title_fr text, + ADD COLUMN IF NOT EXISTS seo_description_it text, + ADD COLUMN IF NOT EXISTS seo_description_en text, + ADD COLUMN IF NOT EXISTS seo_description_de text, + ADD COLUMN IF NOT EXISTS seo_description_fr text; + +CREATE INDEX IF NOT EXISTS ix_shop_product_category_active_sort + ON shop_product (shop_category_id, is_active, sort_order, created_at DESC); + +CREATE INDEX IF NOT EXISTS ix_shop_product_featured_sort + ON shop_product (is_featured, is_active, sort_order, created_at DESC); + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_it text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_en text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_de text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_fr text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_it text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_en text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_de text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_fr text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_it text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_en text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_de text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_fr text; + +UPDATE shop_product +SET + name_it = COALESCE(NULLIF(btrim(name_it), ''), name), + name_en = COALESCE(NULLIF(btrim(name_en), ''), name), + name_de = COALESCE(NULLIF(btrim(name_de), ''), name), + name_fr = COALESCE(NULLIF(btrim(name_fr), ''), name), + excerpt_it = COALESCE(NULLIF(btrim(excerpt_it), ''), excerpt), + excerpt_en = COALESCE(NULLIF(btrim(excerpt_en), ''), excerpt), + excerpt_de = COALESCE(NULLIF(btrim(excerpt_de), ''), excerpt), + excerpt_fr = COALESCE(NULLIF(btrim(excerpt_fr), ''), excerpt), + description_it = COALESCE(NULLIF(btrim(description_it), ''), description), + description_en = COALESCE(NULLIF(btrim(description_en), ''), description), + description_de = COALESCE(NULLIF(btrim(description_de), ''), description), + description_fr = COALESCE(NULLIF(btrim(description_fr), ''), description) +WHERE + NULLIF(btrim(name_it), '') IS NULL + OR NULLIF(btrim(name_en), '') IS NULL + OR NULLIF(btrim(name_de), '') IS NULL + OR NULLIF(btrim(name_fr), '') IS NULL + OR (excerpt IS NOT NULL AND ( + NULLIF(btrim(excerpt_it), '') IS NULL + OR NULLIF(btrim(excerpt_en), '') IS NULL + OR NULLIF(btrim(excerpt_de), '') IS NULL + OR NULLIF(btrim(excerpt_fr), '') IS NULL + )) + OR (description IS NOT NULL AND ( + NULLIF(btrim(description_it), '') IS NULL + OR NULLIF(btrim(description_en), '') IS NULL + OR NULLIF(btrim(description_de), '') IS NULL + OR NULLIF(btrim(description_fr), '') IS NULL + )); + +CREATE TABLE IF NOT EXISTS shop_product_variant +( + shop_product_variant_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + shop_product_id uuid NOT NULL REFERENCES shop_product (shop_product_id) ON DELETE CASCADE, + sku text UNIQUE, + variant_label text NOT NULL, + color_name text NOT NULL, + color_hex text, + internal_material_code text NOT NULL, + price_chf numeric(12, 2) NOT NULL DEFAULT 0.00 CHECK (price_chf >= 0), + is_default boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_shop_product_variant_product_active_sort + ON shop_product_variant (shop_product_id, is_active, sort_order, created_at DESC); + +CREATE INDEX IF NOT EXISTS ix_shop_product_variant_sku + ON shop_product_variant (sku); + +CREATE TABLE IF NOT EXISTS shop_product_model_asset +( + shop_product_model_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + shop_product_id uuid NOT NULL UNIQUE REFERENCES shop_product (shop_product_id) ON DELETE CASCADE, + original_filename text NOT NULL, + stored_relative_path text NOT NULL, + stored_filename text NOT NULL, + file_size_bytes bigint CHECK (file_size_bytes >= 0), + mime_type text, + sha256_hex text, + bounding_box_x_mm numeric(10, 3), + bounding_box_y_mm numeric(10, 3), + bounding_box_z_mm numeric(10, 3), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_shop_product_model_asset_product + ON shop_product_model_asset (shop_product_id); + +ALTER TABLE quote_sessions + ADD COLUMN IF NOT EXISTS session_type text NOT NULL DEFAULT 'PRINT_QUOTE'; + +CREATE INDEX IF NOT EXISTS ix_quote_sessions_session_type + ON quote_sessions (session_type); + +ALTER TABLE quote_sessions + DROP CONSTRAINT IF EXISTS quote_sessions_session_type_check; + +ALTER TABLE quote_sessions + ADD CONSTRAINT quote_sessions_session_type_check + CHECK (session_type IN ('PRINT_QUOTE', 'SHOP_CART')); + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS line_item_type text NOT NULL DEFAULT 'PRINT_FILE'; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS display_name text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_product_id uuid; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_product_variant_id uuid; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_product_slug text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_product_name text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_variant_label text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_variant_color_name text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_variant_color_hex text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS stored_path text; + +CREATE INDEX IF NOT EXISTS ix_quote_line_items_shop_product + ON quote_line_items (shop_product_id); + +CREATE INDEX IF NOT EXISTS ix_quote_line_items_shop_product_variant + ON quote_line_items (shop_product_variant_id); + +ALTER TABLE quote_line_items + DROP CONSTRAINT IF EXISTS quote_line_items_line_item_type_check; + +ALTER TABLE quote_line_items + ADD CONSTRAINT quote_line_items_line_item_type_check + CHECK (line_item_type IN ('PRINT_FILE', 'SHOP_PRODUCT')); + +ALTER TABLE quote_line_items + DROP CONSTRAINT IF EXISTS fk_quote_line_items_shop_product; + +ALTER TABLE quote_line_items + ADD CONSTRAINT fk_quote_line_items_shop_product + FOREIGN KEY (shop_product_id) REFERENCES shop_product (shop_product_id); + +ALTER TABLE quote_line_items + DROP CONSTRAINT IF EXISTS fk_quote_line_items_shop_product_variant; + +ALTER TABLE quote_line_items + ADD CONSTRAINT fk_quote_line_items_shop_product_variant + FOREIGN KEY (shop_product_variant_id) REFERENCES shop_product_variant (shop_product_variant_id); + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS source_type text NOT NULL DEFAULT 'CALCULATOR'; + +CREATE INDEX IF NOT EXISTS ix_orders_source_type + ON orders (source_type); + +ALTER TABLE orders + DROP CONSTRAINT IF EXISTS orders_source_type_check; + +ALTER TABLE orders + ADD CONSTRAINT orders_source_type_check + CHECK (source_type IN ('CALCULATOR', 'SHOP')); + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS item_type text NOT NULL DEFAULT 'PRINT_FILE'; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS display_name text; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_product_id uuid; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_product_variant_id uuid; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_product_slug text; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_product_name text; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_variant_label text; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_variant_color_name text; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_variant_color_hex text; + +CREATE INDEX IF NOT EXISTS ix_order_items_shop_product + ON order_items (shop_product_id); + +CREATE INDEX IF NOT EXISTS ix_order_items_shop_product_variant + ON order_items (shop_product_variant_id); + +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS order_items_item_type_check; + +ALTER TABLE order_items + ADD CONSTRAINT order_items_item_type_check + CHECK (item_type IN ('PRINT_FILE', 'SHOP_PRODUCT')); + +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS fk_order_items_shop_product; + +ALTER TABLE order_items + ADD CONSTRAINT fk_order_items_shop_product + FOREIGN KEY (shop_product_id) REFERENCES shop_product (shop_product_id); + +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS fk_order_items_shop_product_variant; + +ALTER TABLE order_items + ADD CONSTRAINT fk_order_items_shop_product_variant + FOREIGN KEY (shop_product_variant_id) REFERENCES shop_product_variant (shop_product_variant_id); + ALTER TABLE quote_sessions DROP CONSTRAINT IF EXISTS fk_quote_sessions_source_request; diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index a3db5b0..5186321 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -1,6 +1,8 @@ services: backend: # L'immagine usa il tag specificato nel file .env o passato da riga di comando + # Nginx esterno deve servire /media/ con un alias verso + # /mnt/cache/appdata/print-calculator/${ENV}/storage_media/public/ image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-backend:${TAG} container_name: print-calculator-backend-${ENV} ports: @@ -29,6 +31,10 @@ services: - ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles + - MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media} + - SHOP_STORAGE_ROOT=${SHOP_STORAGE_ROOT:-/app/storage_shop} + - MEDIA_FFMPEG_PATH=${MEDIA_FFMPEG_PATH:-/usr/local/bin/ffmpeg-media} + - MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:-26214400} restart: always logging: driver: "json-file" @@ -40,6 +46,8 @@ services: - /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage_quotes - /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders - /mnt/cache/appdata/print-calculator/${ENV}/storage_requests:/app/storage_requests + - /mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media + - /mnt/cache/appdata/print-calculator/${ENV}/storage_shop:/app/storage_shop extra_hosts: - "host.docker.internal:host-gateway" diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index c423e3b..c12362d 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -31,7 +31,6 @@ const appChildRoutes: Routes = [ seoTitle: 'Shop 3D fab', seoDescription: 'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.', - seoRobots: 'noindex, nofollow', }, }, { diff --git a/frontend/src/app/core/layout/navbar.component.html b/frontend/src/app/core/layout/navbar.component.html index 6703ac7..46d2bd8 100644 --- a/frontend/src/app/core/layout/navbar.component.html +++ b/frontend/src/app/core/layout/navbar.component.html @@ -42,6 +42,35 @@
+ +
@@ -13,13 +18,14 @@

{{ errorMessage }}

-
+

Lista ordini

+
-
- +
+
+ @@ -76,6 +101,14 @@ (click)="openDetails(order.id)" > + @@ -84,7 +117,7 @@ - @@ -93,12 +126,23 @@ -
+
-

Dettaglio ordine {{ selectedOrder.orderNumber }}

+
+

Dettaglio ordine {{ selectedOrder.orderNumber }}

+ + {{ orderKindLabel(selectedOrder) }} + +

UUID: {{ selectedOrder.id }}Caricamento dettaglio...

-
-
+
+
Cliente{{ selectedOrder.customerEmail }}
-
+
Stato pagamento{{ selectedOrder.paymentStatus || "PENDING" }}
-
+
Stato ordine{{ selectedOrder.status }}
-
+
+ Tipo ordine{{ orderKindLabel(selectedOrder) }} +
+
Totale{{ selectedOrder.totalChf | currency: "CHF" : "symbol" : "1.2-2" @@ -130,6 +178,7 @@
- -
@@ -188,43 +253,72 @@
-

- {{ item.originalFilename }} -

-

- Qta: {{ item.quantity }} | Materiale: - {{ getItemMaterialLabel(item) }} | Colore: - - - {{ getItemColorLabel(item) }} - - ({{ colorCode }}) - +

+

+ {{ itemDisplayName(item) }} +

+ + {{ isShopItem(item) ? "Shop" : "Calcolatore" }} - | Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer: +
+

+ Qta: {{ item.quantity }} + + Materiale: {{ getItemMaterialLabel(item) }} + + + Variante: {{ variantLabel }} + + + Colore: + + + {{ getItemColorLabel(item) }} + + ({{ colorCode }}) + + + +

+

+ Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer: {{ item.layerHeightMm ?? "-" }} mm | Infill: {{ item.infillPercent ?? "-" }}% | Supporti: {{ formatSupports(item.supportsEnabled) }} - | Riga: +

+

+ Riga: {{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}

- +
+ +
-
+

Nessun ordine selezionato

Seleziona un ordine dalla lista per vedere i dettagli.

@@ -245,39 +339,39 @@

Dettagli stampa ordine {{ selectedOrder.orderNumber }}

-
OrdineTipo Email Pagamento Stato ordine{{ order.orderNumber }} + + {{ orderKindLabel(order) }} + + {{ order.customerEmail }} {{ order.paymentStatus || "PENDING" }} {{ order.status }}
+ Nessun ordine trovato per i filtri selezionati.
+
+
@@ -53,14 +53,14 @@
Sessione + + + + +

+ {{ errorMessage }} +

+

+ {{ successMessage }} +

+ +
+
+
+
+
+

Lista prodotti

+

{{ filteredProducts.length }} risultati con i filtri attivi.

+
+ +
+ +
+ + + + + +
+
+ +
+
+
+

Categorie shop

+

Gestione tassonomia e filtro del catalogo.

+
+ +
+ +
+
+
+ +
+ + {{ category.descendantProductCount }} prodotti + + + {{ category.isActive ? "Attiva" : "Inattiva" }} + +
+
+
+ +
+
+
+

+ {{ + categoryForm.id ? "Modifica categoria" : "Nuova categoria" + }} +

+

+ Aggiorna struttura, SEO e visibilità. +

+

+ Crea una nuova categoria del catalogo. +

+
+
+ +
+ + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ +
+ + + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
ProdottoCategoriaVariantiPrezzo CHFStato
+
+ {{ product.name }} + {{ product.slug }} +
+
{{ product.categoryName }} + {{ product.activeVariantCount }} / {{ product.variantCount }} + + {{ + product.priceFromChf | currency: "CHF" : "symbol" : "1.2-2" + }} + + - + {{ + product.priceToChf | currency: "CHF" : "symbol" : "1.2-2" + }} + + + + {{ + product.isActive + ? product.isFeatured + ? "Featured" + : "Attivo" + : "Inattivo" + }} + +
+ Nessun prodotto trovato con i filtri correnti. +
+
+
+ + + +
+
+
+

+ {{ + productMode === "create" + ? "Nuovo prodotto" + : selectedProduct?.name + }} +

+

+ Compila i campi e salva per creare un nuovo prodotto shop. +

+

+ {{ selectedProduct.slug }} · {{ selectedProduct.categoryName }} +

+
+ + {{ + selectedProduct.isActive + ? selectedProduct.isFeatured + ? "Featured" + : "Attivo" + : "Inattivo" + }} + +
+ +

+ Caricamento dettaglio... +

+ +
+
+ Categoria + {{ selectedProduct.categoryName }} +
+
+ Creato il + {{ + selectedProduct.createdAt | date: "dd.MM.yyyy HH:mm" + }} +
+
+ Aggiornato il + {{ + selectedProduct.updatedAt | date: "dd.MM.yyyy HH:mm" + }} +
+
+ Media + {{ productImages.length }} immagini attive +
+
+ +
+
+
+
+

Dati base

+

Slug, categoria, ordinamento e visibilità del prodotto.

+
+
+ +
+ + + + + +
+ +
+ + + + + +
+
+ +
+
+
+

Contenuti localizzati

+

+ Nome obbligatorio in tutte le lingue. Descrizioni opzionali. +

+
+
+ +
+
+ Lingua editor +

IT / EN / DE / FR

+
+
+ +
+
+ +
+ + + + + +
+
+ +
+
+
+

SEO localizzata

+

+ I campi seguono la lingua attiva. Open Graph usa gli stessi + valori della SEO. +

+
+
+ +
+
+ Lingua SEO +

Stessa lingua attiva dell'editor contenuti

+
+
+ +
+
+ +
+ + + +
+
+ +
+
+
+

Materiali e prezzi

+

+ Scegli i materiali disponibili a stock. I colori vengono presi + automaticamente dai filamenti attivi a magazzino. +

+
+ +
+ +
+ Nessun materiale attivo con stock disponibile. +
+ +
+
+
+
+

+ {{ material.materialCode || "Nuovo materiale" }} +

+

+ {{ materialColorCount(material.materialCode) }} colori da + stock · ordine {{ material.sortOrder }} +

+
+
+ + +
+
+ +
+ + + + + + +
+ + + +
+
+ +
+ Colori disponibili: + + {{ materialColorPreview(material.materialCode).join(", ") }} + + +{{ materialColorCount(material.materialCode) - 6 }} altri + + + + Nessun colore disponibile attualmente a stock. + +
+
+
+
+ +
+
+
+

Immagini e modello 3D

+

+ Upload protetto con whitelist tipi file; il modello 3D è + disponibile solo dopo il primo salvataggio del prodotto. +

+
+
+ +
+ Salva prima il prodotto per collegare immagini e modello 3D. +
+ +
+
+
+
+

Nuova immagine prodotto

+

+ JPG, PNG o WEBP con titolo e alt text in tutte le lingue. +

+
+
+ +
+
+ File immagine + + +
+ +
+ +
+ +
+
+ Testi localizzati immagine +

Titolo e alt text obbligatori

+
+
+ +
+
+ + + + + + + +
+ +
+
+ +
+ +
+
+ +
+
+
+

Immagini attive

+

+ {{ productImages.length }} immagini collegate al prodotto. +

+
+
+ +
+
+
+ +
+ +
+
+ + {{ + image.translations[imageUploadState.activeLanguage] + .title || "Senza titolo" + }} + + + Primaria + +
+ +

+ {{ + image.translations[imageUploadState.activeLanguage] + .altText || "Alt text mancante" + }} +

+ +
+ + +
+ + + +
+
+
+
+
+
+ +
+
+
+

Modello 3D

+

+ Solo STL o 3MF. Limite client {{ maxModelFileSizeMb }} MB, + whitelist server-side e virus scan già attivi. +

+
+
+ +
+
+
+ File + {{ model.originalFilename }} +
+
+ Dimensione + {{ formatFileSize(model.fileSizeBytes) }} +
+
+ Bounding box + + {{ model.boundingBoxXMm || 0 }} × + {{ model.boundingBoxYMm || 0 }} × + {{ model.boundingBoxZMm || 0 }} mm + +
+
+ +
+ + Apri modello + + +
+
+ +
+ Carica o sostituisci modello + + +
+ +
+ +
+
+
+
+ +
+ + + +
+
+
+
+ + + +
Caricamento catalogo shop...
+
+ + +
Nessuna immagine collegata a questo prodotto.
+
+ + +
Nessuna preview
+
+ + +
+ Nessun modello 3D caricato per questo prodotto. +
+
diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.scss b/frontend/src/app/features/admin/pages/admin-shop.component.scss new file mode 100644 index 0000000..6c0cd5c --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-shop.component.scss @@ -0,0 +1,519 @@ +.admin-shop { + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.admin-shop .image-language-button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 3.15rem; + background: #ffffff; + color: var(--color-text-muted); +} + +.admin-shop .image-language-button.empty { + opacity: 0.76; +} + +.admin-shop .image-language-button.complete { + border-color: #b8ddc2; +} + +.admin-shop .image-language-button.incomplete { + border-color: #e8c8c2; +} + +.admin-shop .image-language-button.active { + background: #fff5b8; + border-color: var(--color-brand); + color: var(--color-text); + opacity: 1; +} + +.image-language-button__label { + line-height: 1; +} + +.image-language-button__state { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1rem; + height: 1rem; + padding: 0 0.2rem; + border-radius: 999px; + background: rgba(0, 0, 0, 0.08); + font-size: 0.62rem; + font-weight: 800; + line-height: 1; +} + +.admin-shop .image-language-button.complete .image-language-button__state { + background: #dcefdc; + color: #25603b; +} + +.admin-shop .image-language-button.incomplete .image-language-button__state { + background: #f7ddd7; + color: #944329; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); +} + +.shop-header { + align-items: flex-start; +} + +.shop-header h1 { + margin: 0; +} + +.shop-header p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +.header-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} + +.workspace { + align-items: start; +} + +.resizable-workspace { + display: flex; + align-items: flex-start; + gap: 0; +} + +.resizable-workspace .list-panel { + flex: 0 0 var(--shop-list-panel-width, 53%); + width: var(--shop-list-panel-width, 53%); +} + +.resizable-workspace .detail-panel { + flex: 1 1 auto; +} + +.list-panel, +.detail-panel { + min-width: 0; +} + +.panel-block, +.list-panel, +.detail-panel, +.category-manager, +.category-editor, +.detail-stack, +.variant-stack, +.image-stack, +.media-grid, +.model-summary { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.form-section { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.panel-heading { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); +} + +.panel-heading h2, +.panel-heading h3, +.panel-heading h4, +.detail-header h2 { + margin: 0; +} + +.panel-heading p, +.detail-header p { + margin: var(--space-1) 0 0; + color: var(--color-text-muted); +} + +.list-toolbar { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 0.8fr); + gap: var(--space-2); +} + +.list-toolbar > * { + min-width: 0; +} + +.category-manager { + gap: var(--space-2); +} + +.category-manager__header { + display: flex; + justify-content: space-between; + gap: var(--space-3); + align-items: flex-start; +} + +.category-manager__header h3 { + margin: 0; +} + +.category-manager__header p { + margin: var(--space-1) 0 0; + color: var(--color-text-muted); +} + +.category-manager__body { + display: grid; + grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); + gap: var(--space-3); +} + +.category-list { + display: grid; + gap: var(--space-2); + max-height: 560px; + overflow: auto; +} + +.category-item { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + padding: var(--space-2); + display: grid; + gap: var(--space-2); +} + +.category-item.active { + border-color: var(--color-brand); + background: #fff9de; +} + +.category-item__main { + width: 100%; + border: 0; + background: transparent; + color: inherit; + text-align: left; + padding: 0; + cursor: pointer; + display: grid; + gap: 4px; +} + +.category-item__main strong, +.product-cell strong, +.image-item__header strong { + overflow-wrap: anywhere; +} + +.category-item__main span, +.product-cell span { + font-size: 0.84rem; + color: var(--color-text-muted); +} + +.category-item__meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.category-editor { + gap: var(--space-3); +} + +.textarea-control { + resize: vertical; + min-height: 82px; +} + +.textarea-control--large { + min-height: 136px; +} + +.toggle-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.toggle-row--compact { + align-items: flex-start; +} + +.form-field--wide { + grid-column: 1 / -1; +} + +.input-with-action { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-2); +} + +.seo-counter { + justify-self: end; + font-size: 0.72rem; + font-weight: 700; + color: var(--color-text-muted); +} + +.seo-counter--danger { + color: var(--color-danger-500); +} + +.product-cell { + display: grid; + gap: 4px; +} + +tbody tr { + cursor: pointer; +} + +tbody tr.selected { + background: #fff4c0; +} + +.detail-panel .ui-meta-item { + padding: var(--space-2); +} + +.panel-resizer { + position: relative; + flex: 0 0 16px; + align-self: stretch; + cursor: col-resize; + user-select: none; + touch-action: none; + background: transparent; +} + +.panel-resizer::before { + content: ""; + position: absolute; + top: 22px; + bottom: 22px; + left: 50%; + width: 1px; + transform: translateX(-50%); + background: var(--color-border); +} + +.panel-resizer__grip { + position: absolute; + top: 70px; + left: 50%; + transform: translateX(-50%); + width: 8px; + height: 64px; + border-radius: 999px; + background: + radial-gradient(circle, #b9b2a1 1.2px, transparent 1.2px) center / 8px 10px + repeat-y, + #fffdfa; + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); +} + +.detail-stack { + gap: var(--space-3); +} + +.variant-card, +.image-item { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + padding: var(--space-3); + box-shadow: var(--shadow-sm); +} + +.variant-card { + display: grid; + gap: var(--space-3); +} + +.variant-card__header, +.image-item__header, +.image-item__controls { + display: flex; + justify-content: space-between; + gap: var(--space-2); + align-items: flex-start; +} + +.variant-card__header p, +.image-meta { + margin: var(--space-1) 0 0; + color: var(--color-text-muted); +} + +.variant-card__actions, +.image-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + justify-content: flex-end; +} + +.material-stock-summary { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: baseline; + color: var(--color-text-muted); + font-size: 0.95rem; +} + +.material-stock-summary strong { + color: var(--color-text); +} + +.locked-panel, +.loading-state, +.image-fallback { + padding: var(--space-4); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); + background: #fbfaf6; + color: var(--color-text-muted); + text-align: center; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.preview-card, +.image-item__preview { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--color-bg-card); +} + +.preview-card { + aspect-ratio: 16 / 10; +} + +.preview-card img, +.image-item__preview img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.image-item { + display: grid; + grid-template-columns: 148px minmax(0, 1fr); + gap: var(--space-3); +} + +.image-item__preview { + aspect-ratio: 1; +} + +.image-item__content { + display: grid; + gap: var(--space-3); + min-width: 0; +} + +.link-button { + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.detail-loading { + margin: 0; + color: var(--color-text-muted); +} + +@media (max-width: 1480px) { + .category-manager__body { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1180px) { + .section-header { + flex-direction: column; + align-items: stretch; + } + + .list-toolbar, + .ui-form-grid--two { + grid-template-columns: 1fr; + } + + .image-item { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1060px) { + .resizable-workspace { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); + } + + .panel-resizer { + display: none; + } +} + +@media (max-width: 900px) { + .detail-header, + .panel-heading, + .category-manager__header, + .variant-card__header, + .image-item__header, + .image-item__controls { + flex-direction: column; + } + + .input-with-action { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.ts b/frontend/src/app/features/admin/pages/admin-shop.component.ts new file mode 100644 index 0000000..3045575 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-shop.component.ts @@ -0,0 +1,1798 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + ElementRef, + HostListener, + OnDestroy, + OnInit, + ViewChild, + inject, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { forkJoin } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { + AdminShopCategory, + AdminShopProduct, + AdminShopProductModel, + AdminShopProductVariant, + AdminShopService, + AdminUpsertShopCategoryPayload, + AdminUpsertShopProductPayload, + AdminUpsertShopProductVariantPayload, + AdminPublicMediaUsage, +} from '../services/admin-shop.service'; +import { + AdminFilamentVariant, + AdminOperationsService, +} from '../services/admin-operations.service'; +import { + AdminMediaLanguage, + AdminMediaTranslation, +} from '../services/admin-media.service'; +import { environment } from '../../../../environments/environment'; + +type ShopLanguage = 'it' | 'en' | 'de' | 'fr'; +type ProductMode = 'create' | 'edit'; +type ProductStatusFilter = 'ALL' | 'ACTIVE' | 'INACTIVE' | 'FEATURED'; + +interface CategoryFormState { + id: string | null; + parentCategoryId: string | null; + slug: string; + name: string; + description: string; + seoTitle: string; + seoDescription: string; + ogTitle: string; + ogDescription: string; + indexable: boolean; + isActive: boolean; + sortOrder: number; +} + +interface ProductMaterialFormState { + materialCode: string; + priceChf: string; + isDefault: boolean; + isActive: boolean; + sortOrder: number; +} + +interface ProductFormState { + categoryId: string; + slug: string; + names: Record; + excerpts: Record; + descriptions: Record; + seoTitles: Record; + seoDescriptions: Record; + indexable: boolean; + isFeatured: boolean; + isActive: boolean; + sortOrder: number; + materials: ProductMaterialFormState[]; +} + +interface ProductImageItem { + usageId: string; + mediaAssetId: string; + previewUrl: string | null; + sortOrder: number; + draftSortOrder: number; + isPrimary: boolean; + createdAt: string; + translations: Record; + title: string; + altText: string; +} + +interface ProductImageUploadState { + file: File | null; + previewUrl: string | null; + activeLanguage: AdminMediaLanguage; + translations: Record; + sortOrder: number; + isPrimary: boolean; + saving: boolean; +} + +const SHOP_LANGUAGES: readonly ShopLanguage[] = ['it', 'en', 'de', 'fr']; +const MEDIA_LANGUAGES: readonly AdminMediaLanguage[] = ['it', 'en', 'de', 'fr']; +const LANGUAGE_LABELS: Readonly> = { + it: 'IT', + en: 'EN', + de: 'DE', + fr: 'FR', +}; +const PRODUCT_STATUS_FILTERS: readonly ProductStatusFilter[] = [ + 'ALL', + 'ACTIVE', + 'INACTIVE', + 'FEATURED', +]; +const MAX_MODEL_FILE_SIZE_BYTES = 100 * 1024 * 1024; +const SHOP_LIST_PANEL_WIDTH_STORAGE_KEY = 'admin-shop-list-panel-width'; +const MIN_LIST_PANEL_WIDTH_PERCENT = 32; +const MAX_LIST_PANEL_WIDTH_PERCENT = 68; + +@Component({ + selector: 'app-admin-shop', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-shop.component.html', + styleUrl: './admin-shop.component.scss', +}) +export class AdminShopComponent implements OnInit, OnDestroy { + private readonly adminShopService = inject(AdminShopService); + private readonly adminOperationsService = inject(AdminOperationsService); + @ViewChild('workspaceRef') + private readonly workspaceRef?: ElementRef; + + readonly shopLanguages = SHOP_LANGUAGES; + readonly mediaLanguages = MEDIA_LANGUAGES; + readonly languageLabels = LANGUAGE_LABELS; + readonly productStatusFilters = PRODUCT_STATUS_FILTERS; + readonly maxModelFileSizeMb = Math.round( + MAX_MODEL_FILE_SIZE_BYTES / (1024 * 1024), + ); + + listPanelWidthPercent = 53; + categories: AdminShopCategory[] = []; + products: AdminShopProduct[] = []; + stockFilamentVariants: AdminFilamentVariant[] = []; + filteredProducts: AdminShopProduct[] = []; + selectedProduct: AdminShopProduct | null = null; + selectedProductId: string | null = null; + productImages: ProductImageItem[] = []; + + loading = false; + detailLoading = false; + savingProduct = false; + deletingProduct = false; + savingCategory = false; + deletingCategory = false; + uploadingModel = false; + deletingModel = false; + imageActionIds = new Set(); + isResizingPanels = false; + + productMode: ProductMode = 'create'; + productSearchTerm = ''; + categoryFilter = 'ALL'; + productStatusFilter: ProductStatusFilter = 'ALL'; + showCategoryManager = false; + activeContentLanguage: ShopLanguage = 'it'; + + errorMessage: string | null = null; + successMessage: string | null = null; + + readonly categoryForm: CategoryFormState = this.createEmptyCategoryForm(); + readonly productForm: ProductFormState = this.createEmptyProductForm(); + imageUploadState: ProductImageUploadState = + this.createEmptyImageUploadState(); + modelUploadFile: File | null = null; + + ngOnInit(): void { + this.restoreListPanelWidth(); + this.loadWorkspace(); + } + + ngOnDestroy(): void { + this.revokeImagePreviewUrl(this.imageUploadState.previewUrl); + document.body.style.removeProperty('cursor'); + } + + @HostListener('window:pointermove', ['$event']) + onWindowPointerMove(event: PointerEvent): void { + if (!this.isResizingPanels) { + return; + } + this.updateListPanelWidthFromPointer(event.clientX); + } + + @HostListener('window:pointerup') + @HostListener('window:pointercancel') + onWindowPointerUp(): void { + if (!this.isResizingPanels) { + return; + } + this.isResizingPanels = false; + document.body.style.cursor = ''; + this.persistListPanelWidth(); + } + + loadWorkspace(preferredProductId?: string): void { + this.loading = true; + this.errorMessage = null; + + forkJoin({ + categories: this.adminShopService.getCategories(), + products: this.adminShopService.getProducts(), + filamentVariants: this.adminOperationsService.getFilamentVariants(), + }).subscribe({ + next: ({ categories, products, filamentVariants }) => { + this.categories = categories; + this.products = products; + this.stockFilamentVariants = + this.filterStockedFilamentVariants(filamentVariants); + this.applyProductFilters(); + this.ensureCategoryFilterStillValid(); + this.loading = false; + + const targetProductId = + preferredProductId ?? + (this.productMode === 'edit' ? this.selectedProductId : null); + if ( + targetProductId && + products.some((product) => product.id === targetProductId) + ) { + this.openProduct(targetProductId); + return; + } + + if (this.productMode === 'create') { + this.selectedProduct = null; + this.selectedProductId = null; + this.productImages = []; + if (this.productForm.materials.length === 0) { + this.resetProductForm(); + } + return; + } + + if (this.filteredProducts.length > 0) { + this.openProduct(this.filteredProducts[0].id); + } else if (this.products.length === 0) { + this.startCreateProduct(); + } else { + this.selectedProduct = null; + this.selectedProductId = null; + this.productImages = []; + } + }, + error: (error) => { + this.loading = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Impossibile caricare il back-office shop.', + ); + }, + }); + } + + openProduct(productId: string): void { + this.productMode = 'edit'; + this.selectedProductId = productId; + this.detailLoading = true; + this.errorMessage = null; + + this.adminShopService.getProduct(productId).subscribe({ + next: (product) => { + this.selectedProduct = product; + this.productImages = this.buildProductImages(product); + this.loadProductIntoForm(product); + this.resetImageUploadState(product); + this.modelUploadFile = null; + this.detailLoading = false; + }, + error: (error) => { + this.detailLoading = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Impossibile caricare il dettaglio prodotto.', + ); + }, + }); + } + + startCreateProduct(): void { + this.productMode = 'create'; + this.selectedProduct = null; + this.selectedProductId = null; + this.productImages = []; + this.modelUploadFile = null; + this.activeContentLanguage = 'it'; + this.resetProductForm(); + this.resetImageUploadState(null); + } + + saveProduct(): void { + if (this.savingProduct) { + return; + } + + const validationError = this.validateProductForm(); + if (validationError) { + this.errorMessage = validationError; + this.successMessage = null; + return; + } + + const payload = this.buildProductPayload(); + this.savingProduct = true; + this.errorMessage = null; + this.successMessage = null; + + const request = + this.productMode === 'create' || !this.selectedProductId + ? this.adminShopService.createProduct(payload) + : this.adminShopService.updateProduct(this.selectedProductId, payload); + + request.subscribe({ + next: (product) => { + this.savingProduct = false; + this.productMode = 'edit'; + this.selectedProductId = product.id; + this.successMessage = + this.selectedProduct != null + ? 'Prodotto aggiornato.' + : 'Prodotto creato.'; + this.loadWorkspace(product.id); + }, + error: (error) => { + this.savingProduct = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Salvataggio prodotto non riuscito.', + ); + }, + }); + } + + deleteSelectedProduct(): void { + if (!this.selectedProductId || this.deletingProduct) { + return; + } + + if ( + !window.confirm( + "Eliminare questo prodotto? L'azione non puo essere annullata.", + ) + ) { + return; + } + + this.deletingProduct = true; + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService.deleteProduct(this.selectedProductId).subscribe({ + next: () => { + this.deletingProduct = false; + this.successMessage = 'Prodotto eliminato.'; + this.startCreateProduct(); + this.loadWorkspace(); + }, + error: (error) => { + this.deletingProduct = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Eliminazione prodotto non riuscita.', + ); + }, + }); + } + + onProductSearchChange(value: string): void { + this.productSearchTerm = value; + this.applyProductFilters(); + } + + onCategoryFilterChange(value: string): void { + this.categoryFilter = value || 'ALL'; + this.applyProductFilters(); + } + + onProductStatusFilterChange(value: string): void { + this.productStatusFilter = (value || 'ALL') as ProductStatusFilter; + this.applyProductFilters(); + } + + startPanelResize(event: PointerEvent): void { + if (window.innerWidth <= 1060) { + return; + } + event.preventDefault(); + this.isResizingPanels = true; + document.body.style.cursor = 'col-resize'; + this.updateListPanelWidthFromPointer(event.clientX); + } + + isSelectedProduct(productId: string): boolean { + return this.selectedProductId === productId; + } + + visibleProductCountForCategory(categoryId: string): number { + return this.products.filter((product) => product.categoryId === categoryId) + .length; + } + + categoryOptionLabel(category: AdminShopCategory): string { + return `${' '.repeat(Math.max(0, category.depth || 0))}${category.name}`; + } + + toggleCategoryManager(): void { + this.showCategoryManager = !this.showCategoryManager; + if (this.showCategoryManager && !this.categoryForm.id) { + this.resetCategoryForm(); + } + } + + editCategory(categoryId: string): void { + this.showCategoryManager = true; + this.errorMessage = null; + this.adminShopService.getCategory(categoryId).subscribe({ + next: (category) => { + this.loadCategoryIntoForm(category); + }, + error: (error) => { + this.errorMessage = this.extractErrorMessage( + error, + 'Impossibile caricare la categoria.', + ); + }, + }); + } + + prepareCreateCategory(): void { + this.resetCategoryForm(); + } + + saveCategory(): void { + if (this.savingCategory) { + return; + } + + const validationError = this.validateCategoryForm(); + if (validationError) { + this.errorMessage = validationError; + this.successMessage = null; + return; + } + + const payload = this.buildCategoryPayload(); + this.savingCategory = true; + this.errorMessage = null; + this.successMessage = null; + + const request = this.categoryForm.id + ? this.adminShopService.updateCategory(this.categoryForm.id, payload) + : this.adminShopService.createCategory(payload); + + request.subscribe({ + next: (category) => { + this.savingCategory = false; + this.successMessage = this.categoryForm.id + ? 'Categoria aggiornata.' + : 'Categoria creata.'; + this.loadCategoryIntoForm(category); + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.savingCategory = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Salvataggio categoria non riuscito.', + ); + }, + }); + } + + deleteCategory(): void { + if (!this.categoryForm.id || this.deletingCategory) { + return; + } + + if ( + !window.confirm( + 'Eliminare questa categoria? Fallira se contiene sottocategorie o prodotti.', + ) + ) { + return; + } + + this.deletingCategory = true; + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService.deleteCategory(this.categoryForm.id).subscribe({ + next: () => { + this.deletingCategory = false; + this.successMessage = 'Categoria eliminata.'; + this.resetCategoryForm(); + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.deletingCategory = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Eliminazione categoria non riuscita.', + ); + }, + }); + } + + slugifyProductFromCurrentLanguage(): void { + const source = + this.productForm.names[this.activeContentLanguage] || + this.productForm.names['it']; + this.productForm.slug = this.slugify(source); + } + + slugifyCategoryFromName(): void { + this.categoryForm.slug = this.slugify(this.categoryForm.name); + } + + setActiveContentLanguage(language: ShopLanguage): void { + this.activeContentLanguage = language; + } + + isContentLanguageComplete(language: ShopLanguage): boolean { + return !!this.productForm.names[language].trim(); + } + + isContentLanguageStarted(language: ShopLanguage): boolean { + return ( + !!this.productForm.names[language].trim() || + !!this.productForm.excerpts[language].trim() || + !!this.productForm.descriptions[language].trim() + ); + } + + isContentLanguageIncomplete(language: ShopLanguage): boolean { + return ( + this.isContentLanguageStarted(language) && + !this.isContentLanguageComplete(language) + ); + } + + isSeoLanguageComplete(language: ShopLanguage): boolean { + return ( + !!this.productForm.seoTitles[language].trim() && + !!this.productForm.seoDescriptions[language].trim() + ); + } + + isSeoLanguageStarted(language: ShopLanguage): boolean { + return ( + !!this.productForm.seoTitles[language].trim() || + !!this.productForm.seoDescriptions[language].trim() + ); + } + + isSeoLanguageIncomplete(language: ShopLanguage): boolean { + return ( + this.isSeoLanguageStarted(language) && + !this.isSeoLanguageComplete(language) + ); + } + + addMaterial(): void { + const nextMaterialCode = this.nextAvailableMaterialCode(); + if (!nextMaterialCode) { + return; + } + const sortOrder = (this.productForm.materials.at(-1)?.sortOrder ?? -1) + 1; + const firstMaterial = this.productForm.materials.length === 0; + this.productForm.materials = [ + ...this.productForm.materials, + this.createEmptyMaterialForm(sortOrder, firstMaterial, nextMaterialCode), + ]; + } + + removeMaterial(index: number): void { + if (this.productForm.materials.length <= 1) { + return; + } + + const nextMaterials = this.productForm.materials.filter( + (_, currentIndex) => currentIndex !== index, + ); + if (!nextMaterials.some((material) => material.isDefault)) { + nextMaterials[0].isDefault = true; + } + this.productForm.materials = nextMaterials; + } + + setDefaultMaterial(index: number): void { + this.productForm.materials = this.productForm.materials.map( + (material, currentIndex) => ({ + ...material, + isDefault: currentIndex === index, + }), + ); + } + + availableMaterialChoices(currentMaterialCode: string): string[] { + const normalizedCurrentMaterialCode = currentMaterialCode + .trim() + .toUpperCase(); + const selectedCodes = new Set( + this.productForm.materials + .map((material) => material.materialCode.trim().toUpperCase()) + .filter(Boolean), + ); + + const availableCodes = this.stockMaterialCodes().filter( + (materialCode) => + materialCode === normalizedCurrentMaterialCode || + !selectedCodes.has(materialCode), + ); + + if ( + normalizedCurrentMaterialCode && + !availableCodes.includes(normalizedCurrentMaterialCode) + ) { + return [normalizedCurrentMaterialCode, ...availableCodes]; + } + + return availableCodes; + } + + materialColorCount(materialCode: string): number { + return this.stockVariantsForMaterial(materialCode).length; + } + + materialColorPreview(materialCode: string): string[] { + return this.stockVariantsForMaterial(materialCode) + .map((variant) => variant.colorName.trim()) + .filter(Boolean) + .slice(0, 6); + } + + onModelFileSelected(event: Event): void { + const input = event.target as HTMLInputElement | null; + const file = input?.files?.[0] ?? null; + if (!file) { + this.modelUploadFile = null; + return; + } + + const extension = this.resolveFileExtension(file.name); + if (!['stl', '3mf'].includes(extension)) { + this.modelUploadFile = null; + this.errorMessage = 'Sono ammessi solo file STL o 3MF.'; + return; + } + if (file.size > MAX_MODEL_FILE_SIZE_BYTES) { + this.modelUploadFile = null; + this.errorMessage = `Il modello supera il limite di ${this.maxModelFileSizeMb} MB.`; + return; + } + + this.modelUploadFile = file; + } + + uploadModel(): void { + if ( + !this.selectedProductId || + !this.modelUploadFile || + this.uploadingModel || + this.productMode !== 'edit' + ) { + return; + } + + this.uploadingModel = true; + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService + .uploadProductModel(this.selectedProductId, this.modelUploadFile) + .subscribe({ + next: (product) => { + this.uploadingModel = false; + this.modelUploadFile = null; + this.successMessage = 'Modello 3D aggiornato.'; + this.updateSelectedProduct(product); + this.loadWorkspace(product.id); + }, + error: (error) => { + this.uploadingModel = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Upload modello 3D non riuscito.', + ); + }, + }); + } + + deleteModel(): void { + if ( + !this.selectedProductId || + this.deletingModel || + !this.selectedProduct?.model3d + ) { + return; + } + + if ( + !window.confirm('Rimuovere il modello 3D associato a questo prodotto?') + ) { + return; + } + + this.deletingModel = true; + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService.deleteProductModel(this.selectedProductId).subscribe({ + next: () => { + this.deletingModel = false; + this.modelUploadFile = null; + this.successMessage = 'Modello 3D rimosso.'; + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.deletingModel = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Rimozione modello 3D non riuscita.', + ); + }, + }); + } + + getProductModelUrl(model: AdminShopProductModel | null): string | null { + if (!model?.url) { + return null; + } + return `${environment.apiUrl}${model.url}`; + } + + onImageFileSelected(event: Event): void { + const input = event.target as HTMLInputElement | null; + const file = input?.files?.[0] ?? null; + const previousPreviewUrl = this.imageUploadState.previewUrl; + this.revokeImagePreviewUrl(previousPreviewUrl); + + if (!file) { + this.imageUploadState = { + ...this.imageUploadState, + file: null, + previewUrl: null, + }; + return; + } + + if (!this.isAllowedImageType(file.type, file.name)) { + this.imageUploadState = { + ...this.imageUploadState, + file: null, + previewUrl: null, + }; + this.errorMessage = + 'Sono ammesse immagini JPG, PNG o WEBP per il catalogo.'; + return; + } + + const nextTranslations = this.cloneTranslations( + this.imageUploadState.translations, + ); + if (this.areAllTitlesBlank(nextTranslations)) { + const defaultTitle = this.deriveDefaultTitle(file.name); + for (const language of this.mediaLanguages) { + nextTranslations[language].title = defaultTitle; + } + } + + this.imageUploadState = { + ...this.imageUploadState, + file, + previewUrl: URL.createObjectURL(file), + translations: nextTranslations, + }; + } + + setActiveImageLanguage(language: AdminMediaLanguage): void { + this.imageUploadState = { + ...this.imageUploadState, + activeLanguage: language, + }; + } + + getActiveImageTranslation(): AdminMediaTranslation { + return this.imageUploadState.translations[ + this.imageUploadState.activeLanguage + ]; + } + + isImageLanguageComplete(language: AdminMediaLanguage): boolean { + return this.isTranslationComplete( + this.imageUploadState.translations[language], + ); + } + + isImageLanguageStarted(language: AdminMediaLanguage): boolean { + const translation = this.imageUploadState.translations[language]; + return !!translation.title.trim() || !!translation.altText.trim(); + } + + isImageLanguageIncomplete(language: AdminMediaLanguage): boolean { + return ( + this.isImageLanguageStarted(language) && + !this.isImageLanguageComplete(language) + ); + } + + uploadProductImage(): void { + if ( + !this.selectedProduct || + !this.selectedProductId || + !this.imageUploadState.file || + this.imageUploadState.saving + ) { + return; + } + + const validationError = this.validateImageTranslations( + this.imageUploadState.translations, + ); + if (validationError) { + this.errorMessage = validationError; + this.successMessage = null; + return; + } + + const normalizedTranslations = this.normalizeTranslations( + this.imageUploadState.translations, + ); + const currentProductId = this.selectedProductId; + const uploadFile = this.imageUploadState.file; + const selectedProduct = this.selectedProduct; + + if (!uploadFile || !selectedProduct) { + return; + } + + this.imageUploadState = { + ...this.imageUploadState, + saving: true, + }; + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService + .uploadMediaAsset(uploadFile, { + title: normalizedTranslations['it'].title, + altText: normalizedTranslations['it'].altText, + visibility: 'PUBLIC', + }) + .pipe( + switchMap((asset) => + this.adminShopService.createMediaUsage({ + usageType: selectedProduct.mediaUsageType, + usageKey: selectedProduct.mediaUsageKey, + mediaAssetId: asset.id, + sortOrder: this.imageUploadState.sortOrder, + isPrimary: this.imageUploadState.isPrimary, + isActive: true, + translations: normalizedTranslations, + }), + ), + ) + .subscribe({ + next: () => { + this.imageUploadState = { + ...this.imageUploadState, + saving: false, + }; + this.successMessage = 'Immagine prodotto caricata.'; + this.loadWorkspace(currentProductId); + }, + error: (error) => { + this.imageUploadState = { + ...this.imageUploadState, + saving: false, + }; + this.errorMessage = this.extractErrorMessage( + error, + 'Upload immagine prodotto non riuscito.', + ); + }, + }); + } + + saveImageSortOrder(item: ProductImageItem): void { + if ( + this.imageActionIds.has(item.usageId) || + item.draftSortOrder === item.sortOrder + ) { + return; + } + + this.imageActionIds.add(item.usageId); + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService + .updateMediaUsage(item.usageId, { sortOrder: item.draftSortOrder }) + .subscribe({ + next: () => { + this.imageActionIds.delete(item.usageId); + this.successMessage = 'Ordine immagini aggiornato.'; + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.imageActionIds.delete(item.usageId); + this.errorMessage = this.extractErrorMessage( + error, + 'Aggiornamento ordine immagini non riuscito.', + ); + }, + }); + } + + setPrimaryImage(item: ProductImageItem): void { + if (item.isPrimary || this.imageActionIds.has(item.usageId)) { + return; + } + + this.imageActionIds.add(item.usageId); + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService + .updateMediaUsage(item.usageId, { isPrimary: true, isActive: true }) + .subscribe({ + next: () => { + this.imageActionIds.delete(item.usageId); + this.successMessage = 'Immagine principale aggiornata.'; + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.imageActionIds.delete(item.usageId); + this.errorMessage = this.extractErrorMessage( + error, + 'Aggiornamento immagine principale non riuscito.', + ); + }, + }); + } + + removeImage(item: ProductImageItem): void { + if (this.imageActionIds.has(item.usageId)) { + return; + } + + if (!window.confirm('Rimuovere questa immagine dal prodotto?')) { + return; + } + + this.imageActionIds.add(item.usageId); + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService + .updateMediaUsage(item.usageId, { isActive: false, isPrimary: false }) + .subscribe({ + next: () => { + this.imageActionIds.delete(item.usageId); + this.successMessage = 'Immagine rimossa dal prodotto.'; + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.imageActionIds.delete(item.usageId); + this.errorMessage = this.extractErrorMessage( + error, + 'Rimozione immagine non riuscita.', + ); + }, + }); + } + + isImageBusy(usageId: string): boolean { + return this.imageActionIds.has(usageId); + } + + trackCategory(_: number, category: AdminShopCategory): string { + return category.id; + } + + trackProduct(_: number, product: AdminShopProduct): string { + return product.id; + } + + trackMaterial(_: number, material: ProductMaterialFormState): string { + return `${material.materialCode || 'material'}-${material.sortOrder}`; + } + + trackImage(_: number, image: ProductImageItem): string { + return image.usageId; + } + + formatFileSize(bytes: number | null | undefined): string { + if (!bytes || bytes <= 0) { + return '-'; + } + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; + } + + productStatusChipClass(product: AdminShopProduct): string { + if (!product.isActive) { + return 'ui-status-chip--danger'; + } + if (product.isFeatured) { + return 'ui-status-chip--success'; + } + return 'ui-status-chip--neutral'; + } + + private applyProductFilters(): void { + const searchNeedle = this.productSearchTerm.trim().toLowerCase(); + this.filteredProducts = this.products.filter((product) => { + const matchesCategory = + this.categoryFilter === 'ALL' || + product.categoryId === this.categoryFilter; + const matchesStatus = + this.productStatusFilter === 'ALL' || + (this.productStatusFilter === 'ACTIVE' && product.isActive) || + (this.productStatusFilter === 'INACTIVE' && !product.isActive) || + (this.productStatusFilter === 'FEATURED' && product.isFeatured); + const matchesSearch = + searchNeedle.length === 0 || + [ + product.name, + product.slug, + product.categoryName, + ...product.variants.map((variant) => variant.colorName), + ...product.variants.map((variant) => variant.internalMaterialCode), + ] + .filter(Boolean) + .some((value) => value.toLowerCase().includes(searchNeedle)); + return matchesCategory && matchesStatus && matchesSearch; + }); + } + + private updateListPanelWidthFromPointer(clientX: number): void { + const workspace = this.workspaceRef?.nativeElement; + if (!workspace) { + return; + } + const bounds = workspace.getBoundingClientRect(); + if (bounds.width <= 0) { + return; + } + + const relativeX = clientX - bounds.left; + const nextPercent = (relativeX / bounds.width) * 100; + this.listPanelWidthPercent = this.clampListPanelWidth(nextPercent); + } + + private restoreListPanelWidth(): void { + const storedValue = window.localStorage.getItem( + SHOP_LIST_PANEL_WIDTH_STORAGE_KEY, + ); + if (!storedValue) { + return; + } + const parsed = Number(storedValue); + if (!Number.isFinite(parsed)) { + return; + } + this.listPanelWidthPercent = this.clampListPanelWidth(parsed); + } + + private persistListPanelWidth(): void { + window.localStorage.setItem( + SHOP_LIST_PANEL_WIDTH_STORAGE_KEY, + String(this.listPanelWidthPercent), + ); + } + + private clampListPanelWidth(value: number): number { + return Math.min( + MAX_LIST_PANEL_WIDTH_PERCENT, + Math.max(MIN_LIST_PANEL_WIDTH_PERCENT, value), + ); + } + + private ensureCategoryFilterStillValid(): void { + if ( + this.categoryFilter !== 'ALL' && + !this.categories.some((category) => category.id === this.categoryFilter) + ) { + this.categoryFilter = 'ALL'; + this.applyProductFilters(); + } + } + + private createEmptyCategoryForm(): CategoryFormState { + return { + id: null, + parentCategoryId: null, + slug: '', + name: '', + description: '', + seoTitle: '', + seoDescription: '', + ogTitle: '', + ogDescription: '', + indexable: true, + isActive: true, + sortOrder: 0, + }; + } + + private resetCategoryForm(): void { + Object.assign(this.categoryForm, this.createEmptyCategoryForm()); + } + + private loadCategoryIntoForm(category: AdminShopCategory): void { + Object.assign(this.categoryForm, { + id: category.id, + parentCategoryId: category.parentCategoryId, + slug: category.slug ?? '', + name: category.name ?? '', + description: category.description ?? '', + seoTitle: category.seoTitle ?? '', + seoDescription: category.seoDescription ?? '', + ogTitle: category.ogTitle ?? '', + ogDescription: category.ogDescription ?? '', + indexable: category.indexable, + isActive: category.isActive, + sortOrder: category.sortOrder ?? 0, + }); + } + + private buildCategoryPayload(): AdminUpsertShopCategoryPayload { + return { + parentCategoryId: this.categoryForm.parentCategoryId || null, + slug: this.categoryForm.slug.trim(), + name: this.categoryForm.name.trim(), + description: this.categoryForm.description.trim(), + seoTitle: this.categoryForm.seoTitle.trim(), + seoDescription: this.categoryForm.seoDescription.trim(), + ogTitle: this.categoryForm.ogTitle.trim(), + ogDescription: this.categoryForm.ogDescription.trim(), + indexable: this.categoryForm.indexable, + isActive: this.categoryForm.isActive, + sortOrder: Number(this.categoryForm.sortOrder) || 0, + }; + } + + private validateCategoryForm(): string | null { + if (!this.categoryForm.name.trim()) { + return 'Il nome categoria è obbligatorio.'; + } + if (!this.categoryForm.slug.trim()) { + return 'Lo slug categoria è obbligatorio.'; + } + return null; + } + + private createEmptyProductForm(): ProductFormState { + const defaultCategoryId = + this.categoryFilter !== 'ALL' + ? this.categoryFilter + : (this.categories[0]?.id ?? ''); + const defaultMaterialCode = this.stockMaterialCodes()[0] ?? ''; + return { + categoryId: defaultCategoryId, + slug: '', + names: { + it: '', + en: '', + de: '', + fr: '', + }, + excerpts: { + it: '', + en: '', + de: '', + fr: '', + }, + descriptions: { + it: '', + en: '', + de: '', + fr: '', + }, + seoTitles: { + it: '', + en: '', + de: '', + fr: '', + }, + seoDescriptions: { + it: '', + en: '', + de: '', + fr: '', + }, + indexable: true, + isFeatured: false, + isActive: true, + sortOrder: 0, + materials: defaultMaterialCode + ? [this.createEmptyMaterialForm(0, true, defaultMaterialCode)] + : [], + }; + } + + private resetProductForm(): void { + Object.assign(this.productForm, this.createEmptyProductForm()); + } + + private createEmptyMaterialForm( + sortOrder: number, + isDefault: boolean, + materialCode = '', + ): ProductMaterialFormState { + return { + materialCode, + priceChf: '0.00', + isDefault, + isActive: true, + sortOrder, + }; + } + + private loadProductIntoForm(product: AdminShopProduct): void { + Object.assign(this.productForm, { + categoryId: product.categoryId ?? '', + slug: product.slug ?? '', + names: { + it: product.nameIt ?? '', + en: product.nameEn ?? '', + de: product.nameDe ?? '', + fr: product.nameFr ?? '', + }, + excerpts: { + it: product.excerptIt ?? '', + en: product.excerptEn ?? '', + de: product.excerptDe ?? '', + fr: product.excerptFr ?? '', + }, + descriptions: { + it: product.descriptionIt ?? '', + en: product.descriptionEn ?? '', + de: product.descriptionDe ?? '', + fr: product.descriptionFr ?? '', + }, + seoTitles: { + it: product.seoTitleIt ?? '', + en: product.seoTitleEn ?? '', + de: product.seoTitleDe ?? '', + fr: product.seoTitleFr ?? '', + }, + seoDescriptions: { + it: product.seoDescriptionIt ?? '', + en: product.seoDescriptionEn ?? '', + de: product.seoDescriptionDe ?? '', + fr: product.seoDescriptionFr ?? '', + }, + indexable: product.indexable, + isFeatured: product.isFeatured, + isActive: product.isActive, + sortOrder: product.sortOrder ?? 0, + materials: this.toMaterialForms(product.variants), + }); + } + + private toMaterialForms( + variants: AdminShopProductVariant[], + ): ProductMaterialFormState[] { + if (!variants.length) { + const defaultMaterialCode = this.stockMaterialCodes()[0] ?? ''; + return defaultMaterialCode + ? [this.createEmptyMaterialForm(0, true, defaultMaterialCode)] + : []; + } + + const groups = new Map(); + for (const variant of variants) { + const materialCode = (variant.internalMaterialCode ?? '') + .trim() + .toUpperCase(); + if (!materialCode) { + continue; + } + const group = groups.get(materialCode) ?? []; + group.push(variant); + groups.set(materialCode, group); + } + + const materials = Array.from(groups.entries()) + .map(([materialCode, materialVariants]) => { + const sortedVariants = [...materialVariants].sort( + (left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0), + ); + const firstVariant = sortedVariants[0]; + return { + materialCode, + priceChf: Number(firstVariant?.priceChf ?? 0).toFixed(2), + isDefault: materialVariants.some((variant) => variant.isDefault), + isActive: materialVariants.some((variant) => variant.isActive), + sortOrder: Math.min( + ...materialVariants.map((variant) => variant.sortOrder ?? 0), + ), + }; + }) + .sort((left, right) => left.sortOrder - right.sortOrder); + + if (!materials.some((material) => material.isDefault) && materials[0]) { + materials[0].isDefault = true; + } + + return materials; + } + + private validateProductForm(): string | null { + if (!this.productForm.categoryId) { + return 'Seleziona una categoria per il prodotto.'; + } + if (!this.productForm.slug.trim()) { + return 'Lo slug prodotto è obbligatorio.'; + } + for (const language of this.shopLanguages) { + if (!this.productForm.names[language].trim()) { + return `Il nome prodotto ${this.languageLabels[language]} è obbligatorio.`; + } + if (this.productForm.seoDescriptions[language].trim().length > 160) { + return `La SEO description ${this.languageLabels[language]} deve stare sotto i 160 caratteri.`; + } + } + if (this.productForm.materials.length === 0) { + return 'Seleziona almeno un materiale disponibile a stock.'; + } + + let defaultCount = 0; + const materialCodes = new Set(); + for (const material of this.productForm.materials) { + const materialCode = material.materialCode.trim().toUpperCase(); + if (!materialCode) { + return 'Ogni riga materiale richiede un materiale selezionato.'; + } + if (materialCodes.has(materialCode)) { + return `Il materiale "${materialCode}" è duplicato.`; + } + materialCodes.add(materialCode); + if (!this.stockMaterialCodes().includes(materialCode)) { + return `Il materiale "${materialCode}" non è disponibile nello stock attivo.`; + } + if (this.stockVariantsForMaterial(materialCode).length === 0) { + return `Il materiale "${materialCode}" non ha colori disponibili a stock.`; + } + + const price = Number(material.priceChf); + if (!Number.isFinite(price) || price < 0) { + return `Il materiale "${materialCode}" ha un prezzo non valido.`; + } + if (material.isDefault) { + defaultCount += 1; + } + } + if (defaultCount !== 1) { + return 'Devi impostare un solo materiale predefinito.'; + } + + return null; + } + + private buildProductPayload(): AdminUpsertShopProductPayload { + const variants = this.buildVariantsFromMaterials(); + + return { + categoryId: this.productForm.categoryId, + slug: this.productForm.slug.trim(), + name: this.productForm.names['it'].trim(), + nameIt: this.productForm.names['it'].trim(), + nameEn: this.productForm.names['en'].trim(), + nameDe: this.productForm.names['de'].trim(), + nameFr: this.productForm.names['fr'].trim(), + excerpt: this.optionalValue(this.productForm.excerpts['it']), + excerptIt: this.optionalValue(this.productForm.excerpts['it']), + excerptEn: this.optionalValue(this.productForm.excerpts['en']), + excerptDe: this.optionalValue(this.productForm.excerpts['de']), + excerptFr: this.optionalValue(this.productForm.excerpts['fr']), + description: this.optionalValue(this.productForm.descriptions['it']), + descriptionIt: this.optionalValue(this.productForm.descriptions['it']), + descriptionEn: this.optionalValue(this.productForm.descriptions['en']), + descriptionDe: this.optionalValue(this.productForm.descriptions['de']), + descriptionFr: this.optionalValue(this.productForm.descriptions['fr']), + seoTitle: this.optionalValue(this.productForm.seoTitles['it']), + seoTitleIt: this.optionalValue(this.productForm.seoTitles['it']), + seoTitleEn: this.optionalValue(this.productForm.seoTitles['en']), + seoTitleDe: this.optionalValue(this.productForm.seoTitles['de']), + seoTitleFr: this.optionalValue(this.productForm.seoTitles['fr']), + seoDescription: this.optionalValue( + this.productForm.seoDescriptions['it'], + ), + seoDescriptionIt: this.optionalValue( + this.productForm.seoDescriptions['it'], + ), + seoDescriptionEn: this.optionalValue( + this.productForm.seoDescriptions['en'], + ), + seoDescriptionDe: this.optionalValue( + this.productForm.seoDescriptions['de'], + ), + seoDescriptionFr: this.optionalValue( + this.productForm.seoDescriptions['fr'], + ), + ogTitle: this.optionalValue(this.productForm.seoTitles['it']), + ogDescription: this.optionalValue(this.productForm.seoDescriptions['it']), + indexable: this.productForm.indexable, + isFeatured: this.productForm.isFeatured, + isActive: this.productForm.isActive, + sortOrder: Number(this.productForm.sortOrder) || 0, + variants, + }; + } + + private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] { + const persistedDefaultVariant = this.selectedProduct?.variants.find( + (variant) => variant.isDefault, + ); + const existingVariantsByKey = new Map( + (this.selectedProduct?.variants ?? []).map((variant) => [ + this.variantKey( + variant.internalMaterialCode, + variant.colorName, + variant.colorHex, + ), + variant, + ]), + ); + const persistedDefaultKey = persistedDefaultVariant + ? this.variantKey( + persistedDefaultVariant.internalMaterialCode, + persistedDefaultVariant.colorName, + persistedDefaultVariant.colorHex, + ) + : null; + + const variants: AdminUpsertShopProductVariantPayload[] = []; + let defaultAssigned = false; + + const sortedMaterials = [...this.productForm.materials].sort( + (left, right) => left.sortOrder - right.sortOrder, + ); + + for (const material of sortedMaterials) { + const materialCode = material.materialCode.trim().toUpperCase(); + const stockVariants = this.stockVariantsForMaterial(materialCode); + let defaultVariantKeyForMaterial: string | null = null; + + if (material.isDefault && persistedDefaultKey) { + defaultVariantKeyForMaterial = + stockVariants + .map((variant) => + this.variantKey( + materialCode, + variant.colorName, + variant.colorHex, + ), + ) + .find((variantKey) => variantKey === persistedDefaultKey) ?? null; + } + + stockVariants.forEach((stockVariant, colorIndex) => { + const variantKey = this.variantKey( + materialCode, + stockVariant.colorName, + stockVariant.colorHex, + ); + const existingVariant = existingVariantsByKey.get(variantKey); + const isDefault = + material.isDefault && + !defaultAssigned && + (defaultVariantKeyForMaterial + ? variantKey === defaultVariantKeyForMaterial + : colorIndex === 0); + + variants.push({ + id: existingVariant?.id, + sku: this.optionalValue(existingVariant?.sku ?? ''), + variantLabel: materialCode, + colorName: stockVariant.colorName.trim(), + colorHex: this.optionalValue( + stockVariant.colorHex ?? '', + )?.toUpperCase(), + internalMaterialCode: materialCode, + priceChf: Number(material.priceChf), + isDefault, + isActive: material.isActive, + sortOrder: material.sortOrder * 100 + colorIndex, + }); + + if (isDefault) { + defaultAssigned = true; + } + }); + } + + if (!defaultAssigned && variants[0]) { + variants[0].isDefault = true; + } + + return variants; + } + + stockMaterialCodes(): string[] { + return Array.from( + new Set( + this.stockFilamentVariants.map((variant) => + variant.materialCode.trim().toUpperCase(), + ), + ), + ).sort((left, right) => left.localeCompare(right)); + } + + private stockVariantsForMaterial( + materialCode: string, + ): AdminFilamentVariant[] { + const targetMaterialCode = materialCode.trim().toUpperCase(); + const seenKeys = new Set(); + + return this.stockFilamentVariants + .filter( + (variant) => + variant.materialCode.trim().toUpperCase() === targetMaterialCode, + ) + .sort((left, right) => { + const leftName = `${left.colorName} ${left.variantDisplayName}`.trim(); + const rightName = + `${right.colorName} ${right.variantDisplayName}`.trim(); + return leftName.localeCompare(rightName); + }) + .filter((variant) => { + const key = this.variantKey( + targetMaterialCode, + variant.colorName, + variant.colorHex, + ); + if (seenKeys.has(key)) { + return false; + } + seenKeys.add(key); + return true; + }); + } + + private nextAvailableMaterialCode(): string | null { + const selectedCodes = new Set( + this.productForm.materials + .map((material) => material.materialCode.trim().toUpperCase()) + .filter(Boolean), + ); + + return ( + this.stockMaterialCodes().find( + (materialCode) => !selectedCodes.has(materialCode), + ) ?? null + ); + } + + private filterStockedFilamentVariants( + filamentVariants: AdminFilamentVariant[], + ): AdminFilamentVariant[] { + return filamentVariants.filter( + (variant) => + variant.isActive && + Number(variant.stockFilamentGrams ?? 0) > 0 && + !!variant.materialCode?.trim() && + !!variant.colorName?.trim(), + ); + } + + private variantKey( + materialCode: string | null | undefined, + colorName: string | null | undefined, + colorHex: string | null | undefined, + ): string { + return [ + (materialCode ?? '').trim().toUpperCase(), + (colorName ?? '').trim().toLowerCase(), + (colorHex ?? '').trim().toUpperCase(), + ].join('|'); + } + + private updateSelectedProduct(product: AdminShopProduct): void { + this.selectedProduct = product; + this.selectedProductId = product.id; + this.productImages = this.buildProductImages(product); + this.loadProductIntoForm(product); + this.resetImageUploadState(product); + } + + private buildProductImages(product: AdminShopProduct): ProductImageItem[] { + const publicByAssetId = new Map(); + for (const image of product.images) { + publicByAssetId.set(image.mediaAssetId, image); + } + + return product.mediaUsages + .filter((usage) => usage.isActive) + .map((usage) => { + const publicUsage = publicByAssetId.get(usage.mediaAssetId); + const translations = this.normalizeTranslations(usage.translations); + return { + usageId: usage.id, + mediaAssetId: usage.mediaAssetId, + previewUrl: this.resolveProductImageUrl(publicUsage), + sortOrder: usage.sortOrder ?? 0, + draftSortOrder: usage.sortOrder ?? 0, + isPrimary: usage.isPrimary, + createdAt: usage.createdAt, + translations, + title: + publicUsage?.title ?? + translations[this.imageUploadState.activeLanguage].title, + altText: + publicUsage?.altText ?? + translations[this.imageUploadState.activeLanguage].altText, + }; + }) + .sort((left, right) => { + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder; + } + return left.createdAt.localeCompare(right.createdAt); + }); + } + + private resolveProductImageUrl( + image: AdminPublicMediaUsage | undefined, + ): string | null { + if (!image) { + return null; + } + return image.card?.url ?? image.hero?.url ?? image.thumb?.url ?? null; + } + + private createEmptyImageUploadState(): ProductImageUploadState { + return { + file: null, + previewUrl: null, + activeLanguage: 'it', + translations: this.createEmptyTranslations(), + sortOrder: 0, + isPrimary: false, + saving: false, + }; + } + + private resetImageUploadState(product: AdminShopProduct | null): void { + this.revokeImagePreviewUrl(this.imageUploadState.previewUrl); + const nextSortOrder = (this.productImages.at(-1)?.sortOrder ?? -1) + 1; + this.imageUploadState = { + file: null, + previewUrl: null, + activeLanguage: 'it', + translations: this.createEmptyTranslations(), + sortOrder: Math.max(0, nextSortOrder), + isPrimary: (product?.images.length ?? 0) === 0, + saving: false, + }; + } + + private revokeImagePreviewUrl(previewUrl: string | null): void { + if (previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + } + + private createEmptyTranslations(): Record< + AdminMediaLanguage, + AdminMediaTranslation + > { + return { + it: { title: '', altText: '' }, + en: { title: '', altText: '' }, + de: { title: '', altText: '' }, + fr: { title: '', altText: '' }, + }; + } + + private cloneTranslations( + translations: Record, + ): Record { + return this.normalizeTranslations(translations); + } + + private normalizeTranslations( + translations: Partial< + Record> + >, + ): Record { + return { + it: { + title: translations['it']?.title?.trim() ?? '', + altText: translations['it']?.altText?.trim() ?? '', + }, + en: { + title: translations['en']?.title?.trim() ?? '', + altText: translations['en']?.altText?.trim() ?? '', + }, + de: { + title: translations['de']?.title?.trim() ?? '', + altText: translations['de']?.altText?.trim() ?? '', + }, + fr: { + title: translations['fr']?.title?.trim() ?? '', + altText: translations['fr']?.altText?.trim() ?? '', + }, + }; + } + + private isTranslationComplete(translation: AdminMediaTranslation): boolean { + return !!translation.title.trim() && !!translation.altText.trim(); + } + + private validateImageTranslations( + translations: Record, + ): string | null { + for (const language of this.mediaLanguages) { + if (!this.isTranslationComplete(translations[language])) { + return `Titolo e alt text immagine ${this.languageLabels[language]} sono obbligatori.`; + } + } + return null; + } + + private areAllTitlesBlank( + translations: Record, + ): boolean { + return this.mediaLanguages.every( + (language) => !translations[language].title.trim(), + ); + } + + private deriveDefaultTitle(filename: string): string { + return filename + .replace(/\.[^.]+$/, '') + .replace(/[-_]+/g, ' ') + .trim(); + } + + private optionalValue(value: string): string | undefined { + const normalized = value.trim(); + return normalized ? normalized : undefined; + } + + seoDescriptionLength(language: ShopLanguage): number { + return this.productForm.seoDescriptions[language].trim().length; + } + + private slugify(source: string): string { + return source + .normalize('NFD') + .replace(/\p{Diacritic}+/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + + private resolveFileExtension(filename: string): string { + const lastDotIndex = filename.lastIndexOf('.'); + return lastDotIndex >= 0 + ? filename.slice(lastDotIndex + 1).toLowerCase() + : ''; + } + + private isAllowedImageType(mimeType: string, filename: string): boolean { + if (['image/jpeg', 'image/png', 'image/webp'].includes(mimeType)) { + return true; + } + const extension = this.resolveFileExtension(filename); + return ['jpg', 'jpeg', 'png', 'webp'].includes(extension); + } + + private extractErrorMessage(error: unknown, fallback: string): string { + const candidate = error as { + error?: { message?: string }; + message?: string; + }; + return candidate?.error?.message || candidate?.message || fallback; + } +} diff --git a/frontend/src/app/features/admin/services/admin-media.service.ts b/frontend/src/app/features/admin/services/admin-media.service.ts new file mode 100644 index 0000000..5b99eb2 --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-media.service.ts @@ -0,0 +1,145 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; + +export type AdminMediaLanguage = 'it' | 'en' | 'de' | 'fr'; + +export interface AdminMediaTranslation { + title: string; + altText: string; +} + +export interface AdminMediaVariant { + id: string; + variantName: string; + format: string; + storageKey: string; + mimeType: string; + widthPx: number; + heightPx: number; + fileSizeBytes: number; + isGenerated: boolean; + publicUrl: string | null; + createdAt: string; +} + +export interface AdminMediaUsage { + id: string; + usageType: string; + usageKey: string; + ownerId: string | null; + mediaAssetId: string; + sortOrder: number; + isPrimary: boolean; + isActive: boolean; + translations: Record; + createdAt: string; +} + +export interface AdminMediaAsset { + id: string; + originalFilename: string; + storageKey: string; + mimeType: string; + fileSizeBytes: number; + sha256Hex: string; + widthPx: number | null; + heightPx: number | null; + status: string; + visibility: string; + title: string | null; + altText: string | null; + createdAt: string; + updatedAt: string; + variants: AdminMediaVariant[]; + usages: AdminMediaUsage[]; +} + +export interface AdminMediaUploadPayload { + title?: string; + altText?: string; + visibility?: 'PUBLIC' | 'PRIVATE'; +} + +export interface AdminCreateMediaUsagePayload { + usageType: string; + usageKey: string; + ownerId?: string | null; + mediaAssetId: string; + sortOrder?: number; + isPrimary?: boolean; + isActive?: boolean; + translations: Record; +} + +export interface AdminUpdateMediaUsagePayload { + usageType?: string; + usageKey?: string; + ownerId?: string | null; + mediaAssetId?: string; + sortOrder?: number; + isPrimary?: boolean; + isActive?: boolean; + translations?: Record; +} + +@Injectable({ + providedIn: 'root', +}) +export class AdminMediaService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/api/admin/media`; + + listAssets(): Observable { + return this.http.get(`${this.baseUrl}/assets`, { + withCredentials: true, + }); + } + + uploadAsset( + file: File, + payload: AdminMediaUploadPayload, + ): Observable { + const formData = new FormData(); + formData.append('file', file); + if (payload.title?.trim()) { + formData.append('title', payload.title.trim()); + } + if (payload.altText?.trim()) { + formData.append('altText', payload.altText.trim()); + } + if (payload.visibility?.trim()) { + formData.append('visibility', payload.visibility.trim()); + } + + return this.http.post(`${this.baseUrl}/assets`, formData, { + withCredentials: true, + }); + } + + createUsage( + payload: AdminCreateMediaUsagePayload, + ): Observable { + return this.http.post(`${this.baseUrl}/usages`, payload, { + withCredentials: true, + }); + } + + updateUsage( + usageId: string, + payload: AdminUpdateMediaUsagePayload, + ): Observable { + return this.http.patch( + `${this.baseUrl}/usages/${usageId}`, + payload, + { withCredentials: true }, + ); + } + + deleteUsage(usageId: string): Observable { + return this.http.delete(`${this.baseUrl}/usages/${usageId}`, { + withCredentials: true, + }); + } +} diff --git a/frontend/src/app/features/admin/services/admin-orders.service.ts b/frontend/src/app/features/admin/services/admin-orders.service.ts index 0ac32c4..fb0622a 100644 --- a/frontend/src/app/features/admin/services/admin-orders.service.ts +++ b/frontend/src/app/features/admin/services/admin-orders.service.ts @@ -5,10 +5,19 @@ import { environment } from '../../../../environments/environment'; export interface AdminOrderItem { id: string; + itemType: string; originalFilename: string; + displayName?: string; materialCode: string; colorCode: string; filamentVariantId?: number; + shopProductId?: string; + shopProductVariantId?: string; + shopProductSlug?: string; + shopProductName?: string; + shopVariantLabel?: string; + shopVariantColorName?: string; + shopVariantColorHex?: string; filamentVariantDisplayName?: string; filamentColorName?: string; filamentColorHex?: string; diff --git a/frontend/src/app/features/admin/services/admin-shop.service.ts b/frontend/src/app/features/admin/services/admin-shop.service.ts new file mode 100644 index 0000000..bb14e24 --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-shop.service.ts @@ -0,0 +1,363 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { + AdminCreateMediaUsagePayload, + AdminMediaLanguage, + AdminMediaService, + AdminMediaTranslation, + AdminMediaUsage, + AdminMediaUploadPayload, + AdminMediaAsset, + AdminUpdateMediaUsagePayload, +} from './admin-media.service'; + +export interface AdminMediaTextTranslation { + title: string; + altText: string; +} + +export interface AdminShopCategoryRef { + id: string; + slug: string; + name: string; +} + +export interface AdminShopCategory { + id: string; + parentCategoryId: string | null; + parentCategoryName: string | null; + slug: string; + name: string; + description: string | null; + seoTitle: string | null; + seoDescription: string | null; + ogTitle: string | null; + ogDescription: string | null; + indexable: boolean; + isActive: boolean; + sortOrder: number; + depth: number; + childCount: number; + directProductCount: number; + descendantProductCount: number; + mediaUsageType: string; + mediaUsageKey: string; + breadcrumbs: AdminShopCategoryRef[]; + children: AdminShopCategory[]; + createdAt: string; + updatedAt: string; +} + +export interface AdminUpsertShopCategoryPayload { + parentCategoryId?: string | null; + slug: string; + name: string; + description?: string; + seoTitle?: string; + seoDescription?: string; + ogTitle?: string; + ogDescription?: string; + indexable: boolean; + isActive: boolean; + sortOrder: number; +} + +export interface AdminShopProductVariant { + id: string; + sku: string | null; + variantLabel: string; + colorName: string; + colorHex: string | null; + internalMaterialCode: string; + priceChf: number; + isDefault: boolean; + isActive: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +export interface AdminShopProductModel { + url: string; + originalFilename: string; + mimeType: string; + fileSizeBytes: number; + boundingBoxXMm: number | null; + boundingBoxYMm: number | null; + boundingBoxZMm: number | null; +} + +export interface AdminPublicMediaVariant { + url: string; + widthPx: number | null; + heightPx: number | null; + mimeType: string | null; +} + +export interface AdminPublicMediaUsage { + mediaAssetId: string; + title: string | null; + altText: string | null; + usageType: string; + usageKey: string; + sortOrder: number; + isPrimary: boolean; + thumb: AdminPublicMediaVariant | null; + card: AdminPublicMediaVariant | null; + hero: AdminPublicMediaVariant | null; +} + +export interface AdminShopProduct { + id: string; + categoryId: string; + categoryName: string; + categorySlug: string; + slug: string; + name: string; + nameIt: string; + nameEn: string; + nameDe: string; + nameFr: string; + excerpt: string | null; + excerptIt: string | null; + excerptEn: string | null; + excerptDe: string | null; + excerptFr: string | null; + description: string | null; + descriptionIt: string | null; + descriptionEn: string | null; + descriptionDe: string | null; + descriptionFr: string | null; + seoTitle: string | null; + seoTitleIt: string | null; + seoTitleEn: string | null; + seoTitleDe: string | null; + seoTitleFr: string | null; + seoDescription: string | null; + seoDescriptionIt: string | null; + seoDescriptionEn: string | null; + seoDescriptionDe: string | null; + seoDescriptionFr: string | null; + ogTitle: string | null; + ogDescription: string | null; + indexable: boolean; + isFeatured: boolean; + isActive: boolean; + sortOrder: number; + variantCount: number; + activeVariantCount: number; + priceFromChf: number; + priceToChf: number; + mediaUsageType: string; + mediaUsageKey: string; + mediaUsages: AdminShopMediaUsage[]; + images: AdminPublicMediaUsage[]; + model3d: AdminShopProductModel | null; + variants: AdminShopProductVariant[]; + createdAt: string; + updatedAt: string; +} + +export interface AdminShopMediaUsage + extends Omit { + translations: Record; +} + +export interface AdminUpsertShopProductVariantPayload { + id?: string; + sku?: string; + variantLabel?: string; + colorName: string; + colorHex?: string; + internalMaterialCode: string; + priceChf: number; + isDefault: boolean; + isActive: boolean; + sortOrder: number; +} + +export interface AdminUpsertShopProductPayload { + categoryId: string; + slug: string; + name: string; + nameIt: string; + nameEn: string; + nameDe: string; + nameFr: string; + excerpt?: string; + excerptIt?: string; + excerptEn?: string; + excerptDe?: string; + excerptFr?: string; + description?: string; + descriptionIt?: string; + descriptionEn?: string; + descriptionDe?: string; + descriptionFr?: string; + seoTitle?: string; + seoTitleIt?: string; + seoTitleEn?: string; + seoTitleDe?: string; + seoTitleFr?: string; + seoDescription?: string; + seoDescriptionIt?: string; + seoDescriptionEn?: string; + seoDescriptionDe?: string; + seoDescriptionFr?: string; + ogTitle?: string; + ogDescription?: string; + indexable: boolean; + isFeatured: boolean; + isActive: boolean; + sortOrder: number; + variants: AdminUpsertShopProductVariantPayload[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class AdminShopService { + private readonly http = inject(HttpClient); + private readonly adminMediaService = inject(AdminMediaService); + private readonly productsBaseUrl = `${environment.apiUrl}/api/admin/shop/products`; + private readonly categoriesBaseUrl = `${environment.apiUrl}/api/admin/shop/categories`; + + getCategories(): Observable { + return this.http.get(this.categoriesBaseUrl, { + withCredentials: true, + }); + } + + getCategoryTree(): Observable { + return this.http.get( + `${this.categoriesBaseUrl}/tree`, + { + withCredentials: true, + }, + ); + } + + getCategory(categoryId: string): Observable { + return this.http.get( + `${this.categoriesBaseUrl}/${categoryId}`, + { withCredentials: true }, + ); + } + + createCategory( + payload: AdminUpsertShopCategoryPayload, + ): Observable { + return this.http.post(this.categoriesBaseUrl, payload, { + withCredentials: true, + }); + } + + updateCategory( + categoryId: string, + payload: AdminUpsertShopCategoryPayload, + ): Observable { + return this.http.put( + `${this.categoriesBaseUrl}/${categoryId}`, + payload, + { withCredentials: true }, + ); + } + + deleteCategory(categoryId: string): Observable { + return this.http.delete(`${this.categoriesBaseUrl}/${categoryId}`, { + withCredentials: true, + }); + } + + getProducts(): Observable { + return this.http.get(this.productsBaseUrl, { + withCredentials: true, + }); + } + + getProduct(productId: string): Observable { + return this.http.get( + `${this.productsBaseUrl}/${productId}`, + { + withCredentials: true, + }, + ); + } + + createProduct( + payload: AdminUpsertShopProductPayload, + ): Observable { + return this.http.post(this.productsBaseUrl, payload, { + withCredentials: true, + }); + } + + updateProduct( + productId: string, + payload: AdminUpsertShopProductPayload, + ): Observable { + return this.http.put( + `${this.productsBaseUrl}/${productId}`, + payload, + { withCredentials: true }, + ); + } + + deleteProduct(productId: string): Observable { + return this.http.delete(`${this.productsBaseUrl}/${productId}`, { + withCredentials: true, + }); + } + + uploadProductModel( + productId: string, + file: File, + ): Observable { + const formData = new FormData(); + formData.append('file', file); + return this.http.post( + `${this.productsBaseUrl}/${productId}/model`, + formData, + { withCredentials: true }, + ); + } + + deleteProductModel(productId: string): Observable { + return this.http.delete( + `${this.productsBaseUrl}/${productId}/model`, + { + withCredentials: true, + }, + ); + } + + listMediaAssets(): Observable { + return this.adminMediaService.listAssets(); + } + + uploadMediaAsset( + file: File, + payload: AdminMediaUploadPayload, + ): Observable { + return this.adminMediaService.uploadAsset(file, payload); + } + + createMediaUsage( + payload: AdminCreateMediaUsagePayload, + ): Observable { + return this.adminMediaService.createUsage(payload); + } + + updateMediaUsage( + usageId: string, + payload: AdminUpdateMediaUsagePayload, + ): Observable { + return this.adminMediaService.updateUsage(usageId, payload); + } + + deleteMediaUsage(usageId: string): Observable { + return this.adminMediaService.deleteUsage(usageId); + } +} diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 14c4970..9f3ca0f 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -1,6 +1,6 @@ -
-

{{ "CALC.TITLE" | translate }}

-

{{ "CALC.SUBTITLE" | translate }}

+
+

{{ "CALC.TITLE" | translate }}

+

{{ "CALC.SUBTITLE" | translate }}

@if (error()) { {{ errorKey() | translate }} @@ -8,7 +8,7 @@
@if (step() === "success") { -
+
-
-

{{ "CHECKOUT.TITLE" | translate }}

-

+

+

{{ "CHECKOUT.TITLE" | translate }}

+

Servizio CAD riferito alla richiesta contatto #{{ cadRequestId() }} @@ -10,7 +10,7 @@

-
+
@@ -21,10 +21,12 @@
-
-

{{ "CHECKOUT.CONTACT_INFO" | translate }}

+
+

+ {{ "CHECKOUT.CONTACT_INFO" | translate }} +

-
+
-
-

{{ "CHECKOUT.BILLING_ADDR" | translate }}

+
+

+ {{ "CHECKOUT.BILLING_ADDR" | translate }} +

@@ -61,7 +65,7 @@ -
+
-
+
-
+
-
-