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: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default defineConfig([
},
rules: {
'no-unused-expressions': 'off',
'import/no-unresolved': ['error', { ignore: ['@octokit/rest', 'is-language-code'] }],
'import/no-unresolved': ['error', { ignore: ['@octokit/rest', 'is-language-code', 'uuid'] }],
},
},
{
Expand Down
8,844 changes: 292 additions & 8,552 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"syllable": "5.0.1",
"text-readability": "1.1.1",
"urijs": "1.19.11",
"uuid": "^13.0.0",
"zod": "4.1.12"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@

import { ok } from '@adobe/spacecat-shared-http-utils';
import { FORM_OPPORTUNITY_TYPES, ORIGINS } from '../constants.js';
import { addSuggestions } from './suggestion-utils.js';

export default async function handler(message, context) {
const { log, dataAccess } = context;
const { Opportunity } = dataAccess;
const { auditId, siteId, data } = message;
const { url, guidance, form_source: formsource } = data;
const {
url,
guidance,
form_source: formsource,
suggestions,
} = data;
log.info(`[Form Opportunity] [Site Id: ${siteId}] message received in high-form-views-low-conversions guidance handler: ${JSON.stringify(message, null, 2)}`);

const existingOpportunities = await Opportunity.allBySiteId(siteId);
Expand All @@ -34,6 +40,7 @@ export default async function handler(message, context) {
const wrappedGuidance = { recommendations: guidance };
opportunity.setGuidance(wrappedGuidance);
opportunity.setUpdatedBy('system');
await addSuggestions(opportunity, suggestions);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opportunity gets created before this in low-conversion-handler.js with default guidance, shouldn't this be added there.
Scenario where mystique fails to send response back and oppty remains in backoffice without suggestion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the handler where it receives the guidance generated by Mystique. Suggestions are also generated by Mystique which we will update it here once we have implemented it there.
e.g

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if Mystique doesn’t provide guidance? We will loose on oppties to show on ASO UI as it'll not have suggestion object or am i missing something here.

Copy link

@vdua vdua Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there are no suggestions, or in pending_validation state they will not be visible in ASO UI. That is the contract as of now.

await opportunity.save();
log.debug(`[Form Opportunity] [Site Id: ${siteId}] high-form-views-low-conversions guidance updated oppty : ${JSON.stringify(opportunity, null, 2)}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@

import { ok } from '@adobe/spacecat-shared-http-utils';
import { FORM_OPPORTUNITY_TYPES, ORIGINS } from '../constants.js';
import { addSuggestions } from './suggestion-utils.js';

export default async function handler(message, context) {
const { log, dataAccess } = context;
const { Opportunity } = dataAccess;
const { auditId, siteId, data } = message;
const { url, guidance, form_source: formsource } = data;
const {
url,
guidance,
form_source: formsource,
suggestions,
} = data;
log.info(`[Form Opportunity] [Site Id: ${siteId}] message received in high-page-views-low-form-nav guidance handler: ${JSON.stringify(message, null, 2)}`);

const existingOpportunities = await Opportunity.allBySiteId(siteId);
Expand All @@ -34,6 +40,7 @@ export default async function handler(message, context) {
const wrappedGuidance = { recommendations: guidance };
opportunity.setGuidance(wrappedGuidance);
opportunity.setUpdatedBy('system');
await addSuggestions(opportunity, suggestions);
await opportunity.save();
log.debug(`[Form Opportunity] [Site Id: ${siteId}] high-page-views-low-form-nav guidance updated oppty: ${JSON.stringify(opportunity, null, 2)}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@

import { ok } from '@adobe/spacecat-shared-http-utils';
import { FORM_OPPORTUNITY_TYPES, ORIGINS } from '../constants.js';
import { addSuggestions } from './suggestion-utils.js';

export default async function handler(message, context) {
const { log, dataAccess } = context;
const { Opportunity } = dataAccess;
const { auditId, siteId, data } = message;
const { url, form_source: formsource, guidance } = data;
const {
url,
form_source: formsource,
guidance, suggestions,
} = data;
log.info(`[Form Opportunity] [Site Id: ${siteId}] message received in high-page-views-low-form-views guidance handler: ${JSON.stringify(message, null, 2)}`);

const existingOpportunities = await Opportunity.allBySiteId(siteId);
Expand All @@ -34,9 +39,9 @@ export default async function handler(message, context) {
const wrappedGuidance = { recommendations: guidance };
opportunity.setGuidance(wrappedGuidance);
opportunity.setUpdatedBy('system');
await addSuggestions(opportunity, suggestions);
await opportunity.save();
log.debug(`[Form Opportunity] [Site Id: ${siteId}] high-page-views-low-form-views guidance updated oppty: ${JSON.stringify(opportunity, null, 2)}`);
}

return ok();
}
71 changes: 71 additions & 0 deletions src/forms-opportunities/guidance-handlers/suggestion-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';

/**
* Fetches existing suggestions and merges them with new suggestions
* @param opportunity
* @param newSuggestions
* @returns {Promise<void>}
*/
export async function addSuggestions(
opportunity,
newSuggestions,
) {
const existingSuggestions = await opportunity.getSuggestions();

if (
(existingSuggestions && existingSuggestions.length > 0)
|| (newSuggestions && newSuggestions.length > 0)
) {
// merge existing and new suggestions and add to opportunity.
// To be done once M starts generating suggestions for this guidance
} else {
const emptySuggestionList = [
{
id: uuidv4(),
opportunityId: opportunity.opportunityId,
type: 'CONTENT_UPDATE',
rank: 1,
status: 'PENDING_VALIDATION',
data: {
variations: [
{
name: 'Control',
changes: [
{
type: 'text',
element: null,
text: 'Control',
},
],
variationEditPageUrl: null,
id: uuidv4(),
variationPageUrl: '',
explanation: null,
projectedImpact: null,
previewImage: '',
},
],
},
kpiDeltas: {
estimatedKPILift: 0,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
updatedBy: 'system',
},
];
await opportunity.addSuggestions(emptySuggestionList);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ describe('Guidance High Form Views Low Conversions Handler', () => {
save: sinon.stub().resolvesThis(),
getId: sinon.stub().resolves('testId'),
getSuggestions: sinon.stub().resolves([]),
addSuggestions: sinon.stub(),
setUpdatedBy: sinon.stub(),
};
dataAccessStub.Opportunity.allBySiteId.resolves([existingOpportunity]);
Expand Down Expand Up @@ -113,4 +114,58 @@ describe('Guidance High Form Views Low Conversions Handler', () => {
expect(error.message).to.deep.equal('fetch error');
}
});

it('should create empty suggestion object if none found', async () => {
const existingOpportunity = {
getData: sinon.stub().returns({ form: 'https://example.com', formsource: '.form' }),
getType: sinon.stub().returns(FORM_OPPORTUNITY_TYPES.LOW_CONVERSION),
setAuditId: sinon.stub(),
setGuidance: sinon.stub(),
addSuggestions: sinon.stub(),
save: sinon.stub().resolvesThis(),
getId: sinon.stub().resolves('testId'),
getSuggestions: sinon.stub().resolves([]),
setUpdatedBy: sinon.stub(),
};
dataAccessStub.Opportunity.allBySiteId.resolves([existingOpportunity]);

const messageWithoutSuggestions = {
auditId: 'audit-id',
siteId: 'site-id',
data: {
url: 'https://example.com',
form_source: '.form',
guidance: 'Some guidance'
},
};
await handler(messageWithoutSuggestions, context);
expect(existingOpportunity.addSuggestions).to.be.calledOnce;
});

it('should not create empty suggestion if any suggestion found', async () => {
const existingOpportunity = {
getData: sinon.stub().returns({ form: 'https://example.com', formsource: '.form' }),
getType: sinon.stub().returns(FORM_OPPORTUNITY_TYPES.LOW_CONVERSION),
setAuditId: sinon.stub(),
setGuidance: sinon.stub(),
addSuggestions: sinon.stub(),
save: sinon.stub().resolvesThis(),
getId: sinon.stub().resolves('testId'),
getSuggestions: sinon.stub().resolves(["existing suggestion"]),
setUpdatedBy: sinon.stub(),
};
dataAccessStub.Opportunity.allBySiteId.resolves([existingOpportunity]);

const messageWithoutSuggestions = {
auditId: 'audit-id',
siteId: 'site-id',
data: {
url: 'https://example.com',
form_source: '.form',
guidance: 'Some guidance'
},
};
await handler(messageWithoutSuggestions, context);
expect(existingOpportunity.addSuggestions).to.be.not.called;
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('Guidance High Page Views Low Form Navigation Handler', () => {
url: 'https://example.com',
form_source: '.form',
guidance: 'Some guidance',
suggestions: ['Suggestion 1', 'Suggestion 2'],
},
};
});
Expand All @@ -65,7 +66,10 @@ describe('Guidance High Page Views Low Form Navigation Handler', () => {
getData: sinon.stub().returns({ form: 'https://example.com', formsource: '.form' }),
getType: sinon.stub().returns(FORM_OPPORTUNITY_TYPES.LOW_NAVIGATION),
setAuditId: sinon.stub(),
addSuggestions: sinon.stub(),
setGuidance: sinon.stub(),
getSuggestions: sinon.stub().resolves([]),

save: sinon.stub().resolvesThis(),
setUpdatedBy: sinon.stub(),
};
Expand Down Expand Up @@ -105,4 +109,57 @@ describe('Guidance High Page Views Low Form Navigation Handler', () => {
expect(error.message).to.deep.equal('fetch error');
}
});

it('should create empty suggestion object if none found', async () => {
const existingOpportunity = {
getData: sinon.stub().returns({ form: 'https://example.com', formsource: '.form' }),
getType: sinon.stub().returns(FORM_OPPORTUNITY_TYPES.LOW_NAVIGATION),
setAuditId: sinon.stub(),
addSuggestions: sinon.stub(),
setGuidance: sinon.stub(),
getSuggestions: sinon.stub().resolves([]),
save: sinon.stub().resolvesThis(),
setUpdatedBy: sinon.stub(),
};
dataAccessStub.Opportunity.allBySiteId.resolves([existingOpportunity]);

const messageWithoutSuggestions = {
auditId: 'audit-id',
siteId: 'site-id',
data: {
url: 'https://example.com',
form_source: '.form',
guidance: 'Some guidance'
},
};
await handler(messageWithoutSuggestions, context);
expect(existingOpportunity.addSuggestions).to.be.calledOnce;
});

it('should not create empty suggestion if any suggestion found', async () => {
const existingOpportunity = {
getData: sinon.stub().returns({ form: 'https://example.com', formsource: '.form' }),
getType: sinon.stub().returns(FORM_OPPORTUNITY_TYPES.LOW_NAVIGATION),
setAuditId: sinon.stub(),
addSuggestions: sinon.stub(),
setGuidance: sinon.stub(),
getSuggestions: sinon.stub().resolves(["existing suggestion"]),

save: sinon.stub().resolvesThis(),
setUpdatedBy: sinon.stub(),
};
dataAccessStub.Opportunity.allBySiteId.resolves([existingOpportunity]);

const messageWithoutSuggestions = {
auditId: 'audit-id',
siteId: 'site-id',
data: {
url: 'https://example.com',
form_source: '.form',
guidance: 'Some guidance'
},
};
await handler(messageWithoutSuggestions, context);
expect(existingOpportunity.addSuggestions).to.be.not.called;
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('Guidance High Page Views Low Form Views Handler', () => {
form_source: '.form',
url: 'https://example.com',
guidance: 'Some guidance',
suggestions: ['Suggestion 1', 'Suggestion 2'],
},
};
});
Expand All @@ -66,7 +67,9 @@ describe('Guidance High Page Views Low Form Views Handler', () => {
getType: sinon.stub().returns(FORM_OPPORTUNITY_TYPES.LOW_VIEWS),
setAuditId: sinon.stub(),
setGuidance: sinon.stub(),
addSuggestions: sinon.stub(),
save: sinon.stub().resolvesThis(),
getSuggestions: sinon.stub().resolves([]),
setUpdatedBy: sinon.stub(),
};
dataAccessStub.Opportunity.allBySiteId.resolves([existingOpportunity]);
Expand Down Expand Up @@ -105,4 +108,59 @@ describe('Guidance High Page Views Low Form Views Handler', () => {
expect(error.message).to.deep.equal('fetch error');
}
});

it('should create empty suggestion object if none found', async () => {
const existingOpportunity = {
getData: sinon.stub().returns({ form: 'https://example.com', formsource: '.form' }),
getType: sinon.stub().returns(FORM_OPPORTUNITY_TYPES.LOW_VIEWS),
setAuditId: sinon.stub(),
setGuidance: sinon.stub(),
addSuggestions: sinon.stub(),
getSuggestions: sinon.stub().resolves([]),
save: sinon.stub().resolvesThis(),
setUpdatedBy: sinon.stub(),
};

dataAccessStub.Opportunity.allBySiteId.resolves([existingOpportunity]);

const messageWithoutSuggestions = {
auditId: 'audit-id',
siteId: 'site-id',
data: {
url: 'https://example.com',
form_source: '.form',
guidance: 'Some guidance'
},
};
await handler(messageWithoutSuggestions, context);
expect(existingOpportunity.addSuggestions).to.be.calledOnce;
});

it('should not create empty suggestion if any suggestion found', async () => {


const existingOpportunity = {
getData: sinon.stub().returns({ form: 'https://example.com', formsource: '.form' }),
getType: sinon.stub().returns(FORM_OPPORTUNITY_TYPES.LOW_VIEWS),
setAuditId: sinon.stub(),
setGuidance: sinon.stub(),
addSuggestions: sinon.stub(),
getSuggestions: sinon.stub().resolves(["existing suggestion"]),
save: sinon.stub().resolvesThis(),
setUpdatedBy: sinon.stub(),
};
dataAccessStub.Opportunity.allBySiteId.resolves([existingOpportunity]);

const messageWithoutSuggestions = {
auditId: 'audit-id',
siteId: 'site-id',
data: {
url: 'https://example.com',
form_source: '.form',
guidance: 'Some guidance'
},
};
await handler(messageWithoutSuggestions, context);
expect(existingOpportunity.addSuggestions).to.be.not.called;
});
});