@ideal-photography/shared
Version:
Shared MongoDB and utility logic for Ideal Photography PWAs: users, products, services, bookings, orders/cart, galleries, reviews, notifications, campaigns, settings, audit logs, minimart items/orders, and push notification subscriptions.
1,215 lines (1,067 loc) • 42.7 kB
JavaScript
import Campaign from '../models/Campaign.js';
import User from '../models/User.js';
import { v4 as uuidv4 } from 'uuid';
import campaignSchedulingService from './campaignScheduling.service.js';
import campaignTargetingService from './campaignTargeting.service.js';
class CampaignService {
/**
* Create a new campaign
* @param {Object} campaignData - Campaign data
* @param {string} userId - User ID creating the campaign
* @returns {Promise<Object>} Created campaign
*/
async createCampaign(campaignData, userId) {
try {
// Validate required fields
this.validateCampaignData(campaignData);
// Create campaign with default values
const campaign = new Campaign({
...campaignData,
createdBy: userId,
lastEditedBy: userId,
status: 'draft',
isActive: false,
uuid: uuidv4().replace(/-/g, '').substring(0, 32),
analytics: {
impressions: 0,
clicks: 0,
conversions: 0,
ctr: 0,
conversionRate: 0,
ctaClicks: []
}
});
await campaign.save();
return await this.populateCampaign(campaign);
} catch (error) {
throw new Error(`Failed to create campaign: ${error.message}`);
}
}
/**
* Get campaign by ID
* @param {string} campaignId - Campaign ID
* @param {Object} options - Query options
* @returns {Promise<Object>} Campaign object
*/
async getCampaignById(campaignId, options = {}) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
if (options.populate !== false) {
return await this.populateCampaign(campaign);
}
return campaign;
} catch (error) {
throw new Error(`Failed to get campaign: ${error.message}`);
}
}
/**
* Get campaigns with filtering and pagination
* @param {Object} filters - Filter criteria
* @param {Object} options - Query options (pagination, sorting, etc.)
* @returns {Promise<Object>} Campaigns and metadata
*/
async getCampaigns(filters = {}, options = {}) {
try {
const {
page = 1,
limit = 20,
sort = { priority: -1, createdAt: -1 },
populate = true
} = options;
// Build query based on filters
const query = this.buildCampaignQuery(filters);
// Execute query with pagination
const skip = (page - 1) * limit;
const campaigns = await Campaign.find(query)
.sort(sort)
.skip(skip)
.limit(limit);
// Get total count for pagination
const total = await Campaign.countDocuments(query);
// Populate user references if requested
let populatedCampaigns = campaigns;
if (populate) {
populatedCampaigns = await Promise.all(
campaigns.map(campaign => this.populateCampaign(campaign))
);
}
return {
campaigns: populatedCampaigns,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
hasNext: page < Math.ceil(total / limit),
hasPrev: page > 1
}
};
} catch (error) {
throw new Error(`Failed to get campaigns: ${error.message}`);
}
}
/**
* Get active campaigns
* @param {Object} options - Query options
* @returns {Promise<Array>} Active campaigns
*/
async getActiveCampaigns(options = {}) {
try {
const now = new Date();
const query = {
isActive: true,
'schedule.startDate': { $lte: now },
$or: [
{ 'schedule.endDate': { $exists: false } },
{ 'schedule.endDate': { $gt: now } }
]
};
const campaigns = await Campaign.find(query)
.sort({ priority: -1, createdAt: -1 })
.limit(options.limit || 50);
if (options.populate !== false) {
return await Promise.all(
campaigns.map(campaign => this.populateCampaign(campaign))
);
}
return campaigns;
} catch (error) {
throw new Error(`Failed to get active campaigns: ${error.message}`);
}
}
/**
* Get scheduled campaigns
* @param {Object} options - Query options
* @returns {Promise<Array>} Scheduled campaigns
*/
async getScheduledCampaigns(options = {}) {
try {
const now = new Date();
const query = {
'schedule.startDate': { $gt: now },
status: { $in: ['scheduled', 'draft'] }
};
const campaigns = await Campaign.find(query)
.sort({ 'schedule.startDate': 1 })
.limit(options.limit || 50);
if (options.populate !== false) {
return await Promise.all(
campaigns.map(campaign => this.populateCampaign(campaign))
);
}
return campaigns;
} catch (error) {
throw new Error(`Failed to get scheduled campaigns: ${error.message}`);
}
}
/**
* Get campaigns by placement
* @param {string} placement - Campaign placement
* @param {Object} options - Query options
* @returns {Promise<Array>} Campaigns for the placement
*/
async getCampaignsByPlacement(placement, options = {}) {
try {
const campaigns = await Campaign.find({ placement })
.sort({ priority: -1, createdAt: -1 })
.limit(options.limit || 20);
if (options.populate !== false) {
return await Promise.all(
campaigns.map(campaign => this.populateCampaign(campaign))
);
}
return campaigns;
} catch (error) {
throw new Error(`Failed to get campaigns by placement: ${error.message}`);
}
}
/**
* Get campaigns by type
* @param {string} type - Campaign type
* @param {Object} options - Query options
* @returns {Promise<Array>} Campaigns of the specified type
*/
async getCampaignsByType(type, options = {}) {
try {
const campaigns = await Campaign.find({ type })
.sort({ priority: -1, createdAt: -1 })
.limit(options.limit || 20);
if (options.populate !== false) {
return await Promise.all(
campaigns.map(campaign => this.populateCampaign(campaign))
);
}
return campaigns;
} catch (error) {
throw new Error(`Failed to get campaigns by type: ${error.message}`);
}
}
/**
* Update campaign
* @param {string} campaignId - Campaign ID
* @param {Object} updateData - Data to update
* @param {string} userId - User ID making the update
* @returns {Promise<Object>} Updated campaign
*/
async updateCampaign(campaignId, updateData, userId) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
// Validate update data
this.validateCampaignData(updateData, true);
// Update campaign
Object.assign(campaign, updateData, {
lastEditedBy: userId,
version: campaign.version + 1
});
await campaign.save();
return await this.populateCampaign(campaign);
} catch (error) {
throw new Error(`Failed to update campaign: ${error.message}`);
}
}
/**
* Delete campaign
* @param {string} campaignId - Campaign ID
* @param {string} userId - User ID deleting the campaign
* @returns {Promise<Object>} Deleted campaign
*/
async deleteCampaign(campaignId, userId) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
// Check if campaign is currently active
if (campaign.isCurrentlyActive) {
throw new Error('Cannot delete active campaign. Deactivate first.');
}
await Campaign.findByIdAndDelete(campaignId);
return campaign;
} catch (error) {
throw new Error(`Failed to delete campaign: ${error.message}`);
}
}
/**
* Activate campaign
* @param {string} campaignId - Campaign ID
* @param {string} userId - User ID activating the campaign
* @returns {Promise<Object>} Activated campaign
*/
async activateCampaign(campaignId, userId) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
// Check if campaign is approved (if approval is required)
if (campaign.status === 'draft' && !campaign.approvedBy) {
throw new Error('Campaign must be approved before activation');
}
// Check if campaign start date has been reached
const now = new Date();
if (campaign.schedule.startDate > now) {
throw new Error('Campaign start date has not been reached');
}
campaign.isActive = true;
campaign.status = 'active';
campaign.lastEditedBy = userId;
await campaign.save();
return await this.populateCampaign(campaign);
} catch (error) {
throw new Error(`Failed to activate campaign: ${error.message}`);
}
}
/**
* Deactivate campaign
* @param {string} campaignId - Campaign ID
* @param {string} userId - User ID deactivating the campaign
* @returns {Promise<Object>} Deactivated campaign
*/
async deactivateCampaign(campaignId, userId) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
campaign.isActive = false;
campaign.status = 'paused';
campaign.lastEditedBy = userId;
await campaign.save();
return await this.populateCampaign(campaign);
} catch (error) {
throw new Error(`Failed to deactivate campaign: ${error.message}`);
}
}
/**
* Approve campaign
* @param {string} campaignId - Campaign ID
* @param {string} userId - User ID approving the campaign
* @returns {Promise<Object>} Approved campaign
*/
async approveCampaign(campaignId, userId) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
campaign.status = 'scheduled';
campaign.approvedBy = userId;
campaign.approvedAt = new Date();
campaign.lastEditedBy = userId;
await campaign.save();
return await this.populateCampaign(campaign);
} catch (error) {
throw new Error(`Failed to approve campaign: ${error.message}`);
}
}
/**
* Duplicate campaign
* @param {string} campaignId - Campaign ID to duplicate
* @param {string} userId - User ID creating the duplicate
* @returns {Promise<Object>} Duplicated campaign
*/
async duplicateCampaign(campaignId, userId) {
try {
const originalCampaign = await Campaign.findById(campaignId);
if (!originalCampaign) {
throw new Error('Campaign not found');
}
// Create duplicate with modified data
const campaignData = {
...originalCampaign.toObject(),
_id: undefined,
uuid: undefined,
name: `${originalCampaign.name} (Copy)`,
status: 'draft',
isActive: false,
createdBy: userId,
lastEditedBy: userId,
approvedBy: undefined,
approvedAt: undefined,
analytics: {
impressions: 0,
clicks: 0,
conversions: 0,
ctr: 0,
conversionRate: 0,
ctaClicks: []
},
createdAt: undefined,
updatedAt: undefined
};
const duplicatedCampaign = new Campaign(campaignData);
await duplicatedCampaign.save();
return await this.populateCampaign(duplicatedCampaign);
} catch (error) {
throw new Error(`Failed to duplicate campaign: ${error.message}`);
}
}
/**
* Record campaign impression
* @param {string} campaignId - Campaign ID
* @returns {Promise<Object>} Updated campaign
*/
async recordImpression(campaignId) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
return await campaign.recordImpression();
} catch (error) {
throw new Error(`Failed to record impression: ${error.message}`);
}
}
/**
* Record campaign click
* @param {string} campaignId - Campaign ID
* @param {string} ctaLabel - CTA label (optional)
* @returns {Promise<Object>} Updated campaign
*/
async recordClick(campaignId, ctaLabel = null) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
return await campaign.recordClick(ctaLabel);
} catch (error) {
throw new Error(`Failed to record click: ${error.message}`);
}
}
/**
* Record campaign conversion
* @param {string} campaignId - Campaign ID
* @returns {Promise<Object>} Updated campaign
*/
async recordConversion(campaignId) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
return await campaign.recordConversion();
} catch (error) {
throw new Error(`Failed to record conversion: ${error.message}`);
}
}
/**
* Validate user targeting for a campaign
* @param {Object} campaign - Campaign object
* @param {Object} user - User object
* @returns {boolean} Whether user matches targeting criteria
*/
validateUserTargeting(campaign, user) {
const { targeting } = campaign;
if (!targeting) return true;
// Check user role targeting
if (targeting.userRoles && targeting.userRoles.length > 0) {
if (!targeting.userRoles.includes(user.role)) {
return false;
}
}
// Check new vs returning user targeting
if (targeting.newUsers && !targeting.returningUsers) {
// Only new users
const userAge = Date.now() - new Date(user.createdAt).getTime();
const daysOld = userAge / (1000 * 60 * 60 * 24);
if (daysOld > 30) return false;
} else if (targeting.returningUsers && !targeting.newUsers) {
// Only returning users
const userAge = Date.now() - new Date(user.createdAt).getTime();
const daysOld = userAge / (1000 * 60 * 60 * 24);
if (daysOld <= 30) return false;
}
// Check verified user targeting
if (targeting.verifiedUsers && !user.isVerified) {
return false;
}
// Check geographic targeting (future feature)
if (targeting.countries && targeting.countries.length > 0) {
// TODO: Implement country detection
}
if (targeting.cities && targeting.cities.length > 0) {
// TODO: Implement city detection
}
return true;
}
/**
* Get campaigns for a specific user based on targeting
* @param {Object} user - User object
* @param {Object} options - Query options
* @returns {Promise<Array>} Targeted campaigns for the user
*/
async getTargetedCampaignsForUser(user, options = {}) {
try {
// Get active campaigns
const activeCampaigns = await this.getActiveCampaigns({ populate: false });
// Filter campaigns based on user targeting
const targetedCampaigns = activeCampaigns.filter(campaign =>
this.validateUserTargeting(campaign, user)
);
// Sort by priority and return
const sortedCampaigns = targetedCampaigns.sort((a, b) => {
if (a.priority !== b.priority) {
return b.priority - a.priority;
}
return new Date(b.createdAt) - new Date(a.createdAt);
});
// Apply limit if specified
if (options.limit) {
sortedCampaigns.splice(options.limit);
}
// Populate user references if requested
if (options.populate !== false) {
return await Promise.all(
sortedCampaigns.map(campaign => this.populateCampaign(campaign))
);
}
return sortedCampaigns;
} catch (error) {
throw new Error(`Failed to get targeted campaigns: ${error.message}`);
}
}
/**
* Get campaign analytics summary
* @param {string} campaignId - Campaign ID
* @returns {Promise<Object>} Analytics summary
*/
async getCampaignAnalytics(campaignId) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
const { analytics } = campaign;
const now = new Date();
const startDate = campaign.schedule.startDate;
const endDate = campaign.schedule.endDate;
// Calculate campaign duration
const duration = endDate ?
Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)) :
Math.ceil((now - startDate) / (1000 * 60 * 60 * 24));
// Calculate daily averages
const dailyImpressions = duration > 0 ? analytics.impressions / duration : 0;
const dailyClicks = duration > 0 ? analytics.clicks / duration : 0;
const dailyConversions = duration > 0 ? analytics.conversions / duration : 0;
return {
campaignId,
name: campaign.name,
type: campaign.type,
placement: campaign.placement,
status: campaign.status,
startDate,
endDate,
duration,
analytics: {
...analytics,
dailyImpressions: Math.round(dailyImpressions * 100) / 100,
dailyClicks: Math.round(dailyClicks * 100) / 100,
dailyConversions: Math.round(dailyConversions * 100) / 100
},
performance: {
isPerforming: analytics.ctr > 1.0, // Above 1% CTR
needsOptimization: analytics.ctr < 0.5, // Below 0.5% CTR
conversionEfficiency: analytics.conversionRate
}
};
} catch (error) {
throw new Error(`Failed to get campaign analytics: ${error.message}`);
}
}
/**
* Bulk update campaigns
* @param {Array<string>} campaignIds - Array of campaign IDs
* @param {Object} updateData - Data to update
* @param {string} userId - User ID making the update
* @returns {Promise<Object>} Update result
*/
async bulkUpdateCampaigns(campaignIds, updateData, userId) {
try {
const result = await Campaign.updateMany(
{ _id: { $in: campaignIds } },
{
...updateData,
lastEditedBy: userId,
$inc: { version: 1 }
}
);
return {
success: true,
modifiedCount: result.modifiedCount,
totalCount: campaignIds.length
};
} catch (error) {
throw new Error(`Failed to bulk update campaigns: ${error.message}`);
}
}
/**
* Bulk delete campaigns
* @param {Array<string>} campaignIds - Array of campaign IDs
* @returns {Promise<Object>} Delete result
*/
async bulkDeleteCampaigns(campaignIds) {
try {
// Check if any campaigns are currently active
const activeCampaigns = await Campaign.find({
_id: { $in: campaignIds },
isCurrentlyActive: true
});
if (activeCampaigns.length > 0) {
throw new Error(`Cannot delete ${activeCampaigns.length} active campaigns. Deactivate first.`);
}
const result = await Campaign.deleteMany({ _id: { $in: campaignIds } });
return {
success: true,
deletedCount: result.deletedCount,
totalCount: campaignIds.length
};
} catch (error) {
throw new Error(`Failed to bulk delete campaigns: ${error.message}`);
}
}
// Private helper methods
/**
* Build MongoDB query from filters
* @param {Object} filters - Filter criteria
* @returns {Object} MongoDB query object
*/
buildCampaignQuery(filters) {
const query = {};
if (filters.type) query.type = filters.type;
if (filters.placement) query.placement = filters.placement;
if (filters.status) query.status = filters.status;
if (filters.isActive !== undefined) query.isActive = filters.isActive;
if (filters.isScheduled !== undefined) {
if (filters.isScheduled) {
query['schedule.startDate'] = { $gt: new Date() };
} else {
query['schedule.startDate'] = { $lte: new Date() };
}
}
if (filters.isExpired !== undefined) {
if (filters.isExpired) {
query['schedule.endDate'] = { $lt: new Date() };
} else {
query['schedule.endDate'] = { $gt: new Date() };
}
}
if (filters.createdBy) query.createdBy = filters.createdBy;
if (filters.tags && filters.tags.length > 0) {
query.tags = { $in: filters.tags };
}
return query;
}
/**
* Populate campaign with user references
* @param {Object} campaign - Campaign object
* @returns {Promise<Object>} Populated campaign
*/
async populateCampaign(campaign) {
return await campaign.populate([
{ path: 'createdBy', select: 'firstName lastName email' },
{ path: 'lastEditedBy', select: 'firstName lastName email' },
{ path: 'approvedBy', select: 'firstName lastName email' }
]);
}
/**
* Validate campaign data
* @param {Object} data - Campaign data
* @param {boolean} isUpdate - Whether this is an update operation
*/
validateCampaignData(data, isUpdate = false) {
if (!isUpdate) {
if (!data.name) throw new Error('Campaign name is required');
if (!data.title) throw new Error('Campaign title is required');
if (!data.type) throw new Error('Campaign type is required');
if (!data.placement) throw new Error('Campaign placement is required');
}
// Validate campaign type
const validTypes = ['hero_carousel', 'banner', 'popup', 'notification', 'theme_override'];
if (data.type && !validTypes.includes(data.type)) {
throw new Error('Invalid campaign type');
}
// Validate placement
const validPlacements = ['home_hero', 'top_banner', 'sidebar', 'footer', 'popup_modal', 'notification_bar'];
if (data.placement && !validPlacements.includes(data.placement)) {
throw new Error('Invalid campaign placement');
}
// Validate priority
if (data.priority !== undefined && (data.priority < 0 || data.priority > 100)) {
throw new Error('Priority must be between 0 and 100');
}
// Validate schedule
if (data.schedule) {
this.validateScheduleData(data.schedule);
}
}
/**
* Validate schedule data
* @param {Object} schedule - Schedule data
*/
validateScheduleData(schedule) {
if (schedule.startDate) {
const startDate = new Date(schedule.startDate);
if (isNaN(startDate.getTime())) {
throw new Error('Invalid start date');
}
}
if (schedule.endDate) {
const endDate = new Date(schedule.endDate);
if (isNaN(endDate.getTime())) {
throw new Error('Invalid end date');
}
if (schedule.startDate && new Date(schedule.startDate) >= endDate) {
throw new Error('End date must be after start date');
}
}
if (schedule.isRecurring && schedule.recurrence) {
const validRecurrences = ['daily', 'weekly', 'monthly', 'yearly'];
if (!validRecurrences.includes(schedule.recurrence)) {
throw new Error('Invalid recurrence type');
}
}
}
// ===== ADVANCED SCHEDULING METHODS =====
/**
* Schedule a campaign with advanced options
*/
async scheduleCampaign(campaignId, scheduleData) {
try {
return await campaignSchedulingService.scheduleCampaign(campaignId, scheduleData);
} catch (error) {
throw new Error(`Failed to schedule campaign: ${error.message}`);
}
}
/**
* Unschedule a campaign
*/
async unscheduleCampaign(campaignId) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
campaign.schedule.isScheduled = false;
campaign.schedule.isQueued = false;
campaign.schedule.queuePosition = null;
await campaign.save();
return await this.populateCampaign(campaign);
} catch (error) {
throw new Error(`Failed to unschedule campaign: ${error.message}`);
}
}
/**
* Add campaign to queue
*/
async addToQueue(campaignId, priority = 'normal') {
try {
return await campaignSchedulingService.addToQueue(campaignId, priority);
} catch (error) {
throw new Error(`Failed to add campaign to queue: ${error.message}`);
}
}
/**
* Remove campaign from queue
*/
async removeFromQueue(campaignId) {
try {
return await campaignSchedulingService.removeFromQueue(campaignId);
} catch (error) {
throw new Error(`Failed to remove campaign from queue: ${error.message}`);
}
}
/**
* Move campaign in queue
*/
async moveInQueue(campaignId, newPosition) {
try {
return await campaignSchedulingService.moveInQueue(campaignId, newPosition);
} catch (error) {
throw new Error(`Failed to move campaign in queue: ${error.message}`);
}
}
/**
* Get queued campaigns
*/
async getQueuedCampaigns() {
try {
const campaigns = await campaignSchedulingService.getQueuedCampaigns();
return await Promise.all(
campaigns.map(campaign => this.populateCampaign(campaign))
);
} catch (error) {
throw new Error(`Failed to get queued campaigns: ${error.message}`);
}
}
/**
* Get scheduled campaigns by date range
*/
async getScheduledCampaignsByDate(startDate, endDate) {
try {
const campaigns = await campaignSchedulingService.getScheduledCampaignsByDate(startDate, endDate);
return await Promise.all(
campaigns.map(campaign => this.populateCampaign(campaign))
);
} catch (error) {
throw new Error(`Failed to get scheduled campaigns by date: ${error.message}`);
}
}
/**
* Get campaigns with conflicts
*/
async getCampaignsWithConflicts() {
try {
return await campaignSchedulingService.getCampaignsWithConflicts();
} catch (error) {
throw new Error(`Failed to get campaigns with conflicts: ${error.message}`);
}
}
/**
* Process campaign queue
*/
async processQueue() {
try {
return await campaignSchedulingService.processQueue();
} catch (error) {
throw new Error(`Failed to process queue: ${error.message}`);
}
}
/**
* Auto-activate campaign
*/
async autoActivateCampaign(campaignId) {
try {
return await campaignSchedulingService.autoActivateCampaign(campaignId);
} catch (error) {
throw new Error(`Failed to auto-activate campaign: ${error.message}`);
}
}
/**
* Auto-deactivate campaign
*/
async autoDeactivateCampaign(campaignId) {
try {
return await campaignSchedulingService.autoDeactivateCampaign(campaignId);
} catch (error) {
throw new Error(`Failed to auto-deactivate campaign: ${error.message}`);
}
}
/**
* Update recurrence details
*/
async updateRecurrence(campaignId, recurrenceDetails) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
campaign.schedule.recurrenceDetails = {
...campaign.schedule.recurrenceDetails,
...recurrenceDetails
};
// Recalculate next occurrence
if (campaign.schedule.isRecurring) {
campaign.schedule.nextOccurrence = campaignSchedulingService.calculateNextOccurrence(
campaign.schedule.startDate,
campaign.schedule.recurrenceDetails
);
}
await campaign.save();
return await this.populateCampaign(campaign);
} catch (error) {
throw new Error(`Failed to update recurrence: ${error.message}`);
}
}
/**
* Resolve schedule conflict
*/
async resolveScheduleConflict(campaignId, resolution) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
campaign.schedule.conflictResolution = resolution;
await campaign.save();
// Process the resolution
if (resolution === 'queue') {
await this.addToQueue(campaignId, campaign.schedule.priority);
}
return await this.populateCampaign(campaign);
} catch (error) {
throw new Error(`Failed to resolve schedule conflict: ${error.message}`);
}
}
// ===== ADVANCED TARGETING METHODS =====
/**
* Check if a user matches campaign targeting criteria
*/
async checkUserTargeting(campaignId, userId) {
try {
return await campaignTargetingService.checkUserTargeting(campaignId, userId);
} catch (error) {
throw new Error(`Failed to check user targeting: ${error.message}`);
}
}
/**
* Get targeted campaigns for a specific user
*/
async getTargetedCampaignsForUser(userId, placement = null, type = null) {
try {
return await campaignTargetingService.getTargetedCampaignsForUser(userId, placement, type);
} catch (error) {
throw new Error(`Failed to get targeted campaigns: ${error.message}`);
}
}
/**
* Get campaigns by targeting criteria
*/
async getCampaignsByTargeting(targetingCriteria, options = {}) {
try {
const {
page = 1,
limit = 20,
sort = { priority: -1, createdAt: -1 },
populate = true
} = options;
// Build targeting query
const query = this.buildTargetingQuery(targetingCriteria);
// Execute query with pagination
const skip = (page - 1) * limit;
const campaigns = await Campaign.find(query)
.sort(sort)
.skip(skip)
.limit(limit);
// Get total count for pagination
const total = await Campaign.countDocuments(query);
// Populate user references if requested
let populatedCampaigns = campaigns;
if (populate) {
populatedCampaigns = await Promise.all(
campaigns.map(campaign => this.populateCampaign(campaign))
);
}
return {
campaigns: populatedCampaigns,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
hasNext: page < Math.ceil(total / limit),
hasPrev: page > 1
}
};
} catch (error) {
throw new Error(`Failed to get campaigns by targeting: ${error.message}`);
}
}
/**
* Get user targeting insights for a campaign
*/
async getCampaignTargetingInsights(campaignId) {
try {
const campaign = await Campaign.findById(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
const targeting = campaign.targeting;
if (!targeting) {
return { message: 'No targeting criteria set for this campaign' };
}
// Get user counts for different targeting criteria
const insights = {
totalUsers: await User.countDocuments(),
targetedUsers: 0,
targetingBreakdown: {}
};
// Calculate targeting breakdown
if (targeting.userRoles && targeting.userRoles.length > 0) {
insights.targetingBreakdown.userRoles = {};
for (const role of targeting.userRoles) {
const count = await User.countDocuments({ role });
insights.targetingBreakdown.userRoles[role] = count;
}
}
if (targeting.newUsers !== undefined || targeting.returningUsers !== undefined) {
const newUserCount = await User.countDocuments({
createdAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
});
const returningUserCount = insights.totalUsers - newUserCount;
insights.targetingBreakdown.userBehavior = {
newUsers: newUserCount,
returningUsers: returningUserCount
};
}
if (targeting.countries && targeting.countries.length > 0) {
insights.targetingBreakdown.geographic = {};
for (const country of targeting.countries) {
const count = await User.countDocuments({ 'location.country': country });
insights.targetingBreakdown.geographic[country] = count;
}
}
// Calculate total targeted users (simplified calculation)
insights.targetedUsers = Math.min(
...Object.values(insights.targetingBreakdown).flatMap(obj =>
typeof obj === 'object' ? Object.values(obj) : [obj]
).filter(val => typeof val === 'number')
);
return insights;
} catch (error) {
throw new Error(`Failed to get targeting insights: ${error.message}`);
}
}
/**
* Validate targeting criteria
*/
validateTargetingCriteria(targeting) {
if (!targeting) return true;
// Validate user roles
if (targeting.userRoles) {
const validRoles = ['guest', 'user', 'premium_user', 'admin', 'moderator', 'content_creator', 'business_owner', 'enterprise_user'];
for (const role of targeting.userRoles) {
if (!validRoles.includes(role)) {
throw new Error(`Invalid user role: ${role}`);
}
}
}
// Validate user behavior targeting
if (targeting.newUsers !== undefined && targeting.returningUsers !== undefined) {
if (targeting.newUsers === false && targeting.returningUsers === false) {
throw new Error('Cannot exclude both new and returning users');
}
}
// Validate geographic targeting
if (targeting.radius && targeting.radius < 0) {
throw new Error('Radius must be a positive number');
}
// Validate frequency targeting
if (targeting.maxFrequency && targeting.maxFrequency < 1) {
throw new Error('Max frequency must be at least 1');
}
if (targeting.minTimeBetweenViews && targeting.minTimeBetweenViews < 0) {
throw new Error('Minimum time between views must be non-negative');
}
// Validate timing targeting
if (targeting.timeOfDay) {
const validTimes = ['morning', 'afternoon', 'evening', 'night'];
for (const time of targeting.timeOfDay) {
if (!validTimes.includes(time)) {
throw new Error(`Invalid time of day: ${time}`);
}
}
}
if (targeting.dayOfWeek) {
const validDays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
for (const day of targeting.dayOfWeek) {
if (!validDays.includes(day)) {
throw new Error(`Invalid day of week: ${day}`);
}
}
}
return true;
}
/**
* Build MongoDB query for targeting criteria
*/
buildTargetingQuery(targetingCriteria) {
const query = {};
if (targetingCriteria.userRoles && targetingCriteria.userRoles.length > 0) {
query.role = { $in: targetingCriteria.userRoles };
}
if (targetingCriteria.excludeUserRoles && targetingCriteria.excludeUserRoles.length > 0) {
query.role = { $nin: targetingCriteria.excludeUserRoles };
}
if (targetingCriteria.newUsers !== undefined) {
if (targetingCriteria.newUsers) {
query.createdAt = { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) };
} else {
query.createdAt = { $lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) };
}
}
if (targetingCriteria.verifiedUsers !== undefined) {
query.isVerified = targetingCriteria.verifiedUsers;
}
if (targetingCriteria.countries && targetingCriteria.countries.length > 0) {
query['location.country'] = { $in: targetingCriteria.countries };
}
if (targetingCriteria.cities && targetingCriteria.cities.length > 0) {
query['location.city'] = { $in: targetingCriteria.cities };
}
if (targetingCriteria.deviceType && targetingCriteria.deviceType.length > 0) {
query['deviceInfo.type'] = { $in: targetingCriteria.deviceType };
}
if (targetingCriteria.subscriptionStatus && targetingCriteria.subscriptionStatus.length > 0) {
query.subscriptionStatus = { $in: targetingCriteria.subscriptionStatus };
}
return query;
}
}
export default new CampaignService();