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
24 changes: 12 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 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 @@ -77,16 +77,16 @@
"@adobe/spacecat-shared-ahrefs-client": "1.10.3",
"@adobe/spacecat-shared-athena-client": "1.9.2",
"@adobe/spacecat-shared-brand-client": "1.1.34",
"@adobe/spacecat-shared-data-access": "2.97.1",
"@adobe/spacecat-shared-data-access": "https://gist.githubusercontent.com/sandsinh/40ebf34d5a83568c9f2571d9eca6cc54/raw/cbe43dc5a7bf4efef3ba543bb43f5e87183dd207/adobe-spacecat-shared-data-access-2.97.2.tgz",
"@adobe/spacecat-shared-gpt-client": "1.6.15",
"@adobe/spacecat-shared-http-utils": "1.19.4",
"@adobe/spacecat-shared-ims-client": "1.11.7",
"@adobe/spacecat-shared-rum-api-client": "2.40.3",
"@adobe/spacecat-shared-scrape-client": "2.3.6",
"@adobe/spacecat-shared-slack-client": "1.5.32",
"@adobe/spacecat-shared-tier-client": "1.3.10",
"@adobe/spacecat-shared-tier-client": "1.3.11",
"@adobe/spacecat-shared-tokowaka-client": "1.5.7",
"@adobe/spacecat-shared-utils": "1.88.0",
"@adobe/spacecat-shared-utils": "https://gist.githubusercontent.com/sandsinh/40ebf34d5a83568c9f2571d9eca6cc54/raw/cbe43dc5a7bf4efef3ba543bb43f5e87183dd207/adobe-spacecat-shared-utils-1.89.1.tgz",
"@aws-sdk/client-s3": "3.940.0",
"@aws-sdk/client-sfn": "3.940.0",
"@aws-sdk/client-sqs": "3.940.0",
Expand Down
2 changes: 2 additions & 0 deletions src/controllers/opportunities.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { ValidationError } from '@adobe/spacecat-shared-data-access';
import { OpportunityDto } from '../dto/opportunity.js';
import AccessControlUtil from '../support/access-control-util.js';
import { grantCompleteSuggestionsForOpportunity } from '../support/grant-complete-suggestions.js';

/**
* Opportunities controller.
Expand Down Expand Up @@ -155,6 +156,7 @@ function OpportunitiesController(ctx) {
if (!oppty || oppty.getSiteId() !== siteId) {
return notFound('Opportunity not found');
}
await grantCompleteSuggestionsForOpportunity(dataAccess, site, oppty);
return ok(OpportunityDto.toJSON(oppty));
};

Expand Down
39 changes: 29 additions & 10 deletions src/controllers/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import {
hasText,
isArray, isNonEmptyArray,
isGranted,
isNonEmptyObject,
isObject,
isInteger,
Expand Down Expand Up @@ -77,7 +78,7 @@ function SuggestionsController(ctx, sqs, env) {
};

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

if (!isObject(Opportunity)) {
Expand All @@ -90,6 +91,20 @@ function SuggestionsController(ctx, sqs, env) {

const accessControlUtil = AccessControlUtil.fromContext(ctx);

/**
* When the organization is freemium, returns only suggestions that have been granted (unlocked).
* Otherwise returns the same list unchanged.
* @param {Object} site - Site model (must have getOrganizationId()).
* @param {Array} suggestionEntities - List of suggestion entities.
* @returns {Array} Filtered list when freemium, otherwise unchanged.
*/
const filterGrantedIfFreemium = (site, suggestionEntities) => {
if (!suggestionEntities || suggestionEntities.length === 0) return suggestionEntities;
const organizationId = typeof site?.getOrganizationId === 'function' ? site.getOrganizationId() : undefined;
if (!organizationId || !Entitlement?.isFreemium(organizationId)) return suggestionEntities;
return suggestionEntities.filter((s) => isGranted(s));
};

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

const suggestionEntities = await Suggestion.allByOpportunityId(opptyId);
// Check if the opportunity belongs to the site
if (suggestionEntities.length > 0) {
const oppty = await suggestionEntities[0].getOpportunity();
if (!oppty || oppty.getSiteId() !== siteId) {
return notFound('Opportunity not found');
}
}
const suggestions = suggestionEntities.map((sugg) => SuggestionDto.toJSON(sugg));
const filtered = filterGrantedIfFreemium(site, suggestionEntities);
const suggestions = filtered.map((sugg) => SuggestionDto.toJSON(sugg));
return ok(suggestions);
};

Expand Down Expand Up @@ -170,15 +185,14 @@ function SuggestionsController(ctx, sqs, env) {
});
const { data: suggestionEntities = [], cursor: newCursor = null } = results;

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

const suggestions = suggestionEntities.map((sugg) => SuggestionDto.toJSON(sugg));
const filtered = filterGrantedIfFreemium(site, suggestionEntities);
const suggestions = filtered.map((sugg) => SuggestionDto.toJSON(sugg));

return ok({
suggestions,
Expand Down Expand Up @@ -219,14 +233,14 @@ function SuggestionsController(ctx, sqs, env) {
}

const suggestionEntities = await Suggestion.allByOpportunityIdAndStatus(opptyId, status);
// Check if the opportunity belongs to the site
if (suggestionEntities.length > 0) {
const oppty = await suggestionEntities[0].getOpportunity();
if (!oppty || oppty.getSiteId() !== siteId) {
return notFound('Opportunity not found');
}
}
const suggestions = suggestionEntities.map((sugg) => SuggestionDto.toJSON(sugg));
const filtered = filterGrantedIfFreemium(site, suggestionEntities);
const suggestions = filtered.map((sugg) => SuggestionDto.toJSON(sugg));
return ok(suggestions);
};

Expand Down Expand Up @@ -271,14 +285,14 @@ function SuggestionsController(ctx, sqs, env) {
returnCursor: true,
});
const { data: suggestionEntities = [], cursor: newCursor = null } = results;
// Check if the opportunity belongs to the site
if (suggestionEntities.length > 0) {
const oppty = await suggestionEntities[0].getOpportunity();
if (!oppty || oppty.getSiteId() !== siteId) {
return notFound('Opportunity not found');
}
}
const suggestions = suggestionEntities.map((sugg) => SuggestionDto.toJSON(sugg));
const filtered = filterGrantedIfFreemium(site, suggestionEntities);
const suggestions = filtered.map((sugg) => SuggestionDto.toJSON(sugg));
return ok({
suggestions,
pagination: {
Expand Down Expand Up @@ -328,6 +342,11 @@ function SuggestionsController(ctx, sqs, env) {
if (!opportunity || opportunity.getSiteId() !== siteId) {
return notFound();
}
// Freemium: only allow access to granted suggestions
const organizationId = typeof site?.getOrganizationId === 'function' ? site.getOrganizationId() : undefined;
if (organizationId && Entitlement?.isFreemium(organizationId) && !isGranted(suggestion)) {
return notFound('Suggestion not found');
}
return ok(SuggestionDto.toJSON(suggestion));
};

Expand Down
82 changes: 82 additions & 0 deletions src/support/grant-complete-suggestions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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 {
isGranted,
isSuggestionComplete,
OPPORTUNITY_TYPES,
} from '@adobe/spacecat-shared-utils';

/** Maps opportunity type (e.g. broken-backlinks) to token type for freemium grant. */
const OPPORTUNITY_TYPE_TO_TOKEN_TYPE = {
[OPPORTUNITY_TYPES.BROKEN_BACKLINKS]: 'BROKEN_BACKLINK',
[OPPORTUNITY_TYPES.CWV]: 'CWV',
[OPPORTUNITY_TYPES.ALT_TEXT]: 'ALT_TEXT',
};

/**
* When the organization is freemium, grants up to remaining token count for suggestions that are
* complete (per suggestion-complete.js) and not yet granted. Loads suggestions for the opportunity,
* then runs grant logic. Only runs for opportunity types that have token types and complete
* handlers (broken-backlinks, cwv, alt-text). Mutates and persists grants on suggestion entities.
*
* @param {Object} dataAccess - Data access (Suggestion, Token, Entitlement).
* @param {Object} site - Site model (getOrganizationId, getId).
* @param {Object} opportunity - Opportunity model (getType, getId).
* @returns {Promise<void>}
*/
export async function grantCompleteSuggestionsForOpportunity(dataAccess, site, opportunity) {
if (!dataAccess || !site || !opportunity) return;

const { Suggestion, Token, Entitlement } = dataAccess;
if (!Token || !Suggestion || !Entitlement) return;

const organizationId = typeof site?.getOrganizationId === 'function' ? site.getOrganizationId() : undefined;
if (!organizationId || !Entitlement.isFreemium(organizationId)) return;

const opportunityType = typeof opportunity.getType === 'function' ? opportunity.getType() : opportunity.type;
const tokenType = OPPORTUNITY_TYPE_TO_TOKEN_TYPE[opportunityType];
if (!tokenType) return;

const siteId = typeof site?.getId === 'function' ? site.getId() : undefined;
if (!siteId) return;

const opptyId = typeof opportunity?.getId === 'function' ? opportunity.getId() : opportunity.id;
if (!opptyId) return;

const suggestionEntities = await Suggestion.allByOpportunityId(opptyId);
if (!suggestionEntities?.length) return;

const cycle = new Date().toISOString().slice(0, 7); // YYYY-MM
const remaining = await Token.getRemainingToken(siteId, tokenType, cycle);
if (remaining < 1) return;

const completeUngranted = suggestionEntities
.filter((s) => !isGranted(s) && isSuggestionComplete(s, opportunityType))
.sort((a, b) => (a.getRank?.() ?? a.rank ?? 0) - (b.getRank?.() ?? b.rank ?? 0));

const toGrant = completeUngranted.slice(0, remaining);
const grantedAt = new Date().toISOString();

for (const suggestion of toGrant) {
// eslint-disable-next-line no-await-in-loop -- token use must be sequential per suggestion
const grant = await Token.useToken(siteId, tokenType, cycle);
if (!grant) break;
suggestion.setGrants({
cycle: grant.cycle,
tokenId: grant.tokenId,
grantedAt,
});
// eslint-disable-next-line no-await-in-loop -- save must complete before next iteration
await suggestion.save();
}
}
Loading