Initial commit: Telegram Management System
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:
你的用户名
2025-11-04 15:37:50 +08:00
commit 237c7802e5
3674 changed files with 525172 additions and 0 deletions

View 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"
}
}

View 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();

View 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;
}
}

View 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;

View 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);
};

View 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);

View File

@@ -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);

View 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;

View 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;

View File

@@ -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();

View 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();

View 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;
}
}

View 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' })
]
});