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
4,224 changes: 1,536 additions & 2,688 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"build": "hedy -v --test-bundle",
"deploy": "hedy -v --deploy --aws-deploy-bucket=spacecat-prod-deploy --pkgVersion=latest",
"deploy-stage": "hedy -v --deploy --aws-deploy-bucket=spacecat-stage-deploy --pkgVersion=latest",
"deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l latest --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h",
"deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l sandsinh --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h",
"deploy-secrets": "hedy --aws-update-secrets --params-file=secrets/secrets.env",
"docs": "npm run docs:lint && npm run docs:build",
"docs:build": "npx @redocly/cli build-docs -o ./docs/index.html --config docs/openapi/redocly-config.yaml",
Expand Down Expand Up @@ -76,7 +76,7 @@
"@adobe/spacecat-shared-ahrefs-client": "1.10.7",
"@adobe/spacecat-shared-athena-client": "1.9.7",
"@adobe/spacecat-shared-brand-client": "1.1.38",
"@adobe/spacecat-shared-data-access": "3.22.0",
"@adobe/spacecat-shared-data-access": "https://gist.githubusercontent.com/sandsinh/e6c7d5895ece4f0ad3b32a64701faf3b/raw/285c2ba0d9740c27994aa01b5df272138895c3b0/adobe-spacecat-shared-data-access-3.22.0.tgz",
"@adobe/spacecat-shared-data-access-v2": "npm:@adobe/spacecat-shared-data-access@2.109.0",
"@adobe/spacecat-shared-drs-client": "1.3.0",
"@adobe/spacecat-shared-gpt-client": "1.6.19",
Expand All @@ -87,7 +87,7 @@
"@adobe/spacecat-shared-slack-client": "1.6.3",
"@adobe/spacecat-shared-tier-client": "1.3.15",
"@adobe/spacecat-shared-tokowaka-client": "1.11.2",
"@adobe/spacecat-shared-utils": "1.102.1",
"@adobe/spacecat-shared-utils": "https://gist.githubusercontent.com/sandsinh/e6c7d5895ece4f0ad3b32a64701faf3b/raw/5f65e64f31849dcae0699a7744f07a23f77bcc21/adobe-spacecat-shared-utils-1.102.1.tgz",
"@adobe/spacecat-shared-vault-secrets": "1.3.0",
"@aws-sdk/client-s3": "3.1004.0",
"@aws-sdk/client-secrets-manager": "3.1004.0",
Expand Down
11 changes: 11 additions & 0 deletions src/controllers/opportunities.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
} from '@adobe/spacecat-shared-utils';
import { OpportunityDto } from '../dto/opportunity.js';
import AccessControlUtil from '../support/access-control-util.js';
import { grantSuggestionsForOpportunity } from '../support/grant-suggestions-handler.js';
import { getIsSummitPlgEnabled } from '../support/utils.js';

const VALIDATION_ERROR_NAME = 'ValidationError';

Expand Down Expand Up @@ -156,6 +158,15 @@ function OpportunitiesController(ctx) {
if (!oppty || oppty.getSiteId() !== siteId) {
return notFound('Opportunity not found');
}
const clientType = context.pathInfo?.headers?.['x-client-type'];
if (clientType === 'sites-optimizer-ui' && await getIsSummitPlgEnabled(site, ctx)) {
try {
await grantSuggestionsForOpportunity(dataAccess, site, oppty);
/* c8 ignore next 3 */
} catch (err) {
ctx.log?.warn?.('Grant suggestions handler failed', err?.message ?? err);
}
}
return ok(OpportunityDto.toJSON(oppty));
};

Expand Down
54 changes: 40 additions & 14 deletions src/controllers/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
isValidUUID,
isValidUrl,
} 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, SUGGESTION_SKIP_REASONS } from '../dto/suggestion.js';
Expand All @@ -38,6 +37,7 @@ import {
getIMSPromiseToken,
ErrorWithStatusCode,
getHostName,
getIsSummitPlgEnabled,
} from '../support/utils.js';
import AccessControlUtil from '../support/access-control-util.js';

Expand Down Expand Up @@ -174,7 +174,7 @@ function SuggestionsController(ctx, sqs, env) {
};

const {
Opportunity, Suggestion, Site, Configuration,
Opportunity, Suggestion, SuggestionGrant, Site, Configuration,
} = dataAccess;

if (!isObject(Opportunity)) {
Expand All @@ -187,6 +187,31 @@ function SuggestionsController(ctx, sqs, env) {

const accessControlUtil = AccessControlUtil.fromContext(ctx);

/**
* Filters suggestions to only granted ones when summit-plg is enabled for the site
* and the request originates from the sites-optimizer-ui client.
* Returns all suggestions unchanged when either condition is not met.
* @param {Object} site - Site entity.
* @param {Array} suggestions - Suggestion entities to filter.
* @param {Object} context - Request context.
* @returns {Promise<Array>} Filtered suggestion entities.
*/
const filterByGrantStatus = async (site, suggestions, context) => {
const clientType = context.pathInfo?.headers?.['x-client-type'];
if (clientType !== 'sites-optimizer-ui' || !await getIsSummitPlgEnabled(site, ctx)) {
return suggestions;
}
try {
const ids = suggestions.map((s) => s.getId());
const { grantedIds } = await SuggestionGrant.splitSuggestionsByGrantStatus(ids);
return suggestions.filter((s) => grantedIds.includes(s.getId()));
} catch (err) {
const message = 'Failed to filter suggestions by grant status';
ctx.log?.error?.(message, err?.message ?? err);
throw new Error(message, { cause: err });
}
};

/**
* Gets all suggestions for a given site and opportunity
* @param {Object} context of the request
Expand Down Expand Up @@ -227,24 +252,21 @@ function SuggestionsController(ctx, sqs, env) {

// Fetch all suggestions (single DB call)
let suggestionEntities = await Suggestion.allByOpportunityId(opptyId);

// Check if the opportunity belongs to the site
let opportunity = null;
if (suggestionEntities.length > 0) {
opportunity = await suggestionEntities[0].getOpportunity();
if (!opportunity || opportunity.getSiteId() !== siteId) {
return notFound('Opportunity not found');
}
}

// Filter by status in memory if validated statuses provided
if (statuses.length > 0) {
suggestionEntities = suggestionEntities.filter(
(sugg) => statuses.includes(sugg.getStatus()),
);
}

const suggestions = suggestionEntities.map(
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity),
);
return ok(suggestions);
Expand Down Expand Up @@ -295,16 +317,15 @@ function SuggestionsController(ctx, sqs, env) {
const suggestionEntities = results.data || [];
const newCursor = results.cursor || null;

// Check if the opportunity belongs to the site
let opportunity = null;
if (suggestionEntities.length > 0) {
opportunity = await suggestionEntities[0].getOpportunity();
if (!opportunity || opportunity.getSiteId() !== siteId) {
return notFound('Opportunity not found');
}
}

const suggestions = suggestionEntities.map(
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity),
);

Expand Down Expand Up @@ -352,15 +373,15 @@ function SuggestionsController(ctx, sqs, env) {
}

const suggestionEntities = await Suggestion.allByOpportunityIdAndStatus(opptyId, status);
// Check if the opportunity belongs to the site
let opportunity = null;
if (suggestionEntities.length > 0) {
opportunity = await suggestionEntities[0].getOpportunity();
if (!opportunity || opportunity.getSiteId() !== siteId) {
return notFound('Opportunity not found');
}
}
const suggestions = suggestionEntities.map(
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity),
);
return ok(suggestions);
Expand Down Expand Up @@ -411,15 +432,15 @@ function SuggestionsController(ctx, sqs, env) {
returnCursor: true,
});
const { data: suggestionEntities = [], cursor: newCursor = null } = results;
// Check if the opportunity belongs to the site
let opportunity = null;
if (suggestionEntities.length > 0) {
opportunity = await suggestionEntities[0].getOpportunity();
if (!opportunity || opportunity.getSiteId() !== siteId) {
return notFound('Opportunity not found');
}
}
const suggestions = suggestionEntities.map(
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity),
);
return ok({
Expand Down Expand Up @@ -475,6 +496,11 @@ function SuggestionsController(ctx, sqs, env) {
if (!opportunity || opportunity.getSiteId() !== siteId) {
return notFound();
}
const clientType = context.pathInfo?.headers?.['x-client-type'];
if (clientType === 'sites-optimizer-ui' && await getIsSummitPlgEnabled(site, ctx)
&& !(await SuggestionGrant.isSuggestionGranted(suggestion.getId()))) {
return notFound('Suggestion not found');
}
return ok(SuggestionDto.toJSON(suggestion, view, opportunity));
};

Expand Down
184 changes: 184 additions & 0 deletions src/support/grant-suggestions-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*
* 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 { Suggestion as SuggestionModel } from '@adobe/spacecat-shared-data-access';
import { getTokenGrantConfigByOpportunity } from '@adobe/spacecat-shared-utils';

/**
* Extracts rank from a suggestion entity or plain object.
*/
function getSuggestionRank(s) {
return typeof s?.getRank === 'function' ? s.getRank() : (s?.rank ?? 0);
}

/**
* Creates a suggestion group object.
* @param {Array} items - Suggestions in this group.
* @param {Function} [rankFn] - Custom rank function for the group.
* Defaults to the rank of the first item.
* @returns {{ items: Array, getRank: Function }}
*/
function createGroup(items, rankFn) {
return {
items,
getRank: rankFn ?? (() => getSuggestionRank(items[0])),
};
}

/**
* Default sort: by group rank ascending, then first item id ascending.
*/
function defaultSortFn(groupA, groupB) {
const rankA = groupA.getRank();
const rankB = groupB.getRank();
if (rankA !== rankB) return rankA - rankB;
const a = groupA.items[0];
const b = groupB.items[0];
const idA = typeof a?.getId === 'function' ? a.getId() : (a?.id ?? '');
const idB = typeof b?.getId === 'function' ? b.getId() : (b?.id ?? '');
return idA.localeCompare(idB);
}

/**
* Per-opportunity grouping and sorting strategies.
*
* Each entry is keyed by opportunity type and may define:
*
* groupFn(suggestions) => Array<Group>
* Groups suggestions into logical units. Each group is created via
* createGroup(items, rankFn?) and consumes one token when granted.
* Use this when multiple suggestions should be treated as a single
* grantable unit (e.g. all backlinks pointing to the same broken URL).
* The optional rankFn overrides how the group's rank is computed;
* if omitted, the group rank defaults to the first item's rank.
* Not needed when each suggestion should be granted independently
* (the default is one group per suggestion).
*
* sortFn(groupA, groupB) => number
* Custom comparator for ordering groups. Receives group objects
* with { items, getRank() }. Use this when the default sort
* (rank ascending, then first item's id ascending) does not match
* the desired grant priority for this opportunity type.
* Not needed when the default ascending-rank order is correct.
*
* Opportunities not listed here use the defaults: one group per
* suggestion, sorted by rank ascending then id ascending.
*/
const OPPORTUNITY_STRATEGIES = {
'broken-backlinks': {
groupFn: (suggestions) => {
const groups = new Map();
for (const suggestion of suggestions) {
const data = typeof suggestion?.getData === 'function'
? suggestion.getData()
: suggestion?.data;
const urlTo = data?.url_to ?? data?.urlTo ?? '';
if (!groups.has(urlTo)) {
groups.set(urlTo, []);
}
groups.get(urlTo).push(suggestion);
}
return [...groups.values()].map(
(items) => createGroup(
items,
() => Math.max(...items.map(getSuggestionRank)),
),
);
},
},
};

/**
* Groups and sorts suggestions for grant selection.
* Each group consumes one token when granted.
* Uses per-opportunity strategies from OPPORTUNITY_STRATEGIES,
* falling back to default (one group per suggestion, rank asc).
*
* @param {Array} suggestions - Suggestion entities or plain objects.
* @param {string} [opportunityName] - Opportunity name for
* strategy lookup.
* @returns {Array<{items: Array, getRank: Function}>} Sorted groups.
*/
export function getTopSuggestions(suggestions, opportunityName) {
if (!Array.isArray(suggestions) || suggestions.length === 0) {
return [];
}
const strategy = OPPORTUNITY_STRATEGIES[opportunityName] || {};
const { groupFn, sortFn } = strategy;
// c8 ignore: groupFn branch covered when strategies are added
const groups = groupFn
? groupFn(suggestions) /* c8 ignore next */
: suggestions.map((s) => createGroup([s]));
return [...groups].sort(sortFn ?? defaultSortFn);
}

/**
* Grants top ungranted suggestions for an opportunity. Ensures a
* token exists for the current cycle; if none, creates one with
* total = (config max) minus already-granted count. Then grants
* top ungranted groups up to the remaining token count.
*
* @param {Object} dataAccess - Data access collections.
* @param {Object} site - Site model (getId()).
* @param {Object} opportunity - Opportunity model
* (getId(), getType()).
* @returns {Promise<void>}
*/
export async function grantSuggestionsForOpportunity(dataAccess, site, opportunity) {
const Suggestion = dataAccess?.Suggestion;
const SuggestionGrant = dataAccess?.SuggestionGrant;
const Token = dataAccess?.Token;
const siteId = site?.getId();
const opptyId = opportunity?.getId();
const oppType = opportunity?.getType();
const config = oppType
? getTokenGrantConfigByOpportunity(oppType) : null;
const tokenType = config?.tokenType;

if (!Suggestion || !SuggestionGrant || !Token || !siteId || !opptyId || !config
|| !tokenType) return;

const { STATUSES } = SuggestionModel;
const newSuggestions = await Suggestion
.allByOpportunityIdAndStatus(opptyId, STATUSES.NEW);
const newSuggestionIds = newSuggestions.map((s) => s.getId());
if (!newSuggestionIds.length) return;

let token = await Token.findBySiteIdAndTokenType(siteId, tokenType);
if (!token) {
const { grantIds } = await SuggestionGrant
.splitSuggestionsByGrantStatus(newSuggestionIds);
const suppliedTotal = Math.max(1, config.tokensPerCycle - (grantIds?.length ?? 0));
token = await Token.findBySiteIdAndTokenType(siteId, tokenType, {
createIfNotFound: true,
total: suppliedTotal,
});
}

const remaining = token.getRemaining();
if (remaining <= 0) return;

const { notGrantedIds } = await SuggestionGrant
.splitSuggestionsByGrantStatus(newSuggestionIds);
const notGrantedEntities = newSuggestions
.filter((s) => notGrantedIds.includes(s.getId()));
const topGroups = getTopSuggestions(notGrantedEntities, oppType)
.slice(0, remaining);
await Promise.all(
topGroups.map((group) => {
const ids = group.items.map((s) => s.getId()).filter(Boolean);
return ids.length > 0
? SuggestionGrant.grantSuggestions(ids, siteId, tokenType)
: Promise.resolve();
}),
);
}
Loading
Loading