recommendationService.js•19.9 kB
/**
* Recommendation Service
* Generates optimization recommendations for Facebook ads
*/
const Analytics = require('../models/analytics');
const Campaign = require('../models/campaign');
const AdSet = require('../models/adSet');
const Ad = require('../models/ad');
const facebookApiService = require('./facebookApiService');
const logger = require('../utils/logger');
/**
* Recommendation Service class
*/
class RecommendationService {
/**
* Create a new Recommendation Service instance
* @param {Object} user - User object
*/
constructor(user) {
this.user = user;
this.userId = user._id;
}
/**
* Initialize Facebook API service
* @returns {Promise<void>}
* @private
*/
async _initFacebookApi() {
if (!this.fbApi) {
this.fbApi = await facebookApiService.createForUser(this.user);
}
}
/**
* Get budget optimization recommendations
* @param {string} adAccountId - Ad account ID
* @returns {Promise<Array>} - Budget recommendations
*/
async getBudgetRecommendations(adAccountId) {
try {
// Get all campaigns for the ad account
const campaigns = await Campaign.find({
adAccountId,
userId: this.userId,
status: 'ACTIVE'
});
if (campaigns.length === 0) {
return [];
}
const recommendations = [];
// Analyze each campaign
for (const campaign of campaigns) {
// Get campaign analytics for the last 30 days
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const analytics = await Analytics.find({
entityId: campaign.campaignId,
entityType: 'campaign',
userId: this.userId,
date: { $gte: thirtyDaysAgo }
}).sort({ date: 1 });
if (analytics.length === 0) {
continue;
}
// Calculate performance metrics
const performance = this._calculatePerformanceMetrics(analytics);
// Generate budget recommendations
const budgetRecs = this._generateBudgetRecommendations(campaign, performance);
if (budgetRecs.length > 0) {
recommendations.push({
campaignId: campaign.campaignId,
campaignName: campaign.name,
performance,
recommendations: budgetRecs
});
}
}
return recommendations;
} catch (error) {
logger.error(`Error generating budget recommendations: ${error.message}`);
throw error;
}
}
/**
* Get targeting recommendations
* @param {string} adSetId - Ad set ID
* @returns {Promise<Object>} - Targeting recommendations
*/
async getTargetingRecommendations(adSetId) {
try {
// Get ad set
const adSet = await AdSet.findOne({
adSetId,
userId: this.userId
});
if (!adSet) {
throw new Error(`Ad set not found: ${adSetId}`);
}
// Get ad set analytics for the last 30 days
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const analytics = await Analytics.find({
entityId: adSetId,
entityType: 'adset',
userId: this.userId,
date: { $gte: thirtyDaysAgo }
}).sort({ date: 1 });
// Calculate performance metrics
const performance = this._calculatePerformanceMetrics(analytics);
// Generate targeting recommendations
const targetingRecs = this._generateTargetingRecommendations(adSet, performance);
return {
adSetId: adSet.adSetId,
adSetName: adSet.name,
performance,
currentTargeting: adSet.targeting,
recommendations: targetingRecs
};
} catch (error) {
logger.error(`Error generating targeting recommendations: ${error.message}`);
throw error;
}
}
/**
* Get creative performance recommendations
* @param {string} adAccountId - Ad account ID
* @returns {Promise<Array>} - Creative recommendations
*/
async getCreativeRecommendations(adAccountId) {
try {
// Get all active ads for the ad account
const ads = await Ad.find({
userId: this.userId,
status: 'ACTIVE'
}).populate({
path: 'adSetId',
match: { adAccountId }
});
if (ads.length === 0) {
return [];
}
const recommendations = [];
// Analyze each ad
for (const ad of ads) {
// Get ad analytics for the last 30 days
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const analytics = await Analytics.find({
entityId: ad.adId,
entityType: 'ad',
userId: this.userId,
date: { $gte: thirtyDaysAgo }
}).sort({ date: 1 });
if (analytics.length === 0) {
continue;
}
// Calculate performance metrics
const performance = this._calculatePerformanceMetrics(analytics);
// Generate creative recommendations
const creativeRecs = this._generateCreativeRecommendations(ad, performance);
if (creativeRecs.length > 0) {
recommendations.push({
adId: ad.adId,
adName: ad.name,
performance,
creativeType: ad.creative.type,
recommendations: creativeRecs
});
}
}
return recommendations;
} catch (error) {
logger.error(`Error generating creative recommendations: ${error.message}`);
throw error;
}
}
/**
* Calculate performance metrics from analytics data
* @param {Array} analytics - Analytics data
* @returns {Object} - Performance metrics
* @private
*/
_calculatePerformanceMetrics(analytics) {
if (analytics.length === 0) {
return {
impressions: 0,
clicks: 0,
ctr: 0,
cpc: 0,
spend: 0,
conversions: 0,
conversionRate: 0,
costPerConversion: 0,
roas: 0,
trend: 'stable'
};
}
// Calculate totals
const totals = {
impressions: 0,
clicks: 0,
spend: 0,
conversions: 0
};
analytics.forEach(data => {
totals.impressions += data.impressions;
totals.clicks += data.clicks;
totals.spend += data.spend;
totals.conversions += data.conversions;
});
// Calculate averages
const metrics = {
impressions: totals.impressions,
clicks: totals.clicks,
ctr: totals.clicks / totals.impressions * 100 || 0,
cpc: totals.spend / totals.clicks || 0,
spend: totals.spend,
conversions: totals.conversions,
conversionRate: totals.conversions / totals.clicks * 100 || 0,
costPerConversion: totals.spend / totals.conversions || 0,
roas: totals.conversions * 10 / totals.spend || 0 // Assuming $10 per conversion
};
// Calculate trend
if (analytics.length >= 7) {
const recentData = analytics.slice(-7);
const olderData = analytics.slice(-14, -7);
const recentConversions = recentData.reduce((sum, data) => sum + data.conversions, 0);
const olderConversions = olderData.reduce((sum, data) => sum + data.conversions, 0);
if (recentConversions > olderConversions * 1.2) {
metrics.trend = 'improving';
} else if (recentConversions < olderConversions * 0.8) {
metrics.trend = 'declining';
} else {
metrics.trend = 'stable';
}
} else {
metrics.trend = 'insufficient_data';
}
return metrics;
}
/**
* Generate budget recommendations for a campaign
* @param {Object} campaign - Campaign object
* @param {Object} performance - Performance metrics
* @returns {Array} - Budget recommendations
* @private
*/
_generateBudgetRecommendations(campaign, performance) {
const recommendations = [];
// Check if campaign has sufficient data
if (performance.impressions < 1000 || performance.clicks < 10) {
recommendations.push({
type: 'info',
message: 'Insufficient data to make budget recommendations',
reason: 'Campaign needs more data to generate accurate recommendations',
action: 'Continue running the campaign to gather more data'
});
return recommendations;
}
// Check if campaign is performing well
if (performance.roas >= 2) {
// High ROAS, recommend increasing budget
const currentBudget = campaign.dailyBudget || campaign.lifetimeBudget / 30;
const recommendedIncrease = Math.min(currentBudget * 0.2, 100);
recommendations.push({
type: 'increase_budget',
message: `Increase daily budget by $${recommendedIncrease.toFixed(2)}`,
reason: `Campaign is performing well with a ROAS of ${performance.roas.toFixed(2)}`,
action: 'Gradually increase budget to scale successful campaign',
data: {
currentBudget: currentBudget,
recommendedBudget: currentBudget + recommendedIncrease,
increase: recommendedIncrease,
increasePercentage: 20
}
});
} else if (performance.roas < 1) {
// Low ROAS, recommend decreasing budget
const currentBudget = campaign.dailyBudget || campaign.lifetimeBudget / 30;
const recommendedDecrease = currentBudget * 0.3;
recommendations.push({
type: 'decrease_budget',
message: `Decrease daily budget by $${recommendedDecrease.toFixed(2)}`,
reason: `Campaign has a low ROAS of ${performance.roas.toFixed(2)}`,
action: 'Reduce spend on underperforming campaign while optimizing targeting and creative',
data: {
currentBudget: currentBudget,
recommendedBudget: currentBudget - recommendedDecrease,
decrease: recommendedDecrease,
decreasePercentage: 30
}
});
}
// Check cost per conversion
if (performance.costPerConversion > 0) {
const targetCPA = 10; // Example target cost per acquisition
if (performance.costPerConversion > targetCPA * 1.5) {
recommendations.push({
type: 'high_cpa',
message: `Cost per conversion ($${performance.costPerConversion.toFixed(2)}) is significantly higher than target ($${targetCPA.toFixed(2)})`,
reason: 'Campaign is acquiring customers at a high cost',
action: 'Review targeting and creative to improve conversion efficiency'
});
}
}
// Check if campaign budget is being spent
if (performance.spend < campaign.dailyBudget * 0.8 && performance.trend !== 'improving') {
recommendations.push({
type: 'underspending',
message: 'Campaign is consistently underspending its budget',
reason: 'Targeting may be too narrow or bid strategy may need adjustment',
action: 'Broaden targeting or adjust bid strategy to increase delivery'
});
}
return recommendations;
}
/**
* Generate targeting recommendations for an ad set
* @param {Object} adSet - Ad set object
* @param {Object} performance - Performance metrics
* @returns {Array} - Targeting recommendations
* @private
*/
_generateTargetingRecommendations(adSet, performance) {
const recommendations = [];
const targeting = adSet.targeting || {};
// Check if ad set has sufficient data
if (performance.impressions < 1000 || performance.clicks < 10) {
recommendations.push({
type: 'info',
message: 'Insufficient data to make targeting recommendations',
reason: 'Ad set needs more data to generate accurate recommendations',
action: 'Continue running the ad set to gather more data'
});
return recommendations;
}
// Check audience size
if (targeting.audience_size && targeting.audience_size < 100000) {
recommendations.push({
type: 'audience_size',
message: 'Audience size may be too small',
reason: 'Small audiences can limit delivery and increase costs',
action: 'Consider broadening age range, adding more interests, or expanding locations',
data: {
currentSize: targeting.audience_size,
recommendedMinimum: 100000
}
});
}
// Check age targeting
if (targeting.age_min && targeting.age_max) {
const ageRange = targeting.age_max - targeting.age_min;
if (ageRange < 10 && performance.ctr < 1) {
recommendations.push({
type: 'age_targeting',
message: 'Age range is narrow with low CTR',
reason: 'Narrow age targeting may be limiting performance',
action: 'Consider expanding age range to reach more potential customers',
data: {
currentAgeMin: targeting.age_min,
currentAgeMax: targeting.age_max,
recommendedAgeMin: Math.max(13, targeting.age_min - 5),
recommendedAgeMax: Math.min(65, targeting.age_max + 5)
}
});
}
}
// Check detailed targeting (interests)
if (targeting.flexible_spec && targeting.flexible_spec.length > 3) {
recommendations.push({
type: 'interest_targeting',
message: 'Too many interest layers may be restricting audience',
reason: 'Multiple interest layers create a smaller, more specific audience',
action: 'Simplify interest targeting to broaden reach while maintaining relevance',
data: {
currentInterests: targeting.flexible_spec.length,
recommendedMaximum: 3
}
});
}
// Check placement optimization
if (targeting.placements && targeting.placements.length < 3) {
recommendations.push({
type: 'placements',
message: 'Limited placements may be restricting delivery',
reason: 'Using more placements can improve reach and reduce costs',
action: 'Consider enabling automatic placements to optimize delivery',
data: {
currentPlacements: targeting.placements,
recommendation: 'automatic_placements'
}
});
}
// Check device targeting
if (targeting.device_platforms && targeting.device_platforms.length < 2) {
recommendations.push({
type: 'device_targeting',
message: 'Limited device targeting may be restricting audience',
reason: 'Targeting only specific devices limits potential reach',
action: 'Consider enabling all device types to maximize audience',
data: {
currentDevices: targeting.device_platforms,
recommendedDevices: ['mobile', 'desktop']
}
});
}
// Check location targeting
if (targeting.geo_locations && Object.keys(targeting.geo_locations).length === 1) {
recommendations.push({
type: 'location_targeting',
message: 'Limited location targeting may be restricting audience',
reason: 'Targeting only specific locations limits potential reach',
action: 'Consider expanding location targeting to similar areas',
data: {
currentLocations: targeting.geo_locations
}
});
}
return recommendations;
}
/**
* Generate creative recommendations for an ad
* @param {Object} ad - Ad object
* @param {Object} performance - Performance metrics
* @returns {Array} - Creative recommendations
* @private
*/
_generateCreativeRecommendations(ad, performance) {
const recommendations = [];
const creative = ad.creative || {};
// Check if ad has sufficient data
if (performance.impressions < 1000 || performance.clicks < 10) {
recommendations.push({
type: 'info',
message: 'Insufficient data to make creative recommendations',
reason: 'Ad needs more data to generate accurate recommendations',
action: 'Continue running the ad to gather more data'
});
return recommendations;
}
// Check CTR
if (performance.ctr < 1) {
recommendations.push({
type: 'low_ctr',
message: 'Ad has a low click-through rate',
reason: 'Creative may not be engaging enough for the target audience',
action: 'Test new headlines, images, or calls to action to improve engagement',
data: {
currentCTR: performance.ctr,
benchmarkCTR: 1.0
}
});
// Specific recommendations based on creative type
if (creative.type === 'IMAGE') {
recommendations.push({
type: 'image_creative',
message: 'Consider testing different image creative',
reason: 'Current image may not be resonating with audience',
action: 'Test images with people, bright colors, or product demonstrations'
});
} else if (creative.type === 'VIDEO') {
recommendations.push({
type: 'video_creative',
message: 'Video may not be capturing attention quickly',
reason: 'First 3 seconds of video are critical for engagement',
action: 'Test videos that showcase value proposition in the first 3 seconds'
});
}
}
// Check conversion rate
if (performance.conversionRate < 2) {
recommendations.push({
type: 'low_conversion_rate',
message: 'Ad has a low conversion rate',
reason: 'Creative may be attracting clicks but not driving conversions',
action: 'Ensure ad creative sets clear expectations that match landing page',
data: {
currentConversionRate: performance.conversionRate,
benchmarkConversionRate: 2.0
}
});
}
// Check headline and body text
if (creative.title && creative.title.length > 40) {
recommendations.push({
type: 'headline_length',
message: 'Headline may be too long',
reason: 'Shorter headlines often perform better',
action: 'Test shorter, more impactful headlines (25-40 characters)',
data: {
currentLength: creative.title.length,
recommendedMaxLength: 40
}
});
}
if (creative.body && creative.body.length > 125) {
recommendations.push({
type: 'body_length',
message: 'Ad copy may be too long',
reason: 'Shorter ad copy often performs better on Facebook',
action: 'Test more concise ad copy (80-125 characters)',
data: {
currentLength: creative.body.length,
recommendedMaxLength: 125
}
});
}
// Check call to action
if (!creative.callToAction) {
recommendations.push({
type: 'missing_cta',
message: 'Ad is missing a clear call to action',
reason: 'CTAs guide users on what action to take next',
action: 'Add a clear call to action button like "Learn More" or "Shop Now"'
});
}
// Check for A/B testing
recommendations.push({
type: 'ab_testing',
message: 'Consider A/B testing multiple ad variations',
reason: 'Testing different creatives helps identify what resonates with your audience',
action: 'Create 3-5 variations of this ad with different images, headlines, or CTAs'
});
return recommendations;
}
}
/**
* Create a Recommendation Service instance for a user
* @param {Object} user - User object
* @returns {RecommendationService} - Recommendation Service instance
*/
const createForUser = (user) => {
return new RecommendationService(user);
};
module.exports = {
RecommendationService,
createForUser
};