diff --git a/backend/src/main/java/com/printcalculator/controller/PublicShopController.java b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java index 1043025..cccb415 100644 --- a/backend/src/main/java/com/printcalculator/controller/PublicShopController.java +++ b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java @@ -62,6 +62,12 @@ public class PublicShopController { return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang)); } + @GetMapping("/products/by-id-prefix/{idPrefix}") + public ResponseEntity getProductByIdPrefix(@PathVariable String idPrefix, + @RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicShopCatalogService.getProductByIdPrefix(idPrefix, lang)); + } + @GetMapping("/products/{slug}/model") public ResponseEntity getProductModel(@PathVariable String slug) throws IOException { PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug); diff --git a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java index 1ecd99e..81c715d 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -163,6 +163,28 @@ public class PublicShopCatalogService { ); } + public ShopProductDetailDto getProductByIdPrefix(String idPrefix, String language) { + String normalizedLanguage = normalizeLanguage(language); + String normalizedIdPrefix = normalizeProductIdPrefix(idPrefix); + if (normalizedIdPrefix == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); + } + + CategoryContext categoryContext = loadCategoryContext(normalizedLanguage); + PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage); + ProductEntry entry = requirePublicProductEntry( + productContext.entriesByIdPrefix().get(normalizedIdPrefix), + categoryContext + ); + + return toProductDetailDto( + entry, + productContext.productMediaBySlug(), + productContext.variantColorHexByMaterialAndColor(), + normalizedLanguage + ); + } + public ProductModelDownload getProductModelDownload(String slug) { CategoryContext categoryContext = loadCategoryContext(null); PublicProductContext productContext = loadPublicProductContext(categoryContext, null); @@ -231,11 +253,19 @@ public class PublicShopCatalogService { (left, right) -> left, LinkedHashMap::new )); + Map entriesByIdPrefix = entries.stream() + .collect(Collectors.toMap( + entry -> normalizeProductIdPrefix(ShopPublicPathSupport.productIdPrefix(entry.product().getId())), + entry -> entry, + (left, right) -> left, + LinkedHashMap::new + )); return new PublicProductContext( entries, entriesBySlug, entriesByPublicPath, + entriesByIdPrefix, productMediaBySlug, variantColorHexByMaterialAndColor ); @@ -566,6 +596,15 @@ public class PublicShopCatalogService { return normalized.toLowerCase(Locale.ROOT); } + private String normalizeProductIdPrefix(String idPrefix) { + String normalized = trimToNull(idPrefix); + if (normalized == null) { + return null; + } + normalized = normalized.toLowerCase(Locale.ROOT); + return normalized.matches("^[0-9a-f]{8}$") ? normalized : null; + } + private String trimToNull(String value) { String raw = String.valueOf(value == null ? "" : value).trim(); if (raw.isEmpty()) { @@ -662,6 +701,7 @@ public class PublicShopCatalogService { List entries, Map entriesBySlug, Map entriesByPublicPath, + Map entriesByIdPrefix, Map> productMediaBySlug, Map variantColorHexByMaterialAndColor ) { diff --git a/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java index 9c182d1..b35e58a 100644 --- a/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java @@ -108,6 +108,21 @@ class PublicShopCatalogServiceTest { assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en")); } + @Test + void getProductByIdPrefix_shouldResolveLocalizedProduct() { + ShopCategory category = buildCategory(); + ShopProduct product = buildProduct(category); + ShopProductVariant variant = buildVariant(product); + + stubPublicCatalog(category, product, variant); + + ShopProductDetailDto response = service.getProductByIdPrefix("12345678", "de"); + + assertEquals("bike-wall-hanger", response.slug()); + assertEquals("12345678-bike-wall-hanger", response.publicPath()); + assertEquals("/de/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("de")); + } + @Test void getProductByPublicPath_shouldRejectNonCanonicalSegment() { ShopCategory category = buildCategory(); diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts index f165c08..0fcdfcc 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts @@ -92,10 +92,10 @@ describe('serverOriginInterceptor', () => { it('uses the internal SSR API origin for public shop discovery calls', () => { testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/'; - http.get('/api/shop/products/by-path/example?lang=de').subscribe(); + http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe(); const request = httpMock.expectOne( - 'http://backend:8000/api/shop/products/by-path/example?lang=de', + 'http://backend:8000/api/shop/products/by-id-prefix/91823f84?lang=de', ); expect(request.request.headers.get('authorization')).toBe( 'Basic dGVzdDp0ZXN0', @@ -133,10 +133,10 @@ describe('serverOriginInterceptor', () => { http = TestBed.inject(HttpClient); httpMock = TestBed.inject(HttpTestingController); - http.get('/api/shop/products/by-path/example?lang=de').subscribe(); + http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe(); const request = httpMock.expectOne( - 'http://backend:8000/api/shop/products/by-path/example?lang=de', + 'http://backend:8000/api/shop/products/by-id-prefix/91823f84?lang=de', ); expect(request.request.headers.get('authorization')).toBeNull(); expect(request.request.headers.get('cookie')).toBe('session=abc123'); @@ -184,10 +184,10 @@ describe('serverOriginInterceptor', () => { http = TestBed.inject(HttpClient); httpMock = TestBed.inject(HttpTestingController); - http.get('/api/shop/products/by-path/example?lang=de').subscribe(); + http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe(); const request = httpMock.expectOne( - 'https://dev.3d-fab.ch/api/shop/products/by-path/example?lang=de', + 'https://dev.3d-fab.ch/api/shop/products/by-id-prefix/91823f84?lang=de', ); expect(request.request.headers.get('authorization')).toBeNull(); expect(request.request.headers.get('cookie')).toBe('session=abc123'); diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.ts index 59b7ff7..656c291 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.ts @@ -19,6 +19,7 @@ const FORWARDED_REQUEST_HEADERS = [ const SHOP_DISCOVERY_API_PATTERNS = [ /^\/api\/shop\/categories(?:\/[^/?#]+)?$/i, /^\/api\/shop\/products$/i, + /^\/api\/shop\/products\/by-id-prefix\/[^/?#]+$/i, /^\/api\/shop\/products\/by-path\/[^/?#]+$/i, /^\/api\/shop\/products\/[^/?#]+$/i, ] as const; diff --git a/frontend/src/app/features/shop/product-detail.component.spec.ts b/frontend/src/app/features/shop/product-detail.component.spec.ts index 08c0cf9..dc09961 100644 --- a/frontend/src/app/features/shop/product-detail.component.spec.ts +++ b/frontend/src/app/features/shop/product-detail.component.spec.ts @@ -71,7 +71,13 @@ describe('ProductDetailComponent', () => { }; } - function createComponent(routerUrl = '/de/shop/p/91823f84-bike-wall-hanger') { + function createComponent( + routerUrl = '/de/shop/p/91823f84-bike-wall-hanger', + options?: { + currentLang?: 'it' | 'en' | 'de' | 'fr'; + selectedLang?: 'it' | 'en' | 'de' | 'fr'; + }, + ) { const responseInit: { status?: number } = {}; const seoService = jasmine.createSpyObj('SeoService', [ 'applyResolvedSeo', @@ -93,10 +99,12 @@ describe('ProductDetailComponent', () => { return translations[key] ?? key; }); - const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de'); + const currentLang = signal<'it' | 'en' | 'de' | 'fr'>( + options?.currentLang ?? 'de', + ); const languageService = { currentLang, - selectedLang: () => currentLang(), + selectedLang: () => options?.selectedLang ?? currentLang(), setLocalizedRouteOverrides: jasmine.createSpy( 'setLocalizedRouteOverrides', ), @@ -193,6 +201,21 @@ describe('ProductDetailComponent', () => { ); }); + it('uses the route language for canonical SEO even if the selected translation language lags', () => { + const { component, seoService } = createComponent(undefined, { + currentLang: 'de', + selectedLang: 'en', + }); + + (component as any).applySeo(buildProduct()); + + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger', + }), + ); + }); + it('applies noindex for products explicitly marked as non-indexable', () => { const { component, seoService } = createComponent(); diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 3573be6..b3003bd 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -605,7 +605,7 @@ export class ProductDetailComponent { this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); const robots = product.indexable === false ? 'noindex, nofollow' : 'index, follow'; - const lang = this.languageService.selectedLang(); + const lang = this.languageService.currentLang(); const canonicalPath = product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null; @@ -857,7 +857,7 @@ export class ProductDetailComponent { } const currentTree = this.router.parseUrl(this.router.url); - const lang = this.languageService.selectedLang(); + const lang = this.languageService.currentLang(); const targetPath = product.localizedPaths?.[lang] ?? `/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`; diff --git a/frontend/src/app/features/shop/services/shop.service.spec.ts b/frontend/src/app/features/shop/services/shop.service.spec.ts index c85a0a5..1c4ef78 100644 --- a/frontend/src/app/features/shop/services/shop.service.spec.ts +++ b/frontend/src/app/features/shop/services/shop.service.spec.ts @@ -1,3 +1,4 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, @@ -13,6 +14,11 @@ import { LanguageService } from '../../../core/services/language.service'; describe('ShopService', () => { let service: ShopService; let httpMock: HttpTestingController; + const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it'); + const languageService = { + currentLang, + selectedLang: jasmine.createSpy('selectedLang').and.returnValue('it'), + }; const buildCart = (): ShopCartResponse => ({ session: { @@ -131,13 +137,14 @@ describe('ShopService', () => { ShopService, { provide: LanguageService, - useValue: { - selectedLang: () => 'it', - }, + useValue: languageService, }, ], }); + currentLang.set('it'); + languageService.selectedLang.and.returnValue('it'); + service = TestBed.inject(ShopService); httpMock = TestBed.inject(HttpTestingController); }); @@ -196,7 +203,7 @@ describe('ShopService', () => { return ( request.method === 'GET' && request.url === - 'http://localhost:8000/api/shop/products/by-path/12345678-supporto-cavo-scrivania' && + 'http://localhost:8000/api/shop/products/by-id-prefix/12345678' && request.params.get('lang') === 'it' ); }); @@ -206,47 +213,74 @@ describe('ShopService', () => { expect(response?.name).toBe('Supporto cavo scrivania'); }); - it('rejects product paths whose slug tail does not match the canonical path', () => { - let errorResponse: { status?: number } | undefined; + it('resolves products from the stable uuid prefix even if the slug tail is stale', () => { + let response: ShopProductDetail | undefined; service.getProductByPublicPath('12345678-qualunque-nome').subscribe({ - next: () => fail('Expected canonical path mismatch to return 404'), - error: (error) => { - errorResponse = error; + next: (product) => { + response = product; }, + error: () => fail('Expected stale slug tails to resolve from the uuid prefix'), }); const request = httpMock.expectOne((request) => { return ( request.method === 'GET' && request.url === - 'http://localhost:8000/api/shop/products/by-path/12345678-qualunque-nome' && + 'http://localhost:8000/api/shop/products/by-id-prefix/12345678' && request.params.get('lang') === 'it' ); }); - request.flush('Not found', { status: 404, statusText: 'Not Found' }); - expect(errorResponse?.status).toBe(404); + request.flush(buildProduct()); + + expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab'); }); - it('rejects bare uuid product paths without the localized slug tail', () => { - let errorResponse: { status?: number } | undefined; + it('resolves bare uuid product paths through the stable uuid prefix endpoint', () => { + let response: ShopProductDetail | undefined; service.getProductByPublicPath('12345678').subscribe({ - next: () => fail('Expected bare uuid path to return 404'), - error: (error) => { - errorResponse = error; + next: (product) => { + response = product; }, + error: () => fail('Expected bare uuid path to resolve from the uuid prefix'), }); const request = httpMock.expectOne((request) => { return ( request.method === 'GET' && request.url === - 'http://localhost:8000/api/shop/products/by-path/12345678' && + 'http://localhost:8000/api/shop/products/by-id-prefix/12345678' && request.params.get('lang') === 'it' ); }); - request.flush('Not found', { status: 404, statusText: 'Not Found' }); - expect(errorResponse?.status).toBe(404); + request.flush(buildProduct()); + + expect(response?.publicPath).toBe('12345678-supporto-cavo-scrivania'); + }); + + it('uses the route language for public shop lookups when translate.currentLang lags behind', () => { + let response: ShopProductDetail | undefined; + + currentLang.set('de'); + languageService.selectedLang.and.returnValue('en'); + + service + .getProductByPublicPath('12345678-schreibtisch-kabelhalter') + .subscribe((product) => { + response = product; + }); + + const request = httpMock.expectOne((request) => { + return ( + request.method === 'GET' && + request.url === + 'http://localhost:8000/api/shop/products/by-id-prefix/12345678' && + request.params.get('lang') === 'de' + ); + }); + request.flush(buildProduct()); + + expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab'); }); }); diff --git a/frontend/src/app/features/shop/services/shop.service.ts b/frontend/src/app/features/shop/services/shop.service.ts index 7b5d215..f21949a 100644 --- a/frontend/src/app/features/shop/services/shop.service.ts +++ b/frontend/src/app/features/shop/services/shop.service.ts @@ -290,8 +290,13 @@ export class ShopService { })); } + const productIdPrefix = this.extractProductIdPrefix(normalizedPath); + const endpoint = productIdPrefix + ? `${this.apiUrl}/products/by-id-prefix/${encodeURIComponent(productIdPrefix)}` + : `${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`; + return this.http.get( - `${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`, + endpoint, { params: this.buildLangParams(), }, @@ -304,6 +309,11 @@ export class ShopService { .toLowerCase(); } + private extractProductIdPrefix(value: string): string | null { + const match = value.match(/^([0-9a-f]{8})(?:-|$)/); + return match?.[1] ?? null; + } + loadCart(): Observable { this.cartLoading.set(true); return this.http @@ -455,7 +465,10 @@ export class ShopService { } private buildLangParams(): HttpParams { - return new HttpParams().set('lang', this.languageService.selectedLang()); + // Public shop URLs are localized. During direct loads the translation + // service can still momentarily reflect the browser language, while the + // route language has already been resolved from the URL. + return new HttpParams().set('lang', this.languageService.currentLang()); } private setCart(cart: ShopCartResponse): void {