Skip to content
Merged
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
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@
"@adobe/helix-status": "10.1.5",
"@adobe/helix-universal-logger": "3.0.28",
"@adobe/spacecat-helix-content-sdk": "1.4.33",
"@adobe/spacecat-shared-data-access": "3.47.0",
"@adobe/spacecat-shared-ahrefs-client": "1.10.9",
"@adobe/spacecat-shared-athena-client": "1.9.11",
"@adobe/spacecat-shared-brand-client": "1.1.40",
"@adobe/spacecat-shared-content-client": "1.8.22",
"@adobe/spacecat-shared-data-access": "^3.44.0",
"@adobe/spacecat-shared-data-access-v2": "npm:@adobe/spacecat-shared-data-access@2.109.0",
"@adobe/spacecat-shared-drs-client": "1.4.2",
"@adobe/spacecat-shared-gpt-client": "1.6.21",
Expand Down
172 changes: 137 additions & 35 deletions src/controllers/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import { Suggestion as SuggestionModel, GeoExperiment as GeoExperimentModel } fr
import TokowakaClient from '@adobe/spacecat-shared-tokowaka-client';
import DrsClient, { EXPERIMENT_PHASES } from '@adobe/spacecat-shared-drs-client';
import { SuggestionDto, SUGGESTION_VIEWS, SUGGESTION_SKIP_REASONS } from '../dto/suggestion.js';
import {
getScheduleParams,
buildExperimentMetadata,
} from '../support/geo-experiment-helper.js';
import { FixDto } from '../dto/fix.js';
import { GeoExperimentDto } from '../dto/geo-experiment.js';
import {
Expand All @@ -47,14 +51,6 @@ import { grantSuggestionsForOpportunity } from '../support/grant-suggestions-han

const VALIDATION_ERROR_NAME = 'ValidationError';

const GEO_EXPERIMENT_SCHEDULE = Object.freeze({
PRE_CRON_EXPRESSION: '0 * * * *', // hourly (fires immediately via triggerImmediately: true)
// 5 minutes — only needs to live long enough for the immediate trigger
PRE_EXPIRY_MS: 14 * 60 * 60 * 1000, // 14 hours
PLATFORMS: ['chatgpt_free', 'perplexity'],
PROVIDER_IDS: ['brightdata', 'openai_web_search'],
});

/**
* Suggestions controller.
* @param {object} ctx - Context of the request.
Expand Down Expand Up @@ -187,7 +183,7 @@ function SuggestionsController(ctx, sqs, env) {
};

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

if (!isObject(Opportunity)) {
Expand Down Expand Up @@ -1704,6 +1700,16 @@ function SuggestionsController(ctx, sqs, env) {

let geoExperiment = null;
try {
const preScheduleParams = getScheduleParams(
context,
GeoExperimentModel.TYPES.ONSITE_OPPORTUNITY_DEPLOYMENT,
opportunity.getType(),
'pre',
);
if (!preScheduleParams.cronExpression || !preScheduleParams.expiryMs) {
context.log.warn(`[edge-geo-exp-failed] site: ${apexBaseUrl}, missing schedule config for pre phase`);
throw new Error('Missing required environment variables');
}
const { s3Client, s3Bucket, PutObjectCommand } = context.s3;
let promptSources;
if (domainWideSuggestions.length > 0) {
Expand Down Expand Up @@ -1750,19 +1756,22 @@ function SuggestionsController(ctx, sqs, env) {
promptsCount: prompts.length,
promptsLocation: promptsS3Key,
status: GeoExperimentModel.STATUSES.GENERATING_BASELINE,
phase: GeoExperimentModel.PHASES.PRE_ANALYSIS_SUBMITTED,
phase: GeoExperimentModel.PHASES.PRE_ANALYSIS_STARTED,
suggestionIds: validSuggestionIds,
metadata: {
urls,
},
metadata: buildExperimentMetadata(
context,
{ urls },
GeoExperimentModel.TYPES.ONSITE_OPPORTUNITY_DEPLOYMENT,
opportunity.getType(),
),
updatedBy: profile?.email || 'geo-experiment',
});

if (!geoExperiment?.getId?.()) {
throw new Error('GeoExperiment was not created');
}

context.log.info(`[edge-geo-exp] Created GeoExperiment ${geoExperimentId} with status GENERATING_BASELINE / phase PRE_ANALYSIS_SUBMITTED`);
context.log.info(`[edge-geo-exp] Created GeoExperiment ${geoExperimentId} with status GENERATING_BASELINE / phase PRE_ANALYSIS_STARTED`);

let preScheduleId;
try {
Expand All @@ -1771,10 +1780,10 @@ function SuggestionsController(ctx, sqs, env) {
siteId,
experimentId: geoExperimentId,
experimentPhase: EXPERIMENT_PHASES.PRE,
cronExpression: GEO_EXPERIMENT_SCHEDULE.PRE_CRON_EXPRESSION,
expiresAt: new Date(Date.now() + GEO_EXPERIMENT_SCHEDULE.PRE_EXPIRY_MS).toISOString(),
platforms: GEO_EXPERIMENT_SCHEDULE.PLATFORMS,
providerIds: GEO_EXPERIMENT_SCHEDULE.PROVIDER_IDS,
cronExpression: preScheduleParams.cronExpression,
expiresAt: new Date(Date.now() + preScheduleParams.expiryMs).toISOString(),
platforms: preScheduleParams.platforms,
providerIds: preScheduleParams.providerIds,
triggerImmediately: true,
enableBrandPresence: true,
metadata: { triggered_by: 'spacecat-edge-deploy', opportunityId },
Expand All @@ -1796,20 +1805,6 @@ function SuggestionsController(ctx, sqs, env) {
context.log.error(`[edge-geo-exp-failed] site: ${apexBaseUrl}, Failed to update GeoExperiment pre schedule ID: ${updateError.message}. DRS schedule ${preScheduleId} will expire naturally.`, updateError);
throw updateError;
}
let job;
try {
job = await AsyncJob.create({
status: 'IN_PROGRESS',
metadata: {
jobType: 'geo-experiment',
siteId,
geoExperimentId,
},
});
} catch (jobError) {
context.log.error(`[edge-geo-exp-failed] site: ${apexBaseUrl}, AsyncJob creation failed: ${jobError.message}`, jobError);
throw jobError;
}
const validSuggestionEntities = [
...validSuggestions,
...domainWideSuggestions.map(({ suggestion }) => suggestion),
Expand Down Expand Up @@ -1850,10 +1845,9 @@ function SuggestionsController(ctx, sqs, env) {
success: validSuggestionIds.length,
failed: failedSuggestions.length,
},
jobId: job.getId(),
geoExperimentId,
geoExperimentStatus: GeoExperimentModel.STATUSES.GENERATING_BASELINE,
geoExperimentPhase: GeoExperimentModel.PHASES.PRE_ANALYSIS_SUBMITTED,
geoExperimentPhase: GeoExperimentModel.PHASES.PRE_ANALYSIS_STARTED,
prePhaseScheduleId: preScheduleId,
};
experimentResponse.suggestions.sort((a, b) => a.index - b.index);
Expand All @@ -1867,8 +1861,26 @@ function SuggestionsController(ctx, sqs, env) {
} catch (removeError) {
context.log.error(`[edge-geo-exp-failed] Failed to clean up GeoExperiment ${geoExperimentId}: ${removeError.message}`, removeError);
}
/* c8 ignore stop */
}
const allSuggestionEntities = [
...validSuggestions,
...domainWideSuggestions.map(({ suggestion }) => suggestion),
];
await Promise.allSettled(
allSuggestionEntities
.filter((s) => s.getData()?.edgeOptimizeStatus === 'EXPERIMENT_IN_PROGRESS')
.map(async (s) => {
try {
const { edgeOptimizeStatus: _, ...rest } = s.getData();
s.setData(rest);
s.setUpdatedBy(profile?.email || 'geo-experiment');
await s.save();
} catch (unblockError) {
context.log.error(`[edge-geo-exp-failed] Failed to unblock suggestion ${s.getId()}: ${unblockError.message}`, unblockError);
}
}),
);
/* c8 ignore stop */
const errorResponse = {
suggestions: suggestionIds.map((id, index) => ({
uuid: id,
Expand Down Expand Up @@ -2021,6 +2033,94 @@ function SuggestionsController(ctx, sqs, env) {
});
};

/**
* Patches a geo experiment. All fields are patchable except
* createdAt, updatedAt, and updatedBy (managed automatically).
*/
const patchGeoExperiment = async (context) => {
const { siteId, geoExperimentId } = context.params;
const { authInfo: { profile } } = context.attributes;

if (!isValidUUID(siteId)) return badRequest('Site ID required');
if (!isValidUUID(geoExperimentId)) return badRequest('GeoExperiment ID required');

const requestBody = context.data;
if (!isObject(requestBody)) return badRequest('Request body required');

const site = await Site.findById(siteId);
if (!site) return notFound('Site not found');

if (!await accessControlUtil.hasAccess(site)) {
return forbidden('User does not have access to this site');
}

const geoExperiment = await GeoExperiment.findById(geoExperimentId);
if (!geoExperiment || geoExperiment.getSiteId() !== siteId) {
return notFound('GeoExperiment not found');
}

const PATCHABLE_FIELDS = [
{ key: 'name', setter: 'setName' },
{ key: 'status', setter: 'setStatus' },
{ key: 'phase', setter: 'setPhase' },
{ key: 'type', setter: 'setType' },
{ key: 'preScheduleId', setter: 'setPreScheduleId' },
{ key: 'postScheduleId', setter: 'setPostScheduleId' },
{ key: 'suggestionIds', setter: 'setSuggestionIds' },
{ key: 'promptsCount', setter: 'setPromptsCount' },
{ key: 'promptsLocation', setter: 'setPromptsLocation' },
{ key: 'startTime', setter: 'setStartTime' },
{ key: 'endTime', setter: 'setEndTime' },
{ key: 'metadata', setter: 'setMetadata' },
{ key: 'error', setter: 'setError' },
];

let updates = false;
for (const { key, setter } of PATCHABLE_FIELDS) {
if (requestBody[key] !== undefined) {
geoExperiment[setter](requestBody[key]);
updates = true;
}
}

if (!updates) {
return badRequest('No valid fields to update');
}

geoExperiment.setUpdatedBy(profile?.email || 'geo-experiment');
const updated = await geoExperiment.save();
return ok(GeoExperimentDto.toJSON(updated));
};

/**
* Deletes a geo experiment.
*/
const deleteGeoExperiment = async (context) => {
const { siteId, geoExperimentId } = context.params;

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

const site = await Site.findById(siteId);
if (!site) return notFound('Site not found');

if (!await accessControlUtil.hasAccess(site)) {
return forbidden('User does not have access to this site');
}

const geoExperiment = await GeoExperiment.findById(geoExperimentId);
if (!geoExperiment || geoExperiment.getSiteId() !== siteId) {
return notFound('GeoExperiment not found');
}

await geoExperiment.remove();
return noContent();
};

const rollbackSuggestionFromEdge = async (context) => {
const { siteId, opportunityId } = context.params;
const { authInfo: { profile } } = context.attributes;
Expand Down Expand Up @@ -2398,6 +2498,8 @@ function SuggestionsController(ctx, sqs, env) {
deploySuggestionToEdge,
listGeoExperiments,
getGeoExperiment,
patchGeoExperiment,
deleteGeoExperiment,
rollbackSuggestionFromEdge,
previewSuggestions,
fetchFromEdge,
Expand Down
2 changes: 2 additions & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ export default function getRouteHandlers(
'DELETE /sites/:siteId/opportunities/:opportunityId/suggestions/:suggestionId': suggestionsController.removeSuggestion,
'GET /sites/:siteId/geo-experiments': suggestionsController.listGeoExperiments,
'GET /sites/:siteId/geo-experiments/:geoExperimentId': suggestionsController.getGeoExperiment,
'PATCH /sites/:siteId/geo-experiments/:geoExperimentId': suggestionsController.patchGeoExperiment,
'DELETE /sites/:siteId/geo-experiments/:geoExperimentId': suggestionsController.deleteGeoExperiment,
'GET /sites/:siteId/traffic/paid': paidController.getTopPaidPages,
'GET /sites/:siteId/traffic/paid/page-type-platform-campaign': trafficController.getPaidTrafficByPageTypePlatformCampaign,
'GET /sites/:siteId/traffic/paid/url-page-type': trafficController.getPaidTrafficByUrlPageType,
Expand Down
2 changes: 2 additions & 0 deletions src/routes/required-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export const INTERNAL_ROUTES = [
// Geo experiment — list and detail endpoints (detail includes prompts) used by DRS/UI
'GET /sites/:siteId/geo-experiments',
'GET /sites/:siteId/geo-experiments/:geoExperimentId',
'PATCH /sites/:siteId/geo-experiments/:geoExperimentId',
'DELETE /sites/:siteId/geo-experiments/:geoExperimentId',

// Slack - event subscriptions and commands use Slack's signature verification
'GET /slack/events',
Expand Down
Loading
Loading