Skip to content

Commit 6c7dd48

Browse files
feat: add Slack commands for path-level prerender suggestions (#2758)
## Summary - Adds `path-suggestions {enable/disable} {site}` Slack command to toggle path-level prerender suggestion generation for a site. This sets `pathSuggestionsEnabled` in the site's `handlers.prerender` config, which is read by the audit worker ([spacecat-audit-worker#2500](adobe/spacecat-audit-worker#2500)). - Adds `get path-suggestions {site}` Slack command to check whether path-level suggestions are currently enabled for a site. - Both commands support site lookup by base URL or site ID. ## Test plan - [x] Unit tests for `toggle-path-suggestions` command (11 tests) - [x] Unit tests for `get-path-suggestions-status` command (9 tests) - [ ] Manual Slack test: `path-suggestions enable <site>` then verify config via `get path-suggestions <site>` - [ ] Manual Slack test: `path-suggestions disable <site>` then verify disabled status
1 parent a35aecd commit 6c7dd48

5 files changed

Lines changed: 597 additions & 0 deletions

File tree

src/support/slack/commands.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ import removeDelegate from './commands/remove-delegate.js';
5959
import checkAgenticTrafficDbStatus from './commands/check-agentic-traffic-db-status.js';
6060
import checkCdnLogsStatus from './commands/check-cdn-logs-status.js';
6161
import addOaeStageDomain from './commands/add-oae-stage-domain.js';
62+
import togglePathSuggestions from './commands/toggle-path-suggestions.js';
63+
import getPathSuggestionsStatus from './commands/get-path-suggestions-status.js';
6264

6365
/**
6466
* Returns all commands.
@@ -116,4 +118,6 @@ export default (context) => [
116118
checkAgenticTrafficDbStatus(context),
117119
checkCdnLogsStatus(context),
118120
addOaeStageDomain(context),
121+
togglePathSuggestions(context),
122+
getPathSuggestionsStatus(context),
119123
];
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import BaseCommand from './base.js';
14+
15+
import {
16+
extractURLFromSlackInput,
17+
postErrorMessage,
18+
postSiteNotFoundMessage,
19+
} from '../../../utils/slack/base.js';
20+
21+
const PHRASES = ['get path-suggestions'];
22+
23+
/**
24+
* Factory function to create the GetPathSuggestionsStatusCommand object.
25+
* Checks whether path-level prerender suggestion generation is enabled for a site.
26+
*
27+
* @param {Object} context - The context object.
28+
* @returns {Object} The GetPathSuggestionsStatusCommand object.
29+
*/
30+
function GetPathSuggestionsStatusCommand(context) {
31+
const baseCommand = BaseCommand({
32+
id: 'get-path-suggestions-status',
33+
name: 'Get Path-Level Suggestions Status',
34+
description: 'Checks whether path-level prerender suggestion generation is enabled for a site.',
35+
phrases: PHRASES,
36+
usageText: `${PHRASES[0]} {site}`,
37+
});
38+
39+
const { dataAccess, log } = context;
40+
const { Site } = dataAccess;
41+
42+
/**
43+
* Fetches the site and reports the pathSuggestionsEnabled status
44+
* from the prerender handler config.
45+
*
46+
* @param {string[]} args - The arguments ([siteBaseURL]).
47+
* @param {Object} slackContext - The Slack context object.
48+
* @param {Function} slackContext.say - The Slack say function.
49+
* @returns {Promise} A promise that resolves when the operation is complete.
50+
*/
51+
const handleExecution = async (args, slackContext) => {
52+
const { say } = slackContext;
53+
54+
try {
55+
const [siteInput] = args;
56+
57+
if (!siteInput) {
58+
await say(':warning: Please provide a valid site base URL.');
59+
return;
60+
}
61+
62+
const baseURL = extractURLFromSlackInput(siteInput);
63+
64+
const site = baseURL
65+
? await Site.findByBaseURL(baseURL)
66+
: await Site.findById(siteInput);
67+
68+
if (!site) {
69+
await postSiteNotFoundMessage(say, siteInput);
70+
return;
71+
}
72+
73+
const config = site.getConfig();
74+
const prerenderConfig = config.getHandlerConfig('prerender') || {};
75+
const isEnabled = prerenderConfig.pathSuggestionsEnabled === true;
76+
77+
const statusEmoji = isEnabled ? ':large_green_circle:' : ':red_circle:';
78+
const statusText = isEnabled ? 'enabled' : 'disabled';
79+
80+
await say(`${statusEmoji} Path-level suggestions are *${statusText}* for "${site.getBaseURL()}".`);
81+
} catch (error) {
82+
log.error(error);
83+
await postErrorMessage(say, error);
84+
}
85+
};
86+
87+
baseCommand.init(context);
88+
89+
return {
90+
...baseCommand,
91+
handleExecution,
92+
};
93+
}
94+
95+
export default GetPathSuggestionsStatusCommand;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import { Config } from '@adobe/spacecat-shared-data-access/src/models/site/config.js';
14+
import BaseCommand from './base.js';
15+
16+
import {
17+
extractURLFromSlackInput,
18+
postErrorMessage,
19+
postSiteNotFoundMessage,
20+
} from '../../../utils/slack/base.js';
21+
22+
const PHRASES = ['path-suggestions'];
23+
24+
/**
25+
* Factory function to create the TogglePathSuggestionsCommand object.
26+
* Enables or disables path-level prerender suggestion generation for a site.
27+
* This sets `pathSuggestionsEnabled` in the site's prerender handler config.
28+
*
29+
* @param {Object} context - The context object.
30+
* @returns {Object} The TogglePathSuggestionsCommand object.
31+
*/
32+
function TogglePathSuggestionsCommand(context) {
33+
const baseCommand = BaseCommand({
34+
id: 'toggle-path-suggestions',
35+
name: 'Enable/Disable Path-Level Suggestions',
36+
description: 'Enables or disables path-level prerender suggestion generation for a site.',
37+
phrases: PHRASES,
38+
usageText: `${PHRASES[0]} {enable/disable} {site}`,
39+
});
40+
41+
const { dataAccess, log } = context;
42+
const { Site } = dataAccess;
43+
44+
/**
45+
* Validates input, fetches the site, and toggles pathSuggestionsEnabled
46+
* in the prerender handler config.
47+
*
48+
* @param {string[]} args - The arguments ([action, siteBaseURL]).
49+
* @param {Object} slackContext - The Slack context object.
50+
* @param {Function} slackContext.say - The Slack say function.
51+
* @returns {Promise} A promise that resolves when the operation is complete.
52+
*/
53+
const handleExecution = async (args, slackContext) => {
54+
const { say } = slackContext;
55+
56+
try {
57+
const [actionInput, siteInput] = args;
58+
59+
if (!actionInput || !['enable', 'disable'].includes(actionInput.toLowerCase())) {
60+
await say(':warning: Please specify `enable` or `disable`.');
61+
return;
62+
}
63+
64+
if (!siteInput) {
65+
await say(':warning: Please provide a valid site base URL.');
66+
return;
67+
}
68+
69+
const action = actionInput.toLowerCase();
70+
const isEnable = action === 'enable';
71+
const baseURL = extractURLFromSlackInput(siteInput);
72+
73+
const site = baseURL
74+
? await Site.findByBaseURL(baseURL)
75+
: await Site.findById(siteInput);
76+
77+
if (!site) {
78+
await postSiteNotFoundMessage(say, siteInput);
79+
return;
80+
}
81+
82+
const config = site.getConfig();
83+
const handlers = config.getHandlers() || {};
84+
const prerenderConfig = handlers.prerender || {};
85+
86+
const updatedHandlers = {
87+
...handlers,
88+
prerender: {
89+
...prerenderConfig,
90+
pathSuggestionsEnabled: isEnable,
91+
},
92+
};
93+
94+
const configData = Config.toDynamoItem(config);
95+
configData.handlers = updatedHandlers;
96+
site.setConfig(configData);
97+
await site.save();
98+
99+
const statusEmoji = isEnable ? ':white_check_mark:' : ':no_entry_sign:';
100+
await say(`${statusEmoji} Path-level suggestions have been *${action}d* for "${site.getBaseURL()}".`);
101+
} catch (error) {
102+
log.error(error);
103+
await postErrorMessage(say, error);
104+
}
105+
};
106+
107+
baseCommand.init(context);
108+
109+
return {
110+
...baseCommand,
111+
handleExecution,
112+
};
113+
}
114+
115+
export default TogglePathSuggestionsCommand;
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import { expect, use } from 'chai';
14+
import sinonChai from 'sinon-chai';
15+
import sinon from 'sinon';
16+
import GetPathSuggestionsStatusCommand from '../../../../src/support/slack/commands/get-path-suggestions-status.js';
17+
18+
use(sinonChai);
19+
20+
describe('GetPathSuggestionsStatusCommand', () => {
21+
let context;
22+
let slackContext;
23+
let dataAccessStub;
24+
25+
beforeEach(() => {
26+
dataAccessStub = {
27+
Site: {
28+
findByBaseURL: sinon.stub(),
29+
findById: sinon.stub(),
30+
},
31+
};
32+
context = { dataAccess: dataAccessStub, log: { error: sinon.stub() } };
33+
slackContext = { say: sinon.spy() };
34+
});
35+
36+
describe('Initialization', () => {
37+
it('initializes correctly with base command properties', () => {
38+
const command = GetPathSuggestionsStatusCommand(context);
39+
expect(command.id).to.equal('get-path-suggestions-status');
40+
expect(command.name).to.equal('Get Path-Level Suggestions Status');
41+
expect(command.phrases).to.deep.equal(['get path-suggestions']);
42+
});
43+
});
44+
45+
describe('Handle Execution Method', () => {
46+
it('reports enabled status when pathSuggestionsEnabled is true', async () => {
47+
const mockConfig = {
48+
getHandlerConfig: sinon.stub().withArgs('prerender').returns({
49+
pathSuggestionsEnabled: true,
50+
}),
51+
};
52+
const mockSite = {
53+
getBaseURL: sinon.stub().returns('https://example.com'),
54+
getConfig: sinon.stub().returns(mockConfig),
55+
};
56+
dataAccessStub.Site.findByBaseURL.resolves(mockSite);
57+
58+
const command = GetPathSuggestionsStatusCommand(context);
59+
60+
await command.handleExecution(['example.com'], slackContext);
61+
62+
expect(dataAccessStub.Site.findByBaseURL).to.have.been.calledWith('https://example.com');
63+
expect(slackContext.say).to.have.been.calledWithMatch(/enabled/);
64+
expect(slackContext.say).to.have.been.calledWithMatch(/large_green_circle/);
65+
});
66+
67+
it('reports disabled status when pathSuggestionsEnabled is false', async () => {
68+
const mockConfig = {
69+
getHandlerConfig: sinon.stub().withArgs('prerender').returns({
70+
pathSuggestionsEnabled: false,
71+
}),
72+
};
73+
const mockSite = {
74+
getBaseURL: sinon.stub().returns('https://example.com'),
75+
getConfig: sinon.stub().returns(mockConfig),
76+
};
77+
dataAccessStub.Site.findByBaseURL.resolves(mockSite);
78+
79+
const command = GetPathSuggestionsStatusCommand(context);
80+
81+
await command.handleExecution(['example.com'], slackContext);
82+
83+
expect(slackContext.say).to.have.been.calledWithMatch(/disabled/);
84+
expect(slackContext.say).to.have.been.calledWithMatch(/red_circle/);
85+
});
86+
87+
it('reports disabled when prerender config is missing', async () => {
88+
const mockConfig = {
89+
getHandlerConfig: sinon.stub().withArgs('prerender').returns(null),
90+
};
91+
const mockSite = {
92+
getBaseURL: sinon.stub().returns('https://example.com'),
93+
getConfig: sinon.stub().returns(mockConfig),
94+
};
95+
dataAccessStub.Site.findByBaseURL.resolves(mockSite);
96+
97+
const command = GetPathSuggestionsStatusCommand(context);
98+
99+
await command.handleExecution(['example.com'], slackContext);
100+
101+
expect(slackContext.say).to.have.been.calledWithMatch(/disabled/);
102+
});
103+
104+
it('reports disabled when pathSuggestionsEnabled is not set', async () => {
105+
const mockConfig = {
106+
getHandlerConfig: sinon.stub().withArgs('prerender').returns({}),
107+
};
108+
const mockSite = {
109+
getBaseURL: sinon.stub().returns('https://example.com'),
110+
getConfig: sinon.stub().returns(mockConfig),
111+
};
112+
dataAccessStub.Site.findByBaseURL.resolves(mockSite);
113+
114+
const command = GetPathSuggestionsStatusCommand(context);
115+
116+
await command.handleExecution(['example.com'], slackContext);
117+
118+
expect(slackContext.say).to.have.been.calledWithMatch(/disabled/);
119+
});
120+
121+
it('looks up site by ID when input is not a valid URL', async () => {
122+
const mockConfig = {
123+
getHandlerConfig: sinon.stub().returns({}),
124+
};
125+
const mockSite = {
126+
getBaseURL: sinon.stub().returns('https://example.com'),
127+
getConfig: sinon.stub().returns(mockConfig),
128+
};
129+
dataAccessStub.Site.findById.resolves(mockSite);
130+
131+
const command = GetPathSuggestionsStatusCommand(context);
132+
133+
await command.handleExecution(['some-site-id'], slackContext);
134+
135+
expect(dataAccessStub.Site.findById).to.have.been.calledWith('some-site-id');
136+
expect(dataAccessStub.Site.findByBaseURL).to.not.have.been.called;
137+
});
138+
139+
it('warns when site input is missing', async () => {
140+
const command = GetPathSuggestionsStatusCommand(context);
141+
142+
await command.handleExecution([], slackContext);
143+
144+
expect(slackContext.say).to.have.been.calledWith(':warning: Please provide a valid site base URL.');
145+
});
146+
147+
it('reports site not found', async () => {
148+
dataAccessStub.Site.findByBaseURL.resolves(null);
149+
150+
const command = GetPathSuggestionsStatusCommand(context);
151+
152+
await command.handleExecution(['unknownsite.com'], slackContext);
153+
154+
expect(slackContext.say).to.have.been.calledWithMatch(/No site found/);
155+
});
156+
157+
it('handles errors during execution', async () => {
158+
dataAccessStub.Site.findByBaseURL.throws(new Error('Test Error'));
159+
160+
const command = GetPathSuggestionsStatusCommand(context);
161+
162+
await command.handleExecution(['example.com'], slackContext);
163+
164+
expect(slackContext.say).to.have.been.calledWithMatch(/Oops! Something went wrong: Test Error/);
165+
});
166+
});
167+
});

0 commit comments

Comments
 (0)