diff --git a/src/controllers/llmo/llmo-onboarding.js b/src/controllers/llmo/llmo-onboarding.js index 9246ec406..16d43fe69 100644 --- a/src/controllers/llmo/llmo-onboarding.js +++ b/src/controllers/llmo/llmo-onboarding.js @@ -170,7 +170,7 @@ async function publishToAdminHlx(filename, outputLocation, log) { * @param {Function} say - Optional function to send messages (e.g., Slack say function) * @returns {Promise} */ -export async function copyFilesToSharepoint(dataFolder, context, say = () => {}) { +export async function copyFilesToSharepoint(dataFolder, context, say = () => { }) { const { log, env } = context; const sharepointClient = await createSharePointClient(env); @@ -209,7 +209,7 @@ export async function copyFilesToSharepoint(dataFolder, context, say = () => {}) * @param {Function} say - Optional function to send messages (e.g., Slack say function) * @returns {Promise} */ -export async function updateIndexConfig(dataFolder, context, say = () => {}) { +export async function updateIndexConfig(dataFolder, context, say = () => { }) { const { log, env } = context; log.debug('Starting Git modification of helix query config'); @@ -260,7 +260,7 @@ export async function updateIndexConfig(dataFolder, context, say = () => {}) { * @param {object} slackContext - Slack context (optional, for Slack operations) * @returns {Promise} The organization object */ -export async function createOrFindOrganization(imsOrgId, context, say = () => {}) { +export async function createOrFindOrganization(imsOrgId, context, say = () => { }) { const { dataAccess, log } = context; const { Organization } = dataAccess; @@ -320,7 +320,7 @@ export async function createOrFindSite(baseURL, organizationId, context) { * @param {Function} say - Optional function to send messages (e.g., Slack say function) * @returns {Promise} The entitlement and enrollment objects */ -export async function createEntitlementAndEnrollment(site, context, say = () => {}) { +export async function createEntitlementAndEnrollment(site, context, say = () => { }) { const { log } = context; try { @@ -339,6 +339,35 @@ export async function createEntitlementAndEnrollment(site, context, say = () => } } +export async function createEntitlementAndEnrollmentForOrg(organization, context, say = () => { }) { + const { log } = context; + + try { + const tierClient = TierClient.createForOrg(context, organization, LLMO_PRODUCT_CODE); + const { entitlement: existingEntitlement } = await tierClient.checkValidEntitlement(LLMO_TIER); + const { entitlement } = await tierClient.createEntitlement(LLMO_TIER); + + const wasNewlyCreated = !existingEntitlement + || existingEntitlement.getId() !== entitlement.getId(); + + if (wasNewlyCreated) { + await say(`Successfully created LLMO entitlement ${entitlement.getId()} for organization ${organization.getId()}`); + } else { + await say(`Found existing LLMO entitlement ${entitlement.getId()} for organization ${organization.getId()}`); + } + + log.info(`Successfully ensured LLMO access for organization ${organization.getId()} via entitlement ${entitlement.getId()}`); + + return { + entitlement, + }; + } catch (error) { + log.info(`Ensuring LLMO entitlement failed: ${error.message}`); + await say('❌ Ensuring LLMO entitlement failed'); + throw error; + } +} + export async function enableAudits(site, context, audits = []) { const { dataAccess } = context; const { Configuration } = dataAccess; @@ -350,6 +379,27 @@ export async function enableAudits(site, context, audits = []) { await configuration.save(); } +export async function performLlmoOrgOnboarding(imsOrgId, context, say = () => { }) { + const { log } = context; + + log.info(`Starting LLMO organization onboarding for IMS Org ID: ${imsOrgId}`); + await say(`:gear: Starting LLMO IMS org onboarding for *${imsOrgId}*...`); + const organization = await createOrFindOrganization(imsOrgId, context, say); + + try { + const { entitlement } = await createEntitlementAndEnrollmentForOrg(organization, context, say); + + return { + organization, + message: 'LLMO organization onboarding completed successfully', + entitlement, + }; + } catch (error) { + log.error(`Error creating entitlement for organization: ${error.message}`); + throw new Error(`Failed to create LLMO entitlement for organization: ${error.message}`); + } +} + /** * Complete LLMO onboarding process. * @param {object} params - Onboarding parameters diff --git a/src/controllers/slack.js b/src/controllers/slack.js index bb6637952..f1f77deb4 100644 --- a/src/controllers/slack.js +++ b/src/controllers/slack.js @@ -80,6 +80,7 @@ export function initSlackBot(lambdaContext, App) { app.view('onboard_site_modal', actions.onboardSiteModal(lambdaContext)); app.view('preflight_config_modal', actions.preflight_config_modal(lambdaContext)); app.view('onboard_llmo_modal', actions.onboardLLMOModal(lambdaContext)); + app.view('onboard_llmo_org_modal', actions.onboardLLMOOrgModal(lambdaContext)); app.view('update_ims_org_modal', actions.updateIMSOrgModal(lambdaContext)); return app; diff --git a/src/support/slack/actions/index.js b/src/support/slack/actions/index.js index 4a168c88b..c97cbf9db 100644 --- a/src/support/slack/actions/index.js +++ b/src/support/slack/actions/index.js @@ -21,6 +21,8 @@ import { addEntitlementsAction, updateOrgAction, updateIMSOrgModal, + startLLMOOrgOnboarding, + onboardLLMOOrgModal, } from './onboard-llmo-modal.js'; import { onboardSiteModal, startOnboarding } from './onboard-modal.js'; import { preflightConfigModal } from './preflight-config-modal.js'; @@ -34,9 +36,11 @@ const actions = { rejectOrg, onboardSiteModal, onboardLLMOModal, + onboardLLMOOrgModal, updateIMSOrgModal, start_onboarding: startOnboarding, start_llmo_onboarding: startLLMOOnboarding, + start_llmo_org_onboarding: startLLMOOrgOnboarding, preflight_config_modal: preflightConfigModal, open_preflight_config: openPreflightConfig, add_entitlements_action: addEntitlementsAction, diff --git a/src/support/slack/actions/onboard-llmo-modal.js b/src/support/slack/actions/onboard-llmo-modal.js index 79bbb9d55..1f4f7d99e 100644 --- a/src/support/slack/actions/onboard-llmo-modal.js +++ b/src/support/slack/actions/onboard-llmo-modal.js @@ -11,6 +11,7 @@ */ import { Config } from '@adobe/spacecat-shared-data-access/src/models/site/config.js'; +import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js'; import { postErrorMessage, } from '../../../utils/slack/base.js'; @@ -19,8 +20,10 @@ import { copyFilesToSharepoint, updateIndexConfig, enableAudits, + performLlmoOrgOnboarding, } from '../../../controllers/llmo/llmo-onboarding.js'; +const LLMO_TIER = EntitlementModel.TIERS.FREE_TRIAL; const REFERRAL_TRAFFIC_AUDIT = 'llmo-referral-traffic'; const REFERRAL_TRAFFIC_IMPORT = 'traffic-analysis'; const AGENTIC_TRAFFIC_ANALYSIS_AUDIT = 'cdn-analysis'; @@ -791,3 +794,150 @@ export function updateIMSOrgModal(lambdaContext) { } }; } + +/* Handles "Start Onboarding" button click for IMS org onboarding */ +export function startLLMOOrgOnboarding(lambdaContext) { + const { log } = lambdaContext; + + return async ({ + ack, body, client, respond, + }) => { + try { + await ack(); + + const { user } = body; + + await respond({ + text: `:gear: ${user.name} started the IMS org onboarding process...`, + replace_original: true, + }); + + const originalChannel = body.channel?.id; + const originalThreadTs = body.message?.thread_ts || body.message?.ts; + + await client.views.open({ + trigger_id: body.trigger_id, + view: { + type: 'modal', + callback_id: 'onboard_llmo_org_modal', + private_metadata: JSON.stringify({ + originalChannel, + originalThreadTs, + }), + title: { + type: 'plain_text', + text: 'Onboard IMS Org', + }, + submit: { + type: 'plain_text', + text: 'Start Onboarding', + }, + close: { + type: 'plain_text', + text: 'Cancel', + }, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':rocket: *LLMO IMS Org Onboarding*\n\nProvide the IMS Organization ID to onboard for LLMO.', + }, + }, + { + type: 'input', + block_id: 'ims_org_input', + element: { + type: 'plain_text_input', + action_id: 'ims_org_id', + placeholder: { + type: 'plain_text', + text: 'ABC123@AdobeOrg', + }, + }, + label: { + type: 'plain_text', + text: 'IMS Organization ID', + }, + }, + ], + }, + }); + + log.debug(`User ${user.id} started IMS org onboarding process.`); + } catch (error) { + log.error('Error starting IMS org onboarding:', error); + await postErrorMessage(respond, error); + } + }; +} + +/* Handles IMS org onboarding modal submission */ +export function onboardLLMOOrgModal(lambdaContext) { + const { log } = lambdaContext; + + return async ({ ack, body, client }) => { + try { + const { user, view } = body; + const { values } = view.state; + + const imsOrgId = values.ims_org_input?.ims_org_id?.value?.trim(); + const metadata = JSON.parse(view.private_metadata || '{}'); + const { originalChannel, originalThreadTs } = metadata; + + if (!imsOrgId) { + await ack({ + response_action: 'errors', + errors: { + ims_org_input: 'IMS Organization ID is required', + }, + }); + return; + } + + await ack(); + const responseChannel = originalChannel || body.user.id; + const responseThreadTs = originalChannel ? originalThreadTs : undefined; + + const slackContext = { + say: async (message) => { + await client.chat.postMessage({ + channel: responseChannel, + text: message, + thread_ts: responseThreadTs, + }); + }, + client, + channelId: responseChannel, + threadTs: responseThreadTs, + }; + + try { + const result = await performLlmoOrgOnboarding(imsOrgId, lambdaContext, slackContext.say); + const { organization, message, entitlement } = result; + await slackContext.say(`:white_check_mark: *LLMO IMS org onboarding completed successfully!* + +*Organization:* ${organization.getName()} +*IMS Org ID:* ${imsOrgId} +*Entitlement ID:* ${entitlement.getId()} +*Tier:* ${LLMO_TIER} +*Status:* ${message} + +The organization has been onboarded for LLMO.`); + + log.debug(`IMS org onboarding completed for ${imsOrgId} by user ${user.id}`); + } catch (error) { + log.error(`Error during IMS org onboarding: ${error.message}`); + await slackContext.say(`:x: ${error.message}`); + } + } catch (error) { + log.error('Error handling IMS org onboarding modal:', error); + await ack({ + response_action: 'errors', + errors: { + ims_org_input: 'There was an error processing the onboarding request.', + }, + }); + } + }; +} diff --git a/src/support/slack/commands/llmo-onboard.js b/src/support/slack/commands/llmo-onboard.js index f5619fdd2..e3fb626d4 100644 --- a/src/support/slack/commands/llmo-onboard.js +++ b/src/support/slack/commands/llmo-onboard.js @@ -30,9 +30,9 @@ function LlmoOnboardCommand(context) { const baseCommand = BaseCommand({ id: 'onboard-llmo', name: 'Onboard LLMO', - description: 'Onboards a site for LLMO (Large Language Model Optimizer) through a modal interface.', + description: 'Onboards a site or IMS org for LLMO (Large Language Model Optimizer) through a modal interface.', phrases: PHRASES, - usageText: `${PHRASES[0]} `, + usageText: `${PHRASES[0]} [site url]`, }); const { log } = context; @@ -51,6 +51,39 @@ function LlmoOnboardCommand(context) { const [site] = args; + // If no site parameter provided, trigger IMS org onboarding flow + if (!site) { + const message = { + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':rocket: *LLMO IMS Org Onboarding*\n\nClick the button below to start the IMS organization onboarding process.', + }, + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { + type: 'plain_text', + text: 'Start Onboarding', + }, + value: 'org_onboarding', + action_id: 'start_llmo_org_onboarding', + style: 'primary', + }, + ], + }, + ], + thread_ts: threadTs, + }; + await say(message); + return; + } + const normalizedSite = extractURLFromSlackInput(site); if (!normalizedSite) { diff --git a/test/controllers/llmo-onboarding.test.js b/test/controllers/llmo-onboarding.test.js index 7009607b4..f079dc68b 100644 --- a/test/controllers/llmo-onboarding.test.js +++ b/test/controllers/llmo-onboarding.test.js @@ -520,6 +520,246 @@ describe('LLMO Onboarding Functions', () => { }); }); + describe('createEntitlementAndEnrollmentForOrg', () => { + it('should successfully create entitlement for organization when newly created', async () => { + const mockOrganization = { + getId: sinon.stub().returns('org123'), + }; + + const mockEntitlement = { + getId: sinon.stub().returns('entitlement123'), + }; + + const mockTierClient = { + checkValidEntitlement: sinon.stub().resolves({ entitlement: null }), + createEntitlement: sinon.stub().resolves({ entitlement: mockEntitlement }), + }; + + const mockTierClientConstructor = { + createForOrg: sinon.stub().returns(mockTierClient), + }; + + const { createEntitlementAndEnrollmentForOrg } = await esmock('../../src/controllers/llmo/llmo-onboarding.js', { + '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js': { + Entitlement: { + PRODUCT_CODES: { LLMO: 'LLMO' }, + TIERS: { FREE_TRIAL: 'FREE_TRIAL' }, + }, + }, + '@adobe/spacecat-shared-tier-client': { + default: mockTierClientConstructor, + }, + }); + + const context = { + log: mockLog, + }; + + const mockSay = sinon.stub().resolves(); + + const result = await createEntitlementAndEnrollmentForOrg(mockOrganization, context, mockSay); + + expect(result).to.deep.equal({ + entitlement: mockEntitlement, + }); + + expect(mockTierClientConstructor.createForOrg).to.have.been.calledWith(context, mockOrganization, 'LLMO'); + expect(mockTierClient.checkValidEntitlement).to.have.been.calledWith('FREE_TRIAL'); + expect(mockTierClient.createEntitlement).to.have.been.calledWith('FREE_TRIAL'); + expect(mockSay).to.have.been.calledWith('Successfully created LLMO entitlement entitlement123 for organization org123'); + expect(mockLog.info).to.have.been.calledWith('Successfully ensured LLMO access for organization org123 via entitlement entitlement123'); + }); + + it('should successfully find existing entitlement for organization', async () => { + const mockOrganization = { + getId: sinon.stub().returns('org123'), + }; + + const mockExistingEntitlement = { + getId: sinon.stub().returns('entitlement123'), + }; + + const mockTierClient = { + checkValidEntitlement: sinon.stub().resolves({ entitlement: mockExistingEntitlement }), + createEntitlement: sinon.stub().resolves({ entitlement: mockExistingEntitlement }), + }; + + const mockTierClientConstructor = { + createForOrg: sinon.stub().returns(mockTierClient), + }; + + const { createEntitlementAndEnrollmentForOrg } = await esmock('../../src/controllers/llmo/llmo-onboarding.js', { + '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js': { + Entitlement: { + PRODUCT_CODES: { LLMO: 'LLMO' }, + TIERS: { FREE_TRIAL: 'FREE_TRIAL' }, + }, + }, + '@adobe/spacecat-shared-tier-client': { + default: mockTierClientConstructor, + }, + }); + + const context = { + log: mockLog, + }; + + const mockSay = sinon.stub().resolves(); + + const result = await createEntitlementAndEnrollmentForOrg(mockOrganization, context, mockSay); + + expect(result).to.deep.equal({ + entitlement: mockExistingEntitlement, + }); + + expect(mockSay).to.have.been.calledWith('Found existing LLMO entitlement entitlement123 for organization org123'); + expect(mockLog.info).to.have.been.calledWith('Successfully ensured LLMO access for organization org123 via entitlement entitlement123'); + }); + + it('should handle error when creating entitlement fails', async () => { + const mockOrganization = { + getId: sinon.stub().returns('org123'), + }; + + const error = new Error('Failed to create entitlement'); + + const mockTierClient = { + checkValidEntitlement: sinon.stub().resolves({ entitlement: null }), + createEntitlement: sinon.stub().rejects(error), + }; + + const mockTierClientConstructor = { + createForOrg: sinon.stub().returns(mockTierClient), + }; + + const { createEntitlementAndEnrollmentForOrg } = await esmock('../../src/controllers/llmo/llmo-onboarding.js', { + '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js': { + Entitlement: { + PRODUCT_CODES: { LLMO: 'LLMO' }, + TIERS: { FREE_TRIAL: 'FREE_TRIAL' }, + }, + }, + '@adobe/spacecat-shared-tier-client': { + default: mockTierClientConstructor, + }, + }); + + const context = { + log: mockLog, + }; + + const mockSay = sinon.stub().resolves(); + + try { + await createEntitlementAndEnrollmentForOrg(mockOrganization, context, mockSay); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).to.equal(error); + expect(mockLog.info).to.have.been.calledWith('Ensuring LLMO entitlement failed: Failed to create entitlement'); + expect(mockSay).to.have.been.calledWith('❌ Ensuring LLMO entitlement failed'); + } + }); + }); + + describe('performLlmoOrgOnboarding', () => { + it('should successfully perform LLMO organization onboarding', async () => { + const mockOrganization = { + getId: sinon.stub().returns('org123'), + }; + + const mockEntitlement = { + getId: sinon.stub().returns('entitlement123'), + }; + + const mockTierClient = { + checkValidEntitlement: sinon.stub().resolves({ entitlement: null }), + createEntitlement: sinon.stub().resolves({ entitlement: mockEntitlement }), + }; + + const mockTierClientConstructor = { + createForOrg: sinon.stub().returns(mockTierClient), + }; + + mockDataAccess.Organization.findByImsOrgId.resolves(mockOrganization); + + const { performLlmoOrgOnboarding } = await esmock('../../src/controllers/llmo/llmo-onboarding.js', { + '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js': { + Entitlement: { + PRODUCT_CODES: { LLMO: 'LLMO' }, + TIERS: { FREE_TRIAL: 'FREE_TRIAL' }, + }, + }, + '@adobe/spacecat-shared-tier-client': { + default: mockTierClientConstructor, + }, + }); + + const context = { + dataAccess: mockDataAccess, + log: mockLog, + }; + + const mockSay = sinon.stub().resolves(); + + const result = await performLlmoOrgOnboarding('ABC123@AdobeOrg', context, mockSay); + + expect(result).to.deep.equal({ + organization: mockOrganization, + message: 'LLMO organization onboarding completed successfully', + entitlement: mockEntitlement, + }); + + expect(mockLog.info).to.have.been.calledWith('Starting LLMO organization onboarding for IMS Org ID: ABC123@AdobeOrg'); + expect(mockSay).to.have.been.calledWith(':gear: Starting LLMO IMS org onboarding for *ABC123@AdobeOrg*...'); + }); + + it('should handle error when entitlement creation fails', async () => { + const mockOrganization = { + getId: sinon.stub().returns('org123'), + }; + + const error = new Error('Entitlement creation failed'); + + const mockTierClient = { + checkValidEntitlement: sinon.stub().resolves({ entitlement: null }), + createEntitlement: sinon.stub().rejects(error), + }; + + const mockTierClientConstructor = { + createForOrg: sinon.stub().returns(mockTierClient), + }; + + mockDataAccess.Organization.findByImsOrgId.resolves(mockOrganization); + + const { performLlmoOrgOnboarding } = await esmock('../../src/controllers/llmo/llmo-onboarding.js', { + '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js': { + Entitlement: { + PRODUCT_CODES: { LLMO: 'LLMO' }, + TIERS: { FREE_TRIAL: 'FREE_TRIAL' }, + }, + }, + '@adobe/spacecat-shared-tier-client': { + default: mockTierClientConstructor, + }, + }); + + const context = { + dataAccess: mockDataAccess, + log: mockLog, + }; + + const mockSay = sinon.stub().resolves(); + + try { + await performLlmoOrgOnboarding('ABC123@AdobeOrg', context, mockSay); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err.message).to.equal('Failed to create LLMO entitlement for organization: Entitlement creation failed'); + expect(mockLog.error).to.have.been.calledWith('Error creating entitlement for organization: Entitlement creation failed'); + } + }); + }); + describe('performLlmoOnboarding', () => { it('should successfully perform complete LLMO onboarding process', async () => { // Mock organization diff --git a/test/support/slack/actions/onboard-llmo-modal.test.js b/test/support/slack/actions/onboard-llmo-modal.test.js index 783721a04..5bb622d89 100644 --- a/test/support/slack/actions/onboard-llmo-modal.test.js +++ b/test/support/slack/actions/onboard-llmo-modal.test.js @@ -205,6 +205,7 @@ describe('onboard-llmo-modal', () => { tierClientMock = { createForSite: sinon.stub().returns(mockClientInstance), + createForOrg: sinon.stub().returns(mockClientInstance), }; // Store the mock instance for easier access in tests diff --git a/test/support/slack/commands/llmo-onboard.test.js b/test/support/slack/commands/llmo-onboard.test.js index 9b4e80c2f..3aa696ade 100644 --- a/test/support/slack/commands/llmo-onboard.test.js +++ b/test/support/slack/commands/llmo-onboard.test.js @@ -75,19 +75,39 @@ describe('LlmoOnboardCommand', () => { }); describe('Handle Execution Method', () => { - it('should show usage when no site URL provided', async () => { + it('should show IMS org onboarding button when no parameter provided', async () => { await command.handleExecution([], slackContext); - expect(slackContext.say).to.have.been.calledWith( - 'Usage: _onboard-llmo _', - ); + expect(slackContext.say).to.have.been.calledOnce; + const message = slackContext.say.getCall(0).args[0]; + + expect(message).to.have.property('blocks'); + expect(message).to.have.property('thread_ts', '1234567890.123456'); + + // Check for the section block with org onboarding text + const sectionBlock = message.blocks.find((block) => block.type === 'section'); + expect(sectionBlock).to.exist; + expect(sectionBlock.text.text).to.include('LLMO IMS Org Onboarding'); + expect(sectionBlock.text.text).to.include('IMS organization onboarding process'); + + // Check for the actions block with the button + const actionsBlock = message.blocks.find((block) => block.type === 'actions'); + expect(actionsBlock).to.exist; + expect(actionsBlock.elements).to.have.length(1); + + const button = actionsBlock.elements[0]; + expect(button.type).to.equal('button'); + expect(button.text.text).to.equal('Start Onboarding'); + expect(button.action_id).to.equal('start_llmo_org_onboarding'); + expect(button.value).to.equal('org_onboarding'); + expect(button.style).to.equal('primary'); }); it('should show usage when invalid URL provided', async () => { await command.handleExecution(['invalid-url'], slackContext); expect(slackContext.say).to.have.been.calledWith( - 'Usage: _onboard-llmo _', + 'Usage: _onboard-llmo [site url]_', ); });