Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
Full-stack web application for Telegram management - Frontend: Vue 3 + Vben Admin - Backend: NestJS - Features: User management, group broadcast, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
32
marketing-agent/services/scheduler/package.json
Normal file
32
marketing-agent/services/scheduler/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "scheduler-service",
|
||||
"version": "1.0.0",
|
||||
"description": "Campaign Scheduler Service for Marketing Agent System",
|
||||
"type": "module",
|
||||
"main": "src/app.js",
|
||||
"scripts": {
|
||||
"start": "node src/app.js",
|
||||
"dev": "nodemon src/app.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"mongoose": "^7.0.3",
|
||||
"node-cron": "^3.0.2",
|
||||
"bull": "^4.11.3",
|
||||
"ioredis": "^5.3.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"date-fns-tz": "^2.0.0",
|
||||
"axios": "^1.4.0",
|
||||
"winston": "^3.8.2",
|
||||
"helmet": "^7.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"express-validator": "^7.0.1",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.22"
|
||||
}
|
||||
}
|
||||
92
marketing-agent/services/scheduler/src/app.js
Normal file
92
marketing-agent/services/scheduler/src/app.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import dotenv from 'dotenv';
|
||||
import { connectDatabase } from './config/database.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
import jobProcessor from './services/jobProcessor.js';
|
||||
import campaignsRouter from './routes/campaigns.js';
|
||||
import jobsRouter from './routes/jobs.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3013;
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path}`, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: 'scheduler',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/scheduled-campaigns', campaignsRouter);
|
||||
app.use('/api/jobs', jobsRouter);
|
||||
|
||||
// Error handling
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error('Unhandled error', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Route not found'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
async function start() {
|
||||
try {
|
||||
// Connect to database
|
||||
await connectDatabase();
|
||||
|
||||
// Start job processor
|
||||
await jobProcessor.start();
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Scheduler service running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('SIGINT received, shutting down gracefully');
|
||||
process.exit(0);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start scheduler service', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
30
marketing-agent/services/scheduler/src/config/database.js
Normal file
30
marketing-agent/services/scheduler/src/config/database.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export async function connectDatabase() {
|
||||
try {
|
||||
const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/telegram-scheduler';
|
||||
|
||||
await mongoose.connect(mongoUri, {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
serverSelectionTimeoutMS: 5000,
|
||||
maxPoolSize: 10
|
||||
});
|
||||
|
||||
logger.info('MongoDB connected');
|
||||
|
||||
mongoose.connection.on('error', (err) => {
|
||||
logger.error('MongoDB error', err);
|
||||
});
|
||||
|
||||
mongoose.connection.on('disconnected', () => {
|
||||
logger.warn('MongoDB disconnected');
|
||||
});
|
||||
|
||||
return mongoose.connection;
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect to MongoDB', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
35
marketing-agent/services/scheduler/src/config/redis.js
Normal file
35
marketing-agent/services/scheduler/src/config/redis.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import Redis from 'ioredis';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const redisConfig = {
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
enableReadyCheck: true,
|
||||
lazyConnect: false
|
||||
};
|
||||
|
||||
export const redisClient = new Redis(redisConfig);
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
logger.info('Redis connected');
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => {
|
||||
logger.error('Redis error', err);
|
||||
});
|
||||
|
||||
redisClient.on('close', () => {
|
||||
logger.warn('Redis connection closed');
|
||||
});
|
||||
|
||||
redisClient.on('reconnecting', () => {
|
||||
logger.info('Redis reconnecting...');
|
||||
});
|
||||
|
||||
export default redisClient;
|
||||
74
marketing-agent/services/scheduler/src/middleware/auth.js
Normal file
74
marketing-agent/services/scheduler/src/middleware/auth.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export const authMiddleware = (req, res, next) => {
|
||||
try {
|
||||
// Get token from header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'No authorization header'
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.startsWith('Bearer ')
|
||||
? authHeader.slice(7)
|
||||
: authHeader;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'No token provided'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||
|
||||
// Add user info to request
|
||||
req.user = decoded;
|
||||
req.accountId = decoded.accountId || req.headers['x-account-id'];
|
||||
|
||||
// Validate account ID
|
||||
if (!req.accountId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Account ID required'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Auth middleware error', error);
|
||||
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid token'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Token expired'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Authentication error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const optionalAuth = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
req.accountId = req.headers['x-account-id'];
|
||||
return next();
|
||||
}
|
||||
|
||||
return authMiddleware(req, res, next);
|
||||
};
|
||||
261
marketing-agent/services/scheduler/src/models/ScheduleJob.js
Normal file
261
marketing-agent/services/scheduler/src/models/ScheduleJob.js
Normal file
@@ -0,0 +1,261 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const scheduleJobSchema = new mongoose.Schema({
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tenant',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
scheduleId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'ScheduledCampaign',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
accountId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['campaign_execution', 'pre_check', 'post_process', 'cleanup'],
|
||||
required: true
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'queued', 'running', 'completed', 'failed', 'cancelled'],
|
||||
default: 'pending',
|
||||
index: true
|
||||
},
|
||||
priority: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
index: true
|
||||
},
|
||||
scheduledFor: {
|
||||
type: Date,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
startedAt: Date,
|
||||
completedAt: Date,
|
||||
|
||||
payload: {
|
||||
campaignId: String,
|
||||
targetAudience: mongoose.Schema.Types.Mixed,
|
||||
messageConfig: mongoose.Schema.Types.Mixed,
|
||||
deliverySettings: mongoose.Schema.Types.Mixed
|
||||
},
|
||||
|
||||
attempts: {
|
||||
count: { type: Number, default: 0 },
|
||||
lastAttemptAt: Date,
|
||||
nextRetryAt: Date,
|
||||
maxRetries: { type: Number, default: 3 }
|
||||
},
|
||||
|
||||
result: {
|
||||
success: Boolean,
|
||||
messagesSent: Number,
|
||||
delivered: Number,
|
||||
failed: Number,
|
||||
duration: Number,
|
||||
errors: [{
|
||||
code: String,
|
||||
message: String,
|
||||
details: mongoose.Schema.Types.Mixed,
|
||||
timestamp: Date
|
||||
}],
|
||||
metadata: mongoose.Schema.Types.Mixed
|
||||
},
|
||||
|
||||
dependencies: [{
|
||||
jobId: mongoose.Schema.Types.ObjectId,
|
||||
type: { type: String, enum: ['requires', 'blocks'] }
|
||||
}],
|
||||
|
||||
locks: {
|
||||
acquired: { type: Boolean, default: false },
|
||||
acquiredAt: Date,
|
||||
expiresAt: Date,
|
||||
holder: String
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Indexes
|
||||
scheduleJobSchema.index({ status: 1, scheduledFor: 1 });
|
||||
scheduleJobSchema.index({ scheduleId: 1, status: 1 });
|
||||
scheduleJobSchema.index({ 'locks.acquired': 1, 'locks.expiresAt': 1 });
|
||||
|
||||
// Multi-tenant indexes
|
||||
scheduleJobSchema.index({ tenantId: 1, status: 1, scheduledFor: 1 });
|
||||
scheduleJobSchema.index({ tenantId: 1, scheduleId: 1, status: 1 });
|
||||
scheduleJobSchema.index({ tenantId: 1, 'locks.acquired': 1, 'locks.expiresAt': 1 });
|
||||
|
||||
// TTL index to auto-delete old completed jobs after 30 days
|
||||
scheduleJobSchema.index({ completedAt: 1 }, {
|
||||
expireAfterSeconds: 30 * 24 * 60 * 60,
|
||||
partialFilterExpression: { status: 'completed' }
|
||||
});
|
||||
|
||||
// Methods
|
||||
scheduleJobSchema.methods.canRun = async function() {
|
||||
// Check if already running or completed
|
||||
if (['running', 'completed', 'cancelled'].includes(this.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check dependencies
|
||||
if (this.dependencies && this.dependencies.length > 0) {
|
||||
const requiredJobs = this.dependencies.filter(d => d.type === 'requires');
|
||||
for (const dep of requiredJobs) {
|
||||
const job = await this.constructor.findById(dep.jobId);
|
||||
if (!job || job.status !== 'completed') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if scheduled time has arrived
|
||||
const now = new Date();
|
||||
if (this.scheduledFor > now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
scheduleJobSchema.methods.acquireLock = async function(holder, duration = 300000) { // 5 minutes default
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + duration);
|
||||
|
||||
// Try to acquire lock atomically
|
||||
const result = await this.constructor.findOneAndUpdate(
|
||||
{
|
||||
_id: this._id,
|
||||
$or: [
|
||||
{ 'locks.acquired': false },
|
||||
{ 'locks.expiresAt': { $lt: now } }
|
||||
]
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'locks.acquired': true,
|
||||
'locks.acquiredAt': now,
|
||||
'locks.expiresAt': expiresAt,
|
||||
'locks.holder': holder
|
||||
}
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
return result !== null;
|
||||
};
|
||||
|
||||
scheduleJobSchema.methods.releaseLock = async function() {
|
||||
this.locks = {
|
||||
acquired: false,
|
||||
acquiredAt: null,
|
||||
expiresAt: null,
|
||||
holder: null
|
||||
};
|
||||
await this.save();
|
||||
};
|
||||
|
||||
scheduleJobSchema.methods.markAsRunning = async function() {
|
||||
this.status = 'running';
|
||||
this.startedAt = new Date();
|
||||
this.attempts.count += 1;
|
||||
this.attempts.lastAttemptAt = new Date();
|
||||
await this.save();
|
||||
};
|
||||
|
||||
scheduleJobSchema.methods.markAsCompleted = async function(result) {
|
||||
this.status = 'completed';
|
||||
this.completedAt = new Date();
|
||||
this.result = {
|
||||
...result,
|
||||
duration: this.completedAt - this.startedAt,
|
||||
success: true
|
||||
};
|
||||
await this.releaseLock();
|
||||
await this.save();
|
||||
};
|
||||
|
||||
scheduleJobSchema.methods.markAsFailed = async function(error, shouldRetry = true) {
|
||||
this.result.errors = this.result.errors || [];
|
||||
this.result.errors.push({
|
||||
code: error.code || 'UNKNOWN',
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
if (shouldRetry && this.attempts.count < this.attempts.maxRetries) {
|
||||
this.status = 'pending';
|
||||
// Exponential backoff
|
||||
const retryDelay = Math.pow(2, this.attempts.count) * 60 * 1000; // minutes
|
||||
this.attempts.nextRetryAt = new Date(Date.now() + retryDelay);
|
||||
} else {
|
||||
this.status = 'failed';
|
||||
this.completedAt = new Date();
|
||||
}
|
||||
|
||||
await this.releaseLock();
|
||||
await this.save();
|
||||
};
|
||||
|
||||
// Statics
|
||||
scheduleJobSchema.statics.findReadyJobs = async function(limit = 10) {
|
||||
const now = new Date();
|
||||
return this.find({
|
||||
status: 'pending',
|
||||
scheduledFor: { $lte: now },
|
||||
$or: [
|
||||
{ 'attempts.nextRetryAt': null },
|
||||
{ 'attempts.nextRetryAt': { $lte: now } }
|
||||
]
|
||||
})
|
||||
.sort({ priority: -1, scheduledFor: 1 })
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
scheduleJobSchema.statics.cleanupStaleJobs = async function() {
|
||||
const staleTime = new Date(Date.now() - 60 * 60 * 1000); // 1 hour
|
||||
|
||||
// Release locks on stale running jobs
|
||||
await this.updateMany(
|
||||
{
|
||||
status: 'running',
|
||||
'locks.expiresAt': { $lt: new Date() }
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
status: 'pending',
|
||||
'locks.acquired': false
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mark very old pending jobs as failed
|
||||
const veryOldTime = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24 hours
|
||||
await this.updateMany(
|
||||
{
|
||||
status: 'pending',
|
||||
scheduledFor: { $lt: veryOldTime }
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
status: 'failed',
|
||||
completedAt: new Date()
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default mongoose.model('ScheduleJob', scheduleJobSchema);
|
||||
@@ -0,0 +1,313 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const scheduledCampaignSchema = new mongoose.Schema({
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tenant',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
accountId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
campaignId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
campaignName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['one-time', 'recurring', 'trigger-based'],
|
||||
required: true
|
||||
},
|
||||
schedule: {
|
||||
// For one-time campaigns
|
||||
startDateTime: Date,
|
||||
|
||||
// For recurring campaigns
|
||||
recurring: {
|
||||
pattern: {
|
||||
type: String,
|
||||
enum: ['daily', 'weekly', 'monthly', 'custom']
|
||||
},
|
||||
frequency: {
|
||||
interval: Number, // e.g., every 2 days
|
||||
unit: {
|
||||
type: String,
|
||||
enum: ['minutes', 'hours', 'days', 'weeks', 'months']
|
||||
}
|
||||
},
|
||||
daysOfWeek: [Number], // 0-6 (Sunday-Saturday)
|
||||
daysOfMonth: [Number], // 1-31
|
||||
timeOfDay: String, // HH:MM format
|
||||
timezone: {
|
||||
type: String,
|
||||
default: 'UTC'
|
||||
},
|
||||
endDate: Date,
|
||||
maxOccurrences: Number
|
||||
},
|
||||
|
||||
// For trigger-based campaigns
|
||||
triggers: [{
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['user_event', 'date_based', 'condition_met', 'external_api']
|
||||
},
|
||||
conditions: mongoose.Schema.Types.Mixed,
|
||||
delay: {
|
||||
value: Number,
|
||||
unit: {
|
||||
type: String,
|
||||
enum: ['minutes', 'hours', 'days']
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
|
||||
targetAudience: {
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['all', 'segment', 'group', 'individual', 'dynamic']
|
||||
},
|
||||
segmentId: String,
|
||||
groupIds: [String],
|
||||
userIds: [String],
|
||||
dynamicCriteria: mongoose.Schema.Types.Mixed
|
||||
},
|
||||
|
||||
messageConfig: {
|
||||
templateId: String,
|
||||
content: String,
|
||||
personalization: {
|
||||
enabled: { type: Boolean, default: true },
|
||||
fields: [String]
|
||||
},
|
||||
variations: [{
|
||||
name: String,
|
||||
templateId: String,
|
||||
weight: Number // For A/B testing
|
||||
}]
|
||||
},
|
||||
|
||||
deliverySettings: {
|
||||
priority: {
|
||||
type: String,
|
||||
enum: ['low', 'normal', 'high', 'critical'],
|
||||
default: 'normal'
|
||||
},
|
||||
rateLimiting: {
|
||||
enabled: { type: Boolean, default: true },
|
||||
messagesPerHour: Number,
|
||||
messagesPerUser: Number
|
||||
},
|
||||
retryPolicy: {
|
||||
maxRetries: { type: Number, default: 3 },
|
||||
retryDelay: { type: Number, default: 300 } // seconds
|
||||
},
|
||||
quietHours: {
|
||||
enabled: { type: Boolean, default: true },
|
||||
start: String, // HH:MM format
|
||||
end: String,
|
||||
timezone: String
|
||||
}
|
||||
},
|
||||
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'scheduled', 'active', 'paused', 'completed', 'cancelled', 'failed'],
|
||||
default: 'draft',
|
||||
index: true
|
||||
},
|
||||
|
||||
execution: {
|
||||
nextRunAt: Date,
|
||||
lastRunAt: Date,
|
||||
runCount: { type: Number, default: 0 },
|
||||
history: [{
|
||||
runAt: Date,
|
||||
status: String,
|
||||
messagesSent: Number,
|
||||
errors: Number,
|
||||
duration: Number,
|
||||
details: mongoose.Schema.Types.Mixed
|
||||
}]
|
||||
},
|
||||
|
||||
statistics: {
|
||||
totalRuns: { type: Number, default: 0 },
|
||||
totalMessagesSent: { type: Number, default: 0 },
|
||||
totalDelivered: { type: Number, default: 0 },
|
||||
totalFailed: { type: Number, default: 0 },
|
||||
avgDeliveryRate: { type: Number, default: 0 },
|
||||
lastUpdated: Date
|
||||
},
|
||||
|
||||
metadata: {
|
||||
createdBy: String,
|
||||
updatedBy: String,
|
||||
tags: [String],
|
||||
notes: String,
|
||||
integrations: [{
|
||||
type: String,
|
||||
config: mongoose.Schema.Types.Mixed
|
||||
}]
|
||||
},
|
||||
|
||||
notifications: {
|
||||
onStart: {
|
||||
enabled: { type: Boolean, default: false },
|
||||
channels: [String], // email, slack, webhook
|
||||
recipients: [String]
|
||||
},
|
||||
onComplete: {
|
||||
enabled: { type: Boolean, default: true },
|
||||
channels: [String],
|
||||
recipients: [String]
|
||||
},
|
||||
onFailure: {
|
||||
enabled: { type: Boolean, default: true },
|
||||
channels: [String],
|
||||
recipients: [String]
|
||||
}
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Indexes
|
||||
scheduledCampaignSchema.index({ accountId: 1, status: 1 });
|
||||
scheduledCampaignSchema.index({ 'execution.nextRunAt': 1, status: 1 });
|
||||
scheduledCampaignSchema.index({ type: 1, status: 1 });
|
||||
|
||||
// Multi-tenant indexes
|
||||
scheduledCampaignSchema.index({ tenantId: 1, accountId: 1, status: 1 });
|
||||
scheduledCampaignSchema.index({ tenantId: 1, 'execution.nextRunAt': 1, status: 1 });
|
||||
scheduledCampaignSchema.index({ tenantId: 1, type: 1, status: 1 });
|
||||
|
||||
// Virtual for isActive
|
||||
scheduledCampaignSchema.virtual('isActive').get(function() {
|
||||
return this.status === 'active' || this.status === 'scheduled';
|
||||
});
|
||||
|
||||
// Methods
|
||||
scheduledCampaignSchema.methods.calculateNextRun = function() {
|
||||
if (this.type === 'one-time') {
|
||||
return this.schedule.startDateTime;
|
||||
}
|
||||
|
||||
if (this.type === 'recurring') {
|
||||
const now = new Date();
|
||||
const recurring = this.schedule.recurring;
|
||||
|
||||
// Implementation for calculating next run based on pattern
|
||||
// This would use date-fns or similar library for complex date calculations
|
||||
// Simplified version here
|
||||
if (recurring.pattern === 'daily') {
|
||||
const next = new Date(now);
|
||||
next.setDate(next.getDate() + (recurring.frequency?.interval || 1));
|
||||
return next;
|
||||
}
|
||||
|
||||
// Add more pattern calculations...
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
scheduledCampaignSchema.methods.shouldRun = function() {
|
||||
const now = new Date();
|
||||
|
||||
if (this.status !== 'active' && this.status !== 'scheduled') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.execution.nextRunAt && this.execution.nextRunAt > now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.type === 'recurring') {
|
||||
const recurring = this.schedule.recurring;
|
||||
|
||||
// Check end date
|
||||
if (recurring.endDate && recurring.endDate < now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check max occurrences
|
||||
if (recurring.maxOccurrences && this.execution.runCount >= recurring.maxOccurrences) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
scheduledCampaignSchema.methods.recordExecution = async function(result) {
|
||||
this.execution.lastRunAt = new Date();
|
||||
this.execution.runCount += 1;
|
||||
|
||||
// Add to history
|
||||
this.execution.history.push({
|
||||
runAt: new Date(),
|
||||
status: result.status,
|
||||
messagesSent: result.messagesSent || 0,
|
||||
errors: result.errors || 0,
|
||||
duration: result.duration,
|
||||
details: result.details
|
||||
});
|
||||
|
||||
// Keep only last 100 executions
|
||||
if (this.execution.history.length > 100) {
|
||||
this.execution.history = this.execution.history.slice(-100);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.statistics.totalRuns += 1;
|
||||
this.statistics.totalMessagesSent += result.messagesSent || 0;
|
||||
this.statistics.totalDelivered += result.delivered || 0;
|
||||
this.statistics.totalFailed += result.failed || 0;
|
||||
this.statistics.lastUpdated = new Date();
|
||||
|
||||
if (this.statistics.totalMessagesSent > 0) {
|
||||
this.statistics.avgDeliveryRate =
|
||||
(this.statistics.totalDelivered / this.statistics.totalMessagesSent) * 100;
|
||||
}
|
||||
|
||||
// Calculate next run
|
||||
if (this.type === 'recurring' && result.status === 'success') {
|
||||
this.execution.nextRunAt = this.calculateNextRun();
|
||||
} else if (this.type === 'one-time') {
|
||||
this.status = 'completed';
|
||||
}
|
||||
|
||||
await this.save();
|
||||
};
|
||||
|
||||
// Statics
|
||||
scheduledCampaignSchema.statics.findDueSchedules = async function(limit = 100) {
|
||||
const now = new Date();
|
||||
return this.find({
|
||||
status: { $in: ['active', 'scheduled'] },
|
||||
$or: [
|
||||
{ 'execution.nextRunAt': { $lte: now } },
|
||||
{ 'execution.nextRunAt': null }
|
||||
]
|
||||
})
|
||||
.limit(limit)
|
||||
.sort({ 'execution.nextRunAt': 1 });
|
||||
};
|
||||
|
||||
scheduledCampaignSchema.statics.findByAccountAndStatus = async function(accountId, status) {
|
||||
return this.find({ accountId, status })
|
||||
.sort({ createdAt: -1 });
|
||||
};
|
||||
|
||||
export default mongoose.model('ScheduledCampaign', scheduledCampaignSchema);
|
||||
316
marketing-agent/services/scheduler/src/routes/campaigns.js
Normal file
316
marketing-agent/services/scheduler/src/routes/campaigns.js
Normal file
@@ -0,0 +1,316 @@
|
||||
import express from 'express';
|
||||
import { body, query, param, validationResult } from 'express-validator';
|
||||
import campaignSchedulerService from '../services/campaignSchedulerService.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation middleware
|
||||
const validate = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Create scheduled campaign
|
||||
router.post('/',
|
||||
authMiddleware,
|
||||
[
|
||||
body('campaignId').notEmpty().withMessage('Campaign ID is required'),
|
||||
body('campaignName').notEmpty().withMessage('Campaign name is required'),
|
||||
body('type').isIn(['one-time', 'recurring', 'trigger-based']).withMessage('Invalid campaign type'),
|
||||
body('schedule').isObject().withMessage('Schedule configuration is required'),
|
||||
body('targetAudience').isObject().withMessage('Target audience is required'),
|
||||
body('messageConfig').isObject().withMessage('Message configuration is required')
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const campaignData = {
|
||||
...req.body,
|
||||
accountId: req.accountId
|
||||
};
|
||||
|
||||
const campaign = await campaignSchedulerService.createScheduledCampaign(campaignData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: campaign
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to create scheduled campaign', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get scheduled campaigns
|
||||
router.get('/',
|
||||
authMiddleware,
|
||||
[
|
||||
query('status').optional().isIn(['draft', 'scheduled', 'active', 'paused', 'completed', 'cancelled', 'failed']),
|
||||
query('type').optional().isIn(['one-time', 'recurring', 'trigger-based']),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('skip').optional().isInt({ min: 0 })
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const campaigns = await campaignSchedulerService.getScheduledCampaigns(
|
||||
req.accountId,
|
||||
req.query
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: campaigns
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get scheduled campaigns', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get campaign by ID
|
||||
router.get('/:id',
|
||||
authMiddleware,
|
||||
[
|
||||
param('id').isMongoId().withMessage('Invalid campaign ID')
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const campaigns = await campaignSchedulerService.getScheduledCampaigns(
|
||||
req.accountId,
|
||||
{ _id: req.params.id }
|
||||
);
|
||||
|
||||
if (campaigns.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Campaign not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: campaigns[0]
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get campaign', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update scheduled campaign
|
||||
router.put('/:id',
|
||||
authMiddleware,
|
||||
[
|
||||
param('id').isMongoId().withMessage('Invalid campaign ID'),
|
||||
body('type').optional().isIn(['one-time', 'recurring', 'trigger-based']),
|
||||
body('schedule').optional().isObject(),
|
||||
body('targetAudience').optional().isObject(),
|
||||
body('messageConfig').optional().isObject()
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const campaign = await campaignSchedulerService.updateScheduledCampaign(
|
||||
req.params.id,
|
||||
req.body
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: campaign
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update scheduled campaign', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete scheduled campaign
|
||||
router.delete('/:id',
|
||||
authMiddleware,
|
||||
[
|
||||
param('id').isMongoId().withMessage('Invalid campaign ID')
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const result = await campaignSchedulerService.deleteScheduledCampaign(req.params.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete scheduled campaign', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get campaign history
|
||||
router.get('/:id/history',
|
||||
authMiddleware,
|
||||
[
|
||||
param('id').isMongoId().withMessage('Invalid campaign ID'),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 })
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const history = await campaignSchedulerService.getCampaignHistory(
|
||||
req.params.id,
|
||||
req.query.limit
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: history
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get campaign history', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Pause campaign
|
||||
router.post('/:id/pause',
|
||||
authMiddleware,
|
||||
[
|
||||
param('id').isMongoId().withMessage('Invalid campaign ID')
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const campaign = await campaignSchedulerService.pauseCampaign(req.params.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: campaign
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to pause campaign', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Resume campaign
|
||||
router.post('/:id/resume',
|
||||
authMiddleware,
|
||||
[
|
||||
param('id').isMongoId().withMessage('Invalid campaign ID')
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const campaign = await campaignSchedulerService.resumeCampaign(req.params.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: campaign
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to resume campaign', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Test campaign
|
||||
router.post('/:id/test',
|
||||
authMiddleware,
|
||||
[
|
||||
param('id').isMongoId().withMessage('Invalid campaign ID'),
|
||||
body('targetAudience').optional().isObject(),
|
||||
body('maxRecipients').optional().isInt({ min: 1, max: 100 })
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const result = await campaignSchedulerService.testCampaign(
|
||||
req.params.id,
|
||||
req.body
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to test campaign', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get campaign statistics
|
||||
router.get('/statistics/:period',
|
||||
authMiddleware,
|
||||
[
|
||||
param('period').matches(/^\d+[hdwmy]$/).withMessage('Invalid period format')
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const stats = await campaignSchedulerService.getCampaignStatistics(
|
||||
req.accountId,
|
||||
req.params.period
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get campaign statistics', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
228
marketing-agent/services/scheduler/src/routes/jobs.js
Normal file
228
marketing-agent/services/scheduler/src/routes/jobs.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import express from 'express';
|
||||
import { query, param, validationResult } from 'express-validator';
|
||||
import ScheduleJob from '../models/ScheduleJob.js';
|
||||
import jobProcessor from '../services/jobProcessor.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation middleware
|
||||
const validate = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Get jobs
|
||||
router.get('/',
|
||||
authMiddleware,
|
||||
[
|
||||
query('status').optional().isIn(['pending', 'queued', 'running', 'completed', 'failed', 'cancelled']),
|
||||
query('scheduleId').optional().isMongoId(),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('skip').optional().isInt({ min: 0 })
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const query = { accountId: req.accountId };
|
||||
|
||||
if (req.query.status) {
|
||||
query.status = req.query.status;
|
||||
}
|
||||
|
||||
if (req.query.scheduleId) {
|
||||
query.scheduleId = req.query.scheduleId;
|
||||
}
|
||||
|
||||
const jobs = await ScheduleJob.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(parseInt(req.query.limit) || 50)
|
||||
.skip(parseInt(req.query.skip) || 0)
|
||||
.populate('scheduleId', 'campaignName type');
|
||||
|
||||
const total = await ScheduleJob.countDocuments(query);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
jobs,
|
||||
total,
|
||||
limit: parseInt(req.query.limit) || 50,
|
||||
skip: parseInt(req.query.skip) || 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get jobs', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get job by ID
|
||||
router.get('/:id',
|
||||
authMiddleware,
|
||||
[
|
||||
param('id').isMongoId().withMessage('Invalid job ID')
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const job = await ScheduleJob.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.accountId
|
||||
}).populate('scheduleId');
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Job not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: job
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get job', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Cancel job
|
||||
router.post('/:id/cancel',
|
||||
authMiddleware,
|
||||
[
|
||||
param('id').isMongoId().withMessage('Invalid job ID')
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const job = await ScheduleJob.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.accountId
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Job not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (!['pending', 'queued'].includes(job.status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Can only cancel pending or queued jobs'
|
||||
});
|
||||
}
|
||||
|
||||
job.status = 'cancelled';
|
||||
await job.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: job
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel job', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Retry failed job
|
||||
router.post('/:id/retry',
|
||||
authMiddleware,
|
||||
[
|
||||
param('id').isMongoId().withMessage('Invalid job ID')
|
||||
],
|
||||
validate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const job = await ScheduleJob.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.accountId
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Job not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (job.status !== 'failed') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Can only retry failed jobs'
|
||||
});
|
||||
}
|
||||
|
||||
// Reset job status
|
||||
job.status = 'pending';
|
||||
job.attempts.nextRetryAt = null;
|
||||
await job.save();
|
||||
|
||||
// Add to queue immediately
|
||||
await jobProcessor.jobQueue.add('execute-campaign', {
|
||||
jobId: job._id.toString()
|
||||
}, {
|
||||
priority: job.priority,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: job
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to retry job', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get processor status
|
||||
router.get('/processor/status',
|
||||
authMiddleware,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const status = await jobProcessor.getStatus();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: status
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get processor status', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,474 @@
|
||||
import ScheduledCampaign from '../models/ScheduledCampaign.js';
|
||||
import ScheduleJob from '../models/ScheduleJob.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { CronPattern } from '../utils/cronHelper.js';
|
||||
import Bull from 'bull';
|
||||
import { redisClient } from '../config/redis.js';
|
||||
|
||||
class CampaignSchedulerService {
|
||||
constructor() {
|
||||
// Initialize Bull queue
|
||||
this.jobQueue = new Bull('campaign-jobs', {
|
||||
redis: {
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
password: process.env.REDIS_PASSWORD
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new scheduled campaign
|
||||
*/
|
||||
async createScheduledCampaign(data) {
|
||||
try {
|
||||
const scheduledCampaign = new ScheduledCampaign(data);
|
||||
|
||||
// Validate schedule configuration
|
||||
this.validateSchedule(scheduledCampaign);
|
||||
|
||||
// Calculate initial next run time
|
||||
if (scheduledCampaign.type === 'one-time') {
|
||||
scheduledCampaign.execution.nextRunAt = scheduledCampaign.schedule.startDateTime;
|
||||
} else if (scheduledCampaign.type === 'recurring') {
|
||||
scheduledCampaign.execution.nextRunAt = scheduledCampaign.calculateNextRun();
|
||||
}
|
||||
|
||||
// Set status to scheduled if start time is in future
|
||||
const now = new Date();
|
||||
if (scheduledCampaign.execution.nextRunAt > now) {
|
||||
scheduledCampaign.status = 'scheduled';
|
||||
} else {
|
||||
scheduledCampaign.status = 'active';
|
||||
}
|
||||
|
||||
await scheduledCampaign.save();
|
||||
|
||||
// Create initial job if needed
|
||||
if (scheduledCampaign.status === 'active' || scheduledCampaign.status === 'scheduled') {
|
||||
await this.createScheduleJob(scheduledCampaign);
|
||||
}
|
||||
|
||||
logger.info('Scheduled campaign created', {
|
||||
campaignId: scheduledCampaign.campaignId,
|
||||
type: scheduledCampaign.type,
|
||||
nextRunAt: scheduledCampaign.execution.nextRunAt
|
||||
});
|
||||
|
||||
return scheduledCampaign;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create scheduled campaign', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a scheduled campaign
|
||||
*/
|
||||
async updateScheduledCampaign(id, updates) {
|
||||
try {
|
||||
const campaign = await ScheduledCampaign.findById(id);
|
||||
if (!campaign) {
|
||||
throw new Error('Scheduled campaign not found');
|
||||
}
|
||||
|
||||
// Update fields
|
||||
Object.assign(campaign, updates);
|
||||
|
||||
// Recalculate next run if schedule changed
|
||||
if (updates.schedule) {
|
||||
campaign.execution.nextRunAt = campaign.calculateNextRun();
|
||||
}
|
||||
|
||||
await campaign.save();
|
||||
|
||||
// Update or create job
|
||||
await this.updateScheduleJobs(campaign);
|
||||
|
||||
return campaign;
|
||||
} catch (error) {
|
||||
logger.error('Failed to update scheduled campaign', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a scheduled campaign
|
||||
*/
|
||||
async deleteScheduledCampaign(id) {
|
||||
try {
|
||||
const campaign = await ScheduledCampaign.findById(id);
|
||||
if (!campaign) {
|
||||
throw new Error('Scheduled campaign not found');
|
||||
}
|
||||
|
||||
// Cancel all related jobs
|
||||
await ScheduleJob.updateMany(
|
||||
{ scheduleId: id },
|
||||
{ status: 'cancelled' }
|
||||
);
|
||||
|
||||
// Remove from queue
|
||||
const jobs = await this.jobQueue.getJobs(['waiting', 'delayed']);
|
||||
for (const job of jobs) {
|
||||
if (job.data.scheduleId === id) {
|
||||
await job.remove();
|
||||
}
|
||||
}
|
||||
|
||||
await campaign.remove();
|
||||
|
||||
logger.info('Scheduled campaign deleted', { campaignId: id });
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete scheduled campaign', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled campaigns
|
||||
*/
|
||||
async getScheduledCampaigns(accountId, filters = {}) {
|
||||
try {
|
||||
const query = { accountId };
|
||||
|
||||
if (filters.status) {
|
||||
query.status = filters.status;
|
||||
}
|
||||
|
||||
if (filters.type) {
|
||||
query.type = filters.type;
|
||||
}
|
||||
|
||||
const campaigns = await ScheduledCampaign.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(filters.limit || 100)
|
||||
.skip(filters.skip || 0);
|
||||
|
||||
return campaigns;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get scheduled campaigns', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get campaign execution history
|
||||
*/
|
||||
async getCampaignHistory(campaignId, limit = 50) {
|
||||
try {
|
||||
const campaign = await ScheduledCampaign.findById(campaignId);
|
||||
if (!campaign) {
|
||||
throw new Error('Scheduled campaign not found');
|
||||
}
|
||||
|
||||
// Get recent jobs
|
||||
const jobs = await ScheduleJob.find({ scheduleId: campaignId })
|
||||
.sort({ scheduledFor: -1 })
|
||||
.limit(limit);
|
||||
|
||||
return {
|
||||
campaign: {
|
||||
id: campaign._id,
|
||||
name: campaign.campaignName,
|
||||
type: campaign.type,
|
||||
status: campaign.status
|
||||
},
|
||||
executions: campaign.execution.history.slice(-limit),
|
||||
jobs
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get campaign history', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a scheduled campaign
|
||||
*/
|
||||
async pauseCampaign(id) {
|
||||
try {
|
||||
const campaign = await ScheduledCampaign.findById(id);
|
||||
if (!campaign) {
|
||||
throw new Error('Scheduled campaign not found');
|
||||
}
|
||||
|
||||
campaign.status = 'paused';
|
||||
await campaign.save();
|
||||
|
||||
// Cancel pending jobs
|
||||
await ScheduleJob.updateMany(
|
||||
{ scheduleId: id, status: 'pending' },
|
||||
{ status: 'cancelled' }
|
||||
);
|
||||
|
||||
return campaign;
|
||||
} catch (error) {
|
||||
logger.error('Failed to pause campaign', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a paused campaign
|
||||
*/
|
||||
async resumeCampaign(id) {
|
||||
try {
|
||||
const campaign = await ScheduledCampaign.findById(id);
|
||||
if (!campaign) {
|
||||
throw new Error('Scheduled campaign not found');
|
||||
}
|
||||
|
||||
campaign.status = 'active';
|
||||
campaign.execution.nextRunAt = campaign.calculateNextRun();
|
||||
await campaign.save();
|
||||
|
||||
// Create new job
|
||||
await this.createScheduleJob(campaign);
|
||||
|
||||
return campaign;
|
||||
} catch (error) {
|
||||
logger.error('Failed to resume campaign', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a scheduled campaign (dry run)
|
||||
*/
|
||||
async testCampaign(id, options = {}) {
|
||||
try {
|
||||
const campaign = await ScheduledCampaign.findById(id);
|
||||
if (!campaign) {
|
||||
throw new Error('Scheduled campaign not found');
|
||||
}
|
||||
|
||||
// Create a test job
|
||||
const testJob = new ScheduleJob({
|
||||
scheduleId: campaign._id,
|
||||
accountId: campaign.accountId,
|
||||
type: 'campaign_execution',
|
||||
status: 'pending',
|
||||
priority: 10, // High priority for test
|
||||
scheduledFor: new Date(),
|
||||
payload: {
|
||||
campaignId: campaign.campaignId,
|
||||
targetAudience: options.targetAudience || campaign.targetAudience,
|
||||
messageConfig: campaign.messageConfig,
|
||||
deliverySettings: {
|
||||
...campaign.deliverySettings,
|
||||
testMode: true,
|
||||
maxRecipients: options.maxRecipients || 10
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await testJob.save();
|
||||
|
||||
// Add to queue immediately
|
||||
const job = await this.jobQueue.add('execute-campaign', {
|
||||
jobId: testJob._id.toString(),
|
||||
testMode: true
|
||||
}, {
|
||||
priority: 10,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false
|
||||
});
|
||||
|
||||
return {
|
||||
testJobId: testJob._id,
|
||||
queueJobId: job.id,
|
||||
message: 'Test job created and queued'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to test campaign', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a schedule job
|
||||
*/
|
||||
async createScheduleJob(scheduledCampaign) {
|
||||
try {
|
||||
const job = new ScheduleJob({
|
||||
scheduleId: scheduledCampaign._id,
|
||||
accountId: scheduledCampaign.accountId,
|
||||
type: 'campaign_execution',
|
||||
status: 'pending',
|
||||
priority: this.getPriority(scheduledCampaign.deliverySettings.priority),
|
||||
scheduledFor: scheduledCampaign.execution.nextRunAt,
|
||||
payload: {
|
||||
campaignId: scheduledCampaign.campaignId,
|
||||
targetAudience: scheduledCampaign.targetAudience,
|
||||
messageConfig: scheduledCampaign.messageConfig,
|
||||
deliverySettings: scheduledCampaign.deliverySettings
|
||||
}
|
||||
});
|
||||
|
||||
await job.save();
|
||||
|
||||
// Add to Bull queue with delay
|
||||
const delay = job.scheduledFor.getTime() - Date.now();
|
||||
if (delay > 0) {
|
||||
await this.jobQueue.add('execute-campaign', {
|
||||
jobId: job._id.toString()
|
||||
}, {
|
||||
delay,
|
||||
priority: job.priority,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false
|
||||
});
|
||||
} else {
|
||||
// Execute immediately if past due
|
||||
await this.jobQueue.add('execute-campaign', {
|
||||
jobId: job._id.toString()
|
||||
}, {
|
||||
priority: job.priority,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Schedule job created', {
|
||||
jobId: job._id,
|
||||
scheduledFor: job.scheduledFor
|
||||
});
|
||||
|
||||
return job;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create schedule job', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update schedule jobs for a campaign
|
||||
*/
|
||||
async updateScheduleJobs(campaign) {
|
||||
try {
|
||||
// Cancel existing pending jobs
|
||||
await ScheduleJob.updateMany(
|
||||
{ scheduleId: campaign._id, status: 'pending' },
|
||||
{ status: 'cancelled' }
|
||||
);
|
||||
|
||||
// Create new job if campaign is active
|
||||
if (campaign.status === 'active' || campaign.status === 'scheduled') {
|
||||
await this.createScheduleJob(campaign);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update schedule jobs', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate schedule configuration
|
||||
*/
|
||||
validateSchedule(campaign) {
|
||||
if (campaign.type === 'one-time') {
|
||||
if (!campaign.schedule.startDateTime) {
|
||||
throw new Error('Start date/time is required for one-time campaigns');
|
||||
}
|
||||
if (new Date(campaign.schedule.startDateTime) < new Date()) {
|
||||
throw new Error('Start date/time must be in the future');
|
||||
}
|
||||
} else if (campaign.type === 'recurring') {
|
||||
const recurring = campaign.schedule.recurring;
|
||||
if (!recurring.pattern) {
|
||||
throw new Error('Pattern is required for recurring campaigns');
|
||||
}
|
||||
if (recurring.pattern === 'custom' && (!recurring.frequency.interval || !recurring.frequency.unit)) {
|
||||
throw new Error('Frequency interval and unit are required for custom patterns');
|
||||
}
|
||||
} else if (campaign.type === 'trigger-based') {
|
||||
if (!campaign.schedule.triggers || campaign.schedule.triggers.length === 0) {
|
||||
throw new Error('At least one trigger is required for trigger-based campaigns');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority value from string
|
||||
*/
|
||||
getPriority(priority) {
|
||||
const priorities = {
|
||||
low: -5,
|
||||
normal: 0,
|
||||
high: 5,
|
||||
critical: 10
|
||||
};
|
||||
return priorities[priority] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get campaign statistics
|
||||
*/
|
||||
async getCampaignStatistics(accountId, period = '7d') {
|
||||
try {
|
||||
const startDate = this.getStartDate(period);
|
||||
|
||||
const stats = await ScheduledCampaign.aggregate([
|
||||
{
|
||||
$match: {
|
||||
accountId,
|
||||
createdAt: { $gte: startDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalCampaigns: { $sum: 1 },
|
||||
activeCampaigns: {
|
||||
$sum: {
|
||||
$cond: [{ $in: ['$status', ['active', 'scheduled']] }, 1, 0]
|
||||
}
|
||||
},
|
||||
totalRuns: { $sum: '$statistics.totalRuns' },
|
||||
totalMessagesSent: { $sum: '$statistics.totalMessagesSent' },
|
||||
totalDelivered: { $sum: '$statistics.totalDelivered' },
|
||||
avgDeliveryRate: { $avg: '$statistics.avgDeliveryRate' }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
return stats[0] || {
|
||||
totalCampaigns: 0,
|
||||
activeCampaigns: 0,
|
||||
totalRuns: 0,
|
||||
totalMessagesSent: 0,
|
||||
totalDelivered: 0,
|
||||
avgDeliveryRate: 0
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get campaign statistics', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get start date based on period
|
||||
*/
|
||||
getStartDate(period) {
|
||||
const now = new Date();
|
||||
const match = period.match(/^(\d+)([hdwmy])$/);
|
||||
if (!match) return now;
|
||||
|
||||
const [, value, unit] = match;
|
||||
const amount = parseInt(value);
|
||||
|
||||
switch (unit) {
|
||||
case 'h': return new Date(now - amount * 60 * 60 * 1000);
|
||||
case 'd': return new Date(now - amount * 24 * 60 * 60 * 1000);
|
||||
case 'w': return new Date(now - amount * 7 * 24 * 60 * 60 * 1000);
|
||||
case 'm': return new Date(now.setMonth(now.getMonth() - amount));
|
||||
case 'y': return new Date(now.setFullYear(now.getFullYear() - amount));
|
||||
default: return now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new CampaignSchedulerService();
|
||||
393
marketing-agent/services/scheduler/src/services/jobProcessor.js
Normal file
393
marketing-agent/services/scheduler/src/services/jobProcessor.js
Normal file
@@ -0,0 +1,393 @@
|
||||
import Bull from 'bull';
|
||||
import ScheduleJob from '../models/ScheduleJob.js';
|
||||
import ScheduledCampaign from '../models/ScheduledCampaign.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import axios from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
class JobProcessor {
|
||||
constructor() {
|
||||
this.processorId = uuidv4();
|
||||
this.isProcessing = false;
|
||||
|
||||
// Initialize Bull queue
|
||||
this.jobQueue = new Bull('campaign-jobs', {
|
||||
redis: {
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
password: process.env.REDIS_PASSWORD
|
||||
}
|
||||
});
|
||||
|
||||
// Orchestrator service URL
|
||||
this.orchestratorUrl = process.env.ORCHESTRATOR_URL || 'http://localhost:3004';
|
||||
}
|
||||
|
||||
/**
|
||||
* Start processing jobs
|
||||
*/
|
||||
async start() {
|
||||
logger.info('Starting job processor', { processorId: this.processorId });
|
||||
|
||||
// Process campaign execution jobs
|
||||
this.jobQueue.process('execute-campaign', 5, async (job) => {
|
||||
return await this.processCampaignJob(job);
|
||||
});
|
||||
|
||||
// Handle job events
|
||||
this.jobQueue.on('completed', (job, result) => {
|
||||
logger.info('Job completed', {
|
||||
jobId: job.data.jobId,
|
||||
result
|
||||
});
|
||||
});
|
||||
|
||||
this.jobQueue.on('failed', (job, err) => {
|
||||
logger.error('Job failed', {
|
||||
jobId: job.data.jobId,
|
||||
error: err.message
|
||||
});
|
||||
});
|
||||
|
||||
// Start periodic job check
|
||||
this.startJobCheck();
|
||||
|
||||
// Cleanup stale jobs periodically
|
||||
setInterval(() => {
|
||||
this.cleanupStaleJobs();
|
||||
}, 60 * 60 * 1000); // Every hour
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a campaign job
|
||||
*/
|
||||
async processCampaignJob(job) {
|
||||
const { jobId, testMode } = job.data;
|
||||
|
||||
try {
|
||||
// Get the schedule job
|
||||
const scheduleJob = await ScheduleJob.findById(jobId);
|
||||
if (!scheduleJob) {
|
||||
throw new Error('Schedule job not found');
|
||||
}
|
||||
|
||||
// Check if job can run
|
||||
const canRun = await scheduleJob.canRun();
|
||||
if (!canRun) {
|
||||
logger.warn('Job cannot run', { jobId, status: scheduleJob.status });
|
||||
return { skipped: true, reason: 'Cannot run' };
|
||||
}
|
||||
|
||||
// Try to acquire lock
|
||||
const lockAcquired = await scheduleJob.acquireLock(this.processorId);
|
||||
if (!lockAcquired) {
|
||||
logger.warn('Failed to acquire lock', { jobId });
|
||||
return { skipped: true, reason: 'Lock not acquired' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Mark as running
|
||||
await scheduleJob.markAsRunning();
|
||||
|
||||
// Execute the campaign
|
||||
const result = await this.executeCampaign(scheduleJob, testMode);
|
||||
|
||||
// Mark as completed
|
||||
await scheduleJob.markAsCompleted(result);
|
||||
|
||||
// Update campaign statistics
|
||||
await this.updateCampaignStatistics(scheduleJob.scheduleId, result);
|
||||
|
||||
// Schedule next run if recurring
|
||||
await this.scheduleNextRun(scheduleJob.scheduleId);
|
||||
|
||||
// Send notifications if configured
|
||||
await this.sendNotifications(scheduleJob.scheduleId, 'complete', result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Mark as failed
|
||||
await scheduleJob.markAsFailed(error);
|
||||
|
||||
// Send failure notification
|
||||
await this.sendNotifications(scheduleJob.scheduleId, 'failure', { error: error.message });
|
||||
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to process campaign job', {
|
||||
jobId,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a campaign
|
||||
*/
|
||||
async executeCampaign(scheduleJob, testMode = false) {
|
||||
const { campaignId, targetAudience, messageConfig, deliverySettings } = scheduleJob.payload;
|
||||
|
||||
try {
|
||||
logger.info('Executing campaign', {
|
||||
jobId: scheduleJob._id,
|
||||
campaignId,
|
||||
testMode
|
||||
});
|
||||
|
||||
// Prepare campaign execution request
|
||||
const executionRequest = {
|
||||
campaignId,
|
||||
targetAudience,
|
||||
messageConfig,
|
||||
deliverySettings: {
|
||||
...deliverySettings,
|
||||
testMode
|
||||
},
|
||||
metadata: {
|
||||
scheduleJobId: scheduleJob._id.toString(),
|
||||
scheduledFor: scheduleJob.scheduledFor,
|
||||
executedBy: 'scheduler'
|
||||
}
|
||||
};
|
||||
|
||||
// Call orchestrator service to execute campaign
|
||||
const response = await axios.post(
|
||||
`${this.orchestratorUrl}/api/campaigns/${campaignId}/execute`,
|
||||
executionRequest,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service': 'scheduler',
|
||||
'X-Account-ID': scheduleJob.accountId
|
||||
},
|
||||
timeout: 300000 // 5 minutes timeout
|
||||
}
|
||||
);
|
||||
|
||||
const result = response.data;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messagesSent: result.statistics?.messagesSent || 0,
|
||||
delivered: result.statistics?.delivered || 0,
|
||||
failed: result.statistics?.failed || 0,
|
||||
metadata: result
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to execute campaign', {
|
||||
jobId: scheduleJob._id,
|
||||
campaignId,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update campaign statistics
|
||||
*/
|
||||
async updateCampaignStatistics(scheduleId, result) {
|
||||
try {
|
||||
const campaign = await ScheduledCampaign.findById(scheduleId);
|
||||
if (!campaign) {
|
||||
logger.warn('Campaign not found for statistics update', { scheduleId });
|
||||
return;
|
||||
}
|
||||
|
||||
await campaign.recordExecution({
|
||||
status: result.success ? 'success' : 'failed',
|
||||
messagesSent: result.messagesSent,
|
||||
delivered: result.delivered,
|
||||
failed: result.failed,
|
||||
duration: result.metadata?.duration || 0,
|
||||
details: result.metadata
|
||||
});
|
||||
|
||||
logger.info('Campaign statistics updated', {
|
||||
campaignId: campaign._id,
|
||||
runCount: campaign.execution.runCount
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update campaign statistics', {
|
||||
scheduleId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule next run for recurring campaigns
|
||||
*/
|
||||
async scheduleNextRun(scheduleId) {
|
||||
try {
|
||||
const campaign = await ScheduledCampaign.findById(scheduleId);
|
||||
if (!campaign || campaign.type !== 'recurring' || campaign.status !== 'active') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if should continue
|
||||
if (!campaign.shouldRun()) {
|
||||
campaign.status = 'completed';
|
||||
await campaign.save();
|
||||
logger.info('Recurring campaign completed', { campaignId: campaign._id });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create next job
|
||||
const campaignSchedulerService = (await import('./campaignSchedulerService.js')).default;
|
||||
await campaignSchedulerService.createScheduleJob(campaign);
|
||||
|
||||
logger.info('Next run scheduled', {
|
||||
campaignId: campaign._id,
|
||||
nextRunAt: campaign.execution.nextRunAt
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to schedule next run', {
|
||||
scheduleId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notifications
|
||||
*/
|
||||
async sendNotifications(scheduleId, event, data) {
|
||||
try {
|
||||
const campaign = await ScheduledCampaign.findById(scheduleId);
|
||||
if (!campaign) return;
|
||||
|
||||
const notificationConfig = campaign.notifications[`on${event.charAt(0).toUpperCase()}${event.slice(1)}`];
|
||||
if (!notificationConfig || !notificationConfig.enabled) return;
|
||||
|
||||
// Send notifications through configured channels
|
||||
for (const channel of notificationConfig.channels) {
|
||||
try {
|
||||
await this.sendNotification(channel, {
|
||||
campaign: {
|
||||
id: campaign._id,
|
||||
name: campaign.campaignName
|
||||
},
|
||||
event,
|
||||
data,
|
||||
recipients: notificationConfig.recipients
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', {
|
||||
channel,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notifications', {
|
||||
scheduleId,
|
||||
event,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification through specific channel
|
||||
*/
|
||||
async sendNotification(channel, data) {
|
||||
// Implementation would depend on notification services
|
||||
// For now, just log
|
||||
logger.info('Notification sent', { channel, data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic job check
|
||||
*/
|
||||
startJobCheck() {
|
||||
setInterval(async () => {
|
||||
if (this.isProcessing) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
await this.checkAndQueueJobs();
|
||||
} catch (error) {
|
||||
logger.error('Job check failed', error);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}, 60 * 1000); // Every minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for due jobs and queue them
|
||||
*/
|
||||
async checkAndQueueJobs() {
|
||||
try {
|
||||
// Find ready jobs
|
||||
const readyJobs = await ScheduleJob.findReadyJobs(10);
|
||||
|
||||
for (const job of readyJobs) {
|
||||
try {
|
||||
// Check if already in queue
|
||||
const existingJob = await this.jobQueue.getJob(job._id.toString());
|
||||
if (existingJob) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
await this.jobQueue.add('execute-campaign', {
|
||||
jobId: job._id.toString()
|
||||
}, {
|
||||
priority: job.priority,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false
|
||||
});
|
||||
|
||||
logger.info('Job queued', { jobId: job._id });
|
||||
} catch (error) {
|
||||
logger.error('Failed to queue job', {
|
||||
jobId: job._id,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check and queue jobs', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stale jobs
|
||||
*/
|
||||
async cleanupStaleJobs() {
|
||||
try {
|
||||
await ScheduleJob.cleanupStaleJobs();
|
||||
logger.info('Stale jobs cleaned up');
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup stale jobs', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processor status
|
||||
*/
|
||||
async getStatus() {
|
||||
const waiting = await this.jobQueue.getWaitingCount();
|
||||
const active = await this.jobQueue.getActiveCount();
|
||||
const completed = await this.jobQueue.getCompletedCount();
|
||||
const failed = await this.jobQueue.getFailedCount();
|
||||
|
||||
return {
|
||||
processorId: this.processorId,
|
||||
queue: {
|
||||
waiting,
|
||||
active,
|
||||
completed,
|
||||
failed
|
||||
},
|
||||
isProcessing: this.isProcessing
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new JobProcessor();
|
||||
183
marketing-agent/services/scheduler/src/utils/cronHelper.js
Normal file
183
marketing-agent/services/scheduler/src/utils/cronHelper.js
Normal file
@@ -0,0 +1,183 @@
|
||||
import cron from 'node-cron';
|
||||
import { format, addMinutes, addHours, addDays, addWeeks, addMonths } from 'date-fns';
|
||||
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
|
||||
|
||||
export class CronPattern {
|
||||
/**
|
||||
* Build cron expression from schedule config
|
||||
*/
|
||||
static buildCronExpression(schedule) {
|
||||
const { pattern, timeOfDay, daysOfWeek, daysOfMonth } = schedule;
|
||||
|
||||
// Parse time of day (HH:MM format)
|
||||
const [hour, minute] = (timeOfDay || '00:00').split(':').map(Number);
|
||||
|
||||
switch (pattern) {
|
||||
case 'daily':
|
||||
return `${minute} ${hour} * * *`;
|
||||
|
||||
case 'weekly':
|
||||
const weekDays = daysOfWeek && daysOfWeek.length > 0
|
||||
? daysOfWeek.join(',')
|
||||
: '1'; // Default to Monday
|
||||
return `${minute} ${hour} * * ${weekDays}`;
|
||||
|
||||
case 'monthly':
|
||||
const monthDays = daysOfMonth && daysOfMonth.length > 0
|
||||
? daysOfMonth.join(',')
|
||||
: '1'; // Default to 1st of month
|
||||
return `${minute} ${hour} ${monthDays} * *`;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next run time based on schedule
|
||||
*/
|
||||
static calculateNextRun(schedule, fromDate = new Date()) {
|
||||
const { pattern, frequency, timeOfDay, daysOfWeek, daysOfMonth, timezone } = schedule;
|
||||
|
||||
// Convert to target timezone
|
||||
const zonedDate = timezone ? utcToZonedTime(fromDate, timezone) : fromDate;
|
||||
|
||||
let nextRun;
|
||||
|
||||
if (pattern === 'custom' && frequency) {
|
||||
// Handle custom frequency
|
||||
nextRun = this.addFrequency(zonedDate, frequency.interval, frequency.unit);
|
||||
} else {
|
||||
// Use cron expression
|
||||
const cronExpression = this.buildCronExpression(schedule);
|
||||
if (cronExpression && cron.validate(cronExpression)) {
|
||||
const interval = cron.parseExpression(cronExpression, {
|
||||
currentDate: zonedDate,
|
||||
tz: timezone
|
||||
});
|
||||
nextRun = interval.next().toDate();
|
||||
} else {
|
||||
// Fallback to simple calculation
|
||||
nextRun = this.simpleNextRun(zonedDate, pattern, timeOfDay);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert back to UTC
|
||||
return timezone ? zonedTimeToUtc(nextRun, timezone) : nextRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add frequency to date
|
||||
*/
|
||||
static addFrequency(date, interval, unit) {
|
||||
switch (unit) {
|
||||
case 'minutes':
|
||||
return addMinutes(date, interval);
|
||||
case 'hours':
|
||||
return addHours(date, interval);
|
||||
case 'days':
|
||||
return addDays(date, interval);
|
||||
case 'weeks':
|
||||
return addWeeks(date, interval);
|
||||
case 'months':
|
||||
return addMonths(date, interval);
|
||||
default:
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple next run calculation
|
||||
*/
|
||||
static simpleNextRun(date, pattern, timeOfDay) {
|
||||
const [hour, minute] = (timeOfDay || '00:00').split(':').map(Number);
|
||||
const next = new Date(date);
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
|
||||
// If time has passed today, move to next occurrence
|
||||
if (next <= date) {
|
||||
switch (pattern) {
|
||||
case 'daily':
|
||||
next.setDate(next.getDate() + 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
next.setDate(next.getDate() + 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
next.setMonth(next.getMonth() + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cron expression
|
||||
*/
|
||||
static validate(expression) {
|
||||
return cron.validate(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description of schedule
|
||||
*/
|
||||
static getDescription(schedule) {
|
||||
const { pattern, frequency, timeOfDay, daysOfWeek, daysOfMonth, timezone } = schedule;
|
||||
|
||||
let description = '';
|
||||
|
||||
switch (pattern) {
|
||||
case 'daily':
|
||||
description = `Daily at ${timeOfDay || '00:00'}`;
|
||||
break;
|
||||
|
||||
case 'weekly':
|
||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const days = daysOfWeek?.map(d => dayNames[d]).join(', ') || 'Monday';
|
||||
description = `Weekly on ${days} at ${timeOfDay || '00:00'}`;
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
const dates = daysOfMonth?.join(', ') || '1st';
|
||||
description = `Monthly on day(s) ${dates} at ${timeOfDay || '00:00'}`;
|
||||
break;
|
||||
|
||||
case 'custom':
|
||||
if (frequency) {
|
||||
description = `Every ${frequency.interval} ${frequency.unit}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (timezone && timezone !== 'UTC') {
|
||||
description += ` (${timezone})`;
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if schedule is due
|
||||
*/
|
||||
static isDue(schedule, lastRun, now = new Date()) {
|
||||
const nextRun = this.calculateNextRun(schedule, lastRun);
|
||||
return now >= nextRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next N occurrences
|
||||
*/
|
||||
static getNextOccurrences(schedule, count = 5, fromDate = new Date()) {
|
||||
const occurrences = [];
|
||||
let currentDate = fromDate;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const next = this.calculateNextRun(schedule, currentDate);
|
||||
occurrences.push(next);
|
||||
currentDate = new Date(next.getTime() + 1000); // Add 1 second to get next
|
||||
}
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
}
|
||||
46
marketing-agent/services/scheduler/src/utils/logger.js
Normal file
46
marketing-agent/services/scheduler/src/utils/logger.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import winston from 'winston';
|
||||
|
||||
const { combine, timestamp, printf, colorize, errors } = winston.format;
|
||||
|
||||
const logFormat = printf(({ level, message, timestamp, stack, ...metadata }) => {
|
||||
let msg = `${timestamp} [${level}] : ${message}`;
|
||||
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
msg += ` ${JSON.stringify(metadata)}`;
|
||||
}
|
||||
|
||||
if (stack) {
|
||||
msg += `\n${stack}`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
});
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: combine(
|
||||
errors({ stack: true }),
|
||||
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
process.env.NODE_ENV === 'production' ? winston.format.json() : combine(colorize(), logFormat)
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({
|
||||
filename: 'logs/scheduler-error.log',
|
||||
level: 'error',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: 'logs/scheduler-combined.log',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
})
|
||||
],
|
||||
exceptionHandlers: [
|
||||
new winston.transports.File({ filename: 'logs/scheduler-exceptions.log' })
|
||||
],
|
||||
rejectionHandlers: [
|
||||
new winston.transports.File({ filename: 'logs/scheduler-rejections.log' })
|
||||
]
|
||||
});
|
||||
Reference in New Issue
Block a user