diff --git a/package-lock.json b/package-lock.json index 16bd1ebf3..a670f5a96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,16 +21,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", @@ -1307,9 +1307,9 @@ } }, "node_modules/@adobe/spacecat-shared-data-access": { - "version": "2.97.1", - "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-data-access/-/spacecat-shared-data-access-2.97.1.tgz", - "integrity": "sha512-M5xsWnnGNYwzSe0D0fQqX+UP3hVIzWaoH9G67C2csyHOMlvrCTVBa0e202e/OGDNwGN5/BuPrfhXq9prS6uHgw==", + "version": "2.97.2", + "resolved": "https://gist.githubusercontent.com/sandsinh/40ebf34d5a83568c9f2571d9eca6cc54/raw/cbe43dc5a7bf4efef3ba543bb43f5e87183dd207/adobe-spacecat-shared-data-access-2.97.2.tgz", + "integrity": "sha512-wcMdI6RqAoI0SMgqdOZhmwq7nw30oU5iGsO9tWc4eVKAvLdVj1gDFyxBydMr1pvX/+vzyv52fyzhkYPmDxZhKA==", "license": "Apache-2.0", "dependencies": { "@adobe/spacecat-shared-utils": "1.81.1", @@ -2498,9 +2498,9 @@ } }, "node_modules/@adobe/spacecat-shared-tier-client": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-tier-client/-/spacecat-shared-tier-client-1.3.10.tgz", - "integrity": "sha512-eqBEuboVczrzmvMj/dFTi9p8jBWIOlVe1u0x9qaUKtJJwTrAutFtj5QR7JfxhWcSQdKnOPg1B0rytt4gea+T1Q==", + "version": "1.3.11", + "resolved": "https://gist.githubusercontent.com/sandsinh/40ebf34d5a83568c9f2571d9eca6cc54/raw/2d4e5215b9a5639277fd03acaad254af9ca463f7/adobe-spacecat-shared-tier-client-1.3.11.tgz", + "integrity": "sha512-Qs2C2RIsEix9JURzqOMQHmE8Sy66MF4F6nsSIb5Fl437/dC6b4Gy+RSDW4wO8x8jhJNk59fkkrdJj3h31fr5yA==", "license": "Apache-2.0", "dependencies": { "@adobe/spacecat-shared-data-access": "2.88.7", @@ -2817,9 +2817,9 @@ } }, "node_modules/@adobe/spacecat-shared-utils": { - "version": "1.88.0", - "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-utils/-/spacecat-shared-utils-1.88.0.tgz", - "integrity": "sha512-KGvu8VEiF9TiuBXMkQzB/jqaBgSc4IRL84t9tGm7BgtetqESxxjiNfuQ4BIfsYGklB9jxfP/Suz+QQsR1BeG7g==", + "version": "1.89.1", + "resolved": "https://gist.githubusercontent.com/sandsinh/40ebf34d5a83568c9f2571d9eca6cc54/raw/cbe43dc5a7bf4efef3ba543bb43f5e87183dd207/adobe-spacecat-shared-utils-1.89.1.tgz", + "integrity": "sha512-fJCh4+hRzg62hvuT4FeAWkhDHGPrhgnaHtA+iBog/awvIdrSYUHXi/J/d26DY5s4XhkU9TfNink221Nxmw8Y/A==", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.2.3", diff --git a/package.json b/package.json index 15a34d0e0..2348217d1 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/controllers/opportunities.js b/src/controllers/opportunities.js index 928664870..a453db763 100644 --- a/src/controllers/opportunities.js +++ b/src/controllers/opportunities.js @@ -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. @@ -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)); }; diff --git a/src/controllers/suggestions.js b/src/controllers/suggestions.js index add9291ec..6e107a823 100644 --- a/src/controllers/suggestions.js +++ b/src/controllers/suggestions.js @@ -20,6 +20,7 @@ import { import { hasText, isArray, isNonEmptyArray, + isGranted, isNonEmptyObject, isObject, isInteger, @@ -77,7 +78,7 @@ function SuggestionsController(ctx, sqs, env) { }; const { - Opportunity, Suggestion, Site, Configuration, + Opportunity, Suggestion, Site, Configuration, Entitlement, } = dataAccess; if (!isObject(Opportunity)) { @@ -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 @@ -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); }; @@ -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, @@ -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); }; @@ -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: { @@ -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)); }; diff --git a/src/support/grant-complete-suggestions.js b/src/support/grant-complete-suggestions.js new file mode 100644 index 000000000..20ac1e5c4 --- /dev/null +++ b/src/support/grant-complete-suggestions.js @@ -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} + */ +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(); + } +}