diff --git a/package-lock.json b/package-lock.json index 3153dafe3..ffb9c12d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -916,6 +916,7 @@ "resolved": "https://registry.npmjs.org/@adobe/helix-universal/-/helix-universal-5.4.0.tgz", "integrity": "sha512-3ZfFdjYtpv7RCgul9yyOBsRVsxLNapwt0YjASBhyzJGNjnPxrWDlqDtbpBdwAgA1Nuh9nmjzFDFu8CJWv6BMKw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@adobe/fetch": "4.2.3", "aws4": "1.13.2" @@ -2939,6 +2940,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.940.0.tgz", "integrity": "sha512-u2sXsNJazJbuHeWICvsj6RvNyJh3isedEfPvB21jK/kxcriK+dE/izlKC2cyxUjERCmku0zTFNzY9FhrLbYHjQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -8032,6 +8034,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.80.tgz", "integrity": "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -8263,6 +8266,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -8469,6 +8473,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -8632,6 +8637,7 @@ "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -10572,6 +10578,7 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -10878,6 +10885,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10924,6 +10932,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11399,6 +11408,7 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.12.0.tgz", "integrity": "sha512-lwalRdxXRy+Sn49/vN7W507qqmBRk5Fy2o0a9U6XTjL9IV+oR5PUiiptoBrOcaYCiVuGld8OEbNqhm6wvV3m6A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -12049,6 +12059,7 @@ "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -14225,6 +14236,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -18207,6 +18219,7 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -19262,6 +19275,7 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -22408,6 +22422,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -23077,6 +23092,7 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz", "integrity": "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==", "license": "Apache-2.0", + "peer": true, "bin": { "openai": "bin/cli" }, @@ -24194,6 +24210,7 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -24204,6 +24221,7 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -24913,6 +24931,7 @@ "integrity": "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", @@ -25799,6 +25818,7 @@ "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^13.0.5", @@ -26364,6 +26384,7 @@ "integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", @@ -27428,6 +27449,7 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -28159,6 +28181,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -28420,6 +28443,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -28429,6 +28453,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } diff --git a/src/controllers/suggestions.js b/src/controllers/suggestions.js index 5b5039b09..f9674d629 100644 --- a/src/controllers/suggestions.js +++ b/src/controllers/suggestions.js @@ -24,13 +24,23 @@ import { isObject, isInteger, isValidUUID, + prependSchema, + determineAEMCSPageId, + fetch as adobeFetch, + getPageVersion, + restorePageVersion, } from '@adobe/spacecat-shared-utils'; import { Suggestion as SuggestionModel } from '@adobe/spacecat-shared-data-access'; import TokowakaClient from '@adobe/spacecat-shared-tokowaka-client'; import { SuggestionDto, SUGGESTION_VIEWS } from '../dto/suggestion.js'; import { FixDto } from '../dto/fix.js'; -import { sendAutofixMessage, getIMSPromiseToken, ErrorWithStatusCode } from '../support/utils.js'; +import { + sendAutofixMessage, + getIMSPromiseToken, + exchangePromiseToken, + ErrorWithStatusCode, +} from '../support/utils.js'; import AccessControlUtil from '../support/access-control-util.js'; const VALIDATION_ERROR_NAME = 'ValidationError'; @@ -944,6 +954,234 @@ function SuggestionsController(ctx, sqs, env) { return createResponse(response, 207); }; + const testMetaTagsAutofix = async (context) => { + const { log } = context; + const siteId = context.params?.siteId; + const opportunityId = context.params?.opportunityId; + const suggestionId = context.params?.suggestionId; + + if (!isValidUUID(siteId)) { + return badRequest('Site ID required'); + } + if (!isValidUUID(opportunityId)) { + return badRequest('Opportunity ID required'); + } + if (!isValidUUID(suggestionId)) { + return badRequest('Suggestion ID required'); + } + + const site = await Site.findById(siteId); + if (!site) return notFound('Site not found'); + + if (!await accessControlUtil.hasAccess(site, 'auto_fix')) { + return forbidden('User does not belong to the organization or does not have sufficient permissions'); + } + + const opportunity = await Opportunity.findById(opportunityId); + if (!opportunity || opportunity.getSiteId() !== siteId) { + return notFound('Opportunity not found'); + } + + const suggestion = await Suggestion.findById(suggestionId); + if (!suggestion || suggestion.getOpportunityId() !== opportunityId) { + return notFound('Suggestion not found'); + } + + if (site.getDeliveryType?.() !== 'AEM_CS') { + return badRequest('Test autofix is only supported for AEM_CS sites'); + } + + const deliveryConfig = site.getDeliveryConfig?.() || {}; + const { authorURL } = deliveryConfig; + if (!hasText(authorURL)) { + return badRequest('Author URL not found in site delivery config'); + } + + // Respect same Content API selection behavior as the worker + const preferContentApi = String(deliveryConfig?.preferContentApi ?? 'true').toLowerCase() === 'true'; + const disableForMetaTags = String(deliveryConfig?.disableContentApiForMetaTags ?? 'false').toLowerCase() === 'true'; + if (!preferContentApi || disableForMetaTags) { + return badRequest('Content API is disabled for meta-tags on this site (PSS-only).'); + } + + const data = suggestion.getData?.() || {}; + const rawUrl = data?.url; + if (!hasText(rawUrl)) { + return badRequest('Suggestion is missing required data.url'); + } + const pageURL = prependSchema(rawUrl); + + const newValue = data?.editedSuggestion || data?.aiSuggestion; + if (!hasText(newValue)) { + return badRequest('Suggestion has no editedSuggestion/aiSuggestion to apply'); + } + + const tagName = String(data?.tagName || '').toLowerCase(); + if (!['title', 'description'].includes(tagName)) { + return badRequest('Only title/description meta-tags are supported by this test endpoint right now'); + } + + let promiseTokenResponse; + try { + promiseTokenResponse = await getIMSPromiseToken(context); + } catch (e) { + if (e instanceof ErrorWithStatusCode) { + return badRequest(e.message); + } + return createResponse({ message: 'Error getting promise token' }, 500); + } + + let accessToken; + try { + accessToken = await exchangePromiseToken(context, promiseTokenResponse?.promise_token); + } catch (e) { + if (e instanceof ErrorWithStatusCode) { + return badRequest(e.message); + } + return createResponse({ message: 'Error exchanging promise token' }, 500); + } + + const bearerToken = `Bearer ${accessToken}`; + + const pageId = await determineAEMCSPageId(pageURL, authorURL, bearerToken, true, log); + if (!hasText(pageId)) { + return badRequest(`Unable to resolve pageId for url ${pageURL}. Ensure content-page-id/content-page-ref is present.`); + } + + const pageEndpoint = `${authorURL}/adobe/pages/${pageId}`; + + const getPageEtag = async () => { + const resp = await adobeFetch(pageEndpoint, { + method: 'GET', + headers: { + Authorization: bearerToken, + Accept: 'application/json', + 'x-aem-affinity-type': 'api', + }, + }); + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Failed to fetch page for ETag. Status: ${resp.status} ${resp.statusText}. Response: ${body}`); + } + const etag = resp.headers.get('etag'); + if (!etag) { + throw new Error('Page response missing ETag header'); + } + return etag; + }; + + // 1) Create a pre-change version (checkpoint) + const versionLabel = `aso-test-meta-${suggestionId}`; + const versionDescription = `Backoffice test autofix version for suggestion ${suggestionId}`; + const versionCreateEndpoint = `${authorURL}/adobe/pages/${pageId}/versions`; + const versionCreateResp = await adobeFetch(versionCreateEndpoint, { + method: 'POST', + body: JSON.stringify({ label: versionLabel, description: versionDescription }), + headers: { + 'Content-Type': 'application/json', + Authorization: bearerToken, + Accept: 'application/json', + 'x-aem-affinity-type': 'api', + }, + }); + + if (!versionCreateResp.ok) { + const body = await versionCreateResp.text(); + return createResponse({ + message: `Failed to create page version. Status: ${versionCreateResp.status} ${versionCreateResp.statusText}. Response: ${body}`, + }, 502); + } + + const versionData = await versionCreateResp.json(); + const versionId = versionData?.id; + if (!hasText(versionId)) { + return createResponse({ message: 'Version creation response missing version id' }, 502); + } + + // 2) Apply a Content API patch for title/description + const currentEtag = await getPageEtag(); + const patchPayload = tagName === 'title' ? { title: newValue } : { description: newValue }; + const patchResp = await adobeFetch(pageEndpoint, { + method: 'PATCH', + body: JSON.stringify(patchPayload), + headers: { + 'Content-Type': 'application/json', + Authorization: bearerToken, + Accept: 'application/json', + 'If-Match': currentEtag, + 'x-aem-affinity-type': 'api', + }, + }); + + if (!patchResp.ok) { + const body = await patchResp.text(); + return createResponse({ + message: `Failed to apply Content API patch. Status: ${patchResp.status} ${patchResp.statusText}. Response: ${body}`, + pageId, + versionId, + }, 502); + } + + // 3) Restore the pre-change version (page ETag first; fallback to version ETag) + const restoreWithFallback = async () => { + const pageEtagAfterPatch = await getPageEtag(); + try { + await restorePageVersion( + authorURL, + pageId, + versionId, + bearerToken, + pageEtagAfterPatch, + log, + ); + return { ifMatch: 'page-etag' }; + } catch (e) { + const msg = String(e?.message || e); + const isPrecondition = msg.includes('Status: 412') || msg.toLowerCase().includes('precondition'); + if (!isPrecondition) { + throw e; + } + const { etag: versionEtag } = await getPageVersion( + authorURL, + pageId, + versionId, + bearerToken, + log, + ); + await restorePageVersion( + authorURL, + pageId, + versionId, + bearerToken, + versionEtag, + log, + ); + return { ifMatch: 'version-etag' }; + } + }; + + let restoreMeta; + try { + restoreMeta = await restoreWithFallback(); + } catch (e) { + return createResponse({ + message: `Patch applied but failed to restore version ${versionId}. Error: ${e.message}`, + pageId, + versionId, + }, 502); + } + + return ok({ + pageId, + versionId, + versionLabel, + applied: true, + restored: true, + restoreIfMatch: restoreMeta.ifMatch, + tagName, + }); + }; + const removeSuggestion = async (context) => { const siteId = context.params?.siteId; const opportunityId = context.params?.opportunityId; @@ -1941,6 +2179,7 @@ function SuggestionsController(ctx, sqs, env) { return { autofixSuggestions, + testMetaTagsAutofix, createSuggestions, deploySuggestionToEdge, rollbackSuggestionFromEdge, diff --git a/src/routes/index.js b/src/routes/index.js index 21d023da4..e5e775fc5 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -207,6 +207,7 @@ export default function getRouteHandlers( 'GET /sites/:siteId/opportunities/:opportunityId/suggestions/paged/:limit/:cursor': suggestionsController.getAllForOpportunityPaged, 'GET /sites/:siteId/opportunities/:opportunityId/suggestions/paged/:limit': suggestionsController.getAllForOpportunityPaged, 'PATCH /sites/:siteId/opportunities/:opportunityId/suggestions/auto-fix': suggestionsController.autofixSuggestions, + 'POST /sites/:siteId/opportunities/:opportunityId/suggestions/:suggestionId/auto-fix-test': suggestionsController.testMetaTagsAutofix, 'POST /sites/:siteId/opportunities/:opportunityId/suggestions/edge-deploy': suggestionsController.deploySuggestionToEdge, 'POST /sites/:siteId/opportunities/:opportunityId/suggestions/edge-rollback': suggestionsController.rollbackSuggestionFromEdge, 'POST /sites/:siteId/opportunities/:opportunityId/suggestions/edge-preview': suggestionsController.previewSuggestions,