Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 105 additions & 21 deletions src/tasks/opportunity-status-processor/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { resolveCanonicalUrl } from '@adobe/spacecat-shared-utils';
import { getAuditStatus } from '../../utils/cloudwatch-utils.js';
import { checkAndAlertBotProtection } from '../../utils/bot-detection.js';
import { say } from '../../utils/slack-utils.js';
import { getOpportunitiesForAudit } from './audit-opportunity-map.js';
import { getOpportunitiesForAudit, getAuditsForOpportunity } from './audit-opportunity-map.js';
import { OPPORTUNITY_DEPENDENCY_MAP } from './opportunity-dependency-map.js';

const TASK_TYPE = 'opportunity-status-processor';
Expand Down Expand Up @@ -241,11 +241,51 @@ async function isScrapingAvailable(baseUrl, context, onboardStartTime) {
}

/**
* Checks scrape results for bot protection blocking
* @param {Array} scrapeResults - Array of scrape URL results
* @param {object} context - The context object with log
* @returns {object|null} Bot protection details if detected, null otherwise
* Checks which audit types have completed since onboardStartTime by querying the database.
* An audit is considered completed if a record exists with auditedAt >= onboardStartTime.
* Falls back conservatively (all pending) if the DB query fails.
*
* @param {string} siteId - The site ID
* @param {Array<string>} auditTypes - Audit types expected for this onboard session
* @param {number} onboardStartTime - Onboarding start timestamp in ms
* @param {object} dataAccess - Data access object
* @param {object} log - Logger
* @returns {Promise<{pendingAuditTypes: Array<string>, completedAuditTypes: Array<string>}>}
*/
async function checkAuditCompletionFromDB(siteId, auditTypes, onboardStartTime, dataAccess, log) {
const pendingAuditTypes = [];
const completedAuditTypes = [];
try {
const { Audit } = dataAccess;
const latestAudits = await Audit.allLatestForSite(siteId);
const auditsByType = {};
if (latestAudits) {
for (const audit of latestAudits) {
auditsByType[audit.getAuditType()] = audit;
}
}
for (const auditType of auditTypes) {
const audit = auditsByType[auditType];
if (!audit) {
pendingAuditTypes.push(auditType);
} else {
const auditedAt = new Date(audit.getAuditedAt()).getTime();
if (onboardStartTime && auditedAt < onboardStartTime) {
// Record exists but predates this onboard session — treat as pending
pendingAuditTypes.push(auditType);
} else {
completedAuditTypes.push(auditType);
}
}
}
} catch (error) {
log.warn(`Could not check audit completion from DB for site ${siteId}: ${error.message}`);
// Conservative fallback: mark all as pending so disclaimer is always shown on error
pendingAuditTypes.push(...auditTypes.filter((t) => !completedAuditTypes.includes(t)));
}
return { pendingAuditTypes, completedAuditTypes };
}

/**
* Analyzes missing opportunities and determines the root cause
* @param {Array<string>} missingOpportunities - Array of missing opportunity types
Expand Down Expand Up @@ -558,6 +598,15 @@ export async function runOpportunityStatusProcessor(message, context) {
statusMessages.push(`GSC ${gscStatus}`);
statusMessages.push(`Scraping ${scrapingStatus}`);

// Determine which audits are still pending so opportunity statuses can reflect
// in-progress state (⏳) rather than showing stale data as ✅/❌.
// Only meaningful when we have an onboardStartTime anchor to compare against.
let pendingAuditTypes = [];
if (auditTypes && auditTypes.length > 0 && onboardStartTime) {
// eslint-disable-next-line max-len
({ pendingAuditTypes } = await checkAuditCompletionFromDB(siteId, auditTypes, onboardStartTime, dataAccess, log));
}

// Process opportunities by type to avoid duplicates
// Only process opportunities that are expected based on the profile's audit types
const processedTypes = new Set();
Expand Down Expand Up @@ -586,23 +635,28 @@ export async function runOpportunityStatusProcessor(message, context) {
}
processedTypes.add(opportunityType);

// eslint-disable-next-line no-await-in-loop
const suggestions = await opportunity.getSuggestions();

const opportunityTitle = getOpportunityTitle(opportunityType);
const hasSuggestions = suggestions && suggestions.length > 0;
const status = hasSuggestions ? ':white_check_mark:' : ':x:';
statusMessages.push(`${opportunityTitle} ${status}`);

// Track failed opportunities (no suggestions)
if (!hasSuggestions) {
// Use informational message for opportunities with zero suggestions
const reason = 'Audit executed successfully, opportunity added, but found no suggestions';

failedOpportunities.push({
title: opportunityTitle,
reason,
});

// If the source audit is still running, show ⏳ instead of stale ✅/❌
const sourceAuditIsPending = getAuditsForOpportunity(opportunityType)
.some((auditType) => pendingAuditTypes.includes(auditType));

if (sourceAuditIsPending) {
statusMessages.push(`${opportunityTitle} :hourglass_flowing_sand:`);
} else {
// eslint-disable-next-line no-await-in-loop
const suggestions = await opportunity.getSuggestions();
const hasSuggestions = suggestions && suggestions.length > 0;
const status = hasSuggestions ? ':white_check_mark:' : ':x:';
statusMessages.push(`${opportunityTitle} ${status}`);

// Track failed opportunities (no suggestions)
if (!hasSuggestions) {
failedOpportunities.push({
title: opportunityTitle,
reason: 'Audit executed successfully, opportunity added, but found no suggestions',
});
}
}
}

Expand Down Expand Up @@ -680,6 +734,36 @@ export async function runOpportunityStatusProcessor(message, context) {
} else {
await say(env, log, slackContext, 'No audit errors found');
}

// Audit completion disclaimer — reuse pendingAuditTypes already computed above.
// Only list audit types that have known opportunity mappings; infrastructure audits
// (auto-suggest, auto-fix, scrape, etc.) are not shown since they don't affect
// the displayed opportunity statuses.
if (auditTypes.length > 0) {
const isRecheck = taskContext?.isRecheck === true;
const relevantPendingTypes = pendingAuditTypes.filter(
(t) => getOpportunitiesForAudit(t).length > 0,
);
if (relevantPendingTypes.length > 0) {
const pendingList = relevantPendingTypes.map(getOpportunityTitle).join(', ');
await say(
env,
log,
slackContext,
`:warning: *Heads-up:* The following audit${relevantPendingTypes.length > 1 ? 's' : ''} `
+ `may still be in progress: *${pendingList}*.\n`
+ 'The statuses above reflect data available at this moment and may be incomplete. '
+ `Run \`onboard status ${siteUrl}\` to re-check once all audits have completed.`,
);
} else if (isRecheck) {
await say(
env,
log,
slackContext,
':white_check_mark: All audits have completed. The statuses above are up to date.',
);
}
}
}

log.info(`Processed ${opportunities.length} opportunities for site ${siteId}`);
Expand Down
Loading
Loading