Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ paths:
$ref: './site-opportunities.yaml#/site-opportunity-suggestions-status'
/sites/{siteId}/opportunities/{opportunityId}/suggestions/auto-fix:
$ref: './site-opportunities.yaml#/site-opportunity-suggestions-auto-fix'
/sites/{siteId}/opportunities/{opportunityId}/suggestions/auto-fix-v2:
$ref: './site-opportunities.yaml#/site-opportunity-suggestions-auto-fix-v2'
/sites/{siteId}/site-enrollments:
$ref: './site-enrollments-api.yaml#/site-enrollments-by-site'
/sites/{siteId}/user-activities:
Expand Down
65 changes: 64 additions & 1 deletion docs/openapi/site-opportunities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -510,9 +510,14 @@ site-opportunity-suggestions-auto-fix:
- $ref: './parameters.yaml#/opportunityId'
patch:
operationId: autoFixSiteOpportunitySuggestions
deprecated: true
summary: |
Trigger autofix for one or multiple suggestions
Trigger autofix for one or multiple suggestions (deprecated)
description: |
**Deprecated** — use the v2 endpoint (`/sites/{siteId}/opportunities/{opportunityId}/suggestions/auto-fix-v2`)
instead, which reads the promise token from the request `promise_token` cookie rather
than creating one from the Authorization header via IMS.

Triggers the autofix worker for the given suggestions. By default, suggestions are
transitioned to IN_PROGRESS status before the message is queued. When `action` is set
to `"assess"`, suggestions remain in their current status (no IN_PROGRESS transition)
Expand Down Expand Up @@ -556,6 +561,64 @@ site-opportunity-suggestions-auto-fix:
security:
- ims_key: [ ]

site-opportunity-suggestions-auto-fix-v2:
parameters:
- $ref: './parameters.yaml#/siteId'
- $ref: './parameters.yaml#/opportunityId'
patch:
operationId: autoFixSiteOpportunitySuggestionsV2
summary: |
Trigger autofix for one or multiple suggestions (v2)
description: |
Triggers the autofix worker for the given suggestions. The promise token is read from
the `promise_token` cookie on the request instead of being created from the Authorization
header via IMS (as in v1). Returns 400 if the cookie is missing or empty.

By default, suggestions are transitioned to IN_PROGRESS status before the message is
queued. When `action` is set to `"assess"`, suggestions remain in their current status
(no IN_PROGRESS transition) and the worker performs a read-only fixability assessment
instead.
tags:
- opportunity-suggestions
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
suggestionIds:
type: array
items:
$ref: './schemas.yaml#/Id'
action:
type: string
description: |
Optional action to forward to the autofix worker. When set to `"assess"`,
the suggestion status transition to IN_PROGRESS is skipped and the worker
performs a read-only fixability assessment.
customData:
description: Additional custom data to forward to the autofix worker
required:
- suggestionIds

responses:
'207':
description: |
A list of suggestions that are sent to autofix,
or the status code and error message for the ones failed.
content:
application/json:
schema:
$ref: './schemas.yaml#/SuggestionStatusUpdateListResponse'
examples:
broken-backlinks-suggestions-status-update-response:
$ref: './examples.yaml#/broken-backlinks-suggestions-status-update-response'
'400':
description: Promise token cookie is missing or empty.
security:
- ims_key: [ ]

site-opportunity-suggestion:
parameters:
- $ref: './parameters.yaml#/siteId'
Expand Down
180 changes: 180 additions & 0 deletions src/controllers/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,10 @@ function SuggestionsController(ctx, sqs, env) {
};
return createResponse(fullResponse, 207);
};
/**
* @deprecated Use autofixSuggestionsV2 instead, which reads the promise token from
* the request cookie rather than creating one from the Authorization header via IMS.
*/
const autofixSuggestions = async (context) => {
const siteId = context.params?.siteId;
const opportunityId = context.params?.opportunityId;
Expand Down Expand Up @@ -950,6 +954,181 @@ function SuggestionsController(ctx, sqs, env) {
return createResponse(response, 207);
};

const autofixSuggestionsV2 = async (context) => {
const siteId = context.params?.siteId;
const opportunityId = context.params?.opportunityId;

if (!isValidUUID(siteId)) {
return badRequest('Site ID required');
}

if (!isValidUUID(opportunityId)) {
return badRequest('Opportunity ID required');
}

if (!isNonEmptyObject(context.data)) {
return badRequest('No updates provided');
}
const {
suggestionIds, variations, action, customData,
} = context.data;

if (!isArray(suggestionIds)) {
return badRequest('Request body must be an array of suggestionIds');
}
if (variations && !isArray(variations)) {
return badRequest('variations must be an array');
}
if (action !== undefined && !hasText(action)) {
return badRequest('action cannot be empty');
}
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 configuration = await Configuration.findLatest();
if (!configuration.isHandlerEnabledForSite(`${opportunity.getType()}-auto-fix`, site)) {
return badRequest(`Handler is not enabled for site ${site.getId()} autofix type ${opportunity.getType()}`);
}
const suggestions = await Suggestion.allByOpportunityId(
opportunityId,
);
const validSuggestions = [];
const failedSuggestions = [];
suggestions.forEach((suggestion) => {
if (suggestionIds.includes(suggestion.getId())) {
/* c8 ignore start */
if (isDomainWideSuggestion(suggestion)) {
failedSuggestions.push({
uuid: suggestion.getId(),
index: suggestionIds.indexOf(suggestion.getId()),
message: 'Domain-wide aggregate suggestions cannot be auto-fixed individually',
statusCode: 400,
});
/* c8 ignore stop */
} else if (suggestion.getStatus() === SuggestionModel.STATUSES.NEW) {
validSuggestions.push(suggestion);
} else {
failedSuggestions.push({
uuid: suggestion.getId(),
index: suggestionIds.indexOf(suggestion.getId()),
message: 'Suggestion is not in NEW status',
statusCode: 400,
});
}
}
});

let suggestionGroups;
if (shouldGroupSuggestionsForAutofix(opportunity.getType())) {
const opportunityData = opportunity.getData();
const suggestionsByUrl = validSuggestions.reduce((acc, suggestion) => {
const data = suggestion.getData();
const url = data?.url || data?.recommendations?.[0]?.pageUrl
|| data?.url_from
|| data?.urlFrom
|| opportunityData?.page;
if (!url) return acc;

if (!acc[url]) {
acc[url] = [];
}
acc[url].push(suggestion);
return acc;
}, {});

suggestionGroups = Object.entries(suggestionsByUrl).map(([url, groupedSuggestions]) => ({
groupedSuggestions,
url,
}));
}

suggestionIds.forEach((suggestionId, index) => {
if (!suggestions.find((s) => s.getId() === suggestionId)) {
failedSuggestions.push({
uuid: suggestionId,
index,
message: 'Suggestion not found',
statusCode: 404,
});
}
});
let succeededSuggestions = [];
if (isNonEmptyArray(validSuggestions)) {
succeededSuggestions = await Suggestion.bulkUpdateStatus(
validSuggestions,
SuggestionModel.STATUSES.IN_PROGRESS,
);
}

const cookieHeader = context.pathInfo?.headers?.cookie || '';
const promiseTokenMatch = cookieHeader.match(/(?:^|;\s*)promise_token=([^;]*)/);
const promiseTokenValue = promiseTokenMatch?.[1];
if (!hasText(promiseTokenValue)) {
return badRequest('Promise token cookie is required');
}
const promiseTokenResponse = { promise_token: promiseTokenValue };

const response = {
suggestions: [
...succeededSuggestions.map((suggestion) => ({
uuid: suggestion.getId(),
index: suggestionIds.indexOf(suggestion.getId()),
statusCode: 200,
suggestion: SuggestionDto.toJSON(suggestion),
})),
...failedSuggestions,
],
metadata: {
total: suggestionIds.length,
success: succeededSuggestions.length,
failed: failedSuggestions.length,
},
};
response.suggestions.sort((a, b) => a.index - b.index);
const { AUTOFIX_JOBS_QUEUE: queueUrl } = env;

if (shouldGroupSuggestionsForAutofix(opportunity.getType())) {
await Promise.all(
suggestionGroups.map(({ groupedSuggestions, url }) => sendAutofixMessage(
sqs,
queueUrl,
siteId,
opportunityId,
groupedSuggestions.map((s) => s.getId()),
promiseTokenResponse,
variations,
action,
customData,
{ url },
)),
);
} else {
await sendAutofixMessage(
sqs,
queueUrl,
siteId,
opportunityId,
succeededSuggestions.map((s) => s.getId()),
promiseTokenResponse,
variations,
action,
customData,
);
}

return createResponse(response, 207);
};

const removeSuggestion = async (context) => {
const siteId = context.params?.siteId;
const opportunityId = context.params?.opportunityId;
Expand Down Expand Up @@ -1955,6 +2134,7 @@ function SuggestionsController(ctx, sqs, env) {

return {
autofixSuggestions,
autofixSuggestionsV2,
createSuggestions,
deploySuggestionToEdge,
rollbackSuggestionFromEdge,
Expand Down
1 change: 1 addition & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,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,
'PATCH /sites/:siteId/opportunities/:opportunityId/suggestions/auto-fix-v2': suggestionsController.autofixSuggestionsV2,
'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,
Expand Down
Loading