diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index beb923f27..9903d01c4 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -1602,6 +1602,14 @@ llmo-strategy: This operation completely overwrites the existing strategy with the provided payload. The payload can be any valid JSON object - no schema validation is performed. The strategy is stored in S3 at `workspace/llmo/{siteId}/strategy.json`. + + When status changes are detected (either at the strategy level or at the + opportunity level), email notifications are sent to the relevant recipients. + Initial strategy creation does not trigger strategy status emails. Assignment + notifications go to the assignee only (not the strategy owner). Notification + delivery is awaited, and a summary of sent/failed/skipped counts is included + in the response. Notification failures do not cause the endpoint to return + an error. operationId: saveStrategy requestBody: required: true @@ -1623,8 +1631,25 @@ llmo-strategy: type: string description: The S3 object version ID of the saved strategy example: "abc123def456" + notifications: + type: object + description: Summary of notification delivery + properties: + sent: + type: integer + description: Number of emails sent successfully + failed: + type: integer + description: Number of emails that failed to send + skipped: + type: integer + description: Number of changes skipped (e.g. no valid recipients) + changes: + type: integer + description: Number of status changes detected required: - version + - notifications '400': $ref: './responses.yaml#/400' '401': diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 683b48ced..2338c6991 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -53,6 +53,7 @@ import { import { queryLlmoFiles } from './llmo-query-handler.js'; import { updateModifiedByDetails } from './llmo-config-metadata.js'; import { handleLlmoRationale } from './llmo-rationale.js'; +import { notifyStrategyChanges } from '../../support/opportunity-workspace-notifications.js'; const { readConfig, writeConfig } = llmo; const { readStrategy, writeStrategy } = llmoStrategy; @@ -1172,12 +1173,15 @@ function LlmoController(ctx) { /** * PUT /sites/{siteId}/llmo/strategy - * Saves LLMO strategy data to S3 + * Saves LLMO strategy data to S3. + * Status changes trigger email notifications (when enabled). * @param {object} context - Request context * @returns {Promise} Version of the saved strategy */ const saveStrategy = async (context) => { - const { log, s3, data } = context; + const { + log, s3, data, dataAccess, + } = context; const { siteId } = context.params; try { @@ -1193,13 +1197,52 @@ function LlmoController(ctx) { return badRequest('LLMO strategy storage is not configured for this environment'); } + // Read previous strategy for diff (best-effort, null if not found) + let prevData = null; + let skipNotifications = false; + try { + const prev = await readStrategy(siteId, s3.s3Client, { s3Bucket: s3.s3Bucket }); + if (prev.exists) { + prevData = prev.data; + } + } catch (readError) { + skipNotifications = true; + log.warn(`Could not read previous strategy for site ${siteId} (notifications will be skipped): ${readError.message}`); + } + log.info(`Writing LLMO strategy to S3 for siteId: ${siteId}`); const { version } = await writeStrategy(siteId, data, s3.s3Client, { s3Bucket: s3.s3Bucket, }); log.info(`Successfully saved LLMO strategy for siteId: ${siteId}, version: ${version}`); - return ok({ version }); + + // Await notifications and include summary in response for debugging + let siteBaseUrl = ''; + if (dataAccess?.Site) { + const site = await dataAccess.Site.findById(siteId); + siteBaseUrl = site?.getBaseURL?.() || ''; + } + let notificationSummary = { + sent: 0, failed: 0, skipped: 0, changes: 0, + }; + if (!skipNotifications) { + try { + notificationSummary = await notifyStrategyChanges(context, { + prevData, + nextData: data, + siteId, + siteBaseUrl, + }); + if (notificationSummary.changes > 0) { + log.info(`Strategy notification summary for site ${siteId}: ${JSON.stringify(notificationSummary)}`); + } + } catch (err) { + log.error(`Strategy notification error for site ${siteId}: ${err.message}`); + } + } + + return ok({ version, notifications: notificationSummary }); } catch (error) { log.error(`Error saving llmo strategy for siteId: ${siteId}, error: ${error.message}`); return badRequest(error.message); diff --git a/src/support/email-service.js b/src/support/email-service.js new file mode 100644 index 000000000..8a2d0c6f6 --- /dev/null +++ b/src/support/email-service.js @@ -0,0 +1,117 @@ +/* + * 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 { ImsClient } from '@adobe/spacecat-shared-ims-client'; + +/** + * Acquires an IMS service access token using email-specific credentials. + * Does NOT mutate context.env. + * @param {Object} context - The request context with env and log. + * @returns {Promise} The access token string. + */ +export async function getEmailServiceToken(context) { + const { env } = context; + + const emailEnv = { + ...env, + IMS_CLIENT_ID: env.LLMO_EMAIL_IMS_CLIENT_ID, + IMS_CLIENT_SECRET: env.LLMO_EMAIL_IMS_CLIENT_SECRET, + IMS_CLIENT_CODE: env.LLMO_EMAIL_IMS_CLIENT_CODE, + IMS_SCOPE: env.LLMO_EMAIL_IMS_SCOPE, + }; + + const imsClient = ImsClient.createFrom({ ...context, env: emailEnv }); + + try { + const tokenPayload = await imsClient.getServiceAccessToken(); + return tokenPayload.access_token; + } catch (error) { + context.log.error('[email-service] Failed to acquire IMS token', { error: error.message }); + throw error; + } +} + +/** + * Sends a templated email via Adobe Post Office. + * + * @param {Object} context - The request context (must include env and log). + * @param {Object} options + * @param {string[]} options.recipients - Array of email addresses. + * @param {string} options.templateName - Post Office template name. + * @param {Object} [options.templateData] - Template variable key/value pairs. + * @param {string} [options.locale='en_US'] - Locale for the email. + * @param {string} [options.accessToken] - when provided, skips token acquisition. + * @returns {Promise<{success: boolean, statusCode: number, error?: string, templateUsed: string}>} + * Result object. Never throws by default. + */ +export async function sendEmail(context, { + recipients, + templateName, + templateData, + locale = 'en_US', + accessToken: providedToken, +}) { + const { env, log } = context; + const result = { success: false, statusCode: 0, templateUsed: templateName }; + + try { + if (!recipients || recipients.length === 0) { + result.error = 'No recipients provided'; + return result; + } + + if (!templateName) { + result.error = 'templateName is required'; + return result; + } + + const accessToken = providedToken ?? await getEmailServiceToken(context); + const postOfficeEndpoint = env.ADOBE_POSTOFFICE_ENDPOINT; + + if (!postOfficeEndpoint) { + result.error = 'ADOBE_POSTOFFICE_ENDPOINT is not configured'; + return result; + } + + const body = JSON.stringify({ + toList: recipients.join(','), + templateData: templateData || {}, + }); + const url = `${postOfficeEndpoint}/po-server/message?templateName=${encodeURIComponent(templateName)}&locale=${encodeURIComponent(locale)}`; + + log.info(`[email-service] Sending ${templateName} email to ${recipients.length} recipient(s)`); + + const response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `IMS ${accessToken}`, + 'Content-Type': 'application/json', + }, + body, + }); + + result.statusCode = response.status; + result.success = response.status === 200; + + if (!result.success) { + const responseText = await response.text().catch(() => '(unable to read response body)'); + result.error = `Post Office returned ${response.status}: ${responseText}`; + log.error(`Email send failed for template ${templateName}: ${result.error}`); + } + } catch (error) { + result.error = error.message; + log.error(`Email send error for template ${templateName}: ${error.message}`); + } + + return result; +} diff --git a/src/support/opportunity-workspace-notifications.js b/src/support/opportunity-workspace-notifications.js new file mode 100644 index 000000000..6f02e3c22 --- /dev/null +++ b/src/support/opportunity-workspace-notifications.js @@ -0,0 +1,428 @@ +/* + * 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 { isValidEmail } from '@adobe/spacecat-shared-utils'; +import { sendEmail, getEmailServiceToken } from './email-service.js'; + +/** + * @typedef {Object} StatusChange + * @property {string} type - 'opportunity', 'strategy', or 'assignment' + * @property {string} strategyId + * @property {string} strategyName + * @property {string} [opportunityId] + * @property {string} [opportunityName] + * @property {string} statusBefore + * @property {string} statusAfter + * @property {string[]} recipients - deduplicated valid email addresses + * @property {string} [createdBy] - strategy owner email (optional) + * @property {string} [assignee] - opportunity assignee email (opportunity/assignment changes only) + * @property {string[]} [opportunityNames] - opportunity names for strategy changes + * @property {string} [assigneeBefore] - previous assignee (assignment changes only) + * @property {string} [assigneeAfter] - new assignee (assignment changes only) + */ + +/** + * Resolves email to display name using TrialUser lookup. + * Falls back to email if user not found or lookup fails. + * @param {Object} dataAccess - Context dataAccess (with TrialUser). + * @param {string} email - Email address to resolve. + * @returns {Promise} Display name or email. + */ +async function resolveUserName(dataAccess, email) { + if (!dataAccess?.TrialUser) return email; + try { + const user = await dataAccess.TrialUser.findByEmailId(email); + if (user) { + const first = user.getFirstName(); + const last = user.getLastName(); + const cleanFirst = (first && first !== '-') ? first : ''; + const cleanLast = (last && last !== '-') ? last : ''; + const fullName = `${cleanFirst} ${cleanLast}`.trim(); + return fullName || email; + } + } catch { /* best-effort */ } + return email; +} + +/** + * Extracts hostname from a base URL (e.g. https://www.chevrolet.com -> www.chevrolet.com). + * @param {string} baseUrl - Full base URL. + * @returns {string} Hostname or empty string. + */ +function extractHostnameFromBaseURL(baseUrl) { + if (!baseUrl) return ''; + try { + const url = new URL(baseUrl.startsWith('http') ? baseUrl : `https://${baseUrl}`); + return url.hostname; + } catch { + return ''; + } +} + +/** + * Builds an index of strategy opportunities keyed by opportunityId for fast lookup. + * @param {Object} strategyData - Parsed strategy workspace data. + * @returns {Map>} + * Map of strategyId -> Map of opportunityId -> opportunity object. + */ +function buildOpportunityIndex(strategyData) { + const index = new Map(); + if (!strategyData?.strategies) return index; + + for (const strategy of strategyData.strategies) { + const oppMap = new Map(); + for (const opp of strategy.opportunities || []) { + oppMap.set(opp.opportunityId, opp); + } + index.set(strategy.id, oppMap); + } + return index; +} + +/** + * Builds an index of strategies keyed by id. + * @param {Object} strategyData - Parsed strategy workspace data. + * @returns {Map} Map of strategyId -> strategy object. + */ +function buildStrategyIndex(strategyData) { + const index = new Map(); + if (!strategyData?.strategies) return index; + for (const strategy of strategyData.strategies) { + index.set(strategy.id, strategy); + } + return index; +} + +/** + * Filters and deduplicates an array of candidate email strings. + * Logs a warning for each invalid email. + * @param {string[]} candidates - Array of potential email strings. + * @param {Object} log - Logger instance. + * @returns {string[]} Array of valid, unique emails. + */ +function filterValidEmails(candidates, log) { + const seen = new Set(); + const result = []; + for (const email of candidates) { + if (!email) { + // null, undefined, empty string -- expected for unassigned opps + } else if (typeof email !== 'string') { + log.warn(`Skipping non-string email recipient: ${typeof email}`); + } else if (!isValidEmail(email)) { + log.warn(`Skipping invalid email recipient: ${email}`); + } else { + const lower = email.toLowerCase(); + if (!seen.has(lower)) { + seen.add(lower); + result.push(email); + } + } + } + return result; +} + +/** + * Detects status changes between previous and next strategy workspace data. + * Returns an array of StatusChange objects describing what changed. + * + * @param {Object|null} prevData - Previous strategy workspace data (or null for first save). + * @param {Object} nextData - New strategy workspace data being saved. + * @param {Object} log - Logger instance. + * @returns {StatusChange[]} Array of detected changes. + */ +export function detectStatusChanges(prevData, nextData, log) { + const changes = []; + if (!nextData) return changes; + + // Build name lookup from opportunity library + const libraryOppNames = new Map(); + for (const opp of (nextData.opportunities || [])) { + libraryOppNames.set(opp.id, opp.name); + } + + // When prevData is null (first save), use empty structure as baseline + const effectivePrevData = prevData || { strategies: [] }; + + const prevStrategyIndex = buildStrategyIndex(effectivePrevData); + const prevOppIndex = buildOpportunityIndex(effectivePrevData); + + for (const nextStrategy of nextData.strategies || []) { + const prevStrategy = prevStrategyIndex.get(nextStrategy.id); + + if (!prevStrategy) { + // New strategy (first save or newly added) + // Skip opportunities without an assignee + for (const opp of nextStrategy.opportunities || []) { + if (opp.assignee) { + const oppCandidates = [opp.assignee]; + changes.push({ + type: 'opportunity', + strategyId: nextStrategy.id, + strategyName: nextStrategy.name, + opportunityId: opp.opportunityId, + opportunityName: opp.name || libraryOppNames.get(opp.opportunityId) + || opp.opportunityId, + statusBefore: '', + statusAfter: opp.status, + recipients: filterValidEmails(oppCandidates, log), + createdBy: nextStrategy.createdBy || '', + assignee: opp.assignee, + }); + } + } + } else { + // Existing strategy -- check strategy-level status change + if (prevStrategy.status !== nextStrategy.status) { + const candidateEmails = [ + ...(nextStrategy.opportunities || []).map((o) => o.assignee), + nextStrategy.createdBy, + ]; + const opportunityNames = (nextStrategy.opportunities || []) + .map((o) => o.name || libraryOppNames.get(o.opportunityId) || o.opportunityId); + + changes.push({ + type: 'strategy', + strategyId: nextStrategy.id, + strategyName: nextStrategy.name, + statusBefore: prevStrategy.status, + statusAfter: nextStrategy.status, + recipients: filterValidEmails(candidateEmails, log), + createdBy: nextStrategy.createdBy || '', + opportunityNames, + }); + } + + // Check opportunity-level status changes and assignment changes + const prevOpps = prevOppIndex.get(nextStrategy.id); + for (const nextOpp of nextStrategy.opportunities || []) { + const prevOpp = prevOpps?.get(nextOpp.opportunityId); + if (!prevOpp) { + // New opportunity added to existing strategy with assignee -> emit assignment change + if (nextOpp.assignee) { + changes.push({ + type: 'assignment', + strategyId: nextStrategy.id, + strategyName: nextStrategy.name, + opportunityId: nextOpp.opportunityId, + opportunityName: (nextOpp.name + || libraryOppNames.get(nextOpp.opportunityId) || nextOpp.opportunityId), + assigneeBefore: '', + assigneeAfter: nextOpp.assignee, + statusAfter: nextOpp.status, + recipients: filterValidEmails([nextOpp.assignee], log), + createdBy: nextStrategy.createdBy || '', + assignee: nextOpp.assignee, + }); + } + // Skip status change for new opportunities (no prev status) + } else { + if (prevOpp.status !== nextOpp.status) { + const candidateEmails = [ + nextOpp.assignee, + nextStrategy.createdBy, + ]; + + changes.push({ + type: 'opportunity', + strategyId: nextStrategy.id, + strategyName: nextStrategy.name, + opportunityId: nextOpp.opportunityId, + opportunityName: (nextOpp.name + || libraryOppNames.get(nextOpp.opportunityId) || nextOpp.opportunityId), + statusBefore: prevOpp.status, + statusAfter: nextOpp.status, + recipients: filterValidEmails(candidateEmails, log), + createdBy: nextStrategy.createdBy || '', + assignee: nextOpp.assignee || '', + }); + } + // Assignee changed (empty->user or user->user); assignee removed -> no assignment change + if (prevOpp.assignee !== nextOpp.assignee && nextOpp.assignee) { + changes.push({ + type: 'assignment', + strategyId: nextStrategy.id, + strategyName: nextStrategy.name, + opportunityId: nextOpp.opportunityId, + opportunityName: (nextOpp.name + || libraryOppNames.get(nextOpp.opportunityId) || nextOpp.opportunityId), + assigneeBefore: prevOpp.assignee || '', + assigneeAfter: nextOpp.assignee, + statusAfter: nextOpp.status, + recipients: filterValidEmails([nextOpp.assignee], log), + createdBy: nextStrategy.createdBy || '', + assignee: nextOpp.assignee, + }); + } + } + } + } + } + + return changes; +} + +/** + * Sends email notifications for detected status changes. + * + * @param {Object} context - The request context (env, log, dataAccess). + * @param {Object} params + * @param {StatusChange[]} params.changes - Detected status changes. + * @param {string} [params.siteBaseUrl] - Site base URL for strategy_url. + * @returns {Promise<{sent: number, failed: number, skipped: number}>} + * Summary counts. Never throws. + */ +export async function sendStatusChangeNotifications(context, { + changes, siteBaseUrl, +}) { + const { log, dataAccess } = context; + const summary = { sent: 0, failed: 0, skipped: 0 }; + + const oppTemplateName = 'llmo_opportunity_status_update'; + const stratTemplateName = 'llmo_strategy_update'; + + const hostname = extractHostnameFromBaseURL(siteBaseUrl || ''); + const strategyUrl = hostname + ? `https://llmo.now/${hostname}/insights/opportunity-workspace` + : ''; + + let accessToken; + try { + accessToken = await getEmailServiceToken(context); + } catch (err) { + log.error(`Failed to acquire IMS token for notifications: ${err.message}`); + return summary; + } + + for (const change of changes) { + if (change.recipients.length === 0) { + log.warn(`No valid recipients for ${change.type} status change (${change.strategyId}/${change.opportunityId || 'strategy'}), skipping`); + summary.skipped += 1; + } else { + const isOpportunity = change.type === 'opportunity'; + const isAssignment = change.type === 'assignment'; + const templateName = (isOpportunity || isAssignment) ? oppTemplateName : stratTemplateName; + + const createdBy = change.createdBy || ''; + if (!createdBy) { + log.warn(`Strategy owner (createdBy) is unknown for strategy ${change.strategyId}`); + } + + const strategyOwnerName = createdBy + // eslint-disable-next-line no-await-in-loop + ? await resolveUserName(dataAccess, createdBy) + : '-'; + const strategyOwnerEmail = createdBy || '-'; + + const assigneeEmail = (isOpportunity || isAssignment) ? (change.assignee || '') : ''; + const assigneeName = assigneeEmail + // eslint-disable-next-line no-await-in-loop + ? await resolveUserName(dataAccess, assigneeEmail) + : ''; + + for (const recipient of change.recipients) { + // eslint-disable-next-line no-await-in-loop + const recipientName = await resolveUserName(dataAccess, recipient); + + let templateData; + if (isOpportunity || isAssignment) { + templateData = { + recipient_name: recipientName, + recipient_email: recipient, + assignee_name: assigneeName, + assignee_email: assigneeEmail, + strategy_owner_name: strategyOwnerName, + strategy_owner_email: strategyOwnerEmail, + opportunity_name: change.opportunityName || '', + opportunity_status: change.statusAfter, + strategy_name: change.strategyName, + strategy_url: strategyUrl, + }; + } else { + templateData = { + recipient_name: recipientName, + recipient_email: recipient, + strategy_name: change.strategyName, + strategy_status: change.statusAfter, + strategy_url: strategyUrl, + strategy_owner_name: strategyOwnerName, + strategy_owner_email: strategyOwnerEmail, + opportunity_list: change.opportunityNames || [], + }; + } + + try { + // eslint-disable-next-line no-await-in-loop + const result = await sendEmail(context, { + recipients: [recipient], + templateName, + templateData, + accessToken, + }); + + if (result.success) { + summary.sent += 1; + log.info(`Sent ${change.type} status change email to ${recipient} for ${change.strategyId}`); + } else { + summary.failed += 1; + log.error(`Failed to send ${change.type} status change email to ${recipient}: ${result.error}`); + } + } catch (error) { + summary.failed += 1; + log.error(`Error sending ${change.type} status change email to ${recipient}: ${error.message}`); + } + } + } + } + + return summary; +} + +/** + * Main entry point: detects status changes and sends notifications. + * Safe to call in a fire-and-forget manner; never throws. + * + * @param {Object} context - The request context (env, log, dataAccess, etc.). + * @param {Object} params + * @param {Object|null} params.prevData - Previous strategy data. + * @param {Object} params.nextData - New strategy data. + * @param {string} params.siteId - The site ID. + * @param {string} [params.siteBaseUrl] - Site base URL for strategy_url. + * @returns {Promise<{sent: number, failed: number, skipped: number, changes: number}>} + */ +export async function notifyStrategyChanges(context, { + prevData, nextData, siteId, siteBaseUrl, +}) { + const { log } = context; + + try { + const changes = detectStatusChanges(prevData, nextData, log); + + if (changes.length === 0) { + log.info(`No status changes detected for site ${siteId}, skipping notifications`); + return { + sent: 0, failed: 0, skipped: 0, changes: 0, + }; + } + + log.info(`Detected ${changes.length} status change(s) for site ${siteId}, sending notifications`); + const summary = await sendStatusChangeNotifications(context, { + changes, siteBaseUrl, + }); + + return { ...summary, changes: changes.length }; + } catch (error) { + log.error(`Error in notifyStrategyChanges for site ${siteId}: ${error.message}`); + return { + sent: 0, failed: 0, skipped: 0, changes: 0, + }; + } +} diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index fad779f86..2322e2d43 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -76,6 +76,7 @@ describe('LlmoController', () => { let mockTokowakaClient; let readStrategyStub; let writeStrategyStub; + let notifyStrategyChangesStub; let exchangePromiseTokenStub; let fetchWithTimeoutStub; let postLlmoAlertStub; @@ -194,6 +195,9 @@ describe('LlmoController', () => { '../../../src/support/brand-profile-trigger.js': { triggerBrandProfileAgent: (...args) => triggerBrandProfileAgentStub(...args), }, + '../../../src/support/opportunity-workspace-notifications.js': { + notifyStrategyChanges: (...args) => notifyStrategyChangesStub(...args), + }, '../../../src/support/access-control-util.js': { default: { fromContext(context) { @@ -364,6 +368,7 @@ describe('LlmoController', () => { setConfig: sinon.stub(), save: sinon.stub().resolves(), getOrganization: sinon.stub().resolves(mockOrganization), + getBaseURL: sinon.stub().returns('https://www.example.com'), }; // Reset mockTokowakaClient @@ -456,6 +461,9 @@ describe('LlmoController', () => { writeConfigStub = sinon.stub(); readStrategyStub = sinon.stub(); writeStrategyStub = sinon.stub(); + notifyStrategyChangesStub = sinon.stub().resolves({ + sent: 0, failed: 0, skipped: 0, changes: 0, + }); llmoConfigSchemaStub = { safeParse: sinon.stub().returns({ success: true, data: {} }), }; @@ -4443,13 +4451,35 @@ describe('LlmoController', () => { name: 'Performance Optimization', status: 'pending', url: 'https://example.com/products', - opportunities: [{ opportunityId: 'opp-1', status: 'pending' }], + opportunities: [{ opportunityId: 'opp-1', status: 'pending', assignee: 'user@example.com' }], + createdBy: 'owner@example.com', + }, + ], + }; + + const prevStrategyData = { + opportunities: [ + { id: 'opp-1', name: 'Improve page speed', category: 'Performance' }, + ], + strategies: [ + { + id: 'strategy-1', + name: 'Performance Optimization', + status: 'new', + url: 'https://example.com/products', + opportunities: [{ opportunityId: 'opp-1', status: 'new', assignee: 'user@example.com' }], + createdBy: 'owner@example.com', }, ], }; beforeEach(() => { writeStrategyStub.resolves({ version: 'v1' }); + readStrategyStub.resolves({ data: null, exists: false }); + notifyStrategyChangesStub.resetHistory(); + notifyStrategyChangesStub.resolves({ + sent: 0, failed: 0, skipped: 0, changes: 0, + }); mockContext.data = testStrategyData; }); @@ -4458,7 +4488,10 @@ describe('LlmoController', () => { expect(result.status).to.equal(200); const responseBody = await result.json(); - expect(responseBody).to.deep.equal({ version: 'v1' }); + expect(responseBody.version).to.equal('v1'); + expect(responseBody.notifications).to.deep.equal({ + sent: 0, failed: 0, skipped: 0, changes: 0, + }); expect(writeStrategyStub).to.have.been.calledWith( TEST_SITE_ID, testStrategyData, @@ -4467,6 +4500,102 @@ describe('LlmoController', () => { ); }); + it('should read previous strategy before writing', async () => { + readStrategyStub.resolves({ data: prevStrategyData, exists: true }); + + const result = await controller.saveStrategy(mockContext); + + expect(result.status).to.equal(200); + expect(readStrategyStub).to.have.been.calledWith( + TEST_SITE_ID, + s3Client, + { s3Bucket: TEST_BUCKET }, + ); + }); + + it('should call notifyStrategyChanges with prev and next data', async () => { + readStrategyStub.resolves({ data: prevStrategyData, exists: true }); + + await controller.saveStrategy(mockContext); + + expect(notifyStrategyChangesStub).to.have.been.calledOnce; + const [ctx, params] = notifyStrategyChangesStub.firstCall.args; + expect(ctx).to.equal(mockContext); + expect(params.prevData).to.deep.equal(prevStrategyData); + expect(params.nextData).to.deep.equal(testStrategyData); + expect(params.siteId).to.equal(TEST_SITE_ID); + expect(params.siteBaseUrl).to.equal('https://www.example.com'); + }); + + it('should log strategy notification summary when changes > 0', async () => { + readStrategyStub.resolves({ data: prevStrategyData, exists: true }); + notifyStrategyChangesStub.resolves({ + sent: 1, failed: 0, skipped: 0, changes: 1, + }); + + const result = await controller.saveStrategy(mockContext); + + expect(result.status).to.equal(200); + const responseBody = await result.json(); + expect(responseBody.notifications).to.deep.equal({ + sent: 1, failed: 0, skipped: 0, changes: 1, + }); + expect(mockLog.info).to.have.been.calledWith( + sinon.match(/Strategy notification summary for site .*: .*"changes":1/), + ); + }); + + it('should pass null prevData when previous strategy does not exist', async () => { + readStrategyStub.resolves({ data: null, exists: false }); + + await controller.saveStrategy(mockContext); + + expect(notifyStrategyChangesStub).to.have.been.calledOnce; + const [, params] = notifyStrategyChangesStub.firstCall.args; + expect(params.prevData).to.be.null; + }); + + it('should skip notifications when readStrategy fails (prevents email storm)', async () => { + readStrategyStub.rejects(new Error('S3 read error')); + + const result = await controller.saveStrategy(mockContext); + + expect(result.status).to.equal(200); + const responseBody = await result.json(); + expect(responseBody.notifications).to.deep.equal({ + sent: 0, failed: 0, skipped: 0, changes: 0, + }); + + expect(notifyStrategyChangesStub).to.not.have.been.called; + expect(mockLog.warn).to.have.been.calledWith( + sinon.match(/Could not read previous strategy/), + ); + }); + + it('should still return 200 when notification fails', async () => { + readStrategyStub.resolves({ data: prevStrategyData, exists: true }); + notifyStrategyChangesStub.rejects(new Error('Email service down')); + + const result = await controller.saveStrategy(mockContext); + + expect(result.status).to.equal(200); + const responseBody = await result.json(); + expect(responseBody.version).to.equal('v1'); + expect(responseBody.notifications).to.deep.equal({ + sent: 0, failed: 0, skipped: 0, changes: 0, + }); + }); + + it('should use empty siteBaseUrl when site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + readStrategyStub.resolves({ data: prevStrategyData, exists: true }); + + await controller.saveStrategy(mockContext); + + const [, params] = notifyStrategyChangesStub.firstCall.args; + expect(params.siteBaseUrl).to.equal(''); + }); + it('should return bad request when payload is not an object', async () => { mockContext.data = null; diff --git a/test/support/email-service.test.js b/test/support/email-service.test.js new file mode 100644 index 000000000..172e9fb71 --- /dev/null +++ b/test/support/email-service.test.js @@ -0,0 +1,249 @@ +/* + * 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. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; + +use(sinonChai); + +describe('email-service', () => { + let sendEmail; + let mockContext; + let fetchStub; + let imsClientInstance; + let ImsClientStub; + + before(async () => { + imsClientInstance = { + getServiceAccessToken: sinon.stub().resolves({ access_token: 'test-token' }), + }; + ImsClientStub = { + createFrom: sinon.stub().returns(imsClientInstance), + }; + + const emailService = await esmock('../../src/support/email-service.js', { + '@adobe/spacecat-shared-ims-client': { + ImsClient: ImsClientStub, + }, + }); + + sendEmail = emailService.sendEmail; + }); + + beforeEach(() => { + ImsClientStub.createFrom.resetHistory(); + imsClientInstance.getServiceAccessToken.reset(); + imsClientInstance.getServiceAccessToken.resolves({ access_token: 'test-token' }); + + mockContext = { + env: { + IMS_HOST: 'https://ims.example.com', + LLMO_EMAIL_IMS_CLIENT_ID: 'client-id', + LLMO_EMAIL_IMS_CLIENT_CODE: 'client-code', + LLMO_EMAIL_IMS_CLIENT_SECRET: 'client-secret', + LLMO_EMAIL_IMS_SCOPE: 'email-scope', + ADOBE_POSTOFFICE_ENDPOINT: 'https://postoffice.example.com', + }, + log: { + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + }, + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('sendEmail', () => { + let originalFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + fetchStub = sinon.stub(); + globalThis.fetch = fetchStub; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('should send email successfully', async () => { + fetchStub.resolves({ status: 200, text: async () => 'OK' }); + + const result = await sendEmail(mockContext, { + recipients: ['test@example.com'], + templateName: 'test-template', + templateData: { key: 'value' }, + }); + + expect(result.success).to.be.true; + expect(result.statusCode).to.equal(200); + expect(result.templateUsed).to.equal('test-template'); + expect(fetchStub).to.have.been.calledOnce; + + const [url, options] = fetchStub.firstCall.args; + expect(url).to.include('postoffice.example.com'); + expect(url).to.include('templateName=test-template'); + expect(url).to.include('locale=en_US'); + expect(options.method).to.equal('POST'); + expect(options.headers.Authorization).to.equal('IMS test-token'); + expect(options.headers['Content-Type']).to.equal('application/json'); + expect(options.headers.Accept).to.equal('application/json'); + const body = JSON.parse(options.body); + expect(body).to.deep.equal({ + toList: 'test@example.com', + templateData: { key: 'value' }, + }); + }); + + it('should create ImsClient with email-specific credentials', async () => { + fetchStub.resolves({ status: 200, text: async () => 'OK' }); + + await sendEmail(mockContext, { + recipients: ['test@example.com'], + templateName: 'test-template', + }); + + expect(ImsClientStub.createFrom).to.have.been.calledOnce; + const ctxArg = ImsClientStub.createFrom.firstCall.args[0]; + expect(ctxArg.env.IMS_CLIENT_ID).to.equal('client-id'); + expect(ctxArg.env.IMS_CLIENT_CODE).to.equal('client-code'); + expect(ctxArg.env.IMS_CLIENT_SECRET).to.equal('client-secret'); + expect(ctxArg.env.IMS_SCOPE).to.equal('email-scope'); + expect(ctxArg.env.IMS_HOST).to.equal('https://ims.example.com'); + }); + + it('should return error when no recipients provided', async () => { + const result = await sendEmail(mockContext, { + recipients: [], + templateName: 'test-template', + }); + + expect(result.success).to.be.false; + expect(result.error).to.equal('No recipients provided'); + expect(fetchStub).to.not.have.been.called; + }); + + it('should return error when templateName is missing', async () => { + const result = await sendEmail(mockContext, { + recipients: ['test@example.com'], + templateName: null, + }); + + expect(result.success).to.be.false; + expect(result.error).to.equal('templateName is required'); + expect(fetchStub).to.not.have.been.called; + expect(imsClientInstance.getServiceAccessToken).to.not.have.been.called; + }); + + it('should skip token acquisition when accessToken is provided', async () => { + fetchStub.resolves({ status: 200, text: async () => 'OK' }); + + const result = await sendEmail(mockContext, { + recipients: ['test@example.com'], + templateName: 'test-template', + accessToken: 'provided-token', + }); + + expect(result.success).to.be.true; + expect(ImsClientStub.createFrom).to.not.have.been.called; + const [, options] = fetchStub.firstCall.args; + expect(options.headers.Authorization).to.equal('IMS provided-token'); + }); + + it('should return error when ADOBE_POSTOFFICE_ENDPOINT is not configured', async () => { + delete mockContext.env.ADOBE_POSTOFFICE_ENDPOINT; + + const result = await sendEmail(mockContext, { + recipients: ['test@example.com'], + templateName: 'test-template', + }); + + expect(result.success).to.be.false; + expect(result.error).to.equal('ADOBE_POSTOFFICE_ENDPOINT is not configured'); + }); + + it('should handle non-200 Post Office response', async () => { + fetchStub.resolves({ + status: 500, + text: async () => 'Internal Server Error', + }); + + const result = await sendEmail(mockContext, { + recipients: ['test@example.com'], + templateName: 'test-template', + }); + + expect(result.success).to.be.false; + expect(result.statusCode).to.equal(500); + expect(result.error).to.include('Post Office returned 500'); + expect(mockContext.log.error).to.have.been.called; + }); + + it('should handle fetch errors gracefully (never throw)', async () => { + fetchStub.rejects(new Error('Network failure')); + + const result = await sendEmail(mockContext, { + recipients: ['test@example.com'], + templateName: 'test-template', + }); + + expect(result.success).to.be.false; + expect(result.error).to.equal('Network failure'); + expect(mockContext.log.error).to.have.been.called; + }); + + it('should handle IMS token failure gracefully', async () => { + imsClientInstance.getServiceAccessToken.rejects(new Error('IMS unavailable')); + + const result = await sendEmail(mockContext, { + recipients: ['test@example.com'], + templateName: 'test-template', + }); + + expect(result.success).to.be.false; + expect(result.error).to.equal('IMS unavailable'); + }); + + it('should use custom locale when provided', async () => { + fetchStub.resolves({ status: 200, text: async () => 'OK' }); + + await sendEmail(mockContext, { + recipients: ['test@example.com'], + templateName: 'test-template', + locale: 'de_DE', + }); + + const [url] = fetchStub.firstCall.args; + expect(url).to.include('locale=de_DE'); + }); + + it('should send JSON body with toList as comma-separated string for multiple recipients', async () => { + fetchStub.resolves({ status: 200, text: async () => 'OK' }); + + await sendEmail(mockContext, { + recipients: ['a@example.com', 'b@example.com'], + templateName: 'test-template', + }); + + const [, options] = fetchStub.firstCall.args; + const body = JSON.parse(options.body); + expect(body.toList).to.equal('a@example.com,b@example.com'); + expect(body.templateData).to.deep.equal({}); + }); + }); +}); diff --git a/test/support/opportunity-workspace-notifications.test.js b/test/support/opportunity-workspace-notifications.test.js new file mode 100644 index 000000000..74ba92c76 --- /dev/null +++ b/test/support/opportunity-workspace-notifications.test.js @@ -0,0 +1,1584 @@ +/* + * 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. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; + +use(sinonChai); + +describe('opportunity-workspace-notifications', () => { + let detectStatusChanges; + let sendStatusChangeNotifications; + let notifyStrategyChanges; + let sendEmailStub; + let mockLog; + let mockContext; + + let getEmailServiceTokenStub; + + before(async () => { + sendEmailStub = sinon.stub(); + getEmailServiceTokenStub = sinon.stub().resolves('cached-token'); + + const notifications = await esmock( + '../../src/support/opportunity-workspace-notifications.js', + { + '../../src/support/email-service.js': { + sendEmail: sendEmailStub, + getEmailServiceToken: getEmailServiceTokenStub, + }, + '@adobe/spacecat-shared-utils': { + isValidEmail: (email) => typeof email === 'string' && email.includes('@') && email.includes('.'), + }, + }, + ); + + detectStatusChanges = notifications.detectStatusChanges; + sendStatusChangeNotifications = notifications.sendStatusChangeNotifications; + notifyStrategyChanges = notifications.notifyStrategyChanges; + }); + + beforeEach(() => { + sendEmailStub.reset(); + sendEmailStub.resolves({ success: true, statusCode: 200, templateUsed: 'test' }); + getEmailServiceTokenStub.reset(); + getEmailServiceTokenStub.resolves('cached-token'); + + mockLog = { + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + }; + + mockContext = { + log: mockLog, + dataAccess: { + TrialUser: { + findByEmailId: sinon.stub().callsFake((email) => { + const names = { + 'user@test.com': { first: 'Jane', last: 'User' }, + 'owner@test.com': { first: 'Owner', last: 'Smith' }, + 'user1@test.com': { first: 'User', last: 'One' }, + 'user2@test.com': { first: 'User', last: 'Two' }, + }; + const n = names[email]; + if (n) { + return Promise.resolve({ getFirstName: () => n.first, getLastName: () => n.last }); + } + return Promise.resolve(null); + }), + }, + }, + }; + }); + + describe('detectStatusChanges', () => { + it('should return empty array when prevData is null and nextData has no strategies', () => { + const changes = detectStatusChanges(null, { strategies: [] }, mockLog); + expect(changes).to.be.an('array').that.is.empty; + }); + + it('should detect strategy changes when prevData is null (first save)', () => { + const nextData = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'user@test.com' }], + createdBy: 'owner@test.com', + }], + }; + const changes = detectStatusChanges(null, nextData, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('opportunity'); + expect(changes[0].statusBefore).to.equal(''); + expect(changes[0].statusAfter).to.equal('new'); + expect(changes[0].recipients).to.deep.equal(['user@test.com']); + }); + + it('should detect opportunity changes when prevData is null (first save)', () => { + const nextData = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'in_progress', + opportunities: [ + { opportunityId: 'o1', status: 'completed', assignee: 'user@test.com' }, + { opportunityId: 'o2', status: 'new', assignee: 'other@test.com' }, + ], + createdBy: 'owner@test.com', + }], + }; + const changes = detectStatusChanges(null, nextData, mockLog); + expect(changes).to.have.lengthOf(2); + expect(changes[0].type).to.equal('opportunity'); + expect(changes[0].opportunityId).to.equal('o1'); + expect(changes[0].statusAfter).to.equal('completed'); + expect(changes[0].recipients).to.deep.equal(['user@test.com']); + expect(changes[1].type).to.equal('opportunity'); + expect(changes[1].opportunityId).to.equal('o2'); + expect(changes[1].statusAfter).to.equal('new'); + expect(changes[1].recipients).to.deep.equal(['other@test.com']); + }); + + it('should handle new strategy with undefined opportunities (first save)', () => { + const nextData = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + createdBy: 'owner@test.com', + }], + }; + const changes = detectStatusChanges(null, nextData, mockLog); + expect(changes).to.have.lengthOf(0); // no strategy email on initial creation, no opps + }); + + it('should handle new strategy with null opportunities (first save)', () => { + const nextData = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: null, + createdBy: 'owner@test.com', + }], + }; + const changes = detectStatusChanges(null, nextData, mockLog); + expect(changes).to.have.lengthOf(0); // no strategy email on initial creation, no opps + }); + + it('should use opportunityId when opportunity has no name (new strategy)', () => { + const nextData = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'user@test.com' }], + createdBy: 'owner@test.com', + }], + }; + const changes = detectStatusChanges(null, nextData, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].opportunityName).to.equal('o1'); + }); + + it('should resolve library opportunity name from nextData.opportunities when strategyOpportunity has no name', () => { + const nextData = { + opportunities: [ + { + id: 'lib-opp-1', name: 'EV Charging Expansion', description: '', category: 'energy', + }, + { + id: 'lib-opp-2', name: 'Depot Grid Modernization', description: '', category: 'energy', + }, + ], + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [ + { opportunityId: 'lib-opp-1', status: 'new', assignee: 'user@test.com' }, + { opportunityId: 'lib-opp-2', status: 'new', assignee: 'other@test.com' }, + ], + createdBy: 'owner@test.com', + }], + }; + const changes = detectStatusChanges(null, nextData, mockLog); + expect(changes).to.have.lengthOf(2); + expect(changes[0].opportunityName).to.equal('EV Charging Expansion'); + expect(changes[1].opportunityName).to.equal('Depot Grid Modernization'); + }); + + it('should use empty assignee when new strategy opportunity has no assignee', () => { + const nextData = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', name: 'Opp 1', status: 'new' }], + createdBy: 'owner@test.com', + }], + }; + const changes = detectStatusChanges(null, nextData, mockLog); + expect(changes).to.have.lengthOf(0); + }); + + it('should use empty createdBy when new strategy has no owner', () => { + const nextData = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'user@test.com' }], + }], + }; + const changes = detectStatusChanges(null, nextData, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].createdBy).to.equal(''); + }); + + it('should return empty array when nextData is null', () => { + const changes = detectStatusChanges({ strategies: [] }, null, mockLog); + expect(changes).to.be.an('array').that.is.empty; + }); + + it('should handle data without strategies property', () => { + const changes = detectStatusChanges({}, {}, mockLog); + expect(changes).to.be.an('array').that.is.empty; + }); + + it('should return empty array when no statuses changed', () => { + const data = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'a@b.com' }], + createdBy: 'owner@b.com', + }], + }; + const changes = detectStatusChanges(data, data, mockLog); + expect(changes).to.be.an('array').that.is.empty; + }); + + it('should detect strategy status change', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'user@test.com' }], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'in_progress', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'user@test.com' }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('strategy'); + expect(changes[0].strategyId).to.equal('s1'); + expect(changes[0].statusBefore).to.equal('new'); + expect(changes[0].statusAfter).to.equal('in_progress'); + expect(changes[0].recipients).to.include('user@test.com'); + expect(changes[0].recipients).to.include('owner@test.com'); + }); + + it('should detect opportunity status change', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'o1', name: 'Opp 1', status: 'new', assignee: 'user@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'o1', name: 'Opp 1', status: 'completed', assignee: 'user@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('opportunity'); + expect(changes[0].opportunityId).to.equal('o1'); + expect(changes[0].statusBefore).to.equal('new'); + expect(changes[0].statusAfter).to.equal('completed'); + }); + + it('should detect both strategy and opportunity status changes', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'user@test.com' }], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'in_progress', + opportunities: [{ opportunityId: 'o1', status: 'in_progress', assignee: 'user@test.com' }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(2); + expect(changes[0].type).to.equal('strategy'); + expect(changes[1].type).to.equal('opportunity'); + }); + + it('should deduplicate recipients', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'same@test.com' }], + createdBy: 'same@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'done', assignee: 'same@test.com' }], + createdBy: 'same@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes[0].recipients).to.have.lengthOf(1); + expect(changes[0].recipients[0]).to.equal('same@test.com'); + }); + + it('should filter out invalid emails and log warnings', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'not-an-email' }], + createdBy: '', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'done', assignee: 'not-an-email' }], + createdBy: '', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes[0].recipients).to.have.lengthOf(0); + expect(mockLog.warn).to.have.been.calledWith(sinon.match(/Skipping invalid email/)); + }); + + it('should log warn for non-string email recipient', () => { + const nextData = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 123 }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(null, nextData, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].recipients).to.deep.equal([]); + expect(mockLog.warn).to.have.been.calledWith(sinon.match(/Skipping non-string email recipient: number/)); + }); + + it('should detect changes for new strategies that do not exist in prevData', () => { + const prev = { + strategies: [], + }; + const next = { + strategies: [{ + id: 's-new', + name: 'New Strategy', + status: 'new', + opportunities: [], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(0); + }); + + it('should emit assignment change when new opportunity added to existing strategy with assignee', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'o-new', name: 'New Opp', status: 'new', assignee: 'user@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('assignment'); + expect(changes[0].strategyId).to.equal('s1'); + expect(changes[0].opportunityId).to.equal('o-new'); + expect(changes[0].opportunityName).to.equal('New Opp'); + expect(changes[0].assigneeBefore).to.equal(''); + expect(changes[0].assigneeAfter).to.equal('user@test.com'); + expect(changes[0].statusAfter).to.equal('new'); + expect(changes[0].recipients).to.deep.equal(['user@test.com']); // assignee only, not owner + }); + + it('should not emit assignment change when new opportunity added without assignee', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o-new', status: 'new' }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.be.empty; + }); + + it('should emit assignment change when assignee changes from empty to user', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', name: 'Opp 1', status: 'new' }], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'o1', name: 'Opp 1', status: 'new', assignee: 'user@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('assignment'); + expect(changes[0].assigneeBefore).to.equal(''); + expect(changes[0].assigneeAfter).to.equal('user@test.com'); + expect(changes[0].statusAfter).to.equal('new'); + expect(changes[0].recipients).to.deep.equal(['user@test.com']); // assignee only + }); + + it('should emit assignment change when assignee changes from one user to another', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'o1', name: 'Opp 1', status: 'new', assignee: 'user1@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'o1', name: 'Opp 1', status: 'new', assignee: 'user2@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('assignment'); + expect(changes[0].assigneeBefore).to.equal('user1@test.com'); + expect(changes[0].assigneeAfter).to.equal('user2@test.com'); + expect(changes[0].statusAfter).to.equal('new'); + expect(changes[0].recipients).to.deep.equal(['user2@test.com']); // new assignee only + }); + + it('should not emit assignment change when assignee is removed', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'o1', name: 'Opp 1', status: 'new', assignee: 'user@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', name: 'Opp 1', status: 'new' }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.be.empty; + }); + + it('should resolve library opportunity name and use empty createdBy when new opp added to existing strategy', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [], + }], + }; + const next = { + opportunities: [{ + id: 'lib-1', name: 'Library Opp Name', description: '', category: 'energy', + }], + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'lib-1', status: 'new', assignee: 'user@test.com', + }], + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('assignment'); + expect(changes[0].opportunityName).to.equal('Library Opp Name'); + expect(changes[0].createdBy).to.equal(''); + }); + + it('should resolve library opportunity name and use empty createdBy when assignee changes on existing opp', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'lib-1', status: 'new', assignee: 'user1@test.com', + }], + }], + }; + const next = { + opportunities: [{ + id: 'lib-1', name: 'Library Opp Name', description: '', category: 'energy', + }], + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'lib-1', status: 'new', assignee: 'user2@test.com', + }], + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('assignment'); + expect(changes[0].opportunityName).to.equal('Library Opp Name'); + expect(changes[0].createdBy).to.equal(''); + }); + + it('should fallback to opportunityId when new opp has no name and no library match', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [], + createdBy: 'owner@test.com', + }], + }; + const next = { + opportunities: [], + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'unknown-opp', status: 'new', assignee: 'user@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('assignment'); + expect(changes[0].opportunityName).to.equal('unknown-opp'); + }); + + it('should fallback to opportunityId when assignee changes on existing opp with no name and no library match', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'unknown-opp', status: 'new', assignee: 'user1@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + const next = { + opportunities: [], + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'unknown-opp', status: 'new', assignee: 'user2@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('assignment'); + expect(changes[0].opportunityName).to.equal('unknown-opp'); + }); + + it('should emit both status and assignment changes when both change', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'o1', name: 'Opp 1', status: 'new', assignee: 'user1@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'o1', name: 'Opp 1', status: 'completed', assignee: 'user2@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(2); + const statusChange = changes.find((c) => c.type === 'opportunity'); + const assignmentChange = changes.find((c) => c.type === 'assignment'); + expect(statusChange).to.exist; + expect(assignmentChange).to.exist; + expect(statusChange.statusBefore).to.equal('new'); + expect(statusChange.statusAfter).to.equal('completed'); + expect(assignmentChange.assigneeBefore).to.equal('user1@test.com'); + expect(assignmentChange.assigneeAfter).to.equal('user2@test.com'); + expect(assignmentChange.statusAfter).to.equal('completed'); + expect(assignmentChange.recipients).to.deep.equal(['user2@test.com']); // assignee only, not owner + }); + + it('should collect all assignees for strategy status change', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [ + { opportunityId: 'o1', status: 'new', assignee: 'a@test.com' }, + { opportunityId: 'o2', status: 'new', assignee: 'b@test.com' }, + ], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'completed', + opportunities: [ + { opportunityId: 'o1', status: 'new', assignee: 'a@test.com' }, + { opportunityId: 'o2', status: 'new', assignee: 'b@test.com' }, + ], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes[0].recipients).to.include('a@test.com'); + expect(changes[0].recipients).to.include('b@test.com'); + expect(changes[0].recipients).to.include('owner@test.com'); + }); + + it('should use empty assignee when opportunity has no assignee', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new' }], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'done' }], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].assignee).to.equal(''); + }); + + it('should handle strategy change when opportunities is undefined', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'done', + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('strategy'); + expect(changes[0].recipients).to.include('owner@test.com'); + expect(changes[0].opportunityNames).to.deep.equal([]); + }); + + it('should use empty createdBy when strategy has no owner', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'user@test.com' }], + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'done', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'user@test.com' }], + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('strategy'); + expect(changes[0].createdBy).to.equal(''); + expect(changes[0].recipients).to.include('user@test.com'); + }); + + it('should include opportunityNames for strategy status change', () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [ + { + opportunityId: 'o1', + name: 'EV Charging Expansion', + status: 'new', + assignee: 'a@test.com', + }, + { + opportunityId: 'o2', + name: 'Depot Grid Modernization', + status: 'new', + assignee: 'b@test.com', + }, + ], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'in_progress', + opportunities: [ + { + opportunityId: 'o1', + name: 'EV Charging Expansion', + status: 'new', + assignee: 'a@test.com', + }, + { + opportunityId: 'o2', + name: 'Depot Grid Modernization', + status: 'new', + assignee: 'b@test.com', + }, + ], + createdBy: 'owner@test.com', + }], + }; + + const changes = detectStatusChanges(prev, next, mockLog); + expect(changes).to.have.lengthOf(1); + expect(changes[0].type).to.equal('strategy'); + expect(changes[0].opportunityNames).to.deep.equal(['EV Charging Expansion', 'Depot Grid Modernization']); + }); + }); + + describe('sendStatusChangeNotifications', () => { + it('should skip when recipients list is empty', async () => { + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: [], + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: '', + }); + + expect(summary.skipped).to.equal(1); + expect(sendEmailStub).to.not.have.been.called; + }); + + it('should handle unparseable siteBaseUrl gracefully', async () => { + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'completed', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'http://', + }); + + expect(summary.sent).to.equal(1); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.strategy_url).to.equal(''); + }); + + it('should send opportunity status change email', async () => { + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'completed', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(summary.sent).to.equal(1); + expect(sendEmailStub).to.have.been.calledOnce; + + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateName).to.equal('llmo_opportunity_status_update'); + expect(emailOptions.recipients).to.deep.equal(['user@test.com']); + expect(emailOptions.templateData.recipient_name).to.equal('Jane User'); + expect(emailOptions.templateData.recipient_email).to.equal('user@test.com'); + expect(emailOptions.templateData.assignee_name).to.equal('Jane User'); + expect(emailOptions.templateData.assignee_email).to.equal('user@test.com'); + expect(emailOptions.templateData.strategy_owner_name).to.equal('Owner Smith'); + expect(emailOptions.templateData.strategy_owner_email).to.equal('owner@test.com'); + expect(emailOptions.templateData.opportunity_name).to.equal('Opp 1'); + expect(emailOptions.templateData.opportunity_status).to.equal('completed'); + expect(emailOptions.templateData.strategy_name).to.equal('Strategy 1'); + expect(emailOptions.templateData.strategy_url).to.equal('https://llmo.now/www.example.com/insights/opportunity-workspace'); + }); + + it('should send assignment change email with llmo_opportunity_status_update template', async () => { + const changes = [{ + type: 'assignment', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + assigneeBefore: '', + assigneeAfter: 'user@test.com', + statusAfter: 'new', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(summary.sent).to.equal(1); + expect(sendEmailStub).to.have.been.calledOnce; + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateName).to.equal('llmo_opportunity_status_update'); + expect(emailOptions.recipients).to.deep.equal(['user@test.com']); + }); + + it('should include correct template data for assignment change', async () => { + const changes = [{ + type: 'assignment', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + assigneeBefore: 'user1@test.com', + assigneeAfter: 'user2@test.com', + statusAfter: 'in_progress', + recipients: ['user2@test.com'], + createdBy: 'owner@test.com', + assignee: 'user2@test.com', + }]; + + await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.recipient_name).to.equal('User Two'); + expect(emailOptions.templateData.recipient_email).to.equal('user2@test.com'); + expect(emailOptions.templateData.assignee_name).to.equal('User Two'); + expect(emailOptions.templateData.assignee_email).to.equal('user2@test.com'); + expect(emailOptions.templateData.strategy_owner_name).to.equal('Owner Smith'); + expect(emailOptions.templateData.strategy_owner_email).to.equal('owner@test.com'); + expect(emailOptions.templateData.opportunity_name).to.equal('Opp 1'); + expect(emailOptions.templateData.opportunity_status).to.equal('in_progress'); + expect(emailOptions.templateData.strategy_name).to.equal('Strategy 1'); + expect(emailOptions.templateData.strategy_url).to.equal('https://llmo.now/www.example.com/insights/opportunity-workspace'); + }); + + it('should use "-" for strategy_owner fields when createdBy is missing (assignment)', async () => { + const changes = [{ + type: 'assignment', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + assigneeBefore: '', + assigneeAfter: 'user@test.com', + statusAfter: 'new', + recipients: ['user@test.com'], + createdBy: '', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: '', + }); + + expect(summary.sent).to.equal(1); + expect(mockLog.warn).to.have.been.calledWith(sinon.match(/Strategy owner.*unknown/)); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.strategy_owner_name).to.equal('-'); + expect(emailOptions.templateData.strategy_owner_email).to.equal('-'); + }); + + it('should send strategy status change email', async () => { + const changes = [{ + type: 'strategy', + strategyId: 's1', + strategyName: 'Strategy 1', + statusBefore: 'new', + statusAfter: 'in_progress', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + opportunityNames: ['EV Charging Expansion', 'Depot Grid Modernization'], + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(summary.sent).to.equal(1); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateName).to.equal('llmo_strategy_update'); + expect(emailOptions.templateData.strategy_status).to.equal('in_progress'); + expect(emailOptions.templateData.strategy_owner_name).to.equal('Owner Smith'); + expect(emailOptions.templateData.opportunity_list).to.deep.equal(['EV Charging Expansion', 'Depot Grid Modernization']); + expect(emailOptions.templateData).to.not.have.property('assignee_name'); + expect(emailOptions.templateData).to.not.have.property('assignee_email'); + expect(emailOptions.templateData).to.not.have.property('opportunity_name'); + expect(emailOptions.templateData).to.not.have.property('opportunity_status'); + }); + + it('should send separate emails per recipient', async () => { + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user1@test.com', 'user2@test.com'], + createdBy: 'owner@test.com', + assignee: 'user1@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: '', + }); + + expect(summary.sent).to.equal(2); + expect(sendEmailStub).to.have.been.calledTwice; + }); + + it('should count failed emails', async () => { + sendEmailStub.resolves({ success: false, statusCode: 500, error: 'Server error' }); + + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: '', + }); + + expect(summary.failed).to.equal(1); + expect(summary.sent).to.equal(0); + }); + + it('should handle sendEmail throwing an error', async () => { + sendEmailStub.rejects(new Error('Unexpected error')); + + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: '', + }); + + expect(summary.failed).to.equal(1); + }); + + it('should use "-" for strategy_owner_* when createdBy is missing', async () => { + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user@test.com'], + createdBy: '', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: '', + }); + + expect(summary.sent).to.equal(1); + expect(mockLog.warn).to.have.been.calledWith(sinon.match(/Strategy owner.*unknown/)); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.strategy_owner_name).to.equal('-'); + expect(emailOptions.templateData.strategy_owner_email).to.equal('-'); + }); + + it('should log strategy fallback in skip warning for strategy change with no recipients', async () => { + const changes = [{ + type: 'strategy', + strategyId: 's1', + strategyName: 'Strategy 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: [], + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: '', + }); + + expect(summary.skipped).to.equal(1); + expect(mockLog.warn).to.have.been.calledWith(sinon.match('s1/strategy')); + }); + + it('should handle opportunity change with missing assignee and opportunityName', async () => { + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(summary.sent).to.equal(1); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.assignee_name).to.equal(''); + expect(emailOptions.templateData.assignee_email).to.equal(''); + expect(emailOptions.templateData.opportunity_name).to.equal(''); + }); + + it('should handle strategy change with missing opportunityNames', async () => { + const changes = [{ + type: 'strategy', + strategyId: 's1', + strategyName: 'Strategy 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(summary.sent).to.equal(1); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.opportunity_list).to.deep.equal([]); + }); + + it('should fall back to email when dataAccess has no TrialUser', async () => { + delete mockContext.dataAccess.TrialUser; + + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(summary.sent).to.equal(1); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.recipient_name).to.equal('user@test.com'); + expect(emailOptions.templateData.strategy_owner_name).to.equal('owner@test.com'); + }); + + it('should fall back to email when user names are empty', async () => { + mockContext.dataAccess.TrialUser.findByEmailId.resolves({ + getFirstName: () => null, + getLastName: () => null, + }); + + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(summary.sent).to.equal(1); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.recipient_name).to.equal('user@test.com'); + }); + + it('should fall back to email when TrialUser has placeholder "-" as first/last name', async () => { + mockContext.dataAccess.TrialUser.findByEmailId.resolves({ + getFirstName: () => '-', + getLastName: () => '-', + }); + + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(summary.sent).to.equal(1); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.recipient_name).to.equal('user@test.com'); + expect(emailOptions.templateData.assignee_name).to.equal('user@test.com'); + }); + + it('should fall back to email when TrialUser lookup throws', async () => { + mockContext.dataAccess.TrialUser.findByEmailId.rejects(new Error('DB error')); + + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(summary.sent).to.equal(1); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.recipient_name).to.equal('user@test.com'); + }); + + it('should resolve strategy_url when siteBaseUrl has no http prefix', async () => { + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'www.example.com', + }); + + expect(summary.sent).to.equal(1); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.strategy_url).to.equal('https://llmo.now/www.example.com/insights/opportunity-workspace'); + }); + + it('should fall back to email when TrialUser.findByEmailId returns null', async () => { + mockContext.dataAccess.TrialUser.findByEmailId.resolves(null); + + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['unknown@test.com'], + createdBy: 'owner@test.com', + assignee: 'unknown@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(summary.sent).to.equal(1); + const [, emailOptions] = sendEmailStub.firstCall.args; + expect(emailOptions.templateData.recipient_name).to.equal('unknown@test.com'); + expect(emailOptions.templateData.assignee_name).to.equal('unknown@test.com'); + expect(emailOptions.templateData.strategy_url).to.equal('https://llmo.now/www.example.com/insights/opportunity-workspace'); + }); + + it('should call getEmailServiceToken once and pass token to all sendEmail calls', async () => { + const changes = [ + { + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user1@test.com'], + createdBy: 'owner@test.com', + assignee: 'user1@test.com', + }, + { + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o2', + opportunityName: 'Opp 2', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user2@test.com'], + createdBy: 'owner@test.com', + assignee: 'user2@test.com', + }, + ]; + + await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(getEmailServiceTokenStub).to.have.been.calledOnce; + expect(sendEmailStub).to.have.been.calledTwice; + expect(sendEmailStub.firstCall.args[1].accessToken).to.equal('cached-token'); + expect(sendEmailStub.secondCall.args[1].accessToken).to.equal('cached-token'); + }); + + it('should return early and not send emails when getEmailServiceToken fails', async () => { + getEmailServiceTokenStub.rejects(new Error('IMS unavailable')); + + const changes = [{ + type: 'opportunity', + strategyId: 's1', + strategyName: 'Strategy 1', + opportunityId: 'o1', + opportunityName: 'Opp 1', + statusBefore: 'new', + statusAfter: 'done', + recipients: ['user@test.com'], + createdBy: 'owner@test.com', + assignee: 'user@test.com', + }]; + + const summary = await sendStatusChangeNotifications(mockContext, { + changes, siteBaseUrl: 'https://www.example.com', + }); + + expect(summary.sent).to.equal(0); + expect(summary.failed).to.equal(0); + expect(summary.skipped).to.equal(0); + expect(sendEmailStub).to.not.have.been.called; + expect(mockContext.log.error).to.have.been.calledWith(sinon.match(/Failed to acquire IMS token/)); + }); + }); + + describe('notifyStrategyChanges', () => { + it('should return zero counts when no changes detected', async () => { + const data = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'user@test.com' }], + createdBy: 'owner@test.com', + }], + }; + + const result = await notifyStrategyChanges(mockContext, { + prevData: data, + nextData: data, + siteId: 'site-1', + siteBaseUrl: '', + changedBy: 'admin@test.com', + }); + + expect(result.changes).to.equal(0); + expect(result.sent).to.equal(0); + expect(sendEmailStub).to.not.have.been.called; + }); + + it('should detect changes and send notifications end-to-end', async () => { + const prev = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'o1', name: 'Opp 1', status: 'new', assignee: 'user@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + const next = { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ + opportunityId: 'o1', name: 'Opp 1', status: 'completed', assignee: 'user@test.com', + }], + createdBy: 'owner@test.com', + }], + }; + + const result = await notifyStrategyChanges(mockContext, { + prevData: prev, + nextData: next, + siteId: 'site-1', + siteBaseUrl: 'https://www.example.com', + changedBy: 'admin@test.com', + }); + + expect(result.changes).to.equal(1); + expect(result.sent).to.equal(2); // assignee + owner + }); + + it('should not throw when an unexpected error occurs', async () => { + const faultyContext = { + ...mockContext, + log: { + info: sinon.stub().throws(new Error('Simulated failure')), + warn: sinon.stub(), + error: sinon.stub(), + }, + }; + + const result = await notifyStrategyChanges( + faultyContext, + { + prevData: { + strategies: [{ + id: 's1', status: 'new', opportunities: [], + }], + }, + nextData: { + strategies: [{ + id: 's1', status: 'done', opportunities: [], + }], + }, + siteId: 'site-1', + siteBaseUrl: '', + }, + ); + + expect(result.changes).to.equal(0); + expect(result.sent).to.equal(0); + expect(faultyContext.log.error).to.have.been.calledOnce; + }); + + it('should detect changes and send notifications when prevData is null (first save)', async () => { + const result = await notifyStrategyChanges(mockContext, { + prevData: null, + nextData: { + strategies: [{ + id: 's1', + name: 'Strategy 1', + status: 'new', + opportunities: [{ opportunityId: 'o1', status: 'new', assignee: 'user@test.com' }], + createdBy: 'owner@test.com', + }], + }, + siteId: 'site-1', + siteBaseUrl: '', + changedBy: 'admin@test.com', + }); + + expect(result.changes).to.equal(1); + expect(result.sent).to.equal(1); + }); + }); +});