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:
1
marketing-agent/services/orchestrator/src/app.js
Normal file
1
marketing-agent/services/orchestrator/src/app.js
Normal file
@@ -0,0 +1 @@
|
||||
import './index.js';
|
||||
86
marketing-agent/services/orchestrator/src/config/database.js
Normal file
86
marketing-agent/services/orchestrator/src/config/database.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Sequelize } from 'sequelize';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export class Database {
|
||||
constructor() {
|
||||
this.sequelize = null;
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!Database.instance) {
|
||||
Database.instance = new Database();
|
||||
}
|
||||
return Database.instance;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
try {
|
||||
this.sequelize = new Sequelize({
|
||||
host: process.env.POSTGRES_HOST || 'localhost',
|
||||
port: process.env.POSTGRES_PORT || 5432,
|
||||
database: process.env.POSTGRES_DATABASE || 'marketing_agent',
|
||||
username: process.env.POSTGRES_USER || 'marketing_user',
|
||||
password: process.env.POSTGRES_PASSWORD || 'marketing_pass',
|
||||
dialect: 'postgres',
|
||||
logging: process.env.NODE_ENV === 'development' ? logger.debug : false,
|
||||
pool: {
|
||||
max: parseInt(process.env.DB_POOL_SIZE) || 10,
|
||||
min: 0,
|
||||
acquire: 30000,
|
||||
idle: 10000
|
||||
}
|
||||
});
|
||||
|
||||
await this.sequelize.authenticate();
|
||||
logger.info('Database connection established successfully');
|
||||
|
||||
// Sync models
|
||||
await this.syncModels();
|
||||
|
||||
return this.sequelize;
|
||||
} catch (error) {
|
||||
logger.error('Unable to connect to database:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async syncModels() {
|
||||
try {
|
||||
// Import models
|
||||
const { Task } = await import('../models/Task.js');
|
||||
const { Campaign } = await import('../models/Campaign.js');
|
||||
const { Workflow } = await import('../models/Workflow.js');
|
||||
const { AuditLog } = await import('../models/AuditLog.js');
|
||||
|
||||
// Sync database
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
await this.sequelize.sync({ alter: true });
|
||||
logger.info('Database models synchronized');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error syncing models:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async checkHealth() {
|
||||
try {
|
||||
await this.sequelize.authenticate();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Database health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.sequelize) {
|
||||
await this.sequelize.close();
|
||||
logger.info('Database connection closed');
|
||||
}
|
||||
}
|
||||
|
||||
getSequelize() {
|
||||
return this.sequelize;
|
||||
}
|
||||
}
|
||||
192
marketing-agent/services/orchestrator/src/config/redis.js
Normal file
192
marketing-agent/services/orchestrator/src/config/redis.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import Redis from 'ioredis';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export class RedisClient {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.subscriber = null;
|
||||
this.publisher = null;
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!RedisClient.instance) {
|
||||
RedisClient.instance = new RedisClient();
|
||||
}
|
||||
return RedisClient.instance;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const config = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB) || 0,
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
reconnectOnError: (err) => {
|
||||
const targetError = 'READONLY';
|
||||
if (err.message.includes(targetError)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Main client for general operations
|
||||
this.client = new Redis(config);
|
||||
|
||||
// Separate clients for pub/sub
|
||||
this.subscriber = new Redis(config);
|
||||
this.publisher = new Redis(config);
|
||||
|
||||
// Set up event handlers
|
||||
this.client.on('connect', () => {
|
||||
logger.info('Redis connection established');
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
logger.error('Redis error:', err);
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
logger.warn('Redis connection closed');
|
||||
});
|
||||
|
||||
// Wait for connection
|
||||
await this.client.ping();
|
||||
|
||||
return this.client;
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect to Redis:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async checkHealth() {
|
||||
try {
|
||||
const result = await this.client.ping();
|
||||
return result === 'PONG';
|
||||
} catch (error) {
|
||||
logger.error('Redis health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.client) {
|
||||
await this.client.quit();
|
||||
}
|
||||
if (this.subscriber) {
|
||||
await this.subscriber.quit();
|
||||
}
|
||||
if (this.publisher) {
|
||||
await this.publisher.quit();
|
||||
}
|
||||
logger.info('Redis connections closed');
|
||||
}
|
||||
|
||||
getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
getSubscriber() {
|
||||
return this.subscriber;
|
||||
}
|
||||
|
||||
getPublisher() {
|
||||
return this.publisher;
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
async setWithExpiry(key, value, ttl) {
|
||||
return await this.client.setex(key, ttl, JSON.stringify(value));
|
||||
}
|
||||
|
||||
async get(key) {
|
||||
const value = await this.client.get(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
}
|
||||
|
||||
async del(key) {
|
||||
return await this.client.del(key);
|
||||
}
|
||||
|
||||
async exists(key) {
|
||||
return await this.client.exists(key);
|
||||
}
|
||||
|
||||
async incr(key) {
|
||||
return await this.client.incr(key);
|
||||
}
|
||||
|
||||
async decr(key) {
|
||||
return await this.client.decr(key);
|
||||
}
|
||||
|
||||
async expire(key, seconds) {
|
||||
return await this.client.expire(key, seconds);
|
||||
}
|
||||
|
||||
// List operations
|
||||
async lpush(key, ...values) {
|
||||
return await this.client.lpush(key, ...values.map(v => JSON.stringify(v)));
|
||||
}
|
||||
|
||||
async rpush(key, ...values) {
|
||||
return await this.client.rpush(key, ...values.map(v => JSON.stringify(v)));
|
||||
}
|
||||
|
||||
async lpop(key) {
|
||||
const value = await this.client.lpop(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
}
|
||||
|
||||
async rpop(key) {
|
||||
const value = await this.client.rpop(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
}
|
||||
|
||||
async lrange(key, start, stop) {
|
||||
const values = await this.client.lrange(key, start, stop);
|
||||
return values.map(v => JSON.parse(v));
|
||||
}
|
||||
|
||||
// Hash operations
|
||||
async hset(key, field, value) {
|
||||
return await this.client.hset(key, field, JSON.stringify(value));
|
||||
}
|
||||
|
||||
async hget(key, field) {
|
||||
const value = await this.client.hget(key, field);
|
||||
return value ? JSON.parse(value) : null;
|
||||
}
|
||||
|
||||
async hgetall(key) {
|
||||
const data = await this.client.hgetall(key);
|
||||
const result = {};
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
result[field] = JSON.parse(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Set operations
|
||||
async sadd(key, ...members) {
|
||||
return await this.client.sadd(key, ...members);
|
||||
}
|
||||
|
||||
async srem(key, ...members) {
|
||||
return await this.client.srem(key, ...members);
|
||||
}
|
||||
|
||||
async smembers(key) {
|
||||
return await this.client.smembers(key);
|
||||
}
|
||||
|
||||
async sismember(key, member) {
|
||||
return await this.client.sismember(key, member);
|
||||
}
|
||||
}
|
||||
140
marketing-agent/services/orchestrator/src/index.js
Normal file
140
marketing-agent/services/orchestrator/src/index.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import Hapi from '@hapi/hapi';
|
||||
import dotenv from 'dotenv';
|
||||
import { logger } from './utils/logger.js';
|
||||
import { Database } from './config/database.js';
|
||||
import { RedisClient } from './config/redis.js';
|
||||
import { TaskQueueManager } from './services/TaskQueueManager.js';
|
||||
import { StateMachineEngine } from './services/StateMachineEngine.js';
|
||||
import { MetricsService } from './services/MetricsService.js';
|
||||
import routes from './routes/index.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const init = async () => {
|
||||
// Initialize database
|
||||
await Database.getInstance().connect();
|
||||
|
||||
// Initialize Redis
|
||||
await RedisClient.getInstance().connect();
|
||||
|
||||
// Initialize services
|
||||
const taskQueueManager = TaskQueueManager.getInstance();
|
||||
const stateMachineEngine = StateMachineEngine.getInstance();
|
||||
const metricsService = MetricsService.getInstance();
|
||||
|
||||
// Create Hapi server
|
||||
const server = Hapi.server({
|
||||
port: process.env.PORT || 3001,
|
||||
host: '0.0.0.0',
|
||||
routes: {
|
||||
cors: {
|
||||
origin: ['*'],
|
||||
headers: ['Accept', 'Content-Type', 'Authorization'],
|
||||
credentials: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Register plugins
|
||||
await server.register([
|
||||
{
|
||||
plugin: await import('@hapi/jwt'),
|
||||
options: {}
|
||||
}
|
||||
]);
|
||||
|
||||
// Configure JWT authentication
|
||||
server.auth.strategy('jwt', 'jwt', {
|
||||
keys: process.env.JWT_SECRET,
|
||||
verify: {
|
||||
aud: 'urn:audience:orchestrator',
|
||||
iss: 'urn:issuer:marketing-agent',
|
||||
sub: false,
|
||||
nbf: true,
|
||||
exp: true,
|
||||
maxAgeSec: 14400, // 4 hours
|
||||
timeSkewSec: 15
|
||||
},
|
||||
validate: (artifacts, request, h) => {
|
||||
return {
|
||||
isValid: true,
|
||||
credentials: { user: artifacts.decoded.payload.user }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.auth.default('jwt');
|
||||
|
||||
// Register routes
|
||||
server.route(routes);
|
||||
|
||||
// Health check endpoint
|
||||
server.route({
|
||||
method: 'GET',
|
||||
path: '/health',
|
||||
options: {
|
||||
auth: false,
|
||||
handler: async (request, h) => {
|
||||
const dbHealth = await Database.getInstance().checkHealth();
|
||||
const redisHealth = await RedisClient.getInstance().checkHealth();
|
||||
const queueHealth = await taskQueueManager.checkHealth();
|
||||
|
||||
const isHealthy = dbHealth && redisHealth && queueHealth;
|
||||
|
||||
return h.response({
|
||||
status: isHealthy ? 'healthy' : 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
database: dbHealth ? 'up' : 'down',
|
||||
redis: redisHealth ? 'up' : 'down',
|
||||
queue: queueHealth ? 'up' : 'down'
|
||||
}
|
||||
}).code(isHealthy ? 200 : 503);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Metrics endpoint
|
||||
server.route({
|
||||
method: 'GET',
|
||||
path: '/metrics',
|
||||
options: {
|
||||
auth: false,
|
||||
handler: async (request, h) => {
|
||||
const metrics = await metricsService.getMetrics();
|
||||
return h.response(metrics).type('text/plain');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
await server.start();
|
||||
logger.info(`Orchestrator service started on ${server.info.uri}`);
|
||||
|
||||
// Start background workers
|
||||
await taskQueueManager.startWorkers();
|
||||
logger.info('Task queue workers started');
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('Shutting down gracefully...');
|
||||
await taskQueueManager.stopWorkers();
|
||||
await server.stop();
|
||||
await Database.getInstance().disconnect();
|
||||
await RedisClient.getInstance().disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
// Handle uncaught errors
|
||||
process.on('unhandledRejection', (err) => {
|
||||
logger.error('Unhandled rejection:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Start the service
|
||||
init().catch((err) => {
|
||||
logger.error('Failed to start service:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
173
marketing-agent/services/orchestrator/src/models/Campaign.js
Normal file
173
marketing-agent/services/orchestrator/src/models/Campaign.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import { Database } from '../config/database.js';
|
||||
|
||||
const sequelize = Database.getInstance().getSequelize();
|
||||
|
||||
export const Campaign = sequelize.define('Campaign', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tenants',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
validate: {
|
||||
notEmpty: true
|
||||
}
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.ENUM('message', 'invitation', 'data_collection', 'engagement', 'custom'),
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('draft', 'scheduled', 'active', 'paused', 'completed', 'cancelled'),
|
||||
defaultValue: 'draft',
|
||||
allowNull: false
|
||||
},
|
||||
goals: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {},
|
||||
comment: 'Campaign goals and KPIs'
|
||||
},
|
||||
targetAudience: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {},
|
||||
comment: 'Target audience criteria'
|
||||
},
|
||||
strategy: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {},
|
||||
comment: 'AI-generated campaign strategy'
|
||||
},
|
||||
budget: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true
|
||||
},
|
||||
startDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
endDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
createdBy: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {}
|
||||
},
|
||||
statistics: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
failedTasks: 0,
|
||||
messagesSent: 0,
|
||||
conversionsAchieved: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
}
|
||||
}, {
|
||||
tableName: 'campaigns',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['tenantId']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'name']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'type']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'status']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'startDate']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'endDate']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'createdBy']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Instance methods
|
||||
Campaign.prototype.isActive = function() {
|
||||
const now = new Date();
|
||||
return this.status === 'active' &&
|
||||
(!this.startDate || this.startDate <= now) &&
|
||||
(!this.endDate || this.endDate >= now);
|
||||
};
|
||||
|
||||
Campaign.prototype.updateStatistics = async function(stats) {
|
||||
const currentStats = this.statistics || {};
|
||||
const updatedStats = {
|
||||
...currentStats,
|
||||
...stats,
|
||||
lastUpdated: new Date()
|
||||
};
|
||||
|
||||
this.statistics = updatedStats;
|
||||
await this.save();
|
||||
|
||||
return updatedStats;
|
||||
};
|
||||
|
||||
// Class methods
|
||||
Campaign.getActiveCampaigns = async function() {
|
||||
const now = new Date();
|
||||
return await Campaign.findAll({
|
||||
where: {
|
||||
status: 'active',
|
||||
[Op.or]: [
|
||||
{ startDate: null },
|
||||
{ startDate: { [Op.lte]: now } }
|
||||
],
|
||||
[Op.and]: [
|
||||
{ endDate: null },
|
||||
{ endDate: { [Op.gte]: now } }
|
||||
]
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Campaign.getCampaignsByStatus = async function(status, options = {}) {
|
||||
return await Campaign.findAndCountAll({
|
||||
where: { status },
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
// Define associations
|
||||
Campaign.associate = (models) => {
|
||||
Campaign.hasMany(models.Task, {
|
||||
foreignKey: 'campaignId',
|
||||
as: 'tasks'
|
||||
});
|
||||
|
||||
Campaign.hasMany(models.Workflow, {
|
||||
foreignKey: 'campaignId',
|
||||
as: 'workflows'
|
||||
});
|
||||
};
|
||||
221
marketing-agent/services/orchestrator/src/models/Task.js
Normal file
221
marketing-agent/services/orchestrator/src/models/Task.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import { Database } from '../config/database.js';
|
||||
|
||||
const sequelize = Database.getInstance().getSequelize();
|
||||
|
||||
export const Task = sequelize.define('Task', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tenants',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: 'Task type (e.g., send_message, create_campaign, analyze_data)'
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.ENUM('high', 'normal', 'low'),
|
||||
defaultValue: 'normal',
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('pending', 'processing', 'completed', 'failed', 'cancelled'),
|
||||
defaultValue: 'pending',
|
||||
allowNull: false
|
||||
},
|
||||
data: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'JSON stringified task data'
|
||||
},
|
||||
result: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'JSON stringified task result'
|
||||
},
|
||||
error: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Error message if task failed'
|
||||
},
|
||||
attempts: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
maxAttempts: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 3
|
||||
},
|
||||
campaignId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'Campaigns',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
workflowId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'Workflows',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
parentTaskId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'Tasks',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
createdBy: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
scheduledAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
startedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
completedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
failedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {},
|
||||
comment: 'Additional metadata for the task'
|
||||
}
|
||||
}, {
|
||||
tableName: 'tasks',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['tenantId']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'type']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'status']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'priority']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'campaignId']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'workflowId']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'createdAt']
|
||||
},
|
||||
{
|
||||
fields: ['tenantId', 'scheduledAt']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Instance methods
|
||||
Task.prototype.toJSON = function() {
|
||||
const values = Object.assign({}, this.get());
|
||||
|
||||
// Parse JSON fields
|
||||
if (values.data) {
|
||||
try {
|
||||
values.data = JSON.parse(values.data);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (values.result) {
|
||||
try {
|
||||
values.result = JSON.parse(values.result);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
// Class methods
|
||||
Task.getPendingTasks = async function(limit = 100) {
|
||||
return await Task.findAll({
|
||||
where: {
|
||||
status: 'pending',
|
||||
scheduledAt: {
|
||||
[Op.or]: [
|
||||
null,
|
||||
{ [Op.lte]: new Date() }
|
||||
]
|
||||
}
|
||||
},
|
||||
order: [
|
||||
['priority', 'DESC'],
|
||||
['createdAt', 'ASC']
|
||||
],
|
||||
limit
|
||||
});
|
||||
};
|
||||
|
||||
Task.getTasksByStatus = async function(status, options = {}) {
|
||||
return await Task.findAndCountAll({
|
||||
where: { status },
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
Task.getTasksByCampaign = async function(campaignId, options = {}) {
|
||||
return await Task.findAndCountAll({
|
||||
where: { campaignId },
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
Task.getTasksByWorkflow = async function(workflowId, options = {}) {
|
||||
return await Task.findAndCountAll({
|
||||
where: { workflowId },
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
// Define associations
|
||||
Task.associate = (models) => {
|
||||
Task.belongsTo(models.Campaign, {
|
||||
foreignKey: 'campaignId',
|
||||
as: 'campaign'
|
||||
});
|
||||
|
||||
Task.belongsTo(models.Workflow, {
|
||||
foreignKey: 'workflowId',
|
||||
as: 'workflow'
|
||||
});
|
||||
|
||||
Task.belongsTo(Task, {
|
||||
foreignKey: 'parentTaskId',
|
||||
as: 'parentTask'
|
||||
});
|
||||
|
||||
Task.hasMany(Task, {
|
||||
foreignKey: 'parentTaskId',
|
||||
as: 'subTasks'
|
||||
});
|
||||
};
|
||||
330
marketing-agent/services/orchestrator/src/routes/campaigns.js
Normal file
330
marketing-agent/services/orchestrator/src/routes/campaigns.js
Normal file
@@ -0,0 +1,330 @@
|
||||
import { Campaign } from '../models/Campaign.js';
|
||||
import { CampaignExecutor } from '../services/CampaignExecutor.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import Boom from '@hapi/boom';
|
||||
|
||||
const campaignExecutor = CampaignExecutor.getInstance();
|
||||
|
||||
const routes = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/campaigns',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { status, type, page = 1, limit = 10 } = request.query;
|
||||
const where = {};
|
||||
|
||||
if (status) where.status = status;
|
||||
if (type) where.type = type;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { count, rows: campaigns } = await Campaign.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
campaigns,
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
totalPages: Math.ceil(count / limit)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch campaigns:', error);
|
||||
throw Boom.internal('Failed to fetch campaigns');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/campaigns/{id}',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const campaign = await Campaign.findByPk(request.params.id);
|
||||
|
||||
if (!campaign) {
|
||||
throw Boom.notFound('Campaign not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: campaign
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch campaign:', error);
|
||||
if (error.isBoom) throw error;
|
||||
throw Boom.internal('Failed to fetch campaign');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/campaigns',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
goals,
|
||||
targetAudience,
|
||||
message,
|
||||
startDate,
|
||||
endDate,
|
||||
budget
|
||||
} = request.payload;
|
||||
|
||||
const campaign = await Campaign.create({
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
goals,
|
||||
targetAudience,
|
||||
metadata: { message },
|
||||
startDate,
|
||||
endDate,
|
||||
budget,
|
||||
createdBy: request.auth.credentials?.user?.id || 'system',
|
||||
status: 'draft'
|
||||
});
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: campaign
|
||||
}).code(201);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create campaign:', error);
|
||||
throw Boom.internal('Failed to create campaign');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/campaigns/{id}',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const campaign = await Campaign.findByPk(request.params.id);
|
||||
|
||||
if (!campaign) {
|
||||
throw Boom.notFound('Campaign not found');
|
||||
}
|
||||
|
||||
// Don't allow updating active campaigns
|
||||
if (campaign.status === 'active') {
|
||||
throw Boom.badRequest('Cannot update active campaign');
|
||||
}
|
||||
|
||||
const allowedUpdates = [
|
||||
'name', 'description', 'goals', 'targetAudience',
|
||||
'startDate', 'endDate', 'budget', 'metadata'
|
||||
];
|
||||
|
||||
allowedUpdates.forEach(field => {
|
||||
if (request.payload[field] !== undefined) {
|
||||
campaign[field] = request.payload[field];
|
||||
}
|
||||
});
|
||||
|
||||
await campaign.save();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: campaign
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to update campaign:', error);
|
||||
if (error.isBoom) throw error;
|
||||
throw Boom.internal('Failed to update campaign');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/campaigns/{id}',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const campaign = await Campaign.findByPk(request.params.id);
|
||||
|
||||
if (!campaign) {
|
||||
throw Boom.notFound('Campaign not found');
|
||||
}
|
||||
|
||||
// Don't allow deleting active campaigns
|
||||
if (campaign.status === 'active') {
|
||||
throw Boom.badRequest('Cannot delete active campaign');
|
||||
}
|
||||
|
||||
await campaign.destroy();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Campaign deleted successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete campaign:', error);
|
||||
if (error.isBoom) throw error;
|
||||
throw Boom.internal('Failed to delete campaign');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/campaigns/{id}/execute',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const result = await campaignExecutor.executeCampaign(request.params.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to execute campaign:', error);
|
||||
throw Boom.internal(error.message || 'Failed to execute campaign');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/campaigns/{id}/pause',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const result = await campaignExecutor.pauseCampaign(request.params.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to pause campaign:', error);
|
||||
throw Boom.internal(error.message || 'Failed to pause campaign');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/campaigns/{id}/resume',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const result = await campaignExecutor.resumeCampaign(request.params.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to resume campaign:', error);
|
||||
throw Boom.internal(error.message || 'Failed to resume campaign');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/campaigns/{id}/progress',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const progress = await campaignExecutor.getCampaignProgress(request.params.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: progress
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get campaign progress:', error);
|
||||
throw Boom.internal(error.message || 'Failed to get campaign progress');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/campaigns/{id}/statistics',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const campaign = await Campaign.findByPk(request.params.id);
|
||||
|
||||
if (!campaign) {
|
||||
throw Boom.notFound('Campaign not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: campaign.statistics || {}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get campaign statistics:', error);
|
||||
if (error.isBoom) throw error;
|
||||
throw Boom.internal('Failed to get campaign statistics');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/campaigns/{id}/clone',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const sourceCampaign = await Campaign.findByPk(request.params.id);
|
||||
|
||||
if (!sourceCampaign) {
|
||||
throw Boom.notFound('Campaign not found');
|
||||
}
|
||||
|
||||
const clonedData = sourceCampaign.toJSON();
|
||||
delete clonedData.id;
|
||||
delete clonedData.createdAt;
|
||||
delete clonedData.updatedAt;
|
||||
|
||||
clonedData.name = `${clonedData.name} (Copy)`;
|
||||
clonedData.status = 'draft';
|
||||
clonedData.statistics = {
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
failedTasks: 0,
|
||||
messagesSent: 0,
|
||||
conversionsAchieved: 0,
|
||||
totalCost: 0
|
||||
};
|
||||
|
||||
const clonedCampaign = await Campaign.create(clonedData);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: clonedCampaign
|
||||
}).code(201);
|
||||
} catch (error) {
|
||||
logger.error('Failed to clone campaign:', error);
|
||||
if (error.isBoom) throw error;
|
||||
throw Boom.internal('Failed to clone campaign');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default routes;
|
||||
11
marketing-agent/services/orchestrator/src/routes/index.js
Normal file
11
marketing-agent/services/orchestrator/src/routes/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import campaignRoutes from './campaigns.js';
|
||||
import messageRoutes from './messages.js';
|
||||
import workflowRoutes from './workflows.js';
|
||||
|
||||
const routes = [
|
||||
...campaignRoutes,
|
||||
...messageRoutes,
|
||||
...workflowRoutes
|
||||
];
|
||||
|
||||
export default routes;
|
||||
481
marketing-agent/services/orchestrator/src/routes/messages.js
Normal file
481
marketing-agent/services/orchestrator/src/routes/messages.js
Normal file
@@ -0,0 +1,481 @@
|
||||
import { logger } from '../utils/logger.js';
|
||||
import axios from 'axios';
|
||||
import Boom from '@hapi/boom';
|
||||
import { TemplateClient } from '../../../../shared/utils/templateClient.js';
|
||||
|
||||
// Initialize template client
|
||||
const templateClient = new TemplateClient({
|
||||
baseURL: process.env.TEMPLATE_SERVICE_URL || 'http://localhost:3010'
|
||||
});
|
||||
|
||||
// In-memory storage for templates (should be moved to database)
|
||||
const messageTemplates = new Map();
|
||||
|
||||
// Predefined templates
|
||||
const defaultTemplates = [
|
||||
{
|
||||
id: 'welcome-1',
|
||||
name: 'Welcome Message',
|
||||
content: 'Welcome to {{company}}! 👋\n\nWe\'re excited to have you here. {{personalized_message}}',
|
||||
category: 'onboarding',
|
||||
variables: ['company', 'personalized_message'],
|
||||
usage: 0
|
||||
},
|
||||
{
|
||||
id: 'promo-1',
|
||||
name: 'Special Offer',
|
||||
content: '🎉 Special Offer Alert!\n\n{{offer_title}}\n\n✨ {{discount}}% OFF\n📅 Valid until: {{end_date}}\n\n👉 {{cta_text}}',
|
||||
category: 'promotional',
|
||||
variables: ['offer_title', 'discount', 'end_date', 'cta_text'],
|
||||
usage: 0
|
||||
},
|
||||
{
|
||||
id: 'reminder-1',
|
||||
name: 'Event Reminder',
|
||||
content: '⏰ Reminder: {{event_name}}\n\n📅 Date: {{event_date}}\n📍 Location: {{location}}\n\nDon\'t forget to {{action}}!',
|
||||
category: 'reminder',
|
||||
variables: ['event_name', 'event_date', 'location', 'action'],
|
||||
usage: 0
|
||||
},
|
||||
{
|
||||
id: 'survey-1',
|
||||
name: 'Feedback Request',
|
||||
content: 'Hi {{name}}! 👋\n\nWe\'d love to hear your thoughts about {{product_service}}.\n\nCould you spare 2 minutes for a quick survey?\n\n🔗 {{survey_link}}\n\nThank you! 🙏',
|
||||
category: 'engagement',
|
||||
variables: ['name', 'product_service', 'survey_link'],
|
||||
usage: 0
|
||||
}
|
||||
];
|
||||
|
||||
// Initialize default templates
|
||||
defaultTemplates.forEach(template => {
|
||||
messageTemplates.set(template.id, template);
|
||||
});
|
||||
|
||||
const routes = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/messages/templates',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { category } = request.query;
|
||||
let templates = Array.from(messageTemplates.values());
|
||||
|
||||
if (category) {
|
||||
templates = templates.filter(t => t.category === category);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: templates
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch templates:', error);
|
||||
throw Boom.internal('Failed to fetch templates');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/messages/templates/{id}',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const template = messageTemplates.get(request.params.id);
|
||||
|
||||
if (!template) {
|
||||
throw Boom.notFound('Template not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: template
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch template:', error);
|
||||
if (error.isBoom) throw error;
|
||||
throw Boom.internal('Failed to fetch template');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/messages/templates',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { name, content, category, variables = [] } = request.payload;
|
||||
|
||||
if (!name || !content || !category) {
|
||||
throw Boom.badRequest('Missing required fields: name, content, category');
|
||||
}
|
||||
|
||||
const template = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name,
|
||||
content,
|
||||
category,
|
||||
variables,
|
||||
usage: 0,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
messageTemplates.set(template.id, template);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: template
|
||||
}).code(201);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create template:', error);
|
||||
if (error.isBoom) throw error;
|
||||
throw Boom.internal('Failed to create template');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/messages/templates/{id}',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const template = messageTemplates.get(request.params.id);
|
||||
|
||||
if (!template) {
|
||||
throw Boom.notFound('Template not found');
|
||||
}
|
||||
|
||||
const { name, content, category, variables } = request.payload;
|
||||
|
||||
if (name) template.name = name;
|
||||
if (content) template.content = content;
|
||||
if (category) template.category = category;
|
||||
if (variables) template.variables = variables;
|
||||
|
||||
template.updatedAt = new Date().toISOString();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: template
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to update template:', error);
|
||||
if (error.isBoom) throw error;
|
||||
throw Boom.internal('Failed to update template');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/messages/templates/{id}',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
if (!messageTemplates.has(request.params.id)) {
|
||||
throw Boom.notFound('Template not found');
|
||||
}
|
||||
|
||||
messageTemplates.delete(request.params.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Template deleted successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete template:', error);
|
||||
if (error.isBoom) throw error;
|
||||
throw Boom.internal('Failed to delete template');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/messages/templates/{id}/preview',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const template = messageTemplates.get(request.params.id);
|
||||
|
||||
if (!template) {
|
||||
throw Boom.notFound('Template not found');
|
||||
}
|
||||
|
||||
const { variables } = request.payload;
|
||||
let content = template.content;
|
||||
|
||||
// Replace variables
|
||||
if (variables) {
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
|
||||
content = content.replace(regex, value);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
original: template.content,
|
||||
preview: content,
|
||||
variables: template.variables
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to preview template:', error);
|
||||
if (error.isBoom) throw error;
|
||||
throw Boom.internal('Failed to preview template');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/messages/history',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
// TODO: Implement actual message history from database
|
||||
const mockHistory = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
campaignId: 'c1',
|
||||
campaignName: 'Summer Sale 2025',
|
||||
recipient: '+1234567890',
|
||||
content: 'Special offer: 20% off all items!',
|
||||
status: 'delivered',
|
||||
sentAt: new Date(Date.now() - 3600000).toISOString(),
|
||||
deliveredAt: new Date(Date.now() - 3590000).toISOString()
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
campaignId: 'c2',
|
||||
campaignName: 'Welcome Series',
|
||||
recipient: '+0987654321',
|
||||
content: 'Welcome to our service!',
|
||||
status: 'delivered',
|
||||
sentAt: new Date(Date.now() - 7200000).toISOString(),
|
||||
deliveredAt: new Date(Date.now() - 7190000).toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: mockHistory
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch message history:', error);
|
||||
throw Boom.internal('Failed to fetch message history');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/messages/send',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { accountId, recipient, content, parseMode = 'md' } = request.payload;
|
||||
|
||||
if (!accountId || !recipient || !content) {
|
||||
throw Boom.badRequest('Missing required fields: accountId, recipient, content');
|
||||
}
|
||||
|
||||
// Send via gramjs-adapter
|
||||
const gramjsUrl = process.env.GRAMJS_ADAPTER_URL || 'http://gramjs-adapter:3003';
|
||||
const response = await axios.post(`${gramjsUrl}/messages/send`, {
|
||||
accountId,
|
||||
chatId: recipient,
|
||||
message: content,
|
||||
parseMode
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error('Failed to send message:', error);
|
||||
if (error.response?.status === 400) {
|
||||
throw Boom.badRequest(error.response.data.error || 'Invalid request');
|
||||
}
|
||||
throw Boom.internal(error.response?.data?.error || 'Failed to send message');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/messages/send-with-template',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const {
|
||||
accountId,
|
||||
recipient,
|
||||
templateId,
|
||||
templateName,
|
||||
language = 'en',
|
||||
data = {},
|
||||
parseMode = 'md'
|
||||
} = request.payload;
|
||||
|
||||
if (!accountId || !recipient || (!templateId && !templateName)) {
|
||||
throw Boom.badRequest('Missing required fields: accountId, recipient, and either templateId or templateName');
|
||||
}
|
||||
|
||||
// Render template
|
||||
let renderResult;
|
||||
if (templateId) {
|
||||
renderResult = await templateClient.renderTemplate(templateId, data);
|
||||
} else {
|
||||
renderResult = await templateClient.renderTemplateByName(templateName, language, data);
|
||||
}
|
||||
|
||||
if (!renderResult.success) {
|
||||
throw Boom.badRequest(renderResult.error || 'Failed to render template');
|
||||
}
|
||||
|
||||
// Send via gramjs-adapter
|
||||
const gramjsUrl = process.env.GRAMJS_ADAPTER_URL || 'http://gramjs-adapter:3003';
|
||||
const response = await axios.post(`${gramjsUrl}/messages/send`, {
|
||||
accountId,
|
||||
chatId: recipient,
|
||||
message: renderResult.content,
|
||||
parseMode: renderResult.format === 'html' ? 'html' : parseMode
|
||||
});
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
template: {
|
||||
id: templateId,
|
||||
name: templateName,
|
||||
format: renderResult.format
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to send message with template:', error);
|
||||
if (error.isBoom) throw error;
|
||||
if (error.response?.status === 400) {
|
||||
throw Boom.badRequest(error.response.data.error || 'Invalid request');
|
||||
}
|
||||
throw Boom.internal(error.response?.data?.error || 'Failed to send message with template');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/messages/bulk',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { accountId, recipients, content, parseMode = 'md' } = request.payload;
|
||||
|
||||
if (!accountId || !recipients || !content) {
|
||||
throw Boom.badRequest('Missing required fields: accountId, recipients, content');
|
||||
}
|
||||
|
||||
// Send via gramjs-adapter
|
||||
const gramjsUrl = process.env.GRAMJS_ADAPTER_URL || 'http://gramjs-adapter:3003';
|
||||
const response = await axios.post(`${gramjsUrl}/messages/bulk`, {
|
||||
accountId,
|
||||
recipients,
|
||||
message: content,
|
||||
parseMode
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error('Failed to send bulk messages:', error);
|
||||
throw Boom.internal(error.response?.data?.error || 'Failed to send bulk messages');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/messages/bulk-with-template',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const {
|
||||
accountId,
|
||||
recipients,
|
||||
templateId,
|
||||
templateName,
|
||||
language = 'en',
|
||||
data = {},
|
||||
personalizedData = {},
|
||||
parseMode = 'md'
|
||||
} = request.payload;
|
||||
|
||||
if (!accountId || !recipients || (!templateId && !templateName)) {
|
||||
throw Boom.badRequest('Missing required fields: accountId, recipients, and either templateId or templateName');
|
||||
}
|
||||
|
||||
// Prepare batch render requests
|
||||
const renderRequests = recipients.map(recipient => {
|
||||
const recipientData = {
|
||||
...data,
|
||||
...(personalizedData[recipient] || {})
|
||||
};
|
||||
|
||||
return {
|
||||
templateId,
|
||||
templateName,
|
||||
language,
|
||||
data: recipientData,
|
||||
recipient
|
||||
};
|
||||
});
|
||||
|
||||
// Batch render templates
|
||||
const batchResult = await templateClient.batchRenderTemplates(renderRequests);
|
||||
|
||||
if (!batchResult.success) {
|
||||
throw Boom.badRequest(batchResult.error || 'Failed to render templates');
|
||||
}
|
||||
|
||||
// Prepare messages for bulk send
|
||||
const messages = batchResult.results.map(result => ({
|
||||
recipient: result.recipient,
|
||||
content: result.content,
|
||||
format: result.format
|
||||
}));
|
||||
|
||||
// Send via gramjs-adapter
|
||||
const gramjsUrl = process.env.GRAMJS_ADAPTER_URL || 'http://gramjs-adapter:3003';
|
||||
const response = await axios.post(`${gramjsUrl}/messages/bulk-custom`, {
|
||||
accountId,
|
||||
messages: messages.map(msg => ({
|
||||
chatId: msg.recipient,
|
||||
message: msg.content,
|
||||
parseMode: msg.format === 'html' ? 'html' : parseMode
|
||||
}))
|
||||
});
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
template: {
|
||||
id: templateId,
|
||||
name: templateName,
|
||||
recipientCount: recipients.length
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to send bulk messages with template:', error);
|
||||
if (error.isBoom) throw error;
|
||||
if (error.response?.status === 400) {
|
||||
throw Boom.badRequest(error.response.data.error || 'Invalid request');
|
||||
}
|
||||
throw Boom.internal(error.response?.data?.error || 'Failed to send bulk messages with template');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { logger } from '../utils/logger.js';
|
||||
import Boom from '@hapi/boom';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/workflows',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
// TODO: Implement workflow management
|
||||
return {
|
||||
success: true,
|
||||
data: []
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch workflows:', error);
|
||||
throw Boom.internal('Failed to fetch workflows');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,375 @@
|
||||
import { Campaign } from '../models/Campaign.js';
|
||||
import { Task } from '../models/Task.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import axios from 'axios';
|
||||
import { Queue } from 'bullmq';
|
||||
import { RedisClient } from '../config/redis.js';
|
||||
|
||||
export class CampaignExecutor {
|
||||
constructor() {
|
||||
const redisClient = RedisClient.getInstance();
|
||||
|
||||
this.messageQueue = new Queue('message-queue', {
|
||||
connection: {
|
||||
host: process.env.REDIS_HOST || 'redis',
|
||||
port: parseInt(process.env.REDIS_PORT) || 6379
|
||||
}
|
||||
});
|
||||
|
||||
this.gramjsApiUrl = process.env.GRAMJS_ADAPTER_URL || 'http://gramjs-adapter:3003';
|
||||
this.setupQueueProcessors();
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!CampaignExecutor.instance) {
|
||||
CampaignExecutor.instance = new CampaignExecutor();
|
||||
}
|
||||
return CampaignExecutor.instance;
|
||||
}
|
||||
|
||||
setupQueueProcessors() {
|
||||
// Process messages from the queue
|
||||
this.messageQueue.process('send-message', 5, async (job) => {
|
||||
const { accountId, recipient, message, campaignId, taskId } = job.data;
|
||||
|
||||
try {
|
||||
logger.info(`Processing message job ${job.id} for campaign ${campaignId}`);
|
||||
|
||||
// Send message via gramjs-adapter
|
||||
const response = await axios.post(`${this.gramjsApiUrl}/messages/send`, {
|
||||
accountId,
|
||||
chatId: recipient,
|
||||
message: message.content,
|
||||
parseMode: message.parseMode || 'md',
|
||||
buttons: message.buttons
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
await this.updateTaskStatus(taskId, 'completed', {
|
||||
messageId: response.data.data.messageId,
|
||||
sentAt: new Date()
|
||||
});
|
||||
|
||||
await this.incrementCampaignStats(campaignId, {
|
||||
messagesSent: 1
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error(response.data.error || 'Failed to send message');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to send message for job ${job.id}:`, error);
|
||||
|
||||
// Handle rate limiting
|
||||
if (error.response?.status === 429 || error.message?.includes('FLOOD_WAIT')) {
|
||||
const retryAfter = error.response?.headers['retry-after'] || 60;
|
||||
throw new Error(`Rate limited. Retry after ${retryAfter} seconds`);
|
||||
}
|
||||
|
||||
await this.updateTaskStatus(taskId, 'failed', {
|
||||
error: error.message,
|
||||
failedAt: new Date()
|
||||
});
|
||||
|
||||
await this.incrementCampaignStats(campaignId, {
|
||||
failedTasks: 1
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle bulk message campaigns
|
||||
this.messageQueue.process('bulk-send', 2, async (job) => {
|
||||
const { campaignId, accountId, recipients, message } = job.data;
|
||||
|
||||
try {
|
||||
logger.info(`Processing bulk send for campaign ${campaignId} with ${recipients.length} recipients`);
|
||||
|
||||
// Create individual message jobs
|
||||
const jobs = recipients.map(recipient => ({
|
||||
name: 'send-message',
|
||||
data: {
|
||||
accountId,
|
||||
recipient,
|
||||
message,
|
||||
campaignId,
|
||||
taskId: `${campaignId}-${recipient}`
|
||||
},
|
||||
opts: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false
|
||||
}
|
||||
}));
|
||||
|
||||
await this.messageQueue.addBulk(jobs);
|
||||
|
||||
return { queued: jobs.length };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to process bulk send:`, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async executeCampaign(campaignId) {
|
||||
try {
|
||||
const campaign = await Campaign.findByPk(campaignId);
|
||||
if (!campaign) {
|
||||
throw new Error('Campaign not found');
|
||||
}
|
||||
|
||||
if (campaign.status !== 'scheduled' && campaign.status !== 'active') {
|
||||
throw new Error(`Campaign is not ready for execution. Current status: ${campaign.status}`);
|
||||
}
|
||||
|
||||
// Update campaign status to active
|
||||
campaign.status = 'active';
|
||||
await campaign.save();
|
||||
|
||||
logger.info(`Starting execution of campaign ${campaignId}: ${campaign.name}`);
|
||||
|
||||
// Get campaign tasks or create based on target audience
|
||||
const tasks = await this.generateCampaignTasks(campaign);
|
||||
|
||||
// Queue tasks for execution
|
||||
for (const task of tasks) {
|
||||
await this.queueTask(task, campaign);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
campaignId,
|
||||
tasksQueued: tasks.length
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to execute campaign ${campaignId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async generateCampaignTasks(campaign) {
|
||||
const tasks = [];
|
||||
|
||||
// Get target recipients based on campaign criteria
|
||||
const recipients = await this.getTargetRecipients(campaign.targetAudience);
|
||||
|
||||
// Get available Telegram accounts
|
||||
const accounts = await this.getAvailableAccounts();
|
||||
|
||||
if (accounts.length === 0) {
|
||||
throw new Error('No available Telegram accounts for sending messages');
|
||||
}
|
||||
|
||||
// Distribute recipients across accounts
|
||||
const recipientsPerAccount = Math.ceil(recipients.length / accounts.length);
|
||||
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const accountRecipients = recipients.slice(
|
||||
i * recipientsPerAccount,
|
||||
(i + 1) * recipientsPerAccount
|
||||
);
|
||||
|
||||
if (accountRecipients.length > 0) {
|
||||
const task = await Task.create({
|
||||
campaignId: campaign.id,
|
||||
type: 'bulk_message',
|
||||
status: 'pending',
|
||||
data: {
|
||||
accountId: accounts[i].id,
|
||||
recipients: accountRecipients,
|
||||
message: campaign.metadata.message
|
||||
},
|
||||
scheduledAt: new Date()
|
||||
});
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async queueTask(task, campaign) {
|
||||
const jobData = {
|
||||
campaignId: campaign.id,
|
||||
...task.data
|
||||
};
|
||||
|
||||
const job = await this.messageQueue.add(
|
||||
task.type === 'bulk_message' ? 'bulk-send' : 'send-message',
|
||||
jobData,
|
||||
{
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false
|
||||
}
|
||||
);
|
||||
|
||||
// Update task with job ID
|
||||
task.metadata = { ...task.metadata, jobId: job.id };
|
||||
await task.save();
|
||||
|
||||
logger.info(`Queued task ${task.id} with job ${job.id}`);
|
||||
}
|
||||
|
||||
async getTargetRecipients(targetAudience) {
|
||||
// TODO: Implement recipient selection based on target audience criteria
|
||||
// For now, return mock data
|
||||
return [
|
||||
'+1234567890',
|
||||
'+0987654321',
|
||||
// Add more test recipients
|
||||
];
|
||||
}
|
||||
|
||||
async getAvailableAccounts() {
|
||||
try {
|
||||
const response = await axios.get(`${this.gramjsApiUrl}/accounts`);
|
||||
if (response.data.success) {
|
||||
return response.data.data.filter(account =>
|
||||
account.connected && account.status === 'active'
|
||||
);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
logger.error('Failed to get available accounts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async updateTaskStatus(taskId, status, metadata = {}) {
|
||||
try {
|
||||
const task = await Task.findByPk(taskId);
|
||||
if (task) {
|
||||
task.status = status;
|
||||
task.metadata = { ...task.metadata, ...metadata };
|
||||
await task.save();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update task ${taskId} status:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async incrementCampaignStats(campaignId, stats) {
|
||||
try {
|
||||
const campaign = await Campaign.findByPk(campaignId);
|
||||
if (campaign) {
|
||||
const currentStats = campaign.statistics || {};
|
||||
const updatedStats = {};
|
||||
|
||||
for (const [key, value] of Object.entries(stats)) {
|
||||
updatedStats[key] = (currentStats[key] || 0) + value;
|
||||
}
|
||||
|
||||
await campaign.updateStatistics(updatedStats);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update campaign ${campaignId} stats:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async pauseCampaign(campaignId) {
|
||||
try {
|
||||
const campaign = await Campaign.findByPk(campaignId);
|
||||
if (!campaign) {
|
||||
throw new Error('Campaign not found');
|
||||
}
|
||||
|
||||
campaign.status = 'paused';
|
||||
await campaign.save();
|
||||
|
||||
// Pause all pending jobs for this campaign
|
||||
const jobs = await this.messageQueue.getJobs(['waiting', 'delayed']);
|
||||
for (const job of jobs) {
|
||||
if (job.data.campaignId === campaignId) {
|
||||
await job.pause();
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Campaign ${campaignId} paused`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to pause campaign ${campaignId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resumeCampaign(campaignId) {
|
||||
try {
|
||||
const campaign = await Campaign.findByPk(campaignId);
|
||||
if (!campaign) {
|
||||
throw new Error('Campaign not found');
|
||||
}
|
||||
|
||||
campaign.status = 'active';
|
||||
await campaign.save();
|
||||
|
||||
// Resume all paused jobs for this campaign
|
||||
const jobs = await this.messageQueue.getJobs(['paused']);
|
||||
for (const job of jobs) {
|
||||
if (job.data.campaignId === campaignId) {
|
||||
await job.resume();
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Campaign ${campaignId} resumed`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to resume campaign ${campaignId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCampaignProgress(campaignId) {
|
||||
try {
|
||||
const campaign = await Campaign.findByPk(campaignId);
|
||||
if (!campaign) {
|
||||
throw new Error('Campaign not found');
|
||||
}
|
||||
|
||||
const stats = campaign.statistics || {};
|
||||
const totalTasks = stats.totalTasks || 0;
|
||||
const completedTasks = stats.completedTasks || 0;
|
||||
const failedTasks = stats.failedTasks || 0;
|
||||
const progress = totalTasks > 0 ? ((completedTasks + failedTasks) / totalTasks) * 100 : 0;
|
||||
|
||||
// Get queue stats
|
||||
const waiting = await this.messageQueue.getWaitingCount();
|
||||
const active = await this.messageQueue.getActiveCount();
|
||||
const completed = await this.messageQueue.getCompletedCount();
|
||||
const failed = await this.messageQueue.getFailedCount();
|
||||
|
||||
return {
|
||||
campaign: {
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
status: campaign.status,
|
||||
progress: Math.round(progress)
|
||||
},
|
||||
statistics: stats,
|
||||
queue: {
|
||||
waiting,
|
||||
active,
|
||||
completed,
|
||||
failed
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get campaign progress:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export class MetricsService {
|
||||
constructor() {
|
||||
this.metrics = {
|
||||
campaigns: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
completed: 0,
|
||||
failed: 0
|
||||
},
|
||||
messages: {
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
queued: 0
|
||||
},
|
||||
performance: {
|
||||
avgProcessingTime: 0,
|
||||
queueLength: 0,
|
||||
errorRate: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!MetricsService.instance) {
|
||||
MetricsService.instance = new MetricsService();
|
||||
}
|
||||
return MetricsService.instance;
|
||||
}
|
||||
|
||||
async getMetrics() {
|
||||
try {
|
||||
// Format metrics in Prometheus format
|
||||
const lines = [];
|
||||
|
||||
// Campaign metrics
|
||||
lines.push('# HELP campaigns_total Total number of campaigns');
|
||||
lines.push('# TYPE campaigns_total counter');
|
||||
lines.push(`campaigns_total ${this.metrics.campaigns.total}`);
|
||||
|
||||
lines.push('# HELP campaigns_active Number of active campaigns');
|
||||
lines.push('# TYPE campaigns_active gauge');
|
||||
lines.push(`campaigns_active ${this.metrics.campaigns.active}`);
|
||||
|
||||
// Message metrics
|
||||
lines.push('# HELP messages_sent_total Total number of messages sent');
|
||||
lines.push('# TYPE messages_sent_total counter');
|
||||
lines.push(`messages_sent_total ${this.metrics.messages.sent}`);
|
||||
|
||||
lines.push('# HELP messages_delivered_total Total number of messages delivered');
|
||||
lines.push('# TYPE messages_delivered_total counter');
|
||||
lines.push(`messages_delivered_total ${this.metrics.messages.delivered}`);
|
||||
|
||||
lines.push('# HELP messages_failed_total Total number of failed messages');
|
||||
lines.push('# TYPE messages_failed_total counter');
|
||||
lines.push(`messages_failed_total ${this.metrics.messages.failed}`);
|
||||
|
||||
lines.push('# HELP messages_queued Current number of queued messages');
|
||||
lines.push('# TYPE messages_queued gauge');
|
||||
lines.push(`messages_queued ${this.metrics.messages.queued}`);
|
||||
|
||||
// Performance metrics
|
||||
lines.push('# HELP processing_time_avg Average message processing time in ms');
|
||||
lines.push('# TYPE processing_time_avg gauge');
|
||||
lines.push(`processing_time_avg ${this.metrics.performance.avgProcessingTime}`);
|
||||
|
||||
lines.push('# HELP error_rate Current error rate percentage');
|
||||
lines.push('# TYPE error_rate gauge');
|
||||
lines.push(`error_rate ${this.metrics.performance.errorRate}`);
|
||||
|
||||
return lines.join('\n');
|
||||
} catch (error) {
|
||||
logger.error('Failed to get metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
incrementCampaignCount(status) {
|
||||
this.metrics.campaigns.total++;
|
||||
if (status === 'active') {
|
||||
this.metrics.campaigns.active++;
|
||||
} else if (status === 'completed') {
|
||||
this.metrics.campaigns.completed++;
|
||||
} else if (status === 'failed') {
|
||||
this.metrics.campaigns.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
updateCampaignStatus(oldStatus, newStatus) {
|
||||
if (oldStatus === 'active' && newStatus !== 'active') {
|
||||
this.metrics.campaigns.active--;
|
||||
} else if (oldStatus !== 'active' && newStatus === 'active') {
|
||||
this.metrics.campaigns.active++;
|
||||
}
|
||||
|
||||
if (newStatus === 'completed') {
|
||||
this.metrics.campaigns.completed++;
|
||||
} else if (newStatus === 'failed') {
|
||||
this.metrics.campaigns.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
incrementMessageCount(type) {
|
||||
switch (type) {
|
||||
case 'sent':
|
||||
this.metrics.messages.sent++;
|
||||
break;
|
||||
case 'delivered':
|
||||
this.metrics.messages.delivered++;
|
||||
break;
|
||||
case 'failed':
|
||||
this.metrics.messages.failed++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateQueueLength(length) {
|
||||
this.metrics.messages.queued = length;
|
||||
}
|
||||
|
||||
updateProcessingTime(time) {
|
||||
// Simple moving average
|
||||
const currentAvg = this.metrics.performance.avgProcessingTime;
|
||||
const count = this.metrics.messages.sent + this.metrics.messages.failed;
|
||||
this.metrics.performance.avgProcessingTime =
|
||||
(currentAvg * (count - 1) + time) / count;
|
||||
}
|
||||
|
||||
updateErrorRate() {
|
||||
const total = this.metrics.messages.sent + this.metrics.messages.failed;
|
||||
if (total > 0) {
|
||||
this.metrics.performance.errorRate =
|
||||
(this.metrics.messages.failed / total) * 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { createMachine, interpret } from 'xstate';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export class StateMachineEngine extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.machines = new Map();
|
||||
this.services = new Map();
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!StateMachineEngine.instance) {
|
||||
StateMachineEngine.instance = new StateMachineEngine();
|
||||
}
|
||||
return StateMachineEngine.instance;
|
||||
}
|
||||
|
||||
// Define campaign workflow state machine
|
||||
createCampaignMachine() {
|
||||
return createMachine({
|
||||
id: 'campaign',
|
||||
initial: 'draft',
|
||||
context: {
|
||||
campaignId: null,
|
||||
attempts: 0,
|
||||
errors: []
|
||||
},
|
||||
states: {
|
||||
draft: {
|
||||
on: {
|
||||
SUBMIT: {
|
||||
target: 'validating',
|
||||
actions: ['logTransition']
|
||||
},
|
||||
SAVE: {
|
||||
target: 'draft',
|
||||
actions: ['saveDraft']
|
||||
}
|
||||
}
|
||||
},
|
||||
validating: {
|
||||
invoke: {
|
||||
src: 'validateCampaign',
|
||||
onDone: {
|
||||
target: 'planning',
|
||||
actions: ['updateContext']
|
||||
},
|
||||
onError: {
|
||||
target: 'draft',
|
||||
actions: ['handleError']
|
||||
}
|
||||
}
|
||||
},
|
||||
planning: {
|
||||
invoke: {
|
||||
src: 'generateStrategy',
|
||||
onDone: {
|
||||
target: 'reviewing',
|
||||
actions: ['updateStrategy']
|
||||
},
|
||||
onError: {
|
||||
target: 'draft',
|
||||
actions: ['handleError']
|
||||
}
|
||||
}
|
||||
},
|
||||
reviewing: {
|
||||
on: {
|
||||
APPROVE: {
|
||||
target: 'scheduled',
|
||||
actions: ['approveCampaign']
|
||||
},
|
||||
REJECT: {
|
||||
target: 'draft',
|
||||
actions: ['rejectCampaign']
|
||||
},
|
||||
REQUEST_CHANGES: {
|
||||
target: 'draft',
|
||||
actions: ['requestChanges']
|
||||
}
|
||||
}
|
||||
},
|
||||
scheduled: {
|
||||
on: {
|
||||
START: {
|
||||
target: 'active',
|
||||
cond: 'canStart'
|
||||
},
|
||||
CANCEL: {
|
||||
target: 'cancelled'
|
||||
}
|
||||
},
|
||||
after: {
|
||||
CHECK_START_TIME: {
|
||||
target: 'active',
|
||||
cond: 'isStartTime'
|
||||
}
|
||||
}
|
||||
},
|
||||
active: {
|
||||
on: {
|
||||
PAUSE: {
|
||||
target: 'paused'
|
||||
},
|
||||
COMPLETE: {
|
||||
target: 'completed'
|
||||
},
|
||||
FAIL: {
|
||||
target: 'failed'
|
||||
}
|
||||
},
|
||||
invoke: {
|
||||
src: 'executeCampaign',
|
||||
onDone: {
|
||||
target: 'completed'
|
||||
},
|
||||
onError: {
|
||||
target: 'failed',
|
||||
actions: ['handleError']
|
||||
}
|
||||
}
|
||||
},
|
||||
paused: {
|
||||
on: {
|
||||
RESUME: {
|
||||
target: 'active'
|
||||
},
|
||||
CANCEL: {
|
||||
target: 'cancelled'
|
||||
}
|
||||
}
|
||||
},
|
||||
completed: {
|
||||
type: 'final',
|
||||
entry: ['notifyCompletion']
|
||||
},
|
||||
failed: {
|
||||
on: {
|
||||
RETRY: {
|
||||
target: 'active',
|
||||
cond: 'canRetry'
|
||||
},
|
||||
CANCEL: {
|
||||
target: 'cancelled'
|
||||
}
|
||||
}
|
||||
},
|
||||
cancelled: {
|
||||
type: 'final',
|
||||
entry: ['notifyCancellation']
|
||||
}
|
||||
}
|
||||
}, {
|
||||
actions: {
|
||||
logTransition: (context, event) => {
|
||||
logger.info(`Campaign ${context.campaignId}: ${event.type}`);
|
||||
},
|
||||
saveDraft: (context, event) => {
|
||||
this.emit('campaign:draft:saved', { campaignId: context.campaignId });
|
||||
},
|
||||
updateContext: (context, event) => {
|
||||
Object.assign(context, event.data);
|
||||
},
|
||||
handleError: (context, event) => {
|
||||
context.errors.push(event.data);
|
||||
context.attempts += 1;
|
||||
logger.error(`Campaign ${context.campaignId} error:`, event.data);
|
||||
},
|
||||
updateStrategy: (context, event) => {
|
||||
context.strategy = event.data;
|
||||
this.emit('campaign:strategy:generated', {
|
||||
campaignId: context.campaignId,
|
||||
strategy: event.data
|
||||
});
|
||||
},
|
||||
approveCampaign: (context) => {
|
||||
this.emit('campaign:approved', { campaignId: context.campaignId });
|
||||
},
|
||||
rejectCampaign: (context) => {
|
||||
this.emit('campaign:rejected', { campaignId: context.campaignId });
|
||||
},
|
||||
requestChanges: (context, event) => {
|
||||
this.emit('campaign:changes:requested', {
|
||||
campaignId: context.campaignId,
|
||||
changes: event.changes
|
||||
});
|
||||
},
|
||||
notifyCompletion: (context) => {
|
||||
this.emit('campaign:completed', { campaignId: context.campaignId });
|
||||
},
|
||||
notifyCancellation: (context) => {
|
||||
this.emit('campaign:cancelled', { campaignId: context.campaignId });
|
||||
}
|
||||
},
|
||||
guards: {
|
||||
canStart: (context) => {
|
||||
// Check if campaign can start
|
||||
return true;
|
||||
},
|
||||
isStartTime: (context) => {
|
||||
// Check if scheduled start time has been reached
|
||||
return new Date() >= new Date(context.startDate);
|
||||
},
|
||||
canRetry: (context) => {
|
||||
return context.attempts < 3;
|
||||
}
|
||||
},
|
||||
services: {
|
||||
validateCampaign: async (context) => {
|
||||
// Validate campaign data
|
||||
return { valid: true };
|
||||
},
|
||||
generateStrategy: async (context) => {
|
||||
// Call Claude Agent to generate strategy
|
||||
return {
|
||||
strategy: 'AI-generated strategy',
|
||||
tactics: []
|
||||
};
|
||||
},
|
||||
executeCampaign: async (context) => {
|
||||
// Execute campaign tasks
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Define task execution state machine
|
||||
createTaskMachine() {
|
||||
return createMachine({
|
||||
id: 'task',
|
||||
initial: 'pending',
|
||||
context: {
|
||||
taskId: null,
|
||||
attempts: 0,
|
||||
result: null,
|
||||
error: null
|
||||
},
|
||||
states: {
|
||||
pending: {
|
||||
on: {
|
||||
START: 'validating'
|
||||
}
|
||||
},
|
||||
validating: {
|
||||
invoke: {
|
||||
src: 'validateTask',
|
||||
onDone: {
|
||||
target: 'queued',
|
||||
actions: ['updateContext']
|
||||
},
|
||||
onError: {
|
||||
target: 'failed',
|
||||
actions: ['handleError']
|
||||
}
|
||||
}
|
||||
},
|
||||
queued: {
|
||||
on: {
|
||||
PROCESS: 'processing',
|
||||
CANCEL: 'cancelled'
|
||||
}
|
||||
},
|
||||
processing: {
|
||||
invoke: {
|
||||
src: 'processTask',
|
||||
onDone: {
|
||||
target: 'verifying',
|
||||
actions: ['updateResult']
|
||||
},
|
||||
onError: [
|
||||
{
|
||||
target: 'retrying',
|
||||
cond: 'canRetry',
|
||||
actions: ['incrementAttempts']
|
||||
},
|
||||
{
|
||||
target: 'failed',
|
||||
actions: ['handleError']
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
retrying: {
|
||||
after: {
|
||||
RETRY_DELAY: 'processing'
|
||||
}
|
||||
},
|
||||
verifying: {
|
||||
invoke: {
|
||||
src: 'verifyResult',
|
||||
onDone: 'completed',
|
||||
onError: 'failed'
|
||||
}
|
||||
},
|
||||
completed: {
|
||||
type: 'final',
|
||||
entry: ['notifyCompletion']
|
||||
},
|
||||
failed: {
|
||||
on: {
|
||||
RETRY: {
|
||||
target: 'processing',
|
||||
cond: 'canRetry'
|
||||
}
|
||||
},
|
||||
entry: ['notifyFailure']
|
||||
},
|
||||
cancelled: {
|
||||
type: 'final',
|
||||
entry: ['notifyCancellation']
|
||||
}
|
||||
}
|
||||
}, {
|
||||
actions: {
|
||||
updateContext: (context, event) => {
|
||||
Object.assign(context, event.data);
|
||||
},
|
||||
updateResult: (context, event) => {
|
||||
context.result = event.data;
|
||||
},
|
||||
handleError: (context, event) => {
|
||||
context.error = event.data;
|
||||
logger.error(`Task ${context.taskId} error:`, event.data);
|
||||
},
|
||||
incrementAttempts: (context) => {
|
||||
context.attempts += 1;
|
||||
},
|
||||
notifyCompletion: (context) => {
|
||||
this.emit('task:completed', {
|
||||
taskId: context.taskId,
|
||||
result: context.result
|
||||
});
|
||||
},
|
||||
notifyFailure: (context) => {
|
||||
this.emit('task:failed', {
|
||||
taskId: context.taskId,
|
||||
error: context.error
|
||||
});
|
||||
},
|
||||
notifyCancellation: (context) => {
|
||||
this.emit('task:cancelled', { taskId: context.taskId });
|
||||
}
|
||||
},
|
||||
guards: {
|
||||
canRetry: (context) => {
|
||||
return context.attempts < 3;
|
||||
}
|
||||
},
|
||||
delays: {
|
||||
RETRY_DELAY: (context) => {
|
||||
// Exponential backoff
|
||||
return Math.pow(2, context.attempts) * 1000;
|
||||
}
|
||||
},
|
||||
services: {
|
||||
validateTask: async (context) => {
|
||||
// Validate task requirements
|
||||
return { valid: true };
|
||||
},
|
||||
processTask: async (context) => {
|
||||
// Process the actual task
|
||||
return { success: true };
|
||||
},
|
||||
verifyResult: async (context) => {
|
||||
// Verify task result
|
||||
return { verified: true };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register a state machine
|
||||
registerMachine(name, machine) {
|
||||
this.machines.set(name, machine);
|
||||
logger.info(`State machine registered: ${name}`);
|
||||
}
|
||||
|
||||
// Create and start a state machine service
|
||||
startMachine(name, context = {}) {
|
||||
const machine = this.machines.get(name);
|
||||
if (!machine) {
|
||||
throw new Error(`Machine not found: ${name}`);
|
||||
}
|
||||
|
||||
const service = interpret(machine.withContext({
|
||||
...machine.context,
|
||||
...context
|
||||
}))
|
||||
.onTransition((state) => {
|
||||
logger.debug(`${name} transition:`, state.value);
|
||||
this.emit('machine:transition', {
|
||||
machine: name,
|
||||
state: state.value,
|
||||
context: state.context
|
||||
});
|
||||
})
|
||||
.start();
|
||||
|
||||
const serviceId = `${name}-${Date.now()}`;
|
||||
this.services.set(serviceId, service);
|
||||
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
// Send event to a running service
|
||||
sendEvent(serviceId, event) {
|
||||
const service = this.services.get(serviceId);
|
||||
if (!service) {
|
||||
throw new Error(`Service not found: ${serviceId}`);
|
||||
}
|
||||
|
||||
service.send(event);
|
||||
}
|
||||
|
||||
// Get current state of a service
|
||||
getState(serviceId) {
|
||||
const service = this.services.get(serviceId);
|
||||
if (!service) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: service.state.value,
|
||||
context: service.state.context
|
||||
};
|
||||
}
|
||||
|
||||
// Stop a running service
|
||||
stopMachine(serviceId) {
|
||||
const service = this.services.get(serviceId);
|
||||
if (!service) {
|
||||
return false;
|
||||
}
|
||||
|
||||
service.stop();
|
||||
this.services.delete(serviceId);
|
||||
logger.info(`State machine stopped: ${serviceId}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Initialize default machines
|
||||
initialize() {
|
||||
this.registerMachine('campaign', this.createCampaignMachine());
|
||||
this.registerMachine('task', this.createTaskMachine());
|
||||
logger.info('State machine engine initialized');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { RedisClient } from '../config/redis.js';
|
||||
import { Task } from '../models/Task.js';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export class TaskQueueManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.queues = new Map();
|
||||
this.workers = new Map();
|
||||
this.connection = null;
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!TaskQueueManager.instance) {
|
||||
TaskQueueManager.instance = new TaskQueueManager();
|
||||
}
|
||||
return TaskQueueManager.instance;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.connection = RedisClient.getInstance().getClient();
|
||||
|
||||
// Initialize default queues
|
||||
await this.createQueue('high-priority', { priority: 3 });
|
||||
await this.createQueue('normal-priority', { priority: 2 });
|
||||
await this.createQueue('low-priority', { priority: 1 });
|
||||
await this.createQueue('scheduled', { priority: 2 });
|
||||
|
||||
logger.info('Task queue manager initialized');
|
||||
}
|
||||
|
||||
async createQueue(name, options = {}) {
|
||||
if (this.queues.has(name)) {
|
||||
return this.queues.get(name);
|
||||
}
|
||||
|
||||
const queue = new Queue(name, {
|
||||
connection: this.connection,
|
||||
defaultJobOptions: {
|
||||
attempts: options.attempts || 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2000
|
||||
},
|
||||
removeOnComplete: options.removeOnComplete || 100,
|
||||
removeOnFail: options.removeOnFail || 50
|
||||
}
|
||||
});
|
||||
|
||||
this.queues.set(name, queue);
|
||||
|
||||
logger.info(`Queue created: ${name}`);
|
||||
return queue;
|
||||
}
|
||||
|
||||
async createWorker(queueName, processor, options = {}) {
|
||||
const worker = new Worker(queueName, processor, {
|
||||
connection: this.connection,
|
||||
concurrency: options.concurrency || 5,
|
||||
...options
|
||||
});
|
||||
|
||||
// Set up event handlers
|
||||
worker.on('completed', (job) => {
|
||||
logger.info(`Job completed: ${job.id} in queue ${queueName}`);
|
||||
this.emit('job:completed', { queue: queueName, job });
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
logger.error(`Job failed: ${job.id} in queue ${queueName}`, err);
|
||||
this.emit('job:failed', { queue: queueName, job, error: err });
|
||||
});
|
||||
|
||||
worker.on('active', (job) => {
|
||||
logger.debug(`Job active: ${job.id} in queue ${queueName}`);
|
||||
this.emit('job:active', { queue: queueName, job });
|
||||
});
|
||||
|
||||
worker.on('stalled', (job) => {
|
||||
logger.warn(`Job stalled: ${job.id} in queue ${queueName}`);
|
||||
this.emit('job:stalled', { queue: queueName, job });
|
||||
});
|
||||
|
||||
this.workers.set(queueName, worker);
|
||||
logger.info(`Worker created for queue: ${queueName}`);
|
||||
|
||||
return worker;
|
||||
}
|
||||
|
||||
async addTask(taskData) {
|
||||
const { type, priority = 'normal', data, options = {} } = taskData;
|
||||
|
||||
// Determine queue based on priority
|
||||
const queueName = `${priority}-priority`;
|
||||
const queue = this.queues.get(queueName);
|
||||
|
||||
if (!queue) {
|
||||
throw new Error(`Queue not found: ${queueName}`);
|
||||
}
|
||||
|
||||
// Save task to database
|
||||
const task = await Task.create({
|
||||
type,
|
||||
priority,
|
||||
status: 'pending',
|
||||
data: JSON.stringify(data),
|
||||
createdBy: options.userId || 'system'
|
||||
});
|
||||
|
||||
// Add job to queue
|
||||
const job = await queue.add(type, {
|
||||
taskId: task.id,
|
||||
...data
|
||||
}, {
|
||||
...options,
|
||||
jobId: task.id.toString()
|
||||
});
|
||||
|
||||
logger.info(`Task added to queue: ${task.id} (${type})`);
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
jobId: job.id,
|
||||
queue: queueName
|
||||
};
|
||||
}
|
||||
|
||||
async scheduleTask(taskData, delay) {
|
||||
const result = await this.addTask({
|
||||
...taskData,
|
||||
options: {
|
||||
...taskData.options,
|
||||
delay
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Task scheduled: ${result.taskId} (delay: ${delay}ms)`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getTaskStatus(taskId) {
|
||||
// Check all queues
|
||||
for (const [queueName, queue] of this.queues) {
|
||||
const job = await queue.getJob(taskId.toString());
|
||||
if (job) {
|
||||
return {
|
||||
id: job.id,
|
||||
queue: queueName,
|
||||
status: await job.getState(),
|
||||
progress: job.progress,
|
||||
data: job.data,
|
||||
result: job.returnvalue,
|
||||
failedReason: job.failedReason,
|
||||
attemptsMade: job.attemptsMade,
|
||||
timestamp: job.timestamp,
|
||||
processedOn: job.processedOn,
|
||||
finishedOn: job.finishedOn
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async cancelTask(taskId) {
|
||||
for (const [queueName, queue] of this.queues) {
|
||||
const job = await queue.getJob(taskId.toString());
|
||||
if (job) {
|
||||
await job.remove();
|
||||
|
||||
// Update task status in database
|
||||
await Task.update(
|
||||
{ status: 'cancelled' },
|
||||
{ where: { id: taskId } }
|
||||
);
|
||||
|
||||
logger.info(`Task cancelled: ${taskId}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async retryTask(taskId) {
|
||||
for (const [queueName, queue] of this.queues) {
|
||||
const job = await queue.getJob(taskId.toString());
|
||||
if (job && ['failed', 'completed'].includes(await job.getState())) {
|
||||
await job.retry();
|
||||
logger.info(`Task retried: ${taskId}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async getQueueStats(queueName) {
|
||||
const queue = this.queues.get(queueName);
|
||||
if (!queue) {
|
||||
throw new Error(`Queue not found: ${queueName}`);
|
||||
}
|
||||
|
||||
const [
|
||||
waiting,
|
||||
active,
|
||||
completed,
|
||||
failed,
|
||||
delayed,
|
||||
paused
|
||||
] = await Promise.all([
|
||||
queue.getWaitingCount(),
|
||||
queue.getActiveCount(),
|
||||
queue.getCompletedCount(),
|
||||
queue.getFailedCount(),
|
||||
queue.getDelayedCount(),
|
||||
queue.getPausedCount()
|
||||
]);
|
||||
|
||||
return {
|
||||
name: queueName,
|
||||
counts: {
|
||||
waiting,
|
||||
active,
|
||||
completed,
|
||||
failed,
|
||||
delayed,
|
||||
paused
|
||||
},
|
||||
isPaused: await queue.isPaused()
|
||||
};
|
||||
}
|
||||
|
||||
async getAllQueueStats() {
|
||||
const stats = {};
|
||||
|
||||
for (const queueName of this.queues.keys()) {
|
||||
stats[queueName] = await this.getQueueStats(queueName);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
async pauseQueue(queueName) {
|
||||
const queue = this.queues.get(queueName);
|
||||
if (!queue) {
|
||||
throw new Error(`Queue not found: ${queueName}`);
|
||||
}
|
||||
|
||||
await queue.pause();
|
||||
logger.info(`Queue paused: ${queueName}`);
|
||||
}
|
||||
|
||||
async resumeQueue(queueName) {
|
||||
const queue = this.queues.get(queueName);
|
||||
if (!queue) {
|
||||
throw new Error(`Queue not found: ${queueName}`);
|
||||
}
|
||||
|
||||
await queue.resume();
|
||||
logger.info(`Queue resumed: ${queueName}`);
|
||||
}
|
||||
|
||||
async drainQueue(queueName) {
|
||||
const queue = this.queues.get(queueName);
|
||||
if (!queue) {
|
||||
throw new Error(`Queue not found: ${queueName}`);
|
||||
}
|
||||
|
||||
await queue.drain();
|
||||
logger.info(`Queue drained: ${queueName}`);
|
||||
}
|
||||
|
||||
async cleanQueue(queueName, grace = 0, limit = 100, status) {
|
||||
const queue = this.queues.get(queueName);
|
||||
if (!queue) {
|
||||
throw new Error(`Queue not found: ${queueName}`);
|
||||
}
|
||||
|
||||
const jobs = await queue.clean(grace, limit, status);
|
||||
logger.info(`Queue cleaned: ${queueName} (removed ${jobs.length} jobs)`);
|
||||
|
||||
return jobs.length;
|
||||
}
|
||||
|
||||
async startWorkers() {
|
||||
// High priority worker
|
||||
await this.createWorker('high-priority', async (job) => {
|
||||
return await this.processTask(job);
|
||||
}, { concurrency: 10 });
|
||||
|
||||
// Normal priority worker
|
||||
await this.createWorker('normal-priority', async (job) => {
|
||||
return await this.processTask(job);
|
||||
}, { concurrency: 5 });
|
||||
|
||||
// Low priority worker
|
||||
await this.createWorker('low-priority', async (job) => {
|
||||
return await this.processTask(job);
|
||||
}, { concurrency: 2 });
|
||||
|
||||
logger.info('All workers started');
|
||||
}
|
||||
|
||||
async processTask(job) {
|
||||
const { taskId } = job.data;
|
||||
|
||||
try {
|
||||
// Update task status
|
||||
await Task.update(
|
||||
{ status: 'processing', startedAt: new Date() },
|
||||
{ where: { id: taskId } }
|
||||
);
|
||||
|
||||
// Emit event for other services to handle
|
||||
this.emit('task:process', {
|
||||
taskId,
|
||||
type: job.name,
|
||||
data: job.data
|
||||
});
|
||||
|
||||
// Wait for task completion (this would be handled by specific processors)
|
||||
await job.updateProgress(50);
|
||||
|
||||
// Simulate processing
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
await job.updateProgress(100);
|
||||
|
||||
// Update task status
|
||||
await Task.update(
|
||||
{
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
result: JSON.stringify({ success: true })
|
||||
},
|
||||
{ where: { id: taskId } }
|
||||
);
|
||||
|
||||
return { success: true, taskId };
|
||||
} catch (error) {
|
||||
// Update task status
|
||||
await Task.update(
|
||||
{
|
||||
status: 'failed',
|
||||
failedAt: new Date(),
|
||||
error: error.message
|
||||
},
|
||||
{ where: { id: taskId } }
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stopWorkers() {
|
||||
for (const [name, worker] of this.workers) {
|
||||
await worker.close();
|
||||
logger.info(`Worker stopped: ${name}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async checkHealth() {
|
||||
try {
|
||||
const stats = await this.getAllQueueStats();
|
||||
return Object.keys(stats).length > 0;
|
||||
} catch (error) {
|
||||
logger.error('Queue health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
marketing-agent/services/orchestrator/src/utils/logger.js
Normal file
63
marketing-agent/services/orchestrator/src/utils/logger.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import winston from 'winston';
|
||||
|
||||
const { combine, timestamp, printf, colorize, errors } = winston.format;
|
||||
|
||||
// Custom log format
|
||||
const logFormat = printf(({ level, message, timestamp, stack, ...metadata }) => {
|
||||
let msg = `${timestamp} [${level}] ${message}`;
|
||||
|
||||
if (stack) {
|
||||
msg += `\n${stack}`;
|
||||
}
|
||||
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
msg += ` ${JSON.stringify(metadata)}`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
});
|
||||
|
||||
// Create logger instance
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: combine(
|
||||
errors({ stack: true }),
|
||||
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
logFormat
|
||||
),
|
||||
transports: [
|
||||
// Console transport
|
||||
new winston.transports.Console({
|
||||
format: combine(
|
||||
colorize(),
|
||||
logFormat
|
||||
)
|
||||
}),
|
||||
// File transport for errors
|
||||
new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error',
|
||||
maxsize: 10485760, // 10MB
|
||||
maxFiles: 5
|
||||
}),
|
||||
// File transport for all logs
|
||||
new winston.transports.File({
|
||||
filename: 'logs/combined.log',
|
||||
maxsize: 10485760, // 10MB
|
||||
maxFiles: 10
|
||||
})
|
||||
],
|
||||
exceptionHandlers: [
|
||||
new winston.transports.File({ filename: 'logs/exceptions.log' })
|
||||
],
|
||||
rejectionHandlers: [
|
||||
new winston.transports.File({ filename: 'logs/rejections.log' })
|
||||
]
|
||||
});
|
||||
|
||||
// Create a stream object for Morgan HTTP logging
|
||||
export const stream = {
|
||||
write: (message) => {
|
||||
logger.info(message.trim());
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user