From f9414b2db7421bff6c27674733172d33fcc458b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 11 Mar 2026 15:17:19 +0100 Subject: [PATCH] feat(front-end): sitemap update --- .../controller/SitemapController.java | 37 +++ .../service/shop/ShopSitemapService.java | 242 ++++++++++++++++++ .../src/main/resources/application.properties | 1 + .../service/shop/ShopSitemapServiceTest.java | 116 +++++++++ frontend/public/sitemap-static.xml | 154 +++++++++++ frontend/public/sitemap.xml | 161 +----------- 6 files changed, 558 insertions(+), 153 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/controller/SitemapController.java create mode 100644 backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java create mode 100644 backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java create mode 100644 frontend/public/sitemap-static.xml diff --git a/backend/src/main/java/com/printcalculator/controller/SitemapController.java b/backend/src/main/java/com/printcalculator/controller/SitemapController.java new file mode 100644 index 0000000..27a7d8e --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/SitemapController.java @@ -0,0 +1,37 @@ +package com.printcalculator.controller; + +import com.printcalculator.service.shop.ShopSitemapService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; + +@RestController +public class SitemapController { + private final ShopSitemapService shopSitemapService; + private final long cacheSeconds; + + public SitemapController( + ShopSitemapService shopSitemapService, + @Value("${app.sitemap.shop.cache-seconds:3600}") long cacheSeconds + ) { + this.shopSitemapService = shopSitemapService; + this.cacheSeconds = Math.max(cacheSeconds, 0L); + } + + @GetMapping(value = "/api/sitemap-shop.xml", produces = MediaType.APPLICATION_XML_VALUE) + public ResponseEntity getShopSitemap() { + CacheControl cacheControl = cacheSeconds > 0 + ? CacheControl.maxAge(Duration.ofSeconds(cacheSeconds)).cachePublic() + : CacheControl.noCache(); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/xml;charset=UTF-8")) + .cacheControl(cacheControl) + .body(shopSitemapService.getShopSitemapXml()); + } +} diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java b/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java new file mode 100644 index 0000000..fa4d50b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java @@ -0,0 +1,242 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.repository.ShopCategoryRepository; +import com.printcalculator.repository.ShopProductRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class ShopSitemapService { + private static final List SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES; + private static final String DEFAULT_LANGUAGE = "it"; + private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + private final ShopCategoryRepository shopCategoryRepository; + private final ShopProductRepository shopProductRepository; + private final String frontendBaseUrl; + private final Duration cacheTtl; + private final Clock clock; + + private volatile CachedSitemap cachedSitemap; + + public ShopSitemapService(ShopCategoryRepository shopCategoryRepository, + ShopProductRepository shopProductRepository, + @Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl, + @Value("${app.sitemap.shop.cache-seconds:900}") long cacheSeconds) { + this(shopCategoryRepository, shopProductRepository, frontendBaseUrl, cacheSeconds, Clock.systemUTC()); + } + + ShopSitemapService(ShopCategoryRepository shopCategoryRepository, + ShopProductRepository shopProductRepository, + String frontendBaseUrl, + long cacheSeconds, + Clock clock) { + this.shopCategoryRepository = shopCategoryRepository; + this.shopProductRepository = shopProductRepository; + this.frontendBaseUrl = normalizeBaseUrl(frontendBaseUrl); + this.cacheTtl = cacheSeconds > 0 ? Duration.ofSeconds(cacheSeconds) : Duration.ZERO; + this.clock = clock; + } + + public String getShopSitemapXml() { + Instant now = Instant.now(clock); + CachedSitemap current = cachedSitemap; + if (current != null && now.isBefore(current.expiresAt())) { + return current.xml(); + } + + synchronized (this) { + current = cachedSitemap; + now = Instant.now(clock); + if (current != null && now.isBefore(current.expiresAt())) { + return current.xml(); + } + + String xml = buildSitemapXml(); + Instant expiresAt = cacheTtl.isZero() ? now : now.plus(cacheTtl); + cachedSitemap = new CachedSitemap(xml, expiresAt); + return xml; + } + } + + private String buildSitemapXml() { + List activeCategories = shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc(); + Set activeCategoryIds = activeCategories.stream() + .map(ShopCategory::getId) + .collect(Collectors.toSet()); + + List activeProducts = shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc(); + + StringBuilder xml = new StringBuilder(16_384); + xml.append("\n"); + xml.append("\n"); + + appendCategoryUrls(xml, activeCategories); + appendProductUrls(xml, activeProducts, activeCategoryIds); + + xml.append("\n"); + return xml.toString(); + } + + private void appendCategoryUrls(StringBuilder xml, List categories) { + for (ShopCategory category : categories) { + if (!Boolean.TRUE.equals(category.getIndexable())) { + continue; + } + + String encodedSlug = pathEncodeSegment(category.getSlug()); + Map hrefByLanguage = new LinkedHashMap<>(); + for (String language : SUPPORTED_LANGUAGES) { + hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/" + encodedSlug); + } + + appendUrlEntry(xml, hrefByLanguage, category.getUpdatedAt()); + } + } + + private void appendProductUrls(StringBuilder xml, + List products, + Set activeCategoryIds) { + for (ShopProduct product : products) { + if (!Boolean.TRUE.equals(product.getIndexable())) { + continue; + } + if (product.getCategory() == null || !activeCategoryIds.contains(product.getCategory().getId())) { + continue; + } + + Map hrefByLanguage = new LinkedHashMap<>(); + for (String language : SUPPORTED_LANGUAGES) { + String publicSegment = localizedProductPathSegment(product, language); + hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment)); + } + + appendUrlEntry(xml, hrefByLanguage, product.getUpdatedAt()); + } + } + + private void appendUrlEntry(StringBuilder xml, + Map hrefByLanguage, + OffsetDateTime lastmod) { + String defaultHref = hrefByLanguage.get(DEFAULT_LANGUAGE); + if (defaultHref == null || defaultHref.isBlank()) { + return; + } + + xml.append(" \n"); + xml.append(" ").append(xmlEscape(defaultHref)).append("\n"); + + for (String language : SUPPORTED_LANGUAGES) { + String href = hrefByLanguage.get(language); + if (href == null || href.isBlank()) { + continue; + } + xml.append(" \n"); + } + + xml.append(" \n"); + + if (lastmod != null) { + xml.append(" ").append(LASTMOD_FORMATTER.format(lastmod)).append("\n"); + } + + xml.append(" \n"); + } + + private String localizedProductPathSegment(ShopProduct product, String language) { + String localizedName = product.getNameForLanguage(language); + String idPrefix = productIdPrefix(product.getId()); + String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product"); + return idPrefix.isBlank() ? tail : idPrefix + "-" + tail; + } + + private String productIdPrefix(UUID productId) { + if (productId == null) { + return ""; + } + String raw = productId.toString().trim().toLowerCase(Locale.ROOT); + int dashIndex = raw.indexOf('-'); + if (dashIndex > 0) { + return raw.substring(0, dashIndex); + } + return raw.length() >= 8 ? raw.substring(0, 8) : raw; + } + + static String slugify(String rawValue) { + String safeValue = rawValue == null ? "" : rawValue; + String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD) + .replaceAll("\\p{M}+", "") + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", "") + .replaceAll("-{2,}", "-"); + 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; + } + + private String pathEncodeSegment(String rawSegment) { + String safeSegment = rawSegment == null ? "" : rawSegment; + return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private String xmlEscape(String value) { + return String.valueOf(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + private String normalizeBaseUrl(String baseUrl) { + String normalized = (baseUrl == null ? "" : baseUrl).trim(); + if (normalized.isBlank()) { + return "http://localhost:4200"; + } + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private record CachedSitemap(String xml, Instant expiresAt) { + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 9985c08..8fbd17c 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -56,6 +56,7 @@ app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED: app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch} app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true} app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} +app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600} # Admin back-office authentication admin.password=${ADMIN_PASSWORD} diff --git a/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java new file mode 100644 index 0000000..d2eace0 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java @@ -0,0 +1,116 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.repository.ShopCategoryRepository; +import com.printcalculator.repository.ShopProductRepository; +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 java.time.Clock; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ShopSitemapServiceTest { + + @Mock + private ShopCategoryRepository shopCategoryRepository; + @Mock + private ShopProductRepository shopProductRepository; + + private ShopSitemapService service; + + @BeforeEach + void setUp() { + Clock fixedClock = Clock.fixed(Instant.parse("2026-03-11T10:00:00Z"), ZoneOffset.UTC); + service = new ShopSitemapService( + shopCategoryRepository, + shopProductRepository, + "https://3d-fab.ch/", + 900, + fixedClock + ); + } + + @Test + void getShopSitemapXml_shouldGenerateLocalizedCategoryAndProductEntries() { + ShopCategory visibleCategory = new ShopCategory(); + visibleCategory.setId(UUID.fromString("21111111-1111-1111-1111-111111111111")); + visibleCategory.setSlug("accessori"); + visibleCategory.setIndexable(true); + visibleCategory.setIsActive(true); + visibleCategory.setUpdatedAt(OffsetDateTime.parse("2026-03-10T08:00:00Z")); + + ShopCategory hiddenCategory = new ShopCategory(); + hiddenCategory.setId(UUID.fromString("22222222-2222-2222-2222-222222222222")); + hiddenCategory.setSlug("bozza"); + hiddenCategory.setIndexable(false); + hiddenCategory.setIsActive(true); + hiddenCategory.setUpdatedAt(OffsetDateTime.parse("2026-03-10T09:00:00Z")); + + ShopProduct indexedProduct = new ShopProduct(); + indexedProduct.setId(UUID.fromString("123e4567-e89b-12d3-a456-426614174000")); + indexedProduct.setCategory(visibleCategory); + indexedProduct.setSlug("supporto-bici"); + indexedProduct.setNameIt("Supporto bici"); + indexedProduct.setNameEn("Bike Holder"); + indexedProduct.setNameDe("Fahrrad Halter"); + indexedProduct.setNameFr("Support velo"); + indexedProduct.setIndexable(true); + indexedProduct.setIsActive(true); + indexedProduct.setUpdatedAt(OffsetDateTime.parse("2026-03-11T07:30:00Z")); + + ShopProduct hiddenProduct = new ShopProduct(); + hiddenProduct.setId(UUID.fromString("33333333-3333-3333-3333-333333333333")); + hiddenProduct.setCategory(visibleCategory); + hiddenProduct.setSlug("draft"); + hiddenProduct.setIndexable(false); + hiddenProduct.setIsActive(true); + hiddenProduct.setUpdatedAt(OffsetDateTime.parse("2026-03-11T08:00:00Z")); + + when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()) + .thenReturn(List.of(visibleCategory, hiddenCategory)); + when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()) + .thenReturn(List.of(indexedProduct, hiddenProduct)); + + String xml = service.getShopSitemapXml(); + + assertTrue(xml.contains("https://3d-fab.ch/it/shop/accessori")); + assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/accessori\"")); + assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza")); + + assertTrue(xml.contains("https://3d-fab.ch/it/shop/p/123e4567-supporto-bici")); + assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\"")); + assertTrue(xml.contains("hreflang=\"de\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\"")); + assertTrue(xml.contains("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\"")); + assertTrue(xml.contains("2026-03-11T07:30:00Z")); + assertFalse(xml.contains("33333333-draft")); + } + + @Test + void getShopSitemapXml_shouldServeCachedPayloadWithinTtl() { + when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of()); + when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of()); + + String firstXml = service.getShopSitemapXml(); + String secondXml = service.getShopSitemapXml(); + + assertTrue(firstXml.contains(" + + + https://3d-fab.ch/it + + + + + + weekly + 1.0 + + + https://3d-fab.ch/it/calculator/basic + + + + + + weekly + 0.9 + + + https://3d-fab.ch/it/calculator/advanced + + + + + + weekly + 0.8 + + + https://3d-fab.ch/it/shop + + + + + + weekly + 0.8 + + + https://3d-fab.ch/it/about + + + + + + monthly + 0.7 + + + https://3d-fab.ch/it/contact + + + + + + monthly + 0.7 + + + https://3d-fab.ch/it/privacy + + + + + + yearly + 0.4 + + + https://3d-fab.ch/it/terms + + + + + + yearly + 0.4 + + diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml index c81cb56..3a45048 100644 --- a/frontend/public/sitemap.xml +++ b/frontend/public/sitemap.xml @@ -1,154 +1,9 @@ - - - https://3d-fab.ch/it - - - - - - weekly - 1.0 - - - https://3d-fab.ch/it/calculator/basic - - - - - - weekly - 0.9 - - - https://3d-fab.ch/it/calculator/advanced - - - - - - weekly - 0.8 - - - https://3d-fab.ch/it/shop - - - - - - weekly - 0.8 - - - https://3d-fab.ch/it/about - - - - - - monthly - 0.7 - - - https://3d-fab.ch/it/contact - - - - - - monthly - 0.7 - - - https://3d-fab.ch/it/privacy - - - - - - yearly - 0.4 - - - https://3d-fab.ch/it/terms - - - - - - yearly - 0.4 - - + + + https://3d-fab.ch/sitemap-static.xml + + + https://3d-fab.ch/api/sitemap-shop.xml + +