Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/openapi/parameters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,21 @@ xPromiseToken:
type: string
example: eyJhbGciOiJSUzI1NiIs...

locale:
name: locale
description: |
Locale code in underscore form (e.g. `fr_fr`, `ja_jp`, `de_de`).
When provided and a translation exists, localizable fields (`title`, `description` on
opportunities; AI-generated fields inside `data` on suggestions) are replaced by the
stored translation. Falls back to the original English value when the locale is absent
or no translation has been stored yet for that locale.
in: query
required: false
schema:
type: string
pattern: '^[a-z]{2}_[a-z]{2}$'
example: fr_fr

suggestionView:
name: view
description: |
Expand Down
10 changes: 10 additions & 0 deletions docs/openapi/site-opportunities.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
site-opportunities:
parameters:
- $ref: './parameters.yaml#/siteId'
- $ref: './parameters.yaml#/locale'
get:
operationId: getSiteOpportunities
summary: |
Expand Down Expand Up @@ -71,6 +72,7 @@ site-opportunities-by-status:
required: true
schema:
type: string
- $ref: './parameters.yaml#/locale'
get:
operationId: getSiteOpportunitiesByStatus
summary: |
Expand Down Expand Up @@ -103,6 +105,7 @@ site-opportunity:
parameters:
- $ref: './parameters.yaml#/siteId'
- $ref: './parameters.yaml#/opportunityId'
- $ref: './parameters.yaml#/locale'
get:
operationId: getSiteOpportunity
summary: |
Expand Down Expand Up @@ -197,6 +200,7 @@ site-opportunity-suggestions:
- $ref: './parameters.yaml#/opportunityId'
- $ref: './parameters.yaml#/suggestionView'
- $ref: './parameters.yaml#/suggestionStatus'
- $ref: './parameters.yaml#/locale'
get:
operationId: getSiteOpportunitySuggestions
summary: |
Expand Down Expand Up @@ -278,6 +282,7 @@ site-opportunity-suggestions-by-status:
schema:
type: string
- $ref: './parameters.yaml#/suggestionView'
- $ref: './parameters.yaml#/locale'
get:
operationId: getSiteOpportunitySuggestionsByStatus
summary: |
Expand Down Expand Up @@ -314,6 +319,7 @@ site-opportunity-suggestions-paged:
- $ref: './parameters.yaml#/opportunityId'
- $ref: './parameters.yaml#/limit'
- $ref: './parameters.yaml#/suggestionView'
- $ref: './parameters.yaml#/locale'
get:
operationId: getSiteOpportunitySuggestionsPaged
summary: |
Expand Down Expand Up @@ -353,6 +359,7 @@ site-opportunity-suggestions-paged-with-cursor:
- $ref: './parameters.yaml#/limit'
- $ref: './parameters.yaml#/cursor'
- $ref: './parameters.yaml#/suggestionView'
- $ref: './parameters.yaml#/locale'
get:
operationId: getSiteOpportunitySuggestionsPagedWithCursor
summary: |
Expand Down Expand Up @@ -396,6 +403,7 @@ site-opportunity-suggestions-by-status-paged:
type: string
- $ref: './parameters.yaml#/limit'
- $ref: './parameters.yaml#/suggestionView'
- $ref: './parameters.yaml#/locale'
get:
operationId: getSiteOpportunitySuggestionsByStatusPaged
summary: |
Expand Down Expand Up @@ -439,6 +447,7 @@ site-opportunity-suggestions-by-status-paged-with-cursor:
- $ref: './parameters.yaml#/limit'
- $ref: './parameters.yaml#/cursor'
- $ref: './parameters.yaml#/suggestionView'
- $ref: './parameters.yaml#/locale'
get:
operationId: getSiteOpportunitySuggestionsByStatusPagedWithCursor
summary: |
Expand Down Expand Up @@ -1082,6 +1091,7 @@ site-opportunity-suggestion:
- $ref: './parameters.yaml#/opportunityId'
- $ref: './parameters.yaml#/suggestionId'
- $ref: './parameters.yaml#/suggestionView'
- $ref: './parameters.yaml#/locale'
get:
operationId: getSiteOpportunitySuggestion
summary: |
Expand Down
22 changes: 19 additions & 3 deletions src/controllers/opportunities.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
isValidUUID,
} from '@adobe/spacecat-shared-utils';
import { OpportunityDto } from '../dto/opportunity.js';
import { isValidLocale } from '../utils/validations.js';
import AccessControlUtil from '../support/access-control-util.js';
import { grantSuggestionsForOpportunity } from '../support/grant-suggestions-handler.js';
import { getIsSummitPlgEnabled } from '../support/utils.js';
Expand Down Expand Up @@ -96,6 +97,11 @@ function OpportunitiesController(ctx) {
*/
const getAllForSite = async (context) => {
const siteId = context.params?.siteId;
const locale = context.data?.locale ?? null;

if (!isValidLocale(locale)) {
return badRequest('Invalid locale format');
}

if (!isValidUUID(siteId)) {
return badRequest('Site ID required');
Expand All @@ -111,7 +117,7 @@ function OpportunitiesController(ctx) {

const allOpptys = await Opportunity.allBySiteId(siteId);
const opptys = (await filterForSummitPlg(site, allOpptys, context))
.map((oppty) => OpportunityDto.toJSON(oppty));
.map((oppty) => OpportunityDto.toJSON(oppty, locale));

return ok(opptys);
};
Expand All @@ -124,6 +130,11 @@ function OpportunitiesController(ctx) {
const getByStatus = async (context) => {
const siteId = context.params?.siteId;
const status = context.params?.status;
const locale = context.data?.locale ?? null;

if (!isValidLocale(locale)) {
return badRequest('Invalid locale format');
}

if (!isValidUUID(siteId)) {
return badRequest('Site ID required');
Expand All @@ -142,7 +153,7 @@ function OpportunitiesController(ctx) {

const allOpptys = await Opportunity.allBySiteIdAndStatus(siteId, status);
const opptys = (await filterForSummitPlg(site, allOpptys, context))
.map((oppty) => OpportunityDto.toJSON(oppty));
.map((oppty) => OpportunityDto.toJSON(oppty, locale));

return ok(opptys);
};
Expand All @@ -155,6 +166,11 @@ function OpportunitiesController(ctx) {
const getByID = async (context) => {
const siteId = context.params?.siteId;
const opptyId = context.params?.opportunityId;
const locale = context.data?.locale ?? null;

if (!isValidLocale(locale)) {
return badRequest('Invalid locale format');
}

if (!isValidUUID(siteId)) {
return badRequest('Site ID required');
Expand Down Expand Up @@ -184,7 +200,7 @@ function OpportunitiesController(ctx) {
ctx.log?.warn?.('Grant suggestions handler failed', err?.message ?? err);
}
}
return ok(OpportunityDto.toJSON(oppty));
return ok(OpportunityDto.toJSON(oppty, locale));
};

/**
Expand Down
36 changes: 31 additions & 5 deletions src/controllers/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { Suggestion as SuggestionModel, GeoExperiment as GeoExperimentModel } fr
import TokowakaClient from '@adobe/spacecat-shared-tokowaka-client';
import DrsClient, { EXPERIMENT_PHASES } from '@adobe/spacecat-shared-drs-client';
import { SuggestionDto, SUGGESTION_VIEWS, SUGGESTION_SKIP_REASONS } from '../dto/suggestion.js';
import { isValidLocale } from '../utils/validations.js';
import {
getScheduleParams,
buildExperimentMetadata,
Expand Down Expand Up @@ -301,6 +302,11 @@ function SuggestionsController(ctx, sqs, env) {
const opptyId = context.params?.opportunityId;
const viewParam = context.data?.view;
const statusParam = context.data?.status;
const locale = context.data?.locale ?? null;

if (!isValidLocale(locale)) {
return badRequest('Invalid locale format');
}

if (!isValidUUID(siteId)) {
return badRequest('Site ID required');
Expand Down Expand Up @@ -357,7 +363,7 @@ function SuggestionsController(ctx, sqs, env) {
}
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity),
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale),
);
return ok(suggestions);
};
Expand All @@ -376,6 +382,11 @@ function SuggestionsController(ctx, sqs, env) {
const limit = parseInt(context.params?.limit, 10) || DEFAULT_PAGE_SIZE;
const cursor = context.params?.cursor || null;
const viewParam = context.data?.view;
const locale = context.data?.locale ?? null;

if (!isValidLocale(locale)) {
return badRequest('Invalid locale format');
}

if (!isValidUUID(siteId)) {
return badRequest('Site ID required');
Expand Down Expand Up @@ -418,7 +429,7 @@ function SuggestionsController(ctx, sqs, env) {
}
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity),
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale),
);

return ok({
Expand All @@ -441,6 +452,11 @@ function SuggestionsController(ctx, sqs, env) {
const opptyId = context.params?.opportunityId;
const status = context.params?.status || undefined;
const viewParam = context.data?.view;
const locale = context.data?.locale ?? null;

if (!isValidLocale(locale)) {
return badRequest('Invalid locale format');
}

if (!isValidUUID(siteId)) {
return badRequest('Site ID required');
Expand Down Expand Up @@ -476,7 +492,7 @@ function SuggestionsController(ctx, sqs, env) {
}
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity),
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale),
);
return ok(suggestions);
};
Expand All @@ -493,6 +509,11 @@ function SuggestionsController(ctx, sqs, env) {
const limit = parseInt(context.params?.limit, 10) || DEFAULT_PAGE_SIZE;
const cursor = context.params?.cursor || null;
const viewParam = context.data?.view;
const locale = context.data?.locale ?? null;

if (!isValidLocale(locale)) {
return badRequest('Invalid locale format');
}

if (!isValidUUID(siteId)) {
return badRequest('Site ID required');
Expand Down Expand Up @@ -537,7 +558,7 @@ function SuggestionsController(ctx, sqs, env) {
}
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity),
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale),
);
return ok({
suggestions,
Expand All @@ -559,6 +580,11 @@ function SuggestionsController(ctx, sqs, env) {
const opptyId = context.params?.opportunityId || undefined;
const suggestionId = context.params?.suggestionId || undefined;
const viewParam = context.data?.view;
const locale = context.data?.locale ?? null;

if (!isValidLocale(locale)) {
return badRequest('Invalid locale format');
}

if (!isValidUUID(siteId)) {
return badRequest('Site ID required');
Expand Down Expand Up @@ -598,7 +624,7 @@ function SuggestionsController(ctx, sqs, env) {
&& !(await SuggestionGrant.isSuggestionGranted(suggestion.getId()))) {
return notFound('Suggestion not found');
}
return ok(SuggestionDto.toJSON(suggestion, view, opportunity));
return ok(SuggestionDto.toJSON(suggestion, view, opportunity, locale));
};

/**
Expand Down
66 changes: 48 additions & 18 deletions src/dto/opportunity.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ export const OpportunityDto = {
* that must only appear in the brand-scoped endpoint response, added by the brand
* controller directly. Including them here would expose brand UUIDs on shared sites
* to users who have site access but not brand access.
*
* Translations for `title` and `description` are stored by audit workers in
* `opportunity.data.i18n` as a map of locale → { title, description }.
* When `locale` is supplied the matching translation is promoted to the top-level
* fields and `data.i18n` is stripped from the response so the shape stays stable.
* Falls back to the original English values when the locale is absent or not found.
*
* @param {Readonly<Opportunity>} oppty - Opportunity object.
* @param {string|null} [locale] - Optional locale code (e.g. 'fr_fr', 'ja_jp').
* @returns {{
* id: string,
* siteId: string,
Expand All @@ -40,22 +48,44 @@ export const OpportunityDto = {
* lastAuditedAt: date
* }} JSON object.
*/
toJSON: (oppty) => ({
id: oppty.getId(),
siteId: oppty.getSiteId(),
auditId: oppty.getAuditId(),
runbook: oppty.getRunbook(),
type: oppty.getType(),
data: oppty.getData(),
origin: oppty.getOrigin(),
title: oppty.getTitle(),
description: oppty.getDescription(),
guidance: oppty.getGuidance(),
tags: oppty.getTags(),
status: oppty.getStatus(),
createdAt: oppty.getCreatedAt(),
updatedAt: oppty.getUpdatedAt(),
updatedBy: oppty.getUpdatedBy(),
lastAuditedAt: oppty.getLastAuditedAt(),
}),
toJSON: (oppty, locale = null) => {
const rawData = oppty.getData();
const data = rawData ? (() => {
// eslint-disable-next-line no-unused-vars
const { i18n, ...rest } = rawData;
return rest;
})() : null;

let title = oppty.getTitle();
let description = oppty.getDescription();

if (locale && rawData?.i18n?.[locale]) {
const localized = rawData.i18n[locale];
if (localized.title != null) {
title = localized.title;
}
if (localized.description != null) {
description = localized.description;
}
}

return {
id: oppty.getId(),
siteId: oppty.getSiteId(),
auditId: oppty.getAuditId(),
runbook: oppty.getRunbook(),
type: oppty.getType(),
data,
origin: oppty.getOrigin(),
title,
description,
guidance: oppty.getGuidance(),
tags: oppty.getTags(),
status: oppty.getStatus(),
createdAt: oppty.getCreatedAt(),
updatedAt: oppty.getUpdatedAt(),
updatedBy: oppty.getUpdatedBy(),
lastAuditedAt: oppty.getLastAuditedAt(),
};
},
};
Loading
Loading