Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled

Full-stack web application for Telegram management
- Frontend: Vue 3 + Vben Admin
- Backend: NestJS
- Features: User management, group broadcast, statistics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
你的用户名
2025-11-04 15:37:50 +08:00
commit 237c7802e5
3674 changed files with 525172 additions and 0 deletions

View File

@@ -0,0 +1,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"
}
}

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

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

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