diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 5be1b24..bfb9caf 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -217,9 +217,12 @@ jobs: ADMIN_TTL="${ADMIN_TTL:-480}" printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \ "${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env + if [[ -n "${{ secrets.OPENAI_API_KEY }}" ]]; then + printf 'OPENAI_API_KEY="%s"\n' "${{ secrets.OPENAI_API_KEY }}" >> /tmp/full_env.env + fi echo "Preparing to send env file with variables:" - grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true + grep -Ev "PASSWORD|SECRET|KEY|TOKEN" /tmp/full_env.env || true ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ "setenv ${{ env.ENV }}" < /tmp/full_env.env diff --git a/README.md b/README.md index 06988c5..933dbdc 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,6 @@ Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e A ### Database connection Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente. + +### Deploy e traduzioni OpenAI +Nel deploy Gitea la chiave OpenAI deve stare nel secret `OPENAI_API_KEY`. La pipeline la aggiunge al file `.env` dell'ambiente durante il deploy e il container backend la riceve come variabile runtime. I file `deploy/envs/*.env` restano per i valori specifici di `dev/int/prod`. diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index cde3605..e4c1442 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -130,6 +130,7 @@ public class QuoteSessionController { } @GetMapping("/{id}") + @Transactional(readOnly = true) public ResponseEntity> getQuoteSession(@PathVariable UUID id) { QuoteSession session = sessionRepo.findById(id) .orElseThrow(() -> new RuntimeException("Session not found")); diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java index dc31270..af7e5c5 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java @@ -1,8 +1,11 @@ package com.printcalculator.controller.admin; import com.printcalculator.dto.AdminShopProductDto; +import com.printcalculator.dto.AdminTranslateShopProductRequest; +import com.printcalculator.dto.AdminTranslateShopProductResponse; import com.printcalculator.dto.AdminUpsertShopProductRequest; import com.printcalculator.service.admin.AdminShopProductControllerService; +import com.printcalculator.service.admin.AdminShopProductTranslationService; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.http.HttpHeaders; @@ -29,9 +32,12 @@ import java.util.UUID; @Transactional(readOnly = true) public class AdminShopProductController { private final AdminShopProductControllerService adminShopProductControllerService; + private final AdminShopProductTranslationService adminShopProductTranslationService; - public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService) { + public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService, + AdminShopProductTranslationService adminShopProductTranslationService) { this.adminShopProductControllerService = adminShopProductControllerService; + this.adminShopProductTranslationService = adminShopProductTranslationService; } @GetMapping @@ -50,6 +56,11 @@ public class AdminShopProductController { return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload)); } + @PostMapping("/translate") + public ResponseEntity translateProduct(@RequestBody AdminTranslateShopProductRequest payload) { + return ResponseEntity.ok(adminShopProductTranslationService.translateProduct(payload)); + } + @PutMapping("/{productId}") @Transactional public ResponseEntity updateProduct(@PathVariable UUID productId, diff --git a/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductRequest.java new file mode 100644 index 0000000..25b09dc --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductRequest.java @@ -0,0 +1,89 @@ +package com.printcalculator.dto; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public class AdminTranslateShopProductRequest { + private UUID categoryId; + private String sourceLanguage; + private Boolean overwriteExisting; + private List materialCodes; + private Map names; + private Map excerpts; + private Map descriptions; + private Map seoTitles; + private Map seoDescriptions; + + public UUID getCategoryId() { + return categoryId; + } + + public void setCategoryId(UUID categoryId) { + this.categoryId = categoryId; + } + + public String getSourceLanguage() { + return sourceLanguage; + } + + public void setSourceLanguage(String sourceLanguage) { + this.sourceLanguage = sourceLanguage; + } + + public Boolean getOverwriteExisting() { + return overwriteExisting; + } + + public void setOverwriteExisting(Boolean overwriteExisting) { + this.overwriteExisting = overwriteExisting; + } + + public List getMaterialCodes() { + return materialCodes; + } + + public void setMaterialCodes(List materialCodes) { + this.materialCodes = materialCodes; + } + + public Map getNames() { + return names; + } + + public void setNames(Map names) { + this.names = names; + } + + public Map getExcerpts() { + return excerpts; + } + + public void setExcerpts(Map excerpts) { + this.excerpts = excerpts; + } + + public Map getDescriptions() { + return descriptions; + } + + public void setDescriptions(Map descriptions) { + this.descriptions = descriptions; + } + + public Map getSeoTitles() { + return seoTitles; + } + + public void setSeoTitles(Map seoTitles) { + this.seoTitles = seoTitles; + } + + public Map getSeoDescriptions() { + return seoDescriptions; + } + + public void setSeoDescriptions(Map seoDescriptions) { + this.seoDescriptions = seoDescriptions; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductResponse.java b/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductResponse.java new file mode 100644 index 0000000..1b4be40 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductResponse.java @@ -0,0 +1,70 @@ +package com.printcalculator.dto; + +import java.util.List; +import java.util.Map; + +public class AdminTranslateShopProductResponse { + private String sourceLanguage; + private List targetLanguages; + private Map names; + private Map excerpts; + private Map descriptions; + private Map seoTitles; + private Map seoDescriptions; + + public String getSourceLanguage() { + return sourceLanguage; + } + + public void setSourceLanguage(String sourceLanguage) { + this.sourceLanguage = sourceLanguage; + } + + public List getTargetLanguages() { + return targetLanguages; + } + + public void setTargetLanguages(List targetLanguages) { + this.targetLanguages = targetLanguages; + } + + public Map getNames() { + return names; + } + + public void setNames(Map names) { + this.names = names; + } + + public Map getExcerpts() { + return excerpts; + } + + public void setExcerpts(Map excerpts) { + this.excerpts = excerpts; + } + + public Map getDescriptions() { + return descriptions; + } + + public void setDescriptions(Map descriptions) { + this.descriptions = descriptions; + } + + public Map getSeoTitles() { + return seoTitles; + } + + public void setSeoTitles(Map seoTitles) { + this.seoTitles = seoTitles; + } + + public Map getSeoDescriptions() { + return seoDescriptions; + } + + public void setSeoDescriptions(Map seoDescriptions) { + this.seoDescriptions = seoDescriptions; + } +} diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java index 5b51980..658a6e9 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java @@ -1,6 +1,7 @@ package com.printcalculator.repository; import com.printcalculator.entity.QuoteLineItem; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -8,9 +9,16 @@ import java.util.Optional; import java.util.UUID; public interface QuoteLineItemRepository extends JpaRepository { + @EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"}) List findByQuoteSessionId(UUID quoteSessionId); + + @EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"}) List findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId); + + @EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"}) Optional findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId); + + @EntityGraph(attributePaths = {"shopProductVariant"}) Optional findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id( UUID quoteSessionId, String lineItemType, diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductTranslationService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductTranslationService.java new file mode 100644 index 0000000..84fa20a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductTranslationService.java @@ -0,0 +1,685 @@ +package com.printcalculator.service.admin; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.printcalculator.dto.AdminTranslateShopProductRequest; +import com.printcalculator.dto.AdminTranslateShopProductResponse; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.repository.ShopCategoryRepository; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.safety.Safelist; +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.web.server.ResponseStatusException; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +@Transactional(readOnly = true) +public class AdminShopProductTranslationService { + private static final List SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr"); + private static final Safelist PRODUCT_DESCRIPTION_SAFELIST = Safelist.none() + .addTags("p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a") + .addAttributes("a", "href") + .addProtocols("a", "href", "http", "https", "mailto", "tel"); + private static final String DEFAULT_SHOP_CONTEXT = """ + 3D fab is a Swiss-based 3D printing shop and technical service. + The tone must be practical, clear, technical, and trustworthy. + Avoid hype, avoid invented claims, and avoid vague marketing filler. + Preserve all brand names, measurements, materials, SKUs, codes, and technical terminology exactly when they should not be translated. + When the source field is empty, return an empty string rather than inventing content. + For descriptions, preserve safe HTML structure when present and keep output ready for an ecommerce/admin form. + For SEO, prefer concise, natural phrases suitable for ecommerce and search snippets. + """; + + private final ShopCategoryRepository shopCategoryRepository; + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private final String apiKey; + private final String baseUrl; + private final String model; + private final Duration timeout; + private final String promptCacheKeyPrefix; + private final String additionalBusinessContext; + + public AdminShopProductTranslationService(ShopCategoryRepository shopCategoryRepository, + ObjectMapper objectMapper, + @Value("${openai.translation.api-key:}") String apiKey, + @Value("${openai.translation.base-url:https://api.openai.com/v1}") String baseUrl, + @Value("${openai.translation.model:gpt-5.4}") String model, + @Value("${openai.translation.timeout-seconds:45}") long timeoutSeconds, + @Value("${openai.translation.prompt-cache-key-prefix:printcalc-shop-product-translation-v1}") String promptCacheKeyPrefix, + @Value("${openai.translation.business-context:}") String additionalBusinessContext) { + this.shopCategoryRepository = shopCategoryRepository; + this.objectMapper = objectMapper; + this.apiKey = apiKey != null ? apiKey.trim() : ""; + this.baseUrl = normalizeBaseUrl(baseUrl); + this.model = model != null ? model.trim() : ""; + this.timeout = Duration.ofSeconds(Math.max(timeoutSeconds, 5)); + this.promptCacheKeyPrefix = promptCacheKeyPrefix != null && !promptCacheKeyPrefix.isBlank() + ? promptCacheKeyPrefix.trim() + : "printcalc-shop-product-translation-v1"; + this.additionalBusinessContext = additionalBusinessContext != null ? additionalBusinessContext.trim() : ""; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(this.timeout) + .build(); + } + + public AdminTranslateShopProductResponse translateProduct(AdminTranslateShopProductRequest payload) { + ensureConfigured(); + NormalizedTranslationRequest normalizedRequest = normalizeRequest(payload); + List targetLanguages = resolveTargetLanguages(normalizedRequest); + if (targetLanguages.isEmpty()) { + return emptyResponse(normalizedRequest.sourceLanguage()); + } + + CategoryContext categoryContext = loadCategoryContext(normalizedRequest.categoryId()); + String businessContext = buildBusinessContext(categoryContext, normalizedRequest.materialCodes()); + + TranslationBundle generated = callOpenAiFunction( + "generate_product_translations", + "Generate translated product copy for the requested target languages.", + buildInstructions("Generate the first-pass translations.", businessContext), + buildGenerationInput(normalizedRequest, targetLanguages, categoryContext), + buildTranslationToolSchema(targetLanguages), + "generate" + ); + + TranslationBundle normalizedGenerated = sanitizeBundle(generated, targetLanguages); + List validationNotes = buildValidationNotes(normalizedGenerated, targetLanguages); + + TranslationBundle reviewed = callOpenAiFunction( + "review_product_translations", + "Review and correct translated product copy while preserving meaning, SEO limits, and technical terminology.", + buildInstructions("Review and correct the generated translations.", businessContext), + buildReviewInput(normalizedRequest, normalizedGenerated, targetLanguages, categoryContext, validationNotes), + buildTranslationToolSchema(targetLanguages), + "review" + ); + + TranslationBundle finalBundle = sanitizeBundle(reviewed, targetLanguages); + ensureRequiredTranslations(finalBundle, targetLanguages); + return toResponse(normalizedRequest.sourceLanguage(), targetLanguages, finalBundle); + } + + private void ensureConfigured() { + if (apiKey.isBlank() || model.isBlank()) { + throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, + "OpenAI translation is not configured on the backend" + ); + } + } + + private NormalizedTranslationRequest normalizeRequest(AdminTranslateShopProductRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Translation payload is required"); + } + + String sourceLanguage = normalizeLanguage(payload.getSourceLanguage()); + if (!SUPPORTED_LANGUAGES.contains(sourceLanguage)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported source language"); + } + + Map names = normalizeLocalizedMap(payload.getNames(), false); + Map excerpts = normalizeLocalizedMap(payload.getExcerpts(), false); + Map descriptions = normalizeLocalizedMap(payload.getDescriptions(), true); + Map seoTitles = normalizeLocalizedMap(payload.getSeoTitles(), false); + Map seoDescriptions = normalizeLocalizedMap(payload.getSeoDescriptions(), false); + + if (names.get(sourceLanguage).isBlank()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "The active source language must have a product name before translation" + ); + } + + Set materialCodes = new LinkedHashSet<>(); + if (payload.getMaterialCodes() != null) { + for (String materialCode : payload.getMaterialCodes()) { + String normalizedCode = normalizeOptional(materialCode); + if (normalizedCode != null) { + materialCodes.add(normalizedCode.toUpperCase(Locale.ROOT)); + } + } + } + + return new NormalizedTranslationRequest( + payload.getCategoryId(), + sourceLanguage, + Boolean.TRUE.equals(payload.getOverwriteExisting()), + List.copyOf(materialCodes), + names, + excerpts, + descriptions, + seoTitles, + seoDescriptions + ); + } + + private List resolveTargetLanguages(NormalizedTranslationRequest request) { + List targetLanguages = new ArrayList<>(); + for (String language : SUPPORTED_LANGUAGES) { + if (language.equals(request.sourceLanguage())) { + continue; + } + if (request.overwriteExisting() || needsTranslation(request, language)) { + targetLanguages.add(language); + } + } + return targetLanguages; + } + + private boolean needsTranslation(NormalizedTranslationRequest request, String language) { + return request.names().get(language).isBlank() + || request.excerpts().get(language).isBlank() + || normalizeRichTextOptional(request.descriptions().get(language)) == null + || request.seoTitles().get(language).isBlank() + || request.seoDescriptions().get(language).isBlank(); + } + + private CategoryContext loadCategoryContext(UUID categoryId) { + if (categoryId == null) { + return null; + } + ShopCategory category = shopCategoryRepository.findById(categoryId).orElse(null); + if (category == null) { + return null; + } + return new CategoryContext( + category.getSlug(), + Map.of( + "it", safeValue(category.getNameIt()), + "en", safeValue(category.getNameEn()), + "de", safeValue(category.getNameDe()), + "fr", safeValue(category.getNameFr()) + ), + Map.of( + "it", safeValue(category.getDescriptionIt()), + "en", safeValue(category.getDescriptionEn()), + "de", safeValue(category.getDescriptionDe()), + "fr", safeValue(category.getDescriptionFr()) + ) + ); + } + + private String buildBusinessContext(CategoryContext categoryContext, List materialCodes) { + StringBuilder context = new StringBuilder(DEFAULT_SHOP_CONTEXT); + if (!additionalBusinessContext.isBlank()) { + context.append('\n').append(additionalBusinessContext.trim()); + } + if (categoryContext != null) { + context.append("\nCategory slug: ").append(categoryContext.slug()); + context.append("\nCategory names: ").append(writeJson(categoryContext.names())); + if (categoryContext.descriptions().values().stream().anyMatch(value -> !value.isBlank())) { + context.append("\nCategory descriptions: ").append(writeJson(categoryContext.descriptions())); + } + } + if (materialCodes != null && !materialCodes.isEmpty()) { + context.append("\nMaterial codes present in the product: ").append(String.join(", ", materialCodes)); + } + return context.toString(); + } + + private String buildInstructions(String task, String businessContext) { + return """ + You are a senior ecommerce localization editor. + Task: %s + Return only the function call arguments that match the provided schema. + Always preserve meaning, HTML safety, and technical precision. + Never invent specifications or marketing claims not present in the source. + If a source field is empty, return an empty string for that field. + General context: + %s + """.formatted(task, businessContext); + } + + private String buildGenerationInput(NormalizedTranslationRequest request, + List targetLanguages, + CategoryContext categoryContext) { + ObjectNode input = objectMapper.createObjectNode(); + input.put("sourceLanguage", request.sourceLanguage()); + input.set("targetLanguages", objectMapper.valueToTree(targetLanguages)); + input.put("overwriteExisting", request.overwriteExisting()); + input.set("source", localizedFieldNode(request, request.sourceLanguage())); + input.set("existingTranslations", existingTranslationsNode(request, targetLanguages)); + input.set("materialCodes", objectMapper.valueToTree(request.materialCodes())); + if (categoryContext != null) { + input.put("categorySlug", categoryContext.slug()); + input.set("categoryNames", objectMapper.valueToTree(categoryContext.names())); + } + return writeJson(input); + } + + private String buildReviewInput(NormalizedTranslationRequest request, + TranslationBundle generated, + List targetLanguages, + CategoryContext categoryContext, + List validationNotes) { + ObjectNode input = objectMapper.createObjectNode(); + input.put("sourceLanguage", request.sourceLanguage()); + input.set("targetLanguages", objectMapper.valueToTree(targetLanguages)); + input.set("source", localizedFieldNode(request, request.sourceLanguage())); + input.set("generatedTranslations", generated.toJsonNode(objectMapper)); + input.set("validationNotes", objectMapper.valueToTree(validationNotes)); + input.set("materialCodes", objectMapper.valueToTree(request.materialCodes())); + if (categoryContext != null) { + input.put("categorySlug", categoryContext.slug()); + input.set("categoryNames", objectMapper.valueToTree(categoryContext.names())); + } + return writeJson(input); + } + + private ObjectNode localizedFieldNode(NormalizedTranslationRequest request, String language) { + ObjectNode node = objectMapper.createObjectNode(); + node.put("name", request.names().get(language)); + node.put("excerpt", request.excerpts().get(language)); + node.put("description", request.descriptions().get(language)); + node.put("seoTitle", request.seoTitles().get(language)); + node.put("seoDescription", request.seoDescriptions().get(language)); + return node; + } + + private ObjectNode existingTranslationsNode(NormalizedTranslationRequest request, List targetLanguages) { + ObjectNode node = objectMapper.createObjectNode(); + for (String language : targetLanguages) { + node.set(language, localizedFieldNode(request, language)); + } + return node; + } + + private ObjectNode buildTranslationToolSchema(List targetLanguages) { + ObjectNode root = objectMapper.createObjectNode(); + root.put("type", "object"); + root.put("additionalProperties", false); + + ObjectNode properties = root.putObject("properties"); + ObjectNode translations = properties.putObject("translations"); + translations.put("type", "object"); + translations.put("additionalProperties", false); + + ObjectNode translationProperties = translations.putObject("properties"); + ArrayNode requiredTranslations = translations.putArray("required"); + for (String language : targetLanguages) { + translationProperties.set(language, buildTranslationSchemaForLanguage(language)); + requiredTranslations.add(language); + } + + ArrayNode required = root.putArray("required"); + required.add("translations"); + return root; + } + + private ObjectNode buildTranslationSchemaForLanguage(String language) { + ObjectNode languageSchema = objectMapper.createObjectNode(); + languageSchema.put("type", "object"); + languageSchema.put("additionalProperties", false); + languageSchema.put("description", "Localized product copy for language " + language); + + ObjectNode properties = languageSchema.putObject("properties"); + addSchemaString(properties, "name", "Translated product name. Never empty."); + addSchemaString(properties, "excerpt", "Short excerpt. Empty string if source excerpt is empty."); + addSchemaString(properties, "description", "Product description as safe HTML or empty string if source description is empty."); + addSchemaString(properties, "seoTitle", "SEO title. Empty string if source SEO title is empty."); + addSchemaString(properties, "seoDescription", "SEO description, ideally under 160 characters. Empty string if source SEO description is empty."); + + ArrayNode required = languageSchema.putArray("required"); + required.add("name"); + required.add("excerpt"); + required.add("description"); + required.add("seoTitle"); + required.add("seoDescription"); + return languageSchema; + } + + private void addSchemaString(ObjectNode properties, String name, String description) { + ObjectNode property = properties.putObject(name); + property.put("type", "string"); + property.put("description", description); + } + + private TranslationBundle callOpenAiFunction(String functionName, + String functionDescription, + String instructions, + String input, + ObjectNode parametersSchema, + String cacheSuffix) { + ObjectNode requestPayload = objectMapper.createObjectNode(); + requestPayload.put("model", model); + requestPayload.put("instructions", instructions); + requestPayload.put("input", input); + requestPayload.put("tool_choice", "required"); + requestPayload.put("temperature", 0.2); + requestPayload.put("store", false); + requestPayload.put("prompt_cache_key", promptCacheKeyPrefix + ":" + cacheSuffix); + + ArrayNode tools = requestPayload.putArray("tools"); + ObjectNode tool = tools.addObject(); + tool.put("type", "function"); + tool.put("name", functionName); + tool.put("description", functionDescription); + tool.put("strict", true); + tool.set("parameters", parametersSchema); + + JsonNode responseNode = postResponsesRequest(requestPayload); + JsonNode output = responseNode.path("output"); + if (output.isArray()) { + for (JsonNode item : output) { + if ("function_call".equals(item.path("type").asText())) { + String arguments = item.path("arguments").asText(""); + if (arguments.isBlank()) { + break; + } + try { + JsonNode argumentsNode = objectMapper.readTree(arguments); + JsonNode translationsNode = argumentsNode.path("translations"); + if (!translationsNode.isObject()) { + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, + "OpenAI returned a function call without translations" + ); + } + return TranslationBundle.fromJson(translationsNode); + } catch (JsonProcessingException exception) { + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, + "OpenAI returned invalid JSON arguments", + exception + ); + } + } + } + } + + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, + "OpenAI did not return the expected function call" + ); + } + + private JsonNode postResponsesRequest(ObjectNode requestPayload) { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/responses")) + .timeout(timeout) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(writeJson(requestPayload))) + .build(); + + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode body = readJson(response.body()); + if (response.statusCode() >= 400) { + String message = body.path("error").path("message").asText("").trim(); + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, + message.isBlank() ? "OpenAI translation request failed" : message + ); + } + return body; + } catch (IOException exception) { + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, + "Unable to read the OpenAI translation response", + exception + ); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, + "The OpenAI translation request was interrupted", + exception + ); + } + } + + private List buildValidationNotes(TranslationBundle bundle, List targetLanguages) { + List notes = new ArrayList<>(); + for (String language : targetLanguages) { + if (bundle.names().getOrDefault(language, "").isBlank()) { + notes.add(language + ": translated name is empty and must be fixed"); + } + String seoDescription = bundle.seoDescriptions().getOrDefault(language, ""); + if (seoDescription.length() > 160) { + notes.add(language + ": seoDescription exceeds 160 characters and must be shortened"); + } + String description = bundle.descriptions().getOrDefault(language, ""); + if (!description.isBlank() && normalizeRichTextOptional(description) == null) { + notes.add(language + ": description lost meaningful text during sanitization"); + } + } + if (notes.isEmpty()) { + notes.add("No structural validation issues were found. Review naturalness, terminology, SEO clarity, and consistency."); + } + return notes; + } + + private TranslationBundle sanitizeBundle(TranslationBundle bundle, List targetLanguages) { + Map names = new LinkedHashMap<>(); + Map excerpts = new LinkedHashMap<>(); + Map descriptions = new LinkedHashMap<>(); + Map seoTitles = new LinkedHashMap<>(); + Map seoDescriptions = new LinkedHashMap<>(); + + for (String language : targetLanguages) { + names.put(language, safeValue(bundle.names().get(language))); + excerpts.put(language, safeValue(bundle.excerpts().get(language))); + descriptions.put(language, safeDescription(bundle.descriptions().get(language))); + seoTitles.put(language, safeValue(bundle.seoTitles().get(language))); + seoDescriptions.put(language, limitSeoDescription(safeValue(bundle.seoDescriptions().get(language)))); + } + + return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions); + } + + private void ensureRequiredTranslations(TranslationBundle bundle, List targetLanguages) { + for (String language : targetLanguages) { + if (bundle.names().getOrDefault(language, "").isBlank()) { + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, + "OpenAI did not return a valid translated name for " + language.toUpperCase(Locale.ROOT) + ); + } + } + } + + private AdminTranslateShopProductResponse toResponse(String sourceLanguage, + List targetLanguages, + TranslationBundle bundle) { + AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse(); + response.setSourceLanguage(sourceLanguage); + response.setTargetLanguages(targetLanguages); + response.setNames(bundle.names()); + response.setExcerpts(bundle.excerpts()); + response.setDescriptions(bundle.descriptions()); + response.setSeoTitles(bundle.seoTitles()); + response.setSeoDescriptions(bundle.seoDescriptions()); + return response; + } + + private AdminTranslateShopProductResponse emptyResponse(String sourceLanguage) { + AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse(); + response.setSourceLanguage(sourceLanguage); + response.setTargetLanguages(List.of()); + response.setNames(Map.of()); + response.setExcerpts(Map.of()); + response.setDescriptions(Map.of()); + response.setSeoTitles(Map.of()); + response.setSeoDescriptions(Map.of()); + return response; + } + + private Map normalizeLocalizedMap(Map rawValues, boolean richText) { + Map normalized = new LinkedHashMap<>(); + for (String language : SUPPORTED_LANGUAGES) { + String value = rawValues != null ? rawValues.get(language) : null; + if (richText) { + normalized.put(language, normalizeRichTextOptional(value) != null ? normalizeRichTextOptional(value) : ""); + } else { + normalized.put(language, safeValue(value)); + } + } + return normalized; + } + + private String safeValue(String value) { + return value == null ? "" : value.trim(); + } + + private String safeDescription(String value) { + String normalized = normalizeRichTextOptional(value); + return normalized != null ? normalized : ""; + } + + private String limitSeoDescription(String value) { + String normalized = safeValue(value); + if (normalized.length() <= 160) { + return normalized; + } + int lastSpace = normalized.lastIndexOf(' ', 157); + if (lastSpace >= 120) { + return normalized.substring(0, lastSpace).trim(); + } + return normalized.substring(0, 160).trim(); + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + + private String normalizeLanguage(String language) { + if (language == null) { + return ""; + } + String normalized = language.trim().toLowerCase(Locale.ROOT); + int separatorIndex = normalized.indexOf('-'); + if (separatorIndex > 0) { + normalized = normalized.substring(0, separatorIndex); + } + return normalized; + } + + private String normalizeRichTextOptional(String value) { + String normalized = normalizeOptional(value); + if (normalized == null) { + return null; + } + + String sanitized = Jsoup.clean( + normalized, + "", + PRODUCT_DESCRIPTION_SAFELIST, + new Document.OutputSettings().prettyPrint(false) + ).trim(); + if (sanitized.isBlank()) { + return null; + } + + String plainText = Jsoup.parse(sanitized).text(); + return plainText != null && !plainText.trim().isEmpty() ? sanitized : null; + } + + private String normalizeBaseUrl(String rawBaseUrl) { + String normalized = rawBaseUrl != null && !rawBaseUrl.isBlank() + ? rawBaseUrl.trim() + : "https://api.openai.com/v1"; + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Unable to serialize translation payload", + exception + ); + } + } + + private JsonNode readJson(String rawJson) throws IOException { + return objectMapper.readTree(rawJson); + } + + private record NormalizedTranslationRequest(UUID categoryId, + String sourceLanguage, + boolean overwriteExisting, + List materialCodes, + Map names, + Map excerpts, + Map descriptions, + Map seoTitles, + Map seoDescriptions) { + } + + private record CategoryContext(String slug, + Map names, + Map descriptions) { + } + + private record TranslationBundle(Map names, + Map excerpts, + Map descriptions, + Map seoTitles, + Map seoDescriptions) { + static TranslationBundle fromJson(JsonNode translationsNode) { + Map names = new LinkedHashMap<>(); + Map excerpts = new LinkedHashMap<>(); + Map descriptions = new LinkedHashMap<>(); + Map seoTitles = new LinkedHashMap<>(); + Map seoDescriptions = new LinkedHashMap<>(); + + translationsNode.fieldNames().forEachRemaining(language -> { + JsonNode localizedNode = translationsNode.path(language); + names.put(language, localizedNode.path("name").asText("")); + excerpts.put(language, localizedNode.path("excerpt").asText("")); + descriptions.put(language, localizedNode.path("description").asText("")); + seoTitles.put(language, localizedNode.path("seoTitle").asText("")); + seoDescriptions.put(language, localizedNode.path("seoDescription").asText("")); + }); + + return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions); + } + + ObjectNode toJsonNode(ObjectMapper objectMapper) { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode translations = root.putObject("translations"); + for (String language : names.keySet()) { + ObjectNode languageNode = translations.putObject(language); + languageNode.put("name", names.getOrDefault(language, "")); + languageNode.put("excerpt", excerpts.getOrDefault(language, "")); + languageNode.put("description", descriptions.getOrDefault(language, "")); + languageNode.put("seoTitle", seoTitles.getOrDefault(language, "")); + languageNode.put("seoDescription", seoDescriptions.getOrDefault(language, "")); + } + return root; + } + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 8fbd17c..bd64820 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -57,6 +57,12 @@ app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS: 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} +openai.translation.api-key=${OPENAI_API_KEY:} +openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1} +openai.translation.model=${OPENAI_TRANSLATION_MODEL:gpt-5.4} +openai.translation.timeout-seconds=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:45} +openai.translation.prompt-cache-key-prefix=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:printcalc-shop-product-translation-v1} +openai.translation.business-context=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:} # Admin back-office authentication admin.password=${ADMIN_PASSWORD} diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminShopProductControllerSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminShopProductControllerSecurityTest.java new file mode 100644 index 0000000..fceb9aa --- /dev/null +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminShopProductControllerSecurityTest.java @@ -0,0 +1,149 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.config.SecurityConfig; +import com.printcalculator.dto.AdminTranslateShopProductResponse; +import com.printcalculator.service.admin.AdminShopProductControllerService; +import com.printcalculator.service.admin.AdminShopProductTranslationService; +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.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.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class}) +@Import({ + SecurityConfig.class, + AdminSessionAuthenticationFilter.class, + AdminSessionService.class, + AdminLoginThrottleService.class, + AdminShopProductControllerSecurityTest.TransactionTestConfig.class +}) +@TestPropertySource(properties = { + "admin.password=test-admin-password", + "admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "admin.session.ttl-minutes=60" +}) +class AdminShopProductControllerSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AdminShopProductControllerService adminShopProductControllerService; + + @MockitoBean + private AdminShopProductTranslationService adminShopProductTranslationService; + + @Test + void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception { + mockMvc.perform(post("/api/admin/shop/products/translate") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception { + AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse(); + response.setSourceLanguage("it"); + response.setTargetLanguages(List.of("en", "de", "fr")); + response.setNames(Map.of("en", "Desk cable clip")); + response.setExcerpts(Map.of()); + response.setDescriptions(Map.of()); + response.setSeoTitles(Map.of()); + response.setSeoDescriptions(Map.of()); + + when(adminShopProductTranslationService.translateProduct(org.mockito.ArgumentMatchers.any())) + .thenReturn(response); + + mockMvc.perform(post("/api/admin/shop/products/translate") + .cookie(loginAndExtractCookie()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "sourceLanguage":"it", + "overwriteExisting":false, + "materialCodes":["PLA"], + "names":{"it":"Supporto cavo"}, + "excerpts":{"it":"Accessorio tecnico"}, + "descriptions":{"it":"

Descrizione

"}, + "seoTitles":{"it":"SEO IT"}, + "seoDescriptions":{"it":"SEO description IT"} + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.sourceLanguage").value("it")) + .andExpect(jsonPath("$.targetLanguages[0]").value("en")) + .andExpect(jsonPath("$.names.en").value("Desk cable clip")); + } + + 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/service/admin/AdminShopProductTranslationServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminShopProductTranslationServiceTest.java new file mode 100644 index 0000000..f88daed --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminShopProductTranslationServiceTest.java @@ -0,0 +1,226 @@ +package com.printcalculator.service.admin; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.printcalculator.dto.AdminTranslateShopProductRequest; +import com.printcalculator.dto.AdminTranslateShopProductResponse; +import com.printcalculator.repository.ShopCategoryRepository; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +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.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminShopProductTranslationServiceTest { + + @Mock + private ShopCategoryRepository shopCategoryRepository; + + private HttpServer server; + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + void translateProduct_shouldCallOpenAiTwiceAndReturnReviewedTranslations() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + List capturedRequests = new CopyOnWriteArrayList<>(); + AtomicInteger requestCounter = new AtomicInteger(); + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/v1/responses", exchange -> { + capturedRequests.add(readBody(objectMapper, exchange)); + int currentRequest = requestCounter.incrementAndGet(); + String functionName = currentRequest == 1 + ? "generate_product_translations" + : "review_product_translations"; + String body = functionResponse( + objectMapper, + functionName, + Map.of( + "en", localized("Desk cable clip", "Technical desk accessory", "

Desk cable clip for clean cable routing.

", "Desk cable clip | 3D fab", "Technical 3D printed desk cable clip for clean cable routing."), + "de", localized("Schreibtisch-Kabelhalter", "Technisches Schreibtisch-Zubehor", "

Kabelhalter fur einen aufgeraumten Schreibtisch.

", "Schreibtisch-Kabelhalter | 3D fab", "Technischer 3D-gedruckter Kabelhalter fur einen aufgeraumten Schreibtisch."), + "fr", localized("Support de cable de bureau", "Accessoire technique de bureau", "

Support de cable pour un bureau ordonne.

", "Support de cable de bureau | 3D fab", "Support de cable de bureau imprime en 3D pour garder un espace ordonne.") + ) + ); + writeJsonResponse(exchange, body); + }); + server.start(); + + when(shopCategoryRepository.findById(UUID.fromString("00000000-0000-0000-0000-000000000001"))) + .thenReturn(Optional.empty()); + + AdminShopProductTranslationService service = new AdminShopProductTranslationService( + shopCategoryRepository, + objectMapper, + "test-key", + "http://127.0.0.1:" + server.getAddress().getPort() + "/v1", + "gpt-5.4", + 20, + "test-cache-key", + "Use concise ecommerce wording." + ); + + AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest(); + payload.setCategoryId(UUID.fromString("00000000-0000-0000-0000-000000000001")); + payload.setSourceLanguage("it"); + payload.setOverwriteExisting(false); + payload.setMaterialCodes(List.of("pla", "petg")); + payload.setNames(Map.of( + "it", "Supporto cavo scrivania", + "en", "", + "de", "", + "fr", "" + )); + payload.setExcerpts(Map.of( + "it", "Accessorio tecnico", + "en", "", + "de", "", + "fr", "" + )); + payload.setDescriptions(Map.of( + "it", "

Supporto per tenere i cavi ordinati sulla scrivania.

", + "en", "", + "de", "", + "fr", "" + )); + payload.setSeoTitles(Map.of( + "it", "Supporto cavo scrivania | 3D fab", + "en", "", + "de", "", + "fr", "" + )); + payload.setSeoDescriptions(Map.of( + "it", "Supporto tecnico stampato in 3D per tenere i cavi in ordine sulla scrivania.", + "en", "", + "de", "", + "fr", "" + )); + + AdminTranslateShopProductResponse response = service.translateProduct(payload); + + assertEquals(List.of("en", "de", "fr"), response.getTargetLanguages()); + assertEquals("Desk cable clip", response.getNames().get("en")); + assertTrue(response.getDescriptions().get("en").contains("

")); + assertEquals(2, capturedRequests.size()); + assertEquals("required", capturedRequests.get(0).path("tool_choice").asText()); + assertEquals("test-cache-key:generate", capturedRequests.get(0).path("prompt_cache_key").asText()); + assertEquals("test-cache-key:review", capturedRequests.get(1).path("prompt_cache_key").asText()); + } + + @Test + void translateProduct_shouldSkipOpenAiWhenNoTargetLanguageNeedsUpdates() { + ObjectMapper objectMapper = new ObjectMapper(); + AdminShopProductTranslationService service = new AdminShopProductTranslationService( + shopCategoryRepository, + objectMapper, + "test-key", + "http://127.0.0.1:65535/v1", + "gpt-5.4", + 20, + "test-cache-key", + "" + ); + + AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest(); + payload.setSourceLanguage("it"); + payload.setOverwriteExisting(false); + payload.setNames(Map.of( + "it", "Supporto cavo scrivania", + "en", "Desk cable clip", + "de", "Schreibtisch-Kabelhalter", + "fr", "Support de cable de bureau" + )); + payload.setExcerpts(Map.of( + "it", "Accessorio tecnico", + "en", "Technical desk accessory", + "de", "Technisches Schreibtisch-Zubehor", + "fr", "Accessoire technique de bureau" + )); + payload.setDescriptions(Map.of( + "it", "

Descrizione

", + "en", "

Description

", + "de", "

Beschreibung

", + "fr", "

Description

" + )); + payload.setSeoTitles(Map.of( + "it", "SEO IT", + "en", "SEO EN", + "de", "SEO DE", + "fr", "SEO FR" + )); + payload.setSeoDescriptions(Map.of( + "it", "SEO description IT", + "en", "SEO description EN", + "de", "SEO description DE", + "fr", "SEO description FR" + )); + + AdminTranslateShopProductResponse response = service.translateProduct(payload); + assertTrue(response.getTargetLanguages().isEmpty()); + } + + private JsonNode readBody(ObjectMapper objectMapper, HttpExchange exchange) throws IOException { + return objectMapper.readTree(exchange.getRequestBody()); + } + + private void writeJsonResponse(HttpExchange exchange, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(bytes); + } + } + + private String functionResponse(ObjectMapper objectMapper, + String functionName, + Map> translations) throws IOException { + Map arguments = Map.of("translations", translations); + Map item = Map.of( + "type", "function_call", + "name", functionName, + "arguments", objectMapper.writeValueAsString(arguments) + ); + Map response = Map.of( + "id", "resp_test", + "output", List.of(item) + ); + return objectMapper.writeValueAsString(response); + } + + private Map localized(String name, + String excerpt, + String description, + String seoTitle, + String seoDescription) { + return Map.of( + "name", name, + "excerpt", excerpt, + "description", description, + "seoTitle", seoTitle, + "seoDescription", seoDescription + ); + } +} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 5186321..1086e1c 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -29,6 +29,12 @@ services: - ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} - ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - OPENAI_BASE_URL=${OPENAI_BASE_URL:-} + - OPENAI_TRANSLATION_MODEL=${OPENAI_TRANSLATION_MODEL:-} + - OPENAI_TRANSLATION_TIMEOUT_SECONDS=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:-} + - OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:-} + - OPENAI_TRANSLATION_BUSINESS_CONTEXT=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:-} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles - MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media} diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.html b/frontend/src/app/features/admin/pages/admin-shop.component.html index f2d83d1..de6a537 100644 --- a/frontend/src/app/features/admin/pages/admin-shop.component.html +++ b/frontend/src/app/features/admin/pages/admin-shop.component.html @@ -668,9 +668,31 @@

Contenuti localizzati

- Nome obbligatorio in tutte le lingue. Descrizioni opzionali. + Nome obbligatorio in tutte le lingue. Descrizioni opzionali. La + traduzione usa la lingua editor come sorgente e compila il form + senza salvare.

+ + + +
+
diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.ts b/frontend/src/app/features/admin/pages/admin-shop.component.ts index ebd7b08..8a5fb8f 100644 --- a/frontend/src/app/features/admin/pages/admin-shop.component.ts +++ b/frontend/src/app/features/admin/pages/admin-shop.component.ts @@ -18,6 +18,8 @@ import { AdminShopProductModel, AdminShopProductVariant, AdminShopService, + AdminTranslateShopProductPayload, + AdminTranslateShopProductResponse, AdminUpsertShopCategoryPayload, AdminUpsertShopProductPayload, AdminUpsertShopProductVariantPayload, @@ -174,6 +176,7 @@ export class AdminShopComponent implements OnInit, OnDestroy { loading = false; detailLoading = false; savingProduct = false; + translatingProduct = false; deletingProduct = false; savingCategory = false; deletingCategory = false; @@ -188,6 +191,7 @@ export class AdminShopComponent implements OnInit, OnDestroy { productStatusFilter: ProductStatusFilter = 'ALL'; showCategoryManager = false; activeContentLanguage: ShopLanguage = 'it'; + overwriteExistingTranslations = false; errorMessage: string | null = null; successMessage: string | null = null; @@ -560,6 +564,52 @@ export class AdminShopComponent implements OnInit, OnDestroy { this.categoryForm.slug = this.slugify(source); } + translateProductFromCurrentLanguage(): void { + if (this.translatingProduct) { + return; + } + + this.syncDescriptionFromEditor(this.descriptionEditorElement, true); + + const sourceLanguage = this.activeContentLanguage; + if (!this.productForm.names[sourceLanguage].trim()) { + this.errorMessage = `Il nome prodotto ${this.languageLabels[sourceLanguage]} e obbligatorio per avviare la traduzione.`; + this.successMessage = null; + return; + } + + const payload = this.buildProductTranslationPayload(sourceLanguage); + this.translatingProduct = true; + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService.translateProduct(payload).subscribe({ + next: (response) => { + this.translatingProduct = false; + this.applyProductTranslation(response, payload.overwriteExisting); + this.successMessage = response.targetLanguages.length + ? `Traduzioni ${response.targetLanguages + .map((language) => this.languageLabels[language]) + .join(' / ')} aggiornate nel form.` + : 'Nessun campo da tradurre.'; + }, + error: (error) => { + this.translatingProduct = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Traduzione prodotto non riuscita.', + ); + }, + }); + } + + canTranslateProductFromCurrentLanguage(): boolean { + return ( + !this.translatingProduct && + !!this.productForm.names[this.activeContentLanguage].trim() + ); + } + setActiveContentLanguage(language: ShopLanguage): void { this.syncDescriptionFromEditor(this.descriptionEditorElement, true); this.activeContentLanguage = language; @@ -1669,6 +1719,98 @@ export class AdminShopComponent implements OnInit, OnDestroy { }; } + private buildProductTranslationPayload( + sourceLanguage: ShopLanguage, + ): AdminTranslateShopProductPayload { + const materialCodes = Array.from( + new Set( + this.productForm.materials + .map((material) => material.materialCode.trim().toUpperCase()) + .filter((materialCode) => !!materialCode), + ), + ); + + return { + categoryId: this.productForm.categoryId || undefined, + sourceLanguage, + overwriteExisting: this.overwriteExistingTranslations, + materialCodes, + names: { ...this.productForm.names }, + excerpts: { ...this.productForm.excerpts }, + descriptions: { ...this.productForm.descriptions }, + seoTitles: { ...this.productForm.seoTitles }, + seoDescriptions: { ...this.productForm.seoDescriptions }, + }; + } + + private applyProductTranslation( + response: AdminTranslateShopProductResponse, + overwriteExisting: boolean, + ): void { + for (const language of response.targetLanguages) { + this.mergeLocalizedText( + this.productForm.names, + response.names, + language, + overwriteExisting, + ); + this.mergeLocalizedText( + this.productForm.excerpts, + response.excerpts, + language, + overwriteExisting, + ); + this.mergeLocalizedText( + this.productForm.descriptions, + response.descriptions, + language, + overwriteExisting, + true, + ); + this.mergeLocalizedText( + this.productForm.seoTitles, + response.seoTitles, + language, + overwriteExisting, + ); + this.mergeLocalizedText( + this.productForm.seoDescriptions, + response.seoDescriptions, + language, + overwriteExisting, + ); + } + + this.renderActiveDescriptionInEditor(); + } + + private mergeLocalizedText( + target: Record, + translated: + | Partial> + | Record + | undefined, + language: ShopLanguage, + overwriteExisting: boolean, + richText = false, + ): void { + const incoming = translated?.[language]; + if (incoming === undefined) { + return; + } + + const hasCurrentValue = richText + ? this.hasMeaningfulRichText(target[language] ?? '') + : !!target[language]?.trim(); + if (hasCurrentValue && !overwriteExisting) { + return; + } + + target[language] = richText + ? this.normalizeDescriptionForEditor(incoming) + : incoming.trim(); + } + private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] { const existingVariantsByKey = new Map( (this.selectedProduct?.variants ?? []).map((variant) => [ diff --git a/frontend/src/app/features/admin/services/admin-shop.service.spec.ts b/frontend/src/app/features/admin/services/admin-shop.service.spec.ts new file mode 100644 index 0000000..0b6de48 --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-shop.service.spec.ts @@ -0,0 +1,93 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { + AdminShopService, + AdminTranslateShopProductPayload, +} from './admin-shop.service'; + +describe('AdminShopService', () => { + let service: AdminShopService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [AdminShopService], + }); + + service = TestBed.inject(AdminShopService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('posts product translation requests with credentials', () => { + const payload: AdminTranslateShopProductPayload = { + categoryId: 'category-1', + sourceLanguage: 'it', + overwriteExisting: false, + materialCodes: ['PLA', 'PETG'], + names: { + it: 'Supporto cavo scrivania', + en: '', + de: '', + fr: '', + }, + excerpts: { + it: 'Accessorio tecnico', + en: '', + de: '', + fr: '', + }, + descriptions: { + it: '

Descrizione prodotto

', + en: '', + de: '', + fr: '', + }, + seoTitles: { + it: 'Supporto cavo scrivania | 3D fab', + en: '', + de: '', + fr: '', + }, + seoDescriptions: { + it: 'Supporto tecnico stampato in 3D per scrivania.', + en: '', + de: '', + fr: '', + }, + }; + + service.translateProduct(payload).subscribe((response) => { + expect(response.targetLanguages).toEqual(['en', 'de', 'fr']); + expect(response.names.en).toBe('Desk cable clip'); + }); + + const request = httpMock.expectOne( + 'http://localhost:8000/api/admin/shop/products/translate', + ); + expect(request.request.method).toBe('POST'); + expect(request.request.withCredentials).toBeTrue(); + expect(request.request.body).toEqual(payload); + + request.flush({ + sourceLanguage: 'it', + targetLanguages: ['en', 'de', 'fr'], + names: { + en: 'Desk cable clip', + de: 'Schreibtisch-Kabelhalter', + fr: 'Support de cable de bureau', + }, + excerpts: {}, + descriptions: {}, + seoTitles: {}, + seoDescriptions: {}, + }); + }); +}); diff --git a/frontend/src/app/features/admin/services/admin-shop.service.ts b/frontend/src/app/features/admin/services/admin-shop.service.ts index 7c51342..e452a09 100644 --- a/frontend/src/app/features/admin/services/admin-shop.service.ts +++ b/frontend/src/app/features/admin/services/admin-shop.service.ts @@ -18,6 +18,8 @@ export interface AdminMediaTextTranslation { altText: string; } +export type AdminShopLanguage = 'it' | 'en' | 'de' | 'fr'; + export interface AdminShopCategoryRef { id: string; slug: string; @@ -255,6 +257,28 @@ export interface AdminUpsertShopProductPayload { variants: AdminUpsertShopProductVariantPayload[]; } +export interface AdminTranslateShopProductPayload { + categoryId?: string; + sourceLanguage: AdminShopLanguage; + overwriteExisting: boolean; + materialCodes: string[]; + names: Record; + excerpts: Record; + descriptions: Record; + seoTitles: Record; + seoDescriptions: Record; +} + +export interface AdminTranslateShopProductResponse { + sourceLanguage: AdminShopLanguage; + targetLanguages: AdminShopLanguage[]; + names: Partial>; + excerpts: Partial>; + descriptions: Partial>; + seoTitles: Partial>; + seoDescriptions: Partial>; +} + @Injectable({ providedIn: 'root', }) @@ -351,6 +375,18 @@ export class AdminShopService { }); } + translateProduct( + payload: AdminTranslateShopProductPayload, + ): Observable { + return this.http.post( + `${this.productsBaseUrl}/translate`, + payload, + { + withCredentials: true, + }, + ); + } + uploadProductModel( productId: string, file: File, diff --git a/frontend/src/app/features/home/home.component.scss b/frontend/src/app/features/home/home.component.scss index e557664..dcd2cf5 100644 --- a/frontend/src/app/features/home/home.component.scss +++ b/frontend/src/app/features/home/home.component.scss @@ -56,7 +56,7 @@ width: min(100%, 340px); padding: 1rem 1.1rem; border: 1px solid var(--color-border); - border-left: 4px solid var(--swiss-red); + border-left: 4px solid var(--color-brand); border-radius: 12px; background: #fff; animation: fadeUp 0.85s ease both;