feat(back-end): new translation api with openai
This commit is contained in:
@@ -191,6 +191,7 @@ jobs:
|
||||
fi
|
||||
DEPLOY_OWNER=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
cat "deploy/envs/common.env" > /tmp/common_env.env
|
||||
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
|
||||
|
||||
if [[ "${{ env.ENV }}" == "prod" ]]; then
|
||||
@@ -217,9 +218,15 @@ 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
|
||||
printf 'OPENAI_API_KEY="%s"\n' "${{ secrets.OPENAI_API_KEY }}" >> /tmp/common_env.env
|
||||
|
||||
echo "Preparing to send common env file with variables:"
|
||||
grep -Ev "PASSWORD|SECRET|KEY|TOKEN" /tmp/common_env.env || true
|
||||
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 }}" \
|
||||
"setcommon" < /tmp/common_env.env
|
||||
|
||||
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
||||
"setenv ${{ env.ENV }}" < /tmp/full_env.env
|
||||
|
||||
@@ -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 scrive nel file `common.env` remoto e il container backend la riceve come variabile runtime. Le opzioni non sensibili condivise fra ambienti stanno in [deploy/envs/common.env](/Users/joe/IdeaProjects/print-calculator/deploy/envs/common.env), mentre i file `deploy/envs/*.env` restano per i valori specifici di `dev/int/prod`.
|
||||
|
||||
@@ -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<AdminTranslateShopProductResponse> translateProduct(@RequestBody AdminTranslateShopProductRequest payload) {
|
||||
return ResponseEntity.ok(adminShopProductTranslationService.translateProduct(payload));
|
||||
}
|
||||
|
||||
@PutMapping("/{productId}")
|
||||
@Transactional
|
||||
public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId,
|
||||
|
||||
@@ -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<String> materialCodes;
|
||||
private Map<String, String> names;
|
||||
private Map<String, String> excerpts;
|
||||
private Map<String, String> descriptions;
|
||||
private Map<String, String> seoTitles;
|
||||
private Map<String, String> 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<String> getMaterialCodes() {
|
||||
return materialCodes;
|
||||
}
|
||||
|
||||
public void setMaterialCodes(List<String> materialCodes) {
|
||||
this.materialCodes = materialCodes;
|
||||
}
|
||||
|
||||
public Map<String, String> getNames() {
|
||||
return names;
|
||||
}
|
||||
|
||||
public void setNames(Map<String, String> names) {
|
||||
this.names = names;
|
||||
}
|
||||
|
||||
public Map<String, String> getExcerpts() {
|
||||
return excerpts;
|
||||
}
|
||||
|
||||
public void setExcerpts(Map<String, String> excerpts) {
|
||||
this.excerpts = excerpts;
|
||||
}
|
||||
|
||||
public Map<String, String> getDescriptions() {
|
||||
return descriptions;
|
||||
}
|
||||
|
||||
public void setDescriptions(Map<String, String> descriptions) {
|
||||
this.descriptions = descriptions;
|
||||
}
|
||||
|
||||
public Map<String, String> getSeoTitles() {
|
||||
return seoTitles;
|
||||
}
|
||||
|
||||
public void setSeoTitles(Map<String, String> seoTitles) {
|
||||
this.seoTitles = seoTitles;
|
||||
}
|
||||
|
||||
public Map<String, String> getSeoDescriptions() {
|
||||
return seoDescriptions;
|
||||
}
|
||||
|
||||
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
|
||||
this.seoDescriptions = seoDescriptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class AdminTranslateShopProductResponse {
|
||||
private String sourceLanguage;
|
||||
private List<String> targetLanguages;
|
||||
private Map<String, String> names;
|
||||
private Map<String, String> excerpts;
|
||||
private Map<String, String> descriptions;
|
||||
private Map<String, String> seoTitles;
|
||||
private Map<String, String> seoDescriptions;
|
||||
|
||||
public String getSourceLanguage() {
|
||||
return sourceLanguage;
|
||||
}
|
||||
|
||||
public void setSourceLanguage(String sourceLanguage) {
|
||||
this.sourceLanguage = sourceLanguage;
|
||||
}
|
||||
|
||||
public List<String> getTargetLanguages() {
|
||||
return targetLanguages;
|
||||
}
|
||||
|
||||
public void setTargetLanguages(List<String> targetLanguages) {
|
||||
this.targetLanguages = targetLanguages;
|
||||
}
|
||||
|
||||
public Map<String, String> getNames() {
|
||||
return names;
|
||||
}
|
||||
|
||||
public void setNames(Map<String, String> names) {
|
||||
this.names = names;
|
||||
}
|
||||
|
||||
public Map<String, String> getExcerpts() {
|
||||
return excerpts;
|
||||
}
|
||||
|
||||
public void setExcerpts(Map<String, String> excerpts) {
|
||||
this.excerpts = excerpts;
|
||||
}
|
||||
|
||||
public Map<String, String> getDescriptions() {
|
||||
return descriptions;
|
||||
}
|
||||
|
||||
public void setDescriptions(Map<String, String> descriptions) {
|
||||
this.descriptions = descriptions;
|
||||
}
|
||||
|
||||
public Map<String, String> getSeoTitles() {
|
||||
return seoTitles;
|
||||
}
|
||||
|
||||
public void setSeoTitles(Map<String, String> seoTitles) {
|
||||
this.seoTitles = seoTitles;
|
||||
}
|
||||
|
||||
public Map<String, String> getSeoDescriptions() {
|
||||
return seoDescriptions;
|
||||
}
|
||||
|
||||
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
|
||||
this.seoDescriptions = seoDescriptions;
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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<String> 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<String, String> names = normalizeLocalizedMap(payload.getNames(), false);
|
||||
Map<String, String> excerpts = normalizeLocalizedMap(payload.getExcerpts(), false);
|
||||
Map<String, String> descriptions = normalizeLocalizedMap(payload.getDescriptions(), true);
|
||||
Map<String, String> seoTitles = normalizeLocalizedMap(payload.getSeoTitles(), false);
|
||||
Map<String, String> 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<String> 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<String> resolveTargetLanguages(NormalizedTranslationRequest request) {
|
||||
List<String> 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<String> 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<String> 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<String> targetLanguages,
|
||||
CategoryContext categoryContext,
|
||||
List<String> 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<String> targetLanguages) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
for (String language : targetLanguages) {
|
||||
node.set(language, localizedFieldNode(request, language));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private ObjectNode buildTranslationToolSchema(List<String> 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<String> 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<String> buildValidationNotes(TranslationBundle bundle, List<String> targetLanguages) {
|
||||
List<String> 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<String> targetLanguages) {
|
||||
Map<String, String> names = new LinkedHashMap<>();
|
||||
Map<String, String> excerpts = new LinkedHashMap<>();
|
||||
Map<String, String> descriptions = new LinkedHashMap<>();
|
||||
Map<String, String> seoTitles = new LinkedHashMap<>();
|
||||
Map<String, String> 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<String> 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<String> 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<String, String> normalizeLocalizedMap(Map<String, String> rawValues, boolean richText) {
|
||||
Map<String, String> 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<String> materialCodes,
|
||||
Map<String, String> names,
|
||||
Map<String, String> excerpts,
|
||||
Map<String, String> descriptions,
|
||||
Map<String, String> seoTitles,
|
||||
Map<String, String> seoDescriptions) {
|
||||
}
|
||||
|
||||
private record CategoryContext(String slug,
|
||||
Map<String, String> names,
|
||||
Map<String, String> descriptions) {
|
||||
}
|
||||
|
||||
private record TranslationBundle(Map<String, String> names,
|
||||
Map<String, String> excerpts,
|
||||
Map<String, String> descriptions,
|
||||
Map<String, String> seoTitles,
|
||||
Map<String, String> seoDescriptions) {
|
||||
static TranslationBundle fromJson(JsonNode translationsNode) {
|
||||
Map<String, String> names = new LinkedHashMap<>();
|
||||
Map<String, String> excerpts = new LinkedHashMap<>();
|
||||
Map<String, String> descriptions = new LinkedHashMap<>();
|
||||
Map<String, String> seoTitles = new LinkedHashMap<>();
|
||||
Map<String, String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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":"<p>Descrizione</p>"},
|
||||
"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.
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<JsonNode> 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", "<p>Desk cable clip for clean cable routing.</p>", "Desk cable clip | 3D fab", "Technical 3D printed desk cable clip for clean cable routing."),
|
||||
"de", localized("Schreibtisch-Kabelhalter", "Technisches Schreibtisch-Zubehor", "<p>Kabelhalter fur einen aufgeraumten Schreibtisch.</p>", "Schreibtisch-Kabelhalter | 3D fab", "Technischer 3D-gedruckter Kabelhalter fur einen aufgeraumten Schreibtisch."),
|
||||
"fr", localized("Support de cable de bureau", "Accessoire technique de bureau", "<p>Support de cable pour un bureau ordonne.</p>", "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", "<p>Supporto per tenere i cavi ordinati sulla scrivania.</p>",
|
||||
"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("<p>"));
|
||||
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", "<p>Descrizione</p>",
|
||||
"en", "<p>Description</p>",
|
||||
"de", "<p>Beschreibung</p>",
|
||||
"fr", "<p>Description</p>"
|
||||
));
|
||||
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<String, Map<String, String>> translations) throws IOException {
|
||||
Map<String, Object> arguments = Map.of("translations", translations);
|
||||
Map<String, Object> item = Map.of(
|
||||
"type", "function_call",
|
||||
"name", functionName,
|
||||
"arguments", objectMapper.writeValueAsString(arguments)
|
||||
);
|
||||
Map<String, Object> response = Map.of(
|
||||
"id", "resp_test",
|
||||
"output", List.of(item)
|
||||
);
|
||||
return objectMapper.writeValueAsString(response);
|
||||
}
|
||||
|
||||
private Map<String, String> localized(String name,
|
||||
String excerpt,
|
||||
String description,
|
||||
String seoTitle,
|
||||
String seoDescription) {
|
||||
return Map.of(
|
||||
"name", name,
|
||||
"excerpt", excerpt,
|
||||
"description", description,
|
||||
"seoTitle", seoTitle,
|
||||
"seoDescription", seoDescription
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -669,8 +669,30 @@
|
||||
<h3>Contenuti localizzati</h3>
|
||||
<p>
|
||||
Nome obbligatorio in tutte le lingue. Descrizioni opzionali.
|
||||
La traduzione usa la lingua editor come sorgente e compila il
|
||||
form senza salvare.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ui-button ui-button--ghost"
|
||||
(click)="translateProductFromCurrentLanguage()"
|
||||
[disabled]="!canTranslateProductFromCurrentLanguage()"
|
||||
>
|
||||
{{ translatingProduct ? "Traduco..." : "Traduci" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row toggle-row--compact">
|
||||
<label class="ui-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="overwriteExistingTranslations"
|
||||
name="productOverwriteExistingTranslations"
|
||||
/>
|
||||
<span class="ui-checkbox__mark" aria-hidden="true"></span>
|
||||
<span>Sovrascrivi traduzioni esistenti</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="ui-language-toolbar">
|
||||
|
||||
@@ -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<ShopLanguage, string>,
|
||||
translated:
|
||||
| Partial<Record<ShopLanguage, string>>
|
||||
| Record<ShopLanguage, string>
|
||||
| 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) => [
|
||||
|
||||
@@ -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: '<p>Descrizione prodotto</p>',
|
||||
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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<AdminShopLanguage, string>;
|
||||
excerpts: Record<AdminShopLanguage, string>;
|
||||
descriptions: Record<AdminShopLanguage, string>;
|
||||
seoTitles: Record<AdminShopLanguage, string>;
|
||||
seoDescriptions: Record<AdminShopLanguage, string>;
|
||||
}
|
||||
|
||||
export interface AdminTranslateShopProductResponse {
|
||||
sourceLanguage: AdminShopLanguage;
|
||||
targetLanguages: AdminShopLanguage[];
|
||||
names: Partial<Record<AdminShopLanguage, string>>;
|
||||
excerpts: Partial<Record<AdminShopLanguage, string>>;
|
||||
descriptions: Partial<Record<AdminShopLanguage, string>>;
|
||||
seoTitles: Partial<Record<AdminShopLanguage, string>>;
|
||||
seoDescriptions: Partial<Record<AdminShopLanguage, string>>;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -351,6 +375,18 @@ export class AdminShopService {
|
||||
});
|
||||
}
|
||||
|
||||
translateProduct(
|
||||
payload: AdminTranslateShopProductPayload,
|
||||
): Observable<AdminTranslateShopProductResponse> {
|
||||
return this.http.post<AdminTranslateShopProductResponse>(
|
||||
`${this.productsBaseUrl}/translate`,
|
||||
payload,
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
uploadProductModel(
|
||||
productId: string,
|
||||
file: File,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user