Skip to content
Draft
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
1,788 changes: 1,566 additions & 222 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@adobe/spacecat-shared-ahrefs-client": "1.10.6",
"@adobe/spacecat-shared-athena-client": "1.9.5",
"@adobe/spacecat-shared-brand-client": "1.1.37",
"@adobe/spacecat-shared-content-client": "1.8.16",
"@adobe/spacecat-shared-data-access": "3.6.0",
"@adobe/spacecat-shared-data-access-v2": "npm:@adobe/spacecat-shared-data-access@2.109.0",
"@adobe/spacecat-shared-gpt-client": "1.6.18",
Expand Down
100 changes: 99 additions & 1 deletion src/controllers/fixes.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ import {
notFound,
ok,
} from '@adobe/spacecat-shared-http-utils';
// eslint-disable-next-line import/no-extraneous-dependencies -- listed in package.json dependencies
import { ContentClient } from '@adobe/spacecat-shared-content-client';
import {
hasText, isArray, isIsoDate, isNonEmptyObject, isValidUUID,
} from '@adobe/spacecat-shared-utils';
import AccessControlUtil from '../support/access-control-util.js';
import { FixDto } from '../dto/fix.js';
import { SuggestionDto } from '../dto/suggestion.js';
import { resolveDocumentPath } from '../support/document-path-resolver.js';
import { getImsUserToken } from '../support/utils.js';

const VALIDATION_ERROR_NAME = 'ValidationError';

Expand Down Expand Up @@ -68,12 +72,16 @@ export class FixesController {
/** @type {AccessControlUtil} */
#accessControl;

/** @type {LambdaContext} */
#ctx;

/**
* @param {LambdaContext} ctx
* @param {AccessControlUtil} [accessControl]
*/
constructor(ctx, accessControl = new AccessControlUtil(ctx)) {
const { dataAccess } = ctx;
this.#ctx = ctx;
this.#FixEntity = dataAccess.FixEntity;
this.#Opportunity = dataAccess.Opportunity;
this.#Site = dataAccess.Site;
Expand Down Expand Up @@ -220,10 +228,26 @@ export class FixesController {
return context.data ? badRequest('Request body must be an array') : badRequest('No updates provided');
}

const log = this.#ctx.log || console;

// Pre-fetch site and opportunity info for documentPath enrichment of manual fixes
const enrichmentCtx = await this.#prepareDocumentPathEnrichment(
context.data,
siteId,
opportunityId,
log,
);

const FixEntity = this.#FixEntity;
const fixes = await Promise.all(context.data.map(async (fixData, index) => {
try {
const fixEntity = await FixEntity.create({ ...fixData, opportunityId });
const enrichedFixData = await FixesController.#enrichWithDocumentPath(
fixData,
enrichmentCtx,
log,
);

const fixEntity = await FixEntity.create({ ...enrichedFixData, opportunityId });
if (fixData.suggestionIds) {
const suggestions = await Promise.all(
fixData.suggestionIds.map((id) => this.#Suggestion.findById(id)),
Expand Down Expand Up @@ -254,6 +278,80 @@ export class FixesController {
}, 207);
}

/**
* Prepares context for documentPath enrichment by pre-fetching site and opportunity.
* Only performs lookups when at least one fix in the batch is a manual fix (origin: 'aso')
* that doesn't already have a documentPath.
* For AEM Edge sites, also creates ContentClient when deliveryType is 'aem_edge'.
* @returns {Promise<{site, opportunityType, bearerToken, contentClient?}|null>}
*/
async #prepareDocumentPathEnrichment(fixDataArray, siteId, opportunityId, log) {
const needsEnrichment = fixDataArray.some(
(fixData) => fixData.origin === 'aso' && !fixData.changeDetails?.documentPath,
);
if (!needsEnrichment) return null;

try {
const [site, opportunity] = await Promise.all([
this.#Site.findById(siteId),
this.#Opportunity.findById(opportunityId),
]);

if (!site || !opportunity) return null;

const bearerToken = `Bearer ${getImsUserToken(this.#ctx)}`;
const enrichmentCtx = {
site,
opportunityType: opportunity.getType(),
bearerToken,
};

if (site.getDeliveryType() === 'aem_edge') {
try {
enrichmentCtx.contentClient = await ContentClient.createFrom(this.#ctx, site);
} catch (contentClientErr) {
log.warn(
`Could not create ContentClient for AEM Edge documentPath enrichment: ${contentClientErr.message}`,
);
}
}

return enrichmentCtx;
} catch (e) {
log.warn(`Could not prepare documentPath enrichment: ${e.message}`);
return null;
}
}

/**
* Enriches a fix data object with documentPath when it's a manual fix without one.
* Returns the original fixData unchanged if enrichment is not needed or fails.
*/
static async #enrichWithDocumentPath(fixData, enrichmentCtx, log) {
if (!enrichmentCtx) return fixData;
if (fixData.origin !== 'aso') return fixData;
if (fixData.changeDetails?.documentPath) return fixData;

const {
site, opportunityType, bearerToken, contentClient,
} = enrichmentCtx;
const documentPath = await resolveDocumentPath(
site,
opportunityType,
fixData.changeDetails,
bearerToken,
log,
contentClient ?? undefined, // used for AEM Edge
);

if (!documentPath) return fixData;

return {
...fixData,
changeDetails: { ...fixData.changeDetails, documentPath },
};
}

/**
* Update the status of one or multiple fixes in one transaction
*
Expand Down
193 changes: 193 additions & 0 deletions src/support/document-path-resolver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { determineAEMCSPageId, getPageEditUrl, prependSchema } from '@adobe/spacecat-shared-utils';

const VANITY_URL_MANAGER = 'vanityurlmgr';

const PAGE_URL_FIELDS = {
'broken-internal-links': 'urlFrom',
'meta-tags': 'url',
sitemap: 'pageUrl',
'structured-data': 'url',
canonical: 'url',
hreflang: 'url',
};

/**
* Extracts the page URL from changeDetails based on opportunity type.
* @param {string} opportunityType
* @param {Object} changeDetails
* @returns {string|null}
*/
function extractPageUrl(opportunityType, changeDetails) {
if (!changeDetails) return null;

const field = PAGE_URL_FIELDS[opportunityType];
if (!field) return null;

const url = changeDetails[field];
if (url) return url;

if (opportunityType === 'structured-data') {
return changeDetails.path || null;
}

return null;
}

/**
* Extracts the page pathname (for AEM Edge) from changeDetails.
* Returns a path starting with /, or null.
* @param {string} opportunityType
* @param {Object} changeDetails
* @returns {string|null}
*/
function extractPagePath(opportunityType, changeDetails) {
const pathOrUrl = opportunityType === 'structured-data'
? (changeDetails?.path || changeDetails?.url)
: extractPageUrl(opportunityType, changeDetails);
if (!pathOrUrl) return null;
if (pathOrUrl.startsWith('/')) return pathOrUrl;
try {
return new URL(prependSchema(pathOrUrl)).pathname;
} catch {
return null;
}
}

/**
* Resolves the AEM editor URL for broken-backlinks using site delivery config.
* For vanityurlmgr: uses urlEdited/urlsSuggested from suggestion data as best-effort.
* For all other redirect modes: returns the central redirects file URL.
* @param {Object} deliveryConfig
* @param {Object} changeDetails - suggestion data
* @returns {string|null}
*/
function resolveBrokenBacklinksDocPath(deliveryConfig, changeDetails) {
const authorURL = deliveryConfig?.authorURL;
// Caller (resolveDocumentPath) only invokes us when authorURL is set; this is defensive
/* c8 ignore next 1 - falsy authorURL branch unreachable from caller */
if (!authorURL) return null;

// deliveryConfig is defined when authorURL is set
const { redirectsMode, redirectsSource } = deliveryConfig;
if (redirectsMode === VANITY_URL_MANAGER) {
const targetUrl = changeDetails?.urlEdited
|| changeDetails?.urlsSuggested?.[0]
|| changeDetails?.urlSuggested?.[0];

if (targetUrl) {
try {
const targetPath = new URL(targetUrl).pathname;
return `${authorURL}/mnt/overlay/wcm/core/content/sites/properties.html?item=${targetPath}`;
} catch {
// malformed URL, fall through to default
}
}
}

if (redirectsSource) {
return `${authorURL}${redirectsSource}`;
}

return null;
}

/**
* Resolves the AEM Edge edit URL using ContentClient:
* pathname → resource path → edit or preview URL.
* @param {Object} contentClient - ContentClient instance
* @param {string} pagePath - URL pathname (e.g. /docs/page)
* @returns {Promise<string|null>}
*/
async function resolveAEMEdgeEditUrl(contentClient, pagePath) {
const documentPath = await contentClient.getResourcePath(pagePath);
if (!documentPath) return null;
const docPath = documentPath.replace(/[.]md$/, '');
const editUrl = await contentClient.getEditURL(docPath);
if (editUrl) return editUrl;
const urls = await contentClient.getLivePreviewURLs(docPath);
return urls?.previewURL ?? null;
}

/**
* Resolves the AEM editor documentPath for a given opportunity type and suggestion data.
* For broken-backlinks: uses site-level config (no per-page resolution).
* For page-level opportunities on AEM_CS: resolves page ID and fetches the edit URL.
* For AEM_EDGE: uses ContentClient to resolve pathname → resource path → edit/preview URL
* (when contentClient is provided).
*
* @param {Object} site - Site entity with getDeliveryType(), getDeliveryConfig()
* @param {string} opportunityType - e.g. 'broken-backlinks', 'meta-tags'
* @param {Object} changeDetails - the suggestion data / fix changeDetails
* @param {string} bearerToken - full Authorization header value (e.g. 'Bearer xxx')
* @param {Object} [log] - logger
* @param {Object} [contentClient] - ContentClient for AEM Edge (required when aem_edge)
* @returns {Promise<string|null>} the editor URL or null
*/
export async function resolveDocumentPath(
site,
opportunityType,
changeDetails,
bearerToken,
log = console,
contentClient = null,
) {
try {
const deliveryType = site.getDeliveryType();
const deliveryConfig = site.getDeliveryConfig();
const authorURL = deliveryConfig?.authorURL;

if (opportunityType === 'broken-backlinks') {
if (!authorURL) return null;
return resolveBrokenBacklinksDocPath(deliveryConfig, changeDetails);
}

if (deliveryType === 'aem_cs') {
if (!authorURL) return null;
// meta-tags may have page_id directly in changeDetails (no URL → pageId resolution needed)
if (opportunityType === 'meta-tags') {
const directPageId = changeDetails?.page_id ?? changeDetails?.pageId;
if (directPageId) {
return await getPageEditUrl(authorURL, bearerToken, directPageId);
}
}

const pageUrl = extractPageUrl(opportunityType, changeDetails);
if (!pageUrl) return null;

const preferContentApi = deliveryConfig?.preferContentApi ?? false;
const pageId = await determineAEMCSPageId(
pageUrl,
authorURL,
bearerToken,
preferContentApi,
log,
);
if (!pageId) return null;

return await getPageEditUrl(authorURL, bearerToken, pageId);
}

if (deliveryType === 'aem_edge' && contentClient) {
const pagePath = extractPagePath(opportunityType, changeDetails);
if (!pagePath) return null;
return await resolveAEMEdgeEditUrl(contentClient, pagePath);
}

return null;
} catch (e) {
log.warn(`Failed to resolve documentPath for ${opportunityType}: ${e.message}`);
return null;
}
}
Loading