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:
26
marketing-agent/services/webhook-service/package.json
Normal file
26
marketing-agent/services/webhook-service/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "webhook-service",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Webhook integration service for external system notifications",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"mongoose": "^7.6.3",
|
||||
"axios": "^1.6.0",
|
||||
"p-queue": "^7.4.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"joi": "^17.11.0",
|
||||
"winston": "^3.11.0",
|
||||
"redis": "^4.6.10",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
24
marketing-agent/services/webhook-service/src/config/index.js
Normal file
24
marketing-agent/services/webhook-service/src/config/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export default {
|
||||
port: process.env.PORT || 3009,
|
||||
mongodb: {
|
||||
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/webhook-service'
|
||||
},
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
password: process.env.REDIS_PASSWORD || null
|
||||
},
|
||||
webhook: {
|
||||
maxRetries: parseInt(process.env.WEBHOOK_MAX_RETRIES || '3'),
|
||||
retryDelay: parseInt(process.env.WEBHOOK_RETRY_DELAY || '5000'),
|
||||
timeout: parseInt(process.env.WEBHOOK_TIMEOUT || '30000'),
|
||||
concurrency: parseInt(process.env.WEBHOOK_CONCURRENCY || '10'),
|
||||
signatureHeader: process.env.WEBHOOK_SIGNATURE_HEADER || 'X-Webhook-Signature'
|
||||
},
|
||||
logging: {
|
||||
level: process.env.LOG_LEVEL || 'info'
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'your-jwt-secret-key'
|
||||
}
|
||||
};
|
||||
75
marketing-agent/services/webhook-service/src/index.js
Normal file
75
marketing-agent/services/webhook-service/src/index.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import mongoose from 'mongoose';
|
||||
import config from './config/index.js';
|
||||
import webhookRoutes from './routes/webhooks.js';
|
||||
import eventRoutes from './routes/events.js';
|
||||
import errorHandler from './middleware/errorHandler.js';
|
||||
import { WebhookProcessor } from './services/webhookProcessor.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
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();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/v1/webhooks', webhookRoutes);
|
||||
app.use('/api/v1/webhook-events', eventRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: 'webhook-service',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
// Connect to MongoDB
|
||||
mongoose.connect(config.mongodb.uri)
|
||||
.then(() => {
|
||||
logger.info('Connected to MongoDB');
|
||||
|
||||
// Initialize webhook processor
|
||||
const webhookProcessor = new WebhookProcessor();
|
||||
webhookProcessor.start();
|
||||
|
||||
// Make webhook processor available globally
|
||||
app.set('webhookProcessor', webhookProcessor);
|
||||
|
||||
// Start server
|
||||
const PORT = config.port;
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Webhook service running on port ${PORT}`);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('MongoDB connection error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
const webhookProcessor = app.get('webhookProcessor');
|
||||
if (webhookProcessor) {
|
||||
await webhookProcessor.stop();
|
||||
}
|
||||
await mongoose.connection.close();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import config from '../config/index.js';
|
||||
|
||||
export const authenticate = (req, res, next) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export default function errorHandler(err, req, res, next) {
|
||||
logger.error('Error:', err);
|
||||
|
||||
// Mongoose validation error
|
||||
if (err.name === 'ValidationError') {
|
||||
const errors = Object.values(err.errors).map(e => e.message);
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: errors
|
||||
});
|
||||
}
|
||||
|
||||
// Mongoose duplicate key error
|
||||
if (err.code === 11000) {
|
||||
const field = Object.keys(err.keyPattern)[0];
|
||||
return res.status(409).json({
|
||||
error: `Duplicate value for field: ${field}`
|
||||
});
|
||||
}
|
||||
|
||||
// JWT errors
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token'
|
||||
});
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
error: 'Token expired'
|
||||
});
|
||||
}
|
||||
|
||||
// Default error
|
||||
res.status(err.status || 500).json({
|
||||
error: err.message || 'Internal server error',
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function validateRequest(schema, property = 'body') {
|
||||
return (req, res, next) => {
|
||||
const { error } = schema.validate(req[property], { abortEarly: false });
|
||||
|
||||
if (error) {
|
||||
const errors = error.details.map(detail => ({
|
||||
field: detail.path.join('.'),
|
||||
message: detail.message
|
||||
}));
|
||||
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: errors
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
162
marketing-agent/services/webhook-service/src/models/Webhook.js
Normal file
162
marketing-agent/services/webhook-service/src/models/Webhook.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import mongoose from 'mongoose';
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
const webhookSchema = 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
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
validate: {
|
||||
validator: function(v) {
|
||||
return /^https?:\/\/.+/.test(v);
|
||||
},
|
||||
message: 'Invalid URL format'
|
||||
}
|
||||
},
|
||||
events: [{
|
||||
type: String,
|
||||
enum: [
|
||||
'message.sent',
|
||||
'message.delivered',
|
||||
'message.failed',
|
||||
'message.read',
|
||||
'contact.created',
|
||||
'contact.updated',
|
||||
'contact.deleted',
|
||||
'campaign.started',
|
||||
'campaign.completed',
|
||||
'campaign.failed',
|
||||
'conversion.tracked',
|
||||
'workflow.triggered',
|
||||
'workflow.completed',
|
||||
'account.updated'
|
||||
]
|
||||
}],
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
headers: {
|
||||
type: Map,
|
||||
of: String,
|
||||
default: new Map()
|
||||
},
|
||||
secret: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: () => CryptoJS.lib.WordArray.random(32).toString()
|
||||
},
|
||||
retryConfig: {
|
||||
maxRetries: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
min: 0,
|
||||
max: 10
|
||||
},
|
||||
retryDelay: {
|
||||
type: Number,
|
||||
default: 5000,
|
||||
min: 1000,
|
||||
max: 60000
|
||||
}
|
||||
},
|
||||
timeout: {
|
||||
type: Number,
|
||||
default: 30000,
|
||||
min: 1000,
|
||||
max: 60000
|
||||
},
|
||||
metadata: {
|
||||
type: Map,
|
||||
of: mongoose.Schema.Types.Mixed
|
||||
},
|
||||
stats: {
|
||||
totalCalls: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
successfulCalls: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
failedCalls: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
lastCallAt: Date,
|
||||
lastSuccessAt: Date,
|
||||
lastFailureAt: Date,
|
||||
averageResponseTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
lastError: {
|
||||
message: String,
|
||||
code: String,
|
||||
timestamp: Date
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Indexes
|
||||
webhookSchema.index({ accountId: 1, active: 1 });
|
||||
webhookSchema.index({ events: 1, active: 1 });
|
||||
webhookSchema.index({ 'stats.lastCallAt': -1 });
|
||||
|
||||
// Multi-tenant indexes
|
||||
webhookSchema.index({ tenantId: 1, accountId: 1, active: 1 });
|
||||
webhookSchema.index({ tenantId: 1, events: 1, active: 1 });
|
||||
webhookSchema.index({ tenantId: 1, 'stats.lastCallAt': -1 });
|
||||
|
||||
// Methods
|
||||
webhookSchema.methods.generateSignature = function(payload) {
|
||||
const message = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
return CryptoJS.HmacSHA256(message, this.secret).toString();
|
||||
};
|
||||
|
||||
webhookSchema.methods.verifySignature = function(payload, signature) {
|
||||
const expectedSignature = this.generateSignature(payload);
|
||||
return expectedSignature === signature;
|
||||
};
|
||||
|
||||
webhookSchema.methods.updateStats = function(success, responseTime) {
|
||||
this.stats.totalCalls++;
|
||||
this.stats.lastCallAt = new Date();
|
||||
|
||||
if (success) {
|
||||
this.stats.successfulCalls++;
|
||||
this.stats.lastSuccessAt = new Date();
|
||||
} else {
|
||||
this.stats.failedCalls++;
|
||||
this.stats.lastFailureAt = new Date();
|
||||
}
|
||||
|
||||
// Update average response time
|
||||
if (responseTime) {
|
||||
const currentAvg = this.stats.averageResponseTime || 0;
|
||||
const totalCalls = this.stats.totalCalls;
|
||||
this.stats.averageResponseTime = ((currentAvg * (totalCalls - 1)) + responseTime) / totalCalls;
|
||||
}
|
||||
};
|
||||
|
||||
export default mongoose.model('Webhook', webhookSchema);
|
||||
@@ -0,0 +1,92 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const webhookLogSchema = new mongoose.Schema({
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tenant',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
webhookId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Webhook',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
accountId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
event: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: mongoose.Schema.Types.Mixed,
|
||||
required: true
|
||||
},
|
||||
request: {
|
||||
url: String,
|
||||
method: {
|
||||
type: String,
|
||||
default: 'POST'
|
||||
},
|
||||
headers: {
|
||||
type: Map,
|
||||
of: String
|
||||
},
|
||||
body: mongoose.Schema.Types.Mixed
|
||||
},
|
||||
response: {
|
||||
status: Number,
|
||||
statusText: String,
|
||||
headers: {
|
||||
type: Map,
|
||||
of: String
|
||||
},
|
||||
body: mongoose.Schema.Types.Mixed,
|
||||
responseTime: Number
|
||||
},
|
||||
attempts: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'success', 'failed', 'retrying'],
|
||||
default: 'pending',
|
||||
index: true
|
||||
},
|
||||
error: {
|
||||
message: String,
|
||||
code: String,
|
||||
stack: String
|
||||
},
|
||||
nextRetryAt: {
|
||||
type: Date,
|
||||
index: true
|
||||
},
|
||||
completedAt: Date
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Indexes
|
||||
webhookLogSchema.index({ createdAt: -1 });
|
||||
webhookLogSchema.index({ webhookId: 1, createdAt: -1 });
|
||||
webhookLogSchema.index({ accountId: 1, event: 1, createdAt: -1 });
|
||||
webhookLogSchema.index({ status: 1, nextRetryAt: 1 });
|
||||
|
||||
// TTL index to auto-delete old logs after 30 days
|
||||
webhookLogSchema.index({ createdAt: 1 }, { expireAfterSeconds: 30 * 24 * 60 * 60 });
|
||||
|
||||
// Multi-tenant indexes
|
||||
webhookLogSchema.index({ tenantId: 1, createdAt: -1 });
|
||||
webhookLogSchema.index({ tenantId: 1, webhookId: 1, createdAt: -1 });
|
||||
webhookLogSchema.index({ tenantId: 1, accountId: 1, event: 1, createdAt: -1 });
|
||||
webhookLogSchema.index({ tenantId: 1, status: 1, nextRetryAt: 1 });
|
||||
webhookLogSchema.index({ tenantId: 1, createdAt: 1 }, { expireAfterSeconds: 30 * 24 * 60 * 60 });
|
||||
|
||||
export default mongoose.model('WebhookLog', webhookLogSchema);
|
||||
210
marketing-agent/services/webhook-service/src/routes/events.js
Normal file
210
marketing-agent/services/webhook-service/src/routes/events.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { validateRequest } from '../middleware/validateRequest.js';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schema
|
||||
const triggerEventSchema = Joi.object({
|
||||
event: Joi.string().required().valid(
|
||||
'message.sent',
|
||||
'message.delivered',
|
||||
'message.failed',
|
||||
'message.read',
|
||||
'contact.created',
|
||||
'contact.updated',
|
||||
'contact.deleted',
|
||||
'campaign.started',
|
||||
'campaign.completed',
|
||||
'campaign.failed',
|
||||
'conversion.tracked',
|
||||
'workflow.triggered',
|
||||
'workflow.completed',
|
||||
'account.updated'
|
||||
),
|
||||
payload: Joi.object().required()
|
||||
});
|
||||
|
||||
// Trigger webhook event (for testing or manual triggering)
|
||||
router.post('/trigger', authenticate, validateRequest(triggerEventSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { event, payload } = req.body;
|
||||
const webhookProcessor = req.app.get('webhookProcessor');
|
||||
|
||||
await webhookProcessor.triggerEvent(
|
||||
req.user.accountId,
|
||||
event,
|
||||
payload
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Event triggered successfully',
|
||||
event,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get available events
|
||||
router.get('/types', authenticate, (req, res) => {
|
||||
const eventTypes = [
|
||||
{
|
||||
category: 'Messages',
|
||||
events: [
|
||||
{ value: 'message.sent', label: 'Message Sent', description: 'Triggered when a message is sent' },
|
||||
{ value: 'message.delivered', label: 'Message Delivered', description: 'Triggered when a message is delivered' },
|
||||
{ value: 'message.failed', label: 'Message Failed', description: 'Triggered when a message fails to send' },
|
||||
{ value: 'message.read', label: 'Message Read', description: 'Triggered when a message is read' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Contacts',
|
||||
events: [
|
||||
{ value: 'contact.created', label: 'Contact Created', description: 'Triggered when a new contact is created' },
|
||||
{ value: 'contact.updated', label: 'Contact Updated', description: 'Triggered when a contact is updated' },
|
||||
{ value: 'contact.deleted', label: 'Contact Deleted', description: 'Triggered when a contact is deleted' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Campaigns',
|
||||
events: [
|
||||
{ value: 'campaign.started', label: 'Campaign Started', description: 'Triggered when a campaign starts' },
|
||||
{ value: 'campaign.completed', label: 'Campaign Completed', description: 'Triggered when a campaign completes' },
|
||||
{ value: 'campaign.failed', label: 'Campaign Failed', description: 'Triggered when a campaign fails' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Conversions',
|
||||
events: [
|
||||
{ value: 'conversion.tracked', label: 'Conversion Tracked', description: 'Triggered when a conversion is tracked' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Workflows',
|
||||
events: [
|
||||
{ value: 'workflow.triggered', label: 'Workflow Triggered', description: 'Triggered when a workflow starts' },
|
||||
{ value: 'workflow.completed', label: 'Workflow Completed', description: 'Triggered when a workflow completes' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Account',
|
||||
events: [
|
||||
{ value: 'account.updated', label: 'Account Updated', description: 'Triggered when account settings are updated' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
res.json({ eventTypes });
|
||||
});
|
||||
|
||||
// Get event schema
|
||||
router.get('/schema/:event', authenticate, (req, res) => {
|
||||
const { event } = req.params;
|
||||
|
||||
const schemas = {
|
||||
'message.sent': {
|
||||
messageId: { type: 'string', required: true },
|
||||
campaignId: { type: 'string', required: false },
|
||||
contactId: { type: 'string', required: true },
|
||||
content: { type: 'string', required: true },
|
||||
sentAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'message.delivered': {
|
||||
messageId: { type: 'string', required: true },
|
||||
contactId: { type: 'string', required: true },
|
||||
deliveredAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'message.failed': {
|
||||
messageId: { type: 'string', required: true },
|
||||
contactId: { type: 'string', required: true },
|
||||
error: { type: 'string', required: true },
|
||||
failedAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'message.read': {
|
||||
messageId: { type: 'string', required: true },
|
||||
contactId: { type: 'string', required: true },
|
||||
readAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'contact.created': {
|
||||
contactId: { type: 'string', required: true },
|
||||
phoneNumber: { type: 'string', required: true },
|
||||
firstName: { type: 'string', required: false },
|
||||
lastName: { type: 'string', required: false },
|
||||
tags: { type: 'array', required: false },
|
||||
createdAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'contact.updated': {
|
||||
contactId: { type: 'string', required: true },
|
||||
changes: { type: 'object', required: true },
|
||||
updatedAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'contact.deleted': {
|
||||
contactId: { type: 'string', required: true },
|
||||
deletedAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'campaign.started': {
|
||||
campaignId: { type: 'string', required: true },
|
||||
name: { type: 'string', required: true },
|
||||
targetCount: { type: 'number', required: true },
|
||||
startedAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'campaign.completed': {
|
||||
campaignId: { type: 'string', required: true },
|
||||
name: { type: 'string', required: true },
|
||||
stats: {
|
||||
sent: { type: 'number', required: true },
|
||||
delivered: { type: 'number', required: true },
|
||||
read: { type: 'number', required: true },
|
||||
failed: { type: 'number', required: true }
|
||||
},
|
||||
completedAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'campaign.failed': {
|
||||
campaignId: { type: 'string', required: true },
|
||||
name: { type: 'string', required: true },
|
||||
error: { type: 'string', required: true },
|
||||
failedAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'conversion.tracked': {
|
||||
conversionId: { type: 'string', required: true },
|
||||
contactId: { type: 'string', required: true },
|
||||
campaignId: { type: 'string', required: false },
|
||||
value: { type: 'number', required: false },
|
||||
type: { type: 'string', required: true },
|
||||
trackedAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'workflow.triggered': {
|
||||
workflowId: { type: 'string', required: true },
|
||||
instanceId: { type: 'string', required: true },
|
||||
trigger: { type: 'object', required: true },
|
||||
triggeredAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'workflow.completed': {
|
||||
workflowId: { type: 'string', required: true },
|
||||
instanceId: { type: 'string', required: true },
|
||||
result: { type: 'string', required: true },
|
||||
completedAt: { type: 'datetime', required: true }
|
||||
},
|
||||
'account.updated': {
|
||||
accountId: { type: 'string', required: true },
|
||||
changes: { type: 'object', required: true },
|
||||
updatedAt: { type: 'datetime', required: true }
|
||||
}
|
||||
};
|
||||
|
||||
const schema = schemas[event];
|
||||
if (!schema) {
|
||||
return res.status(404).json({ error: 'Event schema not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
event,
|
||||
schema
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
380
marketing-agent/services/webhook-service/src/routes/webhooks.js
Normal file
380
marketing-agent/services/webhook-service/src/routes/webhooks.js
Normal file
@@ -0,0 +1,380 @@
|
||||
import express from 'express';
|
||||
import Webhook from '../models/Webhook.js';
|
||||
import WebhookLog from '../models/WebhookLog.js';
|
||||
import { validateRequest } from '../middleware/validateRequest.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const createWebhookSchema = Joi.object({
|
||||
name: Joi.string().required().min(1).max(100),
|
||||
description: Joi.string().optional().max(500),
|
||||
url: Joi.string().required().uri({ scheme: ['http', 'https'] }),
|
||||
events: Joi.array().items(
|
||||
Joi.string().valid(
|
||||
'message.sent',
|
||||
'message.delivered',
|
||||
'message.failed',
|
||||
'message.read',
|
||||
'contact.created',
|
||||
'contact.updated',
|
||||
'contact.deleted',
|
||||
'campaign.started',
|
||||
'campaign.completed',
|
||||
'campaign.failed',
|
||||
'conversion.tracked',
|
||||
'workflow.triggered',
|
||||
'workflow.completed',
|
||||
'account.updated'
|
||||
)
|
||||
).required().min(1),
|
||||
headers: Joi.object().pattern(Joi.string(), Joi.string()).optional(),
|
||||
retryConfig: Joi.object({
|
||||
maxRetries: Joi.number().min(0).max(10).optional(),
|
||||
retryDelay: Joi.number().min(1000).max(60000).optional()
|
||||
}).optional(),
|
||||
timeout: Joi.number().min(1000).max(60000).optional(),
|
||||
metadata: Joi.object().optional()
|
||||
});
|
||||
|
||||
const updateWebhookSchema = Joi.object({
|
||||
name: Joi.string().min(1).max(100).optional(),
|
||||
description: Joi.string().max(500).optional(),
|
||||
url: Joi.string().uri({ scheme: ['http', 'https'] }).optional(),
|
||||
events: Joi.array().items(
|
||||
Joi.string().valid(
|
||||
'message.sent',
|
||||
'message.delivered',
|
||||
'message.failed',
|
||||
'message.read',
|
||||
'contact.created',
|
||||
'contact.updated',
|
||||
'contact.deleted',
|
||||
'campaign.started',
|
||||
'campaign.completed',
|
||||
'campaign.failed',
|
||||
'conversion.tracked',
|
||||
'workflow.triggered',
|
||||
'workflow.completed',
|
||||
'account.updated'
|
||||
)
|
||||
).min(1).optional(),
|
||||
headers: Joi.object().pattern(Joi.string(), Joi.string()).optional(),
|
||||
active: Joi.boolean().optional(),
|
||||
retryConfig: Joi.object({
|
||||
maxRetries: Joi.number().min(0).max(10).optional(),
|
||||
retryDelay: Joi.number().min(1000).max(60000).optional()
|
||||
}).optional(),
|
||||
timeout: Joi.number().min(1000).max(60000).optional(),
|
||||
metadata: Joi.object().optional()
|
||||
});
|
||||
|
||||
const testWebhookSchema = Joi.object({
|
||||
event: Joi.string().required(),
|
||||
payload: Joi.object().required()
|
||||
});
|
||||
|
||||
// Create webhook
|
||||
router.post('/', authenticate, validateRequest(createWebhookSchema), async (req, res, next) => {
|
||||
try {
|
||||
const webhook = new Webhook({
|
||||
accountId: req.user.accountId,
|
||||
...req.body,
|
||||
headers: req.body.headers ? new Map(Object.entries(req.body.headers)) : new Map()
|
||||
});
|
||||
|
||||
await webhook.save();
|
||||
|
||||
res.status(201).json({
|
||||
webhook: {
|
||||
id: webhook._id,
|
||||
name: webhook.name,
|
||||
description: webhook.description,
|
||||
url: webhook.url,
|
||||
events: webhook.events,
|
||||
active: webhook.active,
|
||||
secret: webhook.secret,
|
||||
headers: Object.fromEntries(webhook.headers),
|
||||
retryConfig: webhook.retryConfig,
|
||||
timeout: webhook.timeout,
|
||||
metadata: webhook.metadata ? Object.fromEntries(webhook.metadata) : {},
|
||||
createdAt: webhook.createdAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// List webhooks
|
||||
router.get('/', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, active } = req.query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const query = { accountId: req.user.accountId };
|
||||
if (active !== undefined) {
|
||||
query.active = active === 'true';
|
||||
}
|
||||
|
||||
const [webhooks, total] = await Promise.all([
|
||||
Webhook.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit)),
|
||||
Webhook.countDocuments(query)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
webhooks: webhooks.map(webhook => ({
|
||||
id: webhook._id,
|
||||
name: webhook.name,
|
||||
description: webhook.description,
|
||||
url: webhook.url,
|
||||
events: webhook.events,
|
||||
active: webhook.active,
|
||||
stats: webhook.stats,
|
||||
lastError: webhook.lastError,
|
||||
createdAt: webhook.createdAt,
|
||||
updatedAt: webhook.updatedAt
|
||||
})),
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get webhook details
|
||||
router.get('/:id', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const webhook = await Webhook.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
return res.status(404).json({ error: 'Webhook not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
webhook: {
|
||||
id: webhook._id,
|
||||
name: webhook.name,
|
||||
description: webhook.description,
|
||||
url: webhook.url,
|
||||
events: webhook.events,
|
||||
active: webhook.active,
|
||||
secret: webhook.secret,
|
||||
headers: Object.fromEntries(webhook.headers),
|
||||
retryConfig: webhook.retryConfig,
|
||||
timeout: webhook.timeout,
|
||||
metadata: webhook.metadata ? Object.fromEntries(webhook.metadata) : {},
|
||||
stats: webhook.stats,
|
||||
lastError: webhook.lastError,
|
||||
createdAt: webhook.createdAt,
|
||||
updatedAt: webhook.updatedAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update webhook
|
||||
router.put('/:id', authenticate, validateRequest(updateWebhookSchema), async (req, res, next) => {
|
||||
try {
|
||||
const webhook = await Webhook.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
return res.status(404).json({ error: 'Webhook not found' });
|
||||
}
|
||||
|
||||
// Update fields
|
||||
Object.keys(req.body).forEach(key => {
|
||||
if (key === 'headers') {
|
||||
webhook.headers = new Map(Object.entries(req.body.headers));
|
||||
} else if (key === 'metadata') {
|
||||
webhook.metadata = new Map(Object.entries(req.body.metadata));
|
||||
} else {
|
||||
webhook[key] = req.body[key];
|
||||
}
|
||||
});
|
||||
|
||||
await webhook.save();
|
||||
|
||||
res.json({
|
||||
webhook: {
|
||||
id: webhook._id,
|
||||
name: webhook.name,
|
||||
description: webhook.description,
|
||||
url: webhook.url,
|
||||
events: webhook.events,
|
||||
active: webhook.active,
|
||||
headers: Object.fromEntries(webhook.headers),
|
||||
retryConfig: webhook.retryConfig,
|
||||
timeout: webhook.timeout,
|
||||
metadata: webhook.metadata ? Object.fromEntries(webhook.metadata) : {},
|
||||
updatedAt: webhook.updatedAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete webhook
|
||||
router.delete('/:id', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const webhook = await Webhook.findOneAndDelete({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
return res.status(404).json({ error: 'Webhook not found' });
|
||||
}
|
||||
|
||||
// Also delete associated logs
|
||||
await WebhookLog.deleteMany({ webhookId: webhook._id });
|
||||
|
||||
res.json({ message: 'Webhook deleted successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Test webhook
|
||||
router.post('/:id/test', authenticate, validateRequest(testWebhookSchema), async (req, res, next) => {
|
||||
try {
|
||||
const webhook = await Webhook.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
return res.status(404).json({ error: 'Webhook not found' });
|
||||
}
|
||||
|
||||
// Get webhook processor from app
|
||||
const webhookProcessor = req.app.get('webhookProcessor');
|
||||
const log = await webhookProcessor.callWebhook(
|
||||
webhook,
|
||||
req.body.event,
|
||||
req.body.payload
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: log.status === 'success',
|
||||
log: {
|
||||
id: log._id,
|
||||
status: log.status,
|
||||
request: {
|
||||
url: log.request.url,
|
||||
headers: Object.fromEntries(log.request.headers),
|
||||
body: log.request.body
|
||||
},
|
||||
response: log.response ? {
|
||||
status: log.response.status,
|
||||
statusText: log.response.statusText,
|
||||
headers: Object.fromEntries(log.response.headers),
|
||||
body: log.response.body,
|
||||
responseTime: log.response.responseTime
|
||||
} : null,
|
||||
error: log.error
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get webhook logs
|
||||
router.get('/:id/logs', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, status } = req.query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const webhook = await Webhook.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
return res.status(404).json({ error: 'Webhook not found' });
|
||||
}
|
||||
|
||||
const query = { webhookId: webhook._id };
|
||||
if (status) {
|
||||
query.status = status;
|
||||
}
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
WebhookLog.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit)),
|
||||
WebhookLog.countDocuments(query)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
logs: logs.map(log => ({
|
||||
id: log._id,
|
||||
event: log.event,
|
||||
status: log.status,
|
||||
attempts: log.attempts,
|
||||
response: log.response ? {
|
||||
status: log.response.status,
|
||||
responseTime: log.response.responseTime
|
||||
} : null,
|
||||
error: log.error,
|
||||
createdAt: log.createdAt,
|
||||
completedAt: log.completedAt
|
||||
})),
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Regenerate webhook secret
|
||||
router.post('/:id/regenerate-secret', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const webhook = await Webhook.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
return res.status(404).json({ error: 'Webhook not found' });
|
||||
}
|
||||
|
||||
// Generate new secret
|
||||
webhook.secret = CryptoJS.lib.WordArray.random(32).toString();
|
||||
await webhook.save();
|
||||
|
||||
res.json({
|
||||
secret: webhook.secret
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,308 @@
|
||||
import PQueue from 'p-queue';
|
||||
import axios from 'axios';
|
||||
import Webhook from '../models/Webhook.js';
|
||||
import WebhookLog from '../models/WebhookLog.js';
|
||||
import config from '../config/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { createRedisClient } from '../utils/redis.js';
|
||||
|
||||
export class WebhookProcessor {
|
||||
constructor() {
|
||||
this.queue = new PQueue({
|
||||
concurrency: config.webhook.concurrency,
|
||||
interval: 1000,
|
||||
intervalCap: 50
|
||||
});
|
||||
this.redis = null;
|
||||
this.isRunning = false;
|
||||
this.retryInterval = null;
|
||||
}
|
||||
|
||||
async start() {
|
||||
logger.info('Starting webhook processor');
|
||||
this.redis = await createRedisClient();
|
||||
this.isRunning = true;
|
||||
|
||||
// Start retry processor
|
||||
this.startRetryProcessor();
|
||||
|
||||
// Subscribe to webhook events
|
||||
await this.subscribeToEvents();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
logger.info('Stopping webhook processor');
|
||||
this.isRunning = false;
|
||||
|
||||
if (this.retryInterval) {
|
||||
clearInterval(this.retryInterval);
|
||||
}
|
||||
|
||||
await this.queue.onEmpty();
|
||||
|
||||
if (this.redis) {
|
||||
await this.redis.quit();
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeToEvents() {
|
||||
// Subscribe to Redis pub/sub for real-time events
|
||||
const subscriber = this.redis.duplicate();
|
||||
await subscriber.connect();
|
||||
|
||||
await subscriber.subscribe('webhook:event', async (message) => {
|
||||
try {
|
||||
const eventData = JSON.parse(message);
|
||||
await this.processEvent(eventData);
|
||||
} catch (error) {
|
||||
logger.error('Error processing webhook event:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async processEvent(eventData) {
|
||||
const { accountId, event, payload } = eventData;
|
||||
|
||||
// Find active webhooks for this event
|
||||
const webhooks = await Webhook.find({
|
||||
accountId,
|
||||
events: event,
|
||||
active: true
|
||||
});
|
||||
|
||||
// Queue webhook calls
|
||||
for (const webhook of webhooks) {
|
||||
await this.queue.add(() => this.callWebhook(webhook, event, payload));
|
||||
}
|
||||
}
|
||||
|
||||
async callWebhook(webhook, event, payload) {
|
||||
const startTime = Date.now();
|
||||
const log = new WebhookLog({
|
||||
webhookId: webhook._id,
|
||||
accountId: webhook.accountId,
|
||||
event,
|
||||
payload,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
try {
|
||||
// Prepare request
|
||||
const requestBody = {
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: payload,
|
||||
webhookId: webhook._id.toString()
|
||||
};
|
||||
|
||||
// Generate signature
|
||||
const signature = webhook.generateSignature(requestBody);
|
||||
|
||||
// Prepare headers
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
[config.webhook.signatureHeader]: signature,
|
||||
...Object.fromEntries(webhook.headers || [])
|
||||
};
|
||||
|
||||
// Log request details
|
||||
log.request = {
|
||||
url: webhook.url,
|
||||
method: 'POST',
|
||||
headers: new Map(Object.entries(headers)),
|
||||
body: requestBody
|
||||
};
|
||||
|
||||
// Make HTTP request
|
||||
const response = await axios({
|
||||
method: 'POST',
|
||||
url: webhook.url,
|
||||
data: requestBody,
|
||||
headers,
|
||||
timeout: webhook.timeout || config.webhook.timeout,
|
||||
validateStatus: () => true // Don't throw on any status
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Log response
|
||||
log.response = {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: new Map(Object.entries(response.headers)),
|
||||
body: response.data,
|
||||
responseTime
|
||||
};
|
||||
|
||||
// Check if successful (2xx status)
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
log.status = 'success';
|
||||
log.completedAt = new Date();
|
||||
webhook.updateStats(true, responseTime);
|
||||
|
||||
logger.info(`Webhook delivered successfully`, {
|
||||
webhookId: webhook._id,
|
||||
event,
|
||||
status: response.status,
|
||||
responseTime
|
||||
});
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
log.error = {
|
||||
message: error.message,
|
||||
code: error.code || 'UNKNOWN_ERROR',
|
||||
stack: error.stack
|
||||
};
|
||||
|
||||
// Determine if we should retry
|
||||
if (log.attempts < webhook.retryConfig.maxRetries) {
|
||||
log.status = 'retrying';
|
||||
log.nextRetryAt = new Date(Date.now() + webhook.retryConfig.retryDelay * log.attempts);
|
||||
|
||||
logger.warn(`Webhook delivery failed, will retry`, {
|
||||
webhookId: webhook._id,
|
||||
event,
|
||||
attempt: log.attempts,
|
||||
error: error.message
|
||||
});
|
||||
} else {
|
||||
log.status = 'failed';
|
||||
log.completedAt = new Date();
|
||||
webhook.updateStats(false, responseTime);
|
||||
webhook.lastError = {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
logger.error(`Webhook delivery failed after ${log.attempts} attempts`, {
|
||||
webhookId: webhook._id,
|
||||
event,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Save log and webhook stats
|
||||
await log.save();
|
||||
await webhook.save();
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
startRetryProcessor() {
|
||||
this.retryInterval = setInterval(async () => {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
try {
|
||||
// Find logs that need retry
|
||||
const logsToRetry = await WebhookLog.find({
|
||||
status: 'retrying',
|
||||
nextRetryAt: { $lte: new Date() }
|
||||
}).limit(100);
|
||||
|
||||
for (const log of logsToRetry) {
|
||||
const webhook = await Webhook.findById(log.webhookId);
|
||||
if (!webhook || !webhook.active) continue;
|
||||
|
||||
// Update attempt count
|
||||
log.attempts++;
|
||||
await log.save();
|
||||
|
||||
// Queue retry
|
||||
await this.queue.add(() =>
|
||||
this.retryWebhook(webhook, log)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in retry processor:', error);
|
||||
}
|
||||
}, 5000); // Check every 5 seconds
|
||||
}
|
||||
|
||||
async retryWebhook(webhook, log) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Recreate the request
|
||||
const response = await axios({
|
||||
method: log.request.method,
|
||||
url: log.request.url,
|
||||
data: log.request.body,
|
||||
headers: Object.fromEntries(log.request.headers),
|
||||
timeout: webhook.timeout || config.webhook.timeout,
|
||||
validateStatus: () => true
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Update log with response
|
||||
log.response = {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: new Map(Object.entries(response.headers)),
|
||||
body: response.data,
|
||||
responseTime
|
||||
};
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
log.status = 'success';
|
||||
log.completedAt = new Date();
|
||||
webhook.updateStats(true, responseTime);
|
||||
|
||||
logger.info(`Webhook retry successful`, {
|
||||
webhookId: webhook._id,
|
||||
logId: log._id,
|
||||
attempt: log.attempts
|
||||
});
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
log.error = {
|
||||
message: error.message,
|
||||
code: error.code || 'UNKNOWN_ERROR',
|
||||
stack: error.stack
|
||||
};
|
||||
|
||||
if (log.attempts < webhook.retryConfig.maxRetries) {
|
||||
log.status = 'retrying';
|
||||
log.nextRetryAt = new Date(Date.now() + webhook.retryConfig.retryDelay * log.attempts);
|
||||
} else {
|
||||
log.status = 'failed';
|
||||
log.completedAt = new Date();
|
||||
webhook.updateStats(false, responseTime);
|
||||
webhook.lastError = {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await log.save();
|
||||
await webhook.save();
|
||||
}
|
||||
|
||||
// Trigger a webhook event
|
||||
async triggerEvent(accountId, event, payload) {
|
||||
const eventData = {
|
||||
accountId,
|
||||
event,
|
||||
payload,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Publish to Redis for processing
|
||||
await this.redis.publish('webhook:event', JSON.stringify(eventData));
|
||||
|
||||
// Also process immediately
|
||||
await this.processEvent(eventData);
|
||||
}
|
||||
}
|
||||
33
marketing-agent/services/webhook-service/src/utils/logger.js
Normal file
33
marketing-agent/services/webhook-service/src/utils/logger.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import winston from 'winston';
|
||||
import config from '../config/index.js';
|
||||
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: config.logging.level,
|
||||
format: logFormat,
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Add file transport in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
logger.add(new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error'
|
||||
}));
|
||||
|
||||
logger.add(new winston.transports.File({
|
||||
filename: 'logs/combined.log'
|
||||
}));
|
||||
}
|
||||
25
marketing-agent/services/webhook-service/src/utils/redis.js
Normal file
25
marketing-agent/services/webhook-service/src/utils/redis.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createClient } from 'redis';
|
||||
import config from '../config/index.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export async function createRedisClient() {
|
||||
const client = createClient({
|
||||
socket: {
|
||||
host: config.redis.host,
|
||||
port: config.redis.port
|
||||
},
|
||||
password: config.redis.password || undefined
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
logger.error('Redis Client Error:', err);
|
||||
});
|
||||
|
||||
client.on('connect', () => {
|
||||
logger.info('Connected to Redis');
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
return client;
|
||||
}
|
||||
Reference in New Issue
Block a user